init
This commit is contained in:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PUZZLE_ROOT_DIR=/home/mike/dev/puzzle-generator
|
||||||
|
OUT_DIR=/home/mike/dev/puzzle-generator/data
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.idea/
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
10
.idea/material_theme_project_new.xml
generated
Normal file
10
.idea/material_theme_project_new.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
|
<option name="metadata">
|
||||||
|
<MTProjectMetadataState>
|
||||||
|
<option name="userId" value="3464e3da:19b3043877b:-7ff5" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.13" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/puzzle-generator.iml" filepath="$PROJECT_DIR$/.idea/puzzle-generator.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/puzzle-generator.iml
generated
Normal file
8
.idea/puzzle-generator.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
113
.idea/workspace.xml
generated
Normal file
113
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="433bf0ab-7cce-4a92-8a83-29df7a79c34c" name="Changes" comment="">
|
||||||
|
<change afterPath="$PROJECT_DIR$/.env" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/profiles_settings.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/material_theme_project_new.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/puzzle-generator.iml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/tools/puzzle-gen/Dockerfile" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/tools/puzzle-gen/crontab" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/tools/puzzle-gen/generate_daily_puzzles.py" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="EmbeddingIndexingInfo">
|
||||||
|
<option name="cachedIndexableFilesCount" value="27" />
|
||||||
|
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo"><![CDATA[{
|
||||||
|
"associatedIndex": 3
|
||||||
|
}]]></component>
|
||||||
|
<component name="ProjectId" id="370YR5uHxl5ctP3PidO0lQCLY52" />
|
||||||
|
<component name="ProjectLevelVcsManager">
|
||||||
|
<ConfirmationsSetting value="2" id="Add" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
|
"keyToString": {
|
||||||
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
|
"Python.generate_daily_puzzles.executor": "Run",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
|
"git-widget-placeholder": "main",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.detected.package.tslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"settings.editor.selected.configurable": "preferences.keymap"
|
||||||
|
}
|
||||||
|
}]]></component>
|
||||||
|
<component name="RecentsManager">
|
||||||
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/tools/puzzle-gen" />
|
||||||
|
</key>
|
||||||
|
</component>
|
||||||
|
<component name="RunManager">
|
||||||
|
<configuration name="generate_daily_puzzles" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
|
||||||
|
<module name="puzzle-generator" />
|
||||||
|
<option name="ENV_FILES" value="$PROJECT_DIR$/.env" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="PARENT_ENVS" value="true" />
|
||||||
|
<envs>
|
||||||
|
<env name="PYTHONUNBUFFERED" value="1" />
|
||||||
|
</envs>
|
||||||
|
<option name="SDK_HOME" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
|
||||||
|
<option name="IS_MODULE_SDK" value="true" />
|
||||||
|
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||||
|
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||||
|
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||||
|
<option name="RUN_TOOL" value="" />
|
||||||
|
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/tools/puzzle-gen/generate_daily_puzzles.py" />
|
||||||
|
<option name="PARAMETERS" value="" />
|
||||||
|
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||||
|
<option name="EMULATE_TERMINAL" value="false" />
|
||||||
|
<option name="MODULE_MODE" value="false" />
|
||||||
|
<option name="REDIRECT_INPUT" value="false" />
|
||||||
|
<option name="INPUT_FILE" value="" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
<recent_temporary>
|
||||||
|
<list>
|
||||||
|
<item itemvalue="Python.generate_daily_puzzles" />
|
||||||
|
</list>
|
||||||
|
</recent_temporary>
|
||||||
|
</component>
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="433bf0ab-7cce-4a92-8a83-29df7a79c34c" name="Changes" comment="" />
|
||||||
|
<created>1766041444358</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1766041444358</updated>
|
||||||
|
<workItem from="1766041445366" duration="8209000" />
|
||||||
|
</task>
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="com.intellij.coverage.CoverageDataManagerImpl">
|
||||||
|
<SUITE FILE_PATH="coverage/puzzle_generator$generate_daily_puzzles.coverage" NAME="generate_daily_puzzles Coverage Results" MODIFIED="1766049200138" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"gridv2": [
|
||||||
|
"#############",
|
||||||
|
"#############",
|
||||||
|
"####B#PALLET#",
|
||||||
|
"####E#O######",
|
||||||
|
"##D#S#L#T####",
|
||||||
|
"##E#T#I#A####",
|
||||||
|
"##LAAGTANK###",
|
||||||
|
"##F#N#I#K####",
|
||||||
|
"##T#D#E#E####",
|
||||||
|
"########N####",
|
||||||
|
"#############"
|
||||||
|
],
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"word": "LAAGTANK",
|
||||||
|
"clue": "Hoofdonderdeel in beslag genomen",
|
||||||
|
"startRow": 6,
|
||||||
|
"startCol": 2,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "LAAGTANK",
|
||||||
|
"arrowRow": 6,
|
||||||
|
"arrowCol": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "POLITIE",
|
||||||
|
"clue": "Verantwoordelijk bij de inval",
|
||||||
|
"startRow": 2,
|
||||||
|
"startCol": 6,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "POLITIE",
|
||||||
|
"arrowRow": 1,
|
||||||
|
"arrowCol": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "BESTAND",
|
||||||
|
"clue": "Samengestelde hoeveelheid",
|
||||||
|
"startRow": 2,
|
||||||
|
"startCol": 4,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "BESTAND",
|
||||||
|
"arrowRow": 1,
|
||||||
|
"arrowCol": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "PALLET",
|
||||||
|
"clue": "Transportmiddel voor de lachgastank",
|
||||||
|
"startRow": 2,
|
||||||
|
"startCol": 6,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "PALLET",
|
||||||
|
"arrowRow": 2,
|
||||||
|
"arrowCol": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "TANKEN",
|
||||||
|
"clue": "Vervoort voor de lachgastank",
|
||||||
|
"startRow": 4,
|
||||||
|
"startCol": 8,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "TANKEN",
|
||||||
|
"arrowRow": 3,
|
||||||
|
"arrowCol": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "DELFT",
|
||||||
|
"clue": "Stad waar het gebeurde",
|
||||||
|
"startRow": 4,
|
||||||
|
"startCol": 2,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "DELFT",
|
||||||
|
"arrowRow": 3,
|
||||||
|
"arrowCol": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"difficulty": 1,
|
||||||
|
"rewards": {
|
||||||
|
"coins": 50,
|
||||||
|
"stars": 2,
|
||||||
|
"hints": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"gridv2": [
|
||||||
|
[
|
||||||
|
"##############"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"##############"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"########C#####"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"########E#####"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"##DIVUSEN#####"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"########S#####"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"######EQUALIA#"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####G###R##J##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"###BLOKIER#Z##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####O#O####E##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####O#R####N##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####M#T####K##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####I#E####O##"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"####T#X#######"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"##############"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"word": "BLOKIER",
|
||||||
|
"clue": "Persoon die accounts blokt.",
|
||||||
|
"startRow": 8,
|
||||||
|
"startCol": 3,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "BLOKIER",
|
||||||
|
"arrowRow": 8,
|
||||||
|
"arrowCol": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "CENSURE",
|
||||||
|
"clue": "Controle over queer‑accounts.",
|
||||||
|
"startRow": 2,
|
||||||
|
"startCol": 8,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "CENSURE",
|
||||||
|
"arrowRow": 1,
|
||||||
|
"arrowCol": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "DIVUSEN",
|
||||||
|
"clue": "Verdeel en heers accountblok.",
|
||||||
|
"startRow": 4,
|
||||||
|
"startCol": 2,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "DIVUSEN",
|
||||||
|
"arrowRow": 4,
|
||||||
|
"arrowCol": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "EQUALIA",
|
||||||
|
"clue": "Gelijk op abortus.",
|
||||||
|
"startRow": 6,
|
||||||
|
"startCol": 6,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "EQUALIA",
|
||||||
|
"arrowRow": 6,
|
||||||
|
"arrowCol": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "GLOOMIT",
|
||||||
|
"clue": "Verstopt sociale media.",
|
||||||
|
"startRow": 7,
|
||||||
|
"startCol": 4,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "GLOOMIT",
|
||||||
|
"arrowRow": 6,
|
||||||
|
"arrowCol": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "IJZENKO",
|
||||||
|
"clue": "Krachtige blokking.",
|
||||||
|
"startRow": 6,
|
||||||
|
"startCol": 11,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "IJZENKO",
|
||||||
|
"arrowRow": 5,
|
||||||
|
"arrowCol": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "KORTEX",
|
||||||
|
"clue": "Kort maar krachtig.",
|
||||||
|
"startRow": 8,
|
||||||
|
"startCol": 6,
|
||||||
|
"direction": "vertical",
|
||||||
|
"answer": "KORTEX",
|
||||||
|
"arrowRow": 7,
|
||||||
|
"arrowCol": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"difficulty": 1,
|
||||||
|
"rewards": {
|
||||||
|
"coins": 50,
|
||||||
|
"stars": 2,
|
||||||
|
"hints": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
7
data/index.json
Normal file
7
data/index.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"date": "2025-12-18",
|
||||||
|
"files": [
|
||||||
|
"crossword_2025-12-18_01_duizenden-lachgascilinders-in-beslag-genomen-in-de.json",
|
||||||
|
"crossword_2025-12-18_02_meta-blokkeert-tientallen-queer-en-abortus-account.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
puzzle:
|
||||||
|
build:
|
||||||
|
context: ${PUZZLE_ROOT_DIR:-/opt/apps/puzzle}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: puzzle
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [ traefik_net ]
|
||||||
|
volumes:
|
||||||
|
- puzzles_data:/usr/share/nginx/html/puzzles:ro
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.puzzle-main.rule=Host(`puzzle.appmodel.nl`)"
|
||||||
|
- "traefik.http.routers.puzzle-main.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.puzzle-main.tls=true"
|
||||||
|
- "traefik.http.routers.puzzle-main.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.puzzle-main-http.rule=Host(`puzzle.appmodel.nl`)"
|
||||||
|
- "traefik.http.routers.puzzle-main-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.puzzle-main-http.middlewares=redirect-to-https@file"
|
||||||
|
|
||||||
|
puzzle_gen:
|
||||||
|
build:
|
||||||
|
context: ${PUZZLE_ROOT_DIR:-/opt/apps/puzzle}
|
||||||
|
dockerfile: tools/puzzle-gen/Dockerfile
|
||||||
|
container_name: puzzle_gen
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [ traefik_net ]
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Amsterdam
|
||||||
|
LM_STUDIO_BASE_URL: "http://192.168.1.159:1234/v1"
|
||||||
|
PUZZLES_PER_DAY: "3"
|
||||||
|
volumes:
|
||||||
|
- puzzles_data:/data/puzzles:rw
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
puzzles_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_net:
|
||||||
|
external: true
|
||||||
|
name: traefik_net
|
||||||
16
tools/puzzle-gen/Dockerfile
Normal file
16
tools/puzzle-gen/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# supercronic
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/supercronic \
|
||||||
|
https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/supercronic
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY tools/puzzle-gen/generate_daily_puzzles.py /app/generate_daily_puzzles.py
|
||||||
|
COPY tools/puzzle-gen/crontab /app/crontab
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/supercronic", "/app/crontab"]
|
||||||
1
tools/puzzle-gen/crontab
Normal file
1
tools/puzzle-gen/crontab
Normal file
@@ -0,0 +1 @@
|
|||||||
|
15 3 * * * python /app/generate_daily_puzzles.py
|
||||||
388
tools/puzzle-gen/generate_daily_puzzles.py
Normal file
388
tools/puzzle-gen/generate_daily_puzzles.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import json, re
|
||||||
|
|
||||||
|
WORD_RE = re.compile(r"^[A-Z]{3,12}$")
|
||||||
|
EMPTY = " "
|
||||||
|
SIZE = 12
|
||||||
|
|
||||||
|
FEEDS = [
|
||||||
|
"https://feeds.nos.nl/nosnieuwsalgemeen",
|
||||||
|
"https://feeds.nos.nl/nosnieuwstech",
|
||||||
|
"http://newsrss.bbc.co.uk/rss/newsonline_uk_edition/world/rss.xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def env(name, default=None):
|
||||||
|
v = os.getenv(name)
|
||||||
|
return default if v is None or v == "" else v
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(url, timeout=15):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "puzzle-gen/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def http_post_json(url, payload, timeout=45):
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer lm-studio",
|
||||||
|
"User-Agent": "puzzle-gen/1.0",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_rss_items(url, limit=12):
|
||||||
|
raw = http_get(url)
|
||||||
|
root = ET.fromstring(raw)
|
||||||
|
channel = root.find("channel") if root.tag.lower().endswith("rss") else root
|
||||||
|
items = []
|
||||||
|
for it in channel.findall("item"):
|
||||||
|
title = (it.findtext("title") or "").strip()
|
||||||
|
desc = (it.findtext("description") or "").strip()
|
||||||
|
if title:
|
||||||
|
items.append((title, desc))
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def safe_slug(s, maxlen=50):
|
||||||
|
s = s.lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||||
|
return (s[:maxlen] or "news")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_first_json(text: str):
|
||||||
|
"""Parse first JSON value (object OR array) from any text."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
starts = [i for i in (text.find("{"), text.find("[")) if i != -1]
|
||||||
|
if not starts:
|
||||||
|
return None
|
||||||
|
i = min(starts)
|
||||||
|
try:
|
||||||
|
return json.JSONDecoder().raw_decode(text[i:])[0]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_word(raw: str) -> str:
|
||||||
|
# A-Z only, remove hyphens/digits/spaces/etc.
|
||||||
|
w = re.sub(r"[^A-Za-z]", "", (raw or "")).upper()
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_wordcluemap(obj):
|
||||||
|
"""
|
||||||
|
Accepts:
|
||||||
|
- dict: {"WORD":"clue", ...}
|
||||||
|
- list: [{"word":"...","clue":"..."}, {"WOORD":"...","clue":"..."}, ...]
|
||||||
|
Returns dict with keys A-Z 3..12 and non-empty clue.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
items = list(obj.items())
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
items = []
|
||||||
|
for it in obj:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
raw_word = it.get("word") or it.get("WOORD") or it.get("Word")
|
||||||
|
clue = it.get("clue") or it.get("CLUE") or it.get("hint") or it.get("HINT")
|
||||||
|
items.append((raw_word, clue))
|
||||||
|
else:
|
||||||
|
return out
|
||||||
|
|
||||||
|
for raw_word, clue in items:
|
||||||
|
if not isinstance(raw_word, str) or not isinstance(clue, str):
|
||||||
|
continue
|
||||||
|
w = normalize_word(raw_word)
|
||||||
|
if not WORD_RE.fullmatch(w):
|
||||||
|
continue
|
||||||
|
clue = clue.strip()
|
||||||
|
if not clue:
|
||||||
|
continue
|
||||||
|
out[w] = clue
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- generator (no-touch) ----
|
||||||
|
def make_grid():
|
||||||
|
return [[EMPTY for _ in range(SIZE)] for _ in range(SIZE)]
|
||||||
|
|
||||||
|
|
||||||
|
def in_bounds(g, r, c):
|
||||||
|
return 0 <= r < len(g) and 0 <= c < len(g[0])
|
||||||
|
|
||||||
|
|
||||||
|
def can_place_notouch(g, word, r, c, direction):
|
||||||
|
H, W = len(g), len(g[0])
|
||||||
|
if r < 0 or c < 0:
|
||||||
|
return False
|
||||||
|
if direction == "horizontal" and c + len(word) > W:
|
||||||
|
return False
|
||||||
|
if direction == "vertical" and r + len(word) > H:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# no "glue" before/after
|
||||||
|
br = r if direction == "horizontal" else r - 1
|
||||||
|
bc = c - 1 if direction == "horizontal" else c
|
||||||
|
if in_bounds(g, br, bc) and g[br][bc] != EMPTY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ar = r if direction == "horizontal" else r + len(word)
|
||||||
|
ac = c + len(word) if direction == "horizontal" else c
|
||||||
|
if in_bounds(g, ar, ac) and g[ar][ac] != EMPTY:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i, ch in enumerate(word):
|
||||||
|
rr = r if direction == "horizontal" else r + i
|
||||||
|
cc = c + i if direction == "horizontal" else c
|
||||||
|
cell = g[rr][cc]
|
||||||
|
crossing = cell != EMPTY
|
||||||
|
if crossing and cell != ch:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not crossing:
|
||||||
|
if direction == "horizontal":
|
||||||
|
if in_bounds(g, rr - 1, cc) and g[rr - 1][cc] != EMPTY: return False
|
||||||
|
if in_bounds(g, rr + 1, cc) and g[rr + 1][cc] != EMPTY: return False
|
||||||
|
else:
|
||||||
|
if in_bounds(g, rr, cc - 1) and g[rr][cc - 1] != EMPTY: return False
|
||||||
|
if in_bounds(g, rr, cc + 1) and g[rr][cc + 1] != EMPTY: return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def place_word(g, word, r, c, direction):
|
||||||
|
for i, ch in enumerate(word):
|
||||||
|
rr = r if direction == "horizontal" else r + i
|
||||||
|
cc = c + i if direction == "horizontal" else c
|
||||||
|
g[rr][cc] = ch
|
||||||
|
|
||||||
|
|
||||||
|
def find_spots(g, word, placed):
|
||||||
|
spots = []
|
||||||
|
for p in placed:
|
||||||
|
pw = p["word"]
|
||||||
|
for i, pch in enumerate(pw):
|
||||||
|
pr = p["row"] if p["direction"] == "horizontal" else p["row"] + i
|
||||||
|
pc = p["col"] + i if p["direction"] == "horizontal" else p["col"]
|
||||||
|
for j, wch in enumerate(word):
|
||||||
|
if wch != pch:
|
||||||
|
continue
|
||||||
|
direction = "vertical" if p["direction"] == "horizontal" else "horizontal"
|
||||||
|
r = pr if direction == "horizontal" else pr - j
|
||||||
|
c = pc - j if direction == "horizontal" else pc
|
||||||
|
if can_place_notouch(g, word, r, c, direction):
|
||||||
|
spots.append((r, c, direction))
|
||||||
|
return spots
|
||||||
|
|
||||||
|
|
||||||
|
def generate_puzzle(wordcluemap, rnd):
|
||||||
|
words = sorted(wordcluemap.keys(), key=len, reverse=True)
|
||||||
|
g = make_grid()
|
||||||
|
placed = []
|
||||||
|
|
||||||
|
first = words[0]
|
||||||
|
sr = SIZE // 2
|
||||||
|
sc = (SIZE - len(first)) // 2
|
||||||
|
if not can_place_notouch(g, first, sr, sc, "horizontal"):
|
||||||
|
return None
|
||||||
|
place_word(g, first, sr, sc, "horizontal")
|
||||||
|
placed.append({"word": first, "clue": wordcluemap[first], "row": sr, "col": sc, "direction": "horizontal"})
|
||||||
|
|
||||||
|
for w in words[1:]:
|
||||||
|
spots = find_spots(g, w, placed)
|
||||||
|
rnd.shuffle(spots)
|
||||||
|
if not spots:
|
||||||
|
continue
|
||||||
|
r, c, d = spots[0]
|
||||||
|
place_word(g, w, r, c, d)
|
||||||
|
placed.append({"word": w, "clue": wordcluemap[w], "row": r, "col": c, "direction": d})
|
||||||
|
|
||||||
|
return {"grid": g, "placed": placed}
|
||||||
|
|
||||||
|
|
||||||
|
def export_format(puz, difficulty=1, rewards=None):
|
||||||
|
if rewards is None:
|
||||||
|
rewards = {"coins": 50, "stars": 2, "hints": 1}
|
||||||
|
|
||||||
|
g = puz["grid"]
|
||||||
|
placed = puz["placed"]
|
||||||
|
H, W = len(g), len(g[0])
|
||||||
|
|
||||||
|
cells = []
|
||||||
|
for p in placed:
|
||||||
|
for i in range(len(p["word"])):
|
||||||
|
r = p["row"] if p["direction"] == "horizontal" else p["row"] + i
|
||||||
|
c = p["col"] + i if p["direction"] == "horizontal" else p["col"]
|
||||||
|
cells.append((r, c))
|
||||||
|
# arrow cell: before the start
|
||||||
|
ar = p["row"] if p["direction"] == "horizontal" else p["row"] - 1
|
||||||
|
ac = p["col"] - 1 if p["direction"] == "horizontal" else p["col"]
|
||||||
|
cells.append((ar, ac))
|
||||||
|
|
||||||
|
minR = min(r for r, _ in cells) - 1
|
||||||
|
minC = min(c for _, c in cells) - 1
|
||||||
|
maxR = max(r for r, _ in cells) + 1
|
||||||
|
maxC = max(c for _, c in cells) + 1
|
||||||
|
|
||||||
|
def ch_at(r, c):
|
||||||
|
if r < 0 or c < 0 or r >= H or c >= W:
|
||||||
|
return "#"
|
||||||
|
ch = g[r][c]
|
||||||
|
return "#" if ch == EMPTY else ch
|
||||||
|
|
||||||
|
gridv2 = []
|
||||||
|
for r in range(minR, maxR + 1):
|
||||||
|
row = "".join(ch_at(r, c) for c in range(minC, maxC + 1))
|
||||||
|
gridv2.append(row)
|
||||||
|
|
||||||
|
words_out = []
|
||||||
|
for p in placed:
|
||||||
|
arrowRow = (p["row"] if p["direction"] == "horizontal" else p["row"] - 1) - minR
|
||||||
|
arrowCol = (p["col"] - 1 if p["direction"] == "horizontal" else p["col"]) - minC
|
||||||
|
words_out.append({
|
||||||
|
"word": p["word"],
|
||||||
|
"clue": p["clue"],
|
||||||
|
"startRow": p["row"] - minR,
|
||||||
|
"startCol": p["col"] - minC,
|
||||||
|
"direction": p["direction"],
|
||||||
|
"answer": p["word"],
|
||||||
|
"arrowRow": arrowRow,
|
||||||
|
"arrowCol": arrowCol,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"gridv2": gridv2, "words": words_out, "difficulty": difficulty, "rewards": rewards}
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(base_url):
|
||||||
|
try:
|
||||||
|
data = json.loads(http_get(f"{base_url}/models").decode("utf-8"))
|
||||||
|
return [m.get("id") for m in data.get("data", []) if m.get("id")]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def llm_make_wordcluemap(base_url, model, title, desc, n_words=12):
|
||||||
|
prompt = f"""
|
||||||
|
Geef ALLEEN een JSON object terug (geen array, geen markdown).
|
||||||
|
Formaat exact:
|
||||||
|
{{
|
||||||
|
"WOORD": "clue",
|
||||||
|
...
|
||||||
|
}}
|
||||||
|
|
||||||
|
Regels:
|
||||||
|
- WOORD: alleen letters A-Z, geen streepjes, geen cijfers, lengte 3..12.
|
||||||
|
- waarde: clue in het Nederlands, kort.
|
||||||
|
- Maak {n_words} items.
|
||||||
|
Thema: {title}
|
||||||
|
Context: {desc[:260]}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Return STRICT JSON object only."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
data = http_post_json(f"{base_url}/chat/completions", payload)
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
obj = extract_first_json(content)
|
||||||
|
wc = sanitize_wordcluemap(obj)
|
||||||
|
|
||||||
|
# Repair pass (als model toch array/invalid stuff geeft)
|
||||||
|
if len(wc) < max(6, n_words - 4):
|
||||||
|
repair = f"""
|
||||||
|
Zet dit om naar een STRICT JSON OBJECT (geen array) "WOORD":"clue".
|
||||||
|
WOORD: A-Z only, 3..12, geen streepjes/cijfers. Vervang ongeldige woorden door passende synoniemen.
|
||||||
|
Input:
|
||||||
|
{content}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
payload["messages"] = [
|
||||||
|
{"role": "system", "content": "Return STRICT JSON object only."},
|
||||||
|
{"role": "user", "content": repair},
|
||||||
|
]
|
||||||
|
data = http_post_json(f"{base_url}/chat/completions", payload)
|
||||||
|
content2 = data["choices"][0]["message"]["content"]
|
||||||
|
obj2 = extract_first_json(content2)
|
||||||
|
wc2 = sanitize_wordcluemap(obj2)
|
||||||
|
if len(wc2) > len(wc):
|
||||||
|
wc = wc2
|
||||||
|
|
||||||
|
return wc
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_url = env("LM_STUDIO_BASE_URL", "http://192.168.1.159:1234/v1")
|
||||||
|
out_dir = env("OUT_DIR", "/data/puzzles")
|
||||||
|
per_day = int(env("PUZZLES_PER_DAY", "3"))
|
||||||
|
today = dt.date.today().isoformat()
|
||||||
|
rnd = random.Random(today)
|
||||||
|
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for f in FEEDS:
|
||||||
|
try:
|
||||||
|
items.extend(fetch_rss_items(f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not items:
|
||||||
|
raise SystemExit("No RSS items found")
|
||||||
|
|
||||||
|
models = list_models(base_url)
|
||||||
|
model = env("LM_MODEL", models[0] if models else "model-identifier")
|
||||||
|
|
||||||
|
made = 0
|
||||||
|
for idx in range(1, per_day + 1):
|
||||||
|
title, desc = rnd.choice(items)
|
||||||
|
slug = safe_slug(title)
|
||||||
|
|
||||||
|
wc = llm_make_wordcluemap(base_url, model, title, desc, n_words=12)
|
||||||
|
if len(wc) < 8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
puz = generate_puzzle(wc, rnd)
|
||||||
|
if not puz or len(puz["placed"]) < 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
exported = export_format(puz, difficulty=1, rewards={"coins": 50, "stars": 2, "hints": 1})
|
||||||
|
fn = f"crossword_{today}_{idx:02d}_{slug}.json"
|
||||||
|
path = os.path.join(out_dir, fn)
|
||||||
|
with open(path, "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(exported, fp, ensure_ascii=False, indent=2)
|
||||||
|
made += 1
|
||||||
|
|
||||||
|
# index.json (handig voor je frontend)
|
||||||
|
files = sorted([f for f in os.listdir(out_dir) if f.startswith(f"crossword_{today}_") and f.endswith(".json")])
|
||||||
|
with open(os.path.join(out_dir, "index.json"), "w", encoding="utf-8") as fp:
|
||||||
|
json.dump({"date": today, "files": files}, fp, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"Generated {made} puzzles for {today}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user