From 3e60cad1a03491822ae47dae04869cd72483f43d Mon Sep 17 00:00:00 2001 From: mike Date: Thu, 18 Dec 2025 10:24:52 +0100 Subject: [PATCH] init --- .env | 2 + .gitignore | 1 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/material_theme_project_new.xml | 10 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/puzzle-generator.iml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 113 +++++ ...hgascilinders-in-beslag-genomen-in-de.json | 83 ++++ ...t-tientallen-queer-en-abortus-account.json | 127 ++++++ data/index.json | 7 + docker-compose.yml | 41 ++ tools/puzzle-gen/Dockerfile | 16 + tools/puzzle-gen/crontab | 1 + tools/puzzle-gen/generate_daily_puzzles.py | 388 ++++++++++++++++++ 16 files changed, 824 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/material_theme_project_new.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/puzzle-generator.iml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 data/crossword_2025-12-18_01_duizenden-lachgascilinders-in-beslag-genomen-in-de.json create mode 100644 data/crossword_2025-12-18_02_meta-blokkeert-tientallen-queer-en-abortus-account.json create mode 100644 data/index.json create mode 100644 docker-compose.yml create mode 100644 tools/puzzle-gen/Dockerfile create mode 100644 tools/puzzle-gen/crontab create mode 100644 tools/puzzle-gen/generate_daily_puzzles.py diff --git a/.env b/.env new file mode 100644 index 0000000..855b496 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +PUZZLE_ROOT_DIR=/home/mike/dev/puzzle-generator +OUT_DIR=/home/mike/dev/puzzle-generator/data \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..53df99e --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1d3ce46 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..703ade5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/puzzle-generator.iml b/.idea/puzzle-generator.iml new file mode 100644 index 0000000..d8b3f6c --- /dev/null +++ b/.idea/puzzle-generator.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..f396ced --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1766041444358 + + + + + + + + + \ No newline at end of file diff --git a/data/crossword_2025-12-18_01_duizenden-lachgascilinders-in-beslag-genomen-in-de.json b/data/crossword_2025-12-18_01_duizenden-lachgascilinders-in-beslag-genomen-in-de.json new file mode 100644 index 0000000..e0271ab --- /dev/null +++ b/data/crossword_2025-12-18_01_duizenden-lachgascilinders-in-beslag-genomen-in-de.json @@ -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 + } +} \ No newline at end of file diff --git a/data/crossword_2025-12-18_02_meta-blokkeert-tientallen-queer-en-abortus-account.json b/data/crossword_2025-12-18_02_meta-blokkeert-tientallen-queer-en-abortus-account.json new file mode 100644 index 0000000..aec8e97 --- /dev/null +++ b/data/crossword_2025-12-18_02_meta-blokkeert-tientallen-queer-en-abortus-account.json @@ -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 + } +} \ No newline at end of file diff --git a/data/index.json b/data/index.json new file mode 100644 index 0000000..5d1d9c3 --- /dev/null +++ b/data/index.json @@ -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" + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..875cd3b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/tools/puzzle-gen/Dockerfile b/tools/puzzle-gen/Dockerfile new file mode 100644 index 0000000..df7db3e --- /dev/null +++ b/tools/puzzle-gen/Dockerfile @@ -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"] diff --git a/tools/puzzle-gen/crontab b/tools/puzzle-gen/crontab new file mode 100644 index 0000000..85d3197 --- /dev/null +++ b/tools/puzzle-gen/crontab @@ -0,0 +1 @@ +15 3 * * * python /app/generate_daily_puzzles.py \ No newline at end of file diff --git a/tools/puzzle-gen/generate_daily_puzzles.py b/tools/puzzle-gen/generate_daily_puzzles.py new file mode 100644 index 0000000..ff5e591 --- /dev/null +++ b/tools/puzzle-gen/generate_daily_puzzles.py @@ -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()