diff --git a/generator/backfill.py b/generator/backfill.py new file mode 100644 index 0000000..8a2ad1b --- /dev/null +++ b/generator/backfill.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Generate missing archive editions for a date range.""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import sys +import time +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import topics + +from generate import ( + THEMES, + build_archiv_entries, + build_system_prompt, + build_user_prompt, + call_openrouter, + date_human, + generate_hero_image, + load_archive, + log, + render_template, + save_json_atomic, + write_text_atomic, +) + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def parse_date(value: str) -> date: + return date.fromisoformat(value) + + +def generate_day( + day: date, + api_key: str, + model: str, + pixazo_key: str | None, + site_dir: Path, + data_dir: Path, + archive: list[dict], + *, + update_index: bool, + pixazo_pause: float, +) -> list[dict]: + date_iso = day.isoformat() + if any(entry.get("date") == date_iso for entry in archive): + log(f"skipping {date_iso}, already in archive") + return archive + + selected_topics = topics.pick_topics(day) + theme = random.Random(f"theme-{date_iso}").choice(THEMES) + log( + f"generating edition for {date_iso} theme={theme} " + f"topics={[t['name'] for t in selected_topics]}" + ) + + llm_json = call_openrouter(api_key, model, selected_topics) + + hero_image = None + if pixazo_key: + hero_image = generate_hero_image( + pixazo_key, + site_dir, + date_iso, + llm_json["image_prompt"], + llm_json["image_negative_prompt"], + ) + if pixazo_pause > 0: + log(f"pausing {pixazo_pause:.0f}s after Pixazo call") + time.sleep(pixazo_pause) + else: + log("PIXAZO_API_KEY not set, skipping hero image") + + human_date = date_human(day) + site_url = os.environ.get("SITE_URL", "https://mobea.de") + startup_base = { + "date": date_iso, + "date_human": human_date, + "theme": theme, + "hero_image": hero_image, + "s": llm_json, + "site_url": site_url, + } + archiv_day_html = render_template( + "startup.html.j2", + {**startup_base, "canonical_path": f"/archiv/{date_iso}.html"}, + ) + + archiv_day_path = site_dir / "archiv" / f"{date_iso}.html" + write_text_atomic(archiv_day_path, archiv_day_html) + log(f"wrote {archiv_day_path}") + + if update_index: + startup_html = render_template( + "startup.html.j2", + {**startup_base, "canonical_path": "/"}, + ) + write_text_atomic(site_dir / "index.html", startup_html) + log(f"wrote {site_dir / 'index.html'}") + + archive_entry = { + "date": date_iso, + "date_human": human_date, + "backronym": " ".join(llm_json["backronym_words"]), + "tagline": llm_json["tagline"], + "theme": theme, + "topics": [topic["name"] for topic in selected_topics], + "has_image": hero_image is not None, + "prompts": { + "content_system": build_system_prompt(), + "content_user": build_user_prompt(selected_topics), + "image_prompt": llm_json["image_prompt"], + "image_negative_prompt": llm_json["image_negative_prompt"], + }, + } + archive = [entry for entry in archive if entry.get("date") != date_iso] + archive.append(archive_entry) + save_json_atomic(data_dir / "archive.json", archive) + log(f"updated {data_dir / 'archive.json'}") + return archive + + +def main() -> int: + parser = argparse.ArgumentParser(description="Backfill mobea archive editions") + parser.add_argument("--from", dest="from_date", required=True, help="Start date (YYYY-MM-DD)") + parser.add_argument("--to", dest="to_date", default=None, help="End date (YYYY-MM-DD), default today UTC") + parser.add_argument("--site-dir", default=None) + parser.add_argument("--data-dir", default=None) + parser.add_argument("--env-file", default=None) + parser.add_argument( + "--pixazo-pause", + type=float, + default=30.0, + help="Seconds to wait after each Pixazo image generation (default: 30)", + ) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parent.parent + env_file = Path(args.env_file) if args.env_file else repo_root / ".env" + load_env_file(env_file) + + api_key = os.environ.get("OPENROUTER_API_KEY") + model = os.environ.get("OPENROUTER_MODEL") + pixazo_key = os.environ.get("PIXAZO_API_KEY") + site_dir = Path(args.site_dir or os.environ.get("SITE_DIR", repo_root / "public")) + data_dir = Path(args.data_dir or os.environ.get("DATA_DIR", repo_root / "data")) + + if not api_key: + log("error: OPENROUTER_API_KEY is required") + return 1 + if not model: + log("error: OPENROUTER_MODEL is required") + return 1 + + start = parse_date(args.from_date) + end = parse_date(args.to_date) if args.to_date else datetime.now(timezone.utc).date() + if start > end: + log("error: --from must not be after --to") + return 1 + + site_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + + archive = load_archive(data_dir) + current = start + days: list[date] = [] + while current <= end: + days.append(current) + current += timedelta(days=1) + + log(f"backfill {start.isoformat()} .. {end.isoformat()} ({len(days)} day(s))") + + for idx, day in enumerate(days): + try: + archive = generate_day( + day, + api_key, + model, + pixazo_key, + site_dir, + data_dir, + archive, + update_index=(idx == len(days) - 1), + pixazo_pause=args.pixazo_pause, + ) + except Exception as exc: + log(f"error: failed for {day.isoformat()}: {exc}") + return 1 + + latest_image = next( + ( + f"/img/{entry['date']}.jpg" + for entry in sorted(archive, key=lambda e: e["date"], reverse=True) + if entry.get("has_image") + ), + "/og-default.jpg", + ) + archiv_html = render_template( + "archiv.html.j2", + { + "entries": build_archiv_entries(archive, site_dir), + "site_url": os.environ.get("SITE_URL", "https://mobea.de"), + "og_image": latest_image, + }, + ) + write_text_atomic(site_dir / "archiv" / "index.html", archiv_html) + log(f"wrote {site_dir / 'archiv' / 'index.html'}") + log("backfill complete") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/generator/create_variants.py b/generator/create_variants.py new file mode 100644 index 0000000..103af0c --- /dev/null +++ b/generator/create_variants.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Create responsive image variants and patch existing HTML img tags.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +from generate import log, write_text_atomic +from images import create_responsive_variants, iter_source_images + +HERO_IMG_RE = re.compile( + r'([^' +) +THUMB_IMG_RE = re.compile( + r'([^' +) + + +def patch_html_images(path: Path, descriptors: dict[str, dict[str, str]]) -> bool: + html = path.read_text(encoding="utf-8") + original = html + + def replace_hero(match: re.Match[str]) -> str: + date_iso, alt = match.group(1), match.group(2) + image = descriptors.get(date_iso) + if not image: + return match.group(0) + return ( + f'' + ) + + def replace_thumb(match: re.Match[str]) -> str: + date_iso, alt = match.group(1), match.group(2) + image = descriptors.get(date_iso) + if not image: + return match.group(0) + return ( + f'{alt}' + ) + + html = HERO_IMG_RE.sub(replace_hero, html) + html = THUMB_IMG_RE.sub(replace_thumb, html) + if html != original: + write_text_atomic(path, html) + log(f"patched images in {path}") + return True + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create responsive hero image variants") + parser.add_argument("--site-dir", default=None) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parent.parent + site_dir = Path(args.site_dir or os.environ.get("SITE_DIR", repo_root / "public")) + img_dir = site_dir / "img" + + descriptors: dict[str, dict[str, str]] = {} + for source in iter_source_images(img_dir): + date_iso = source.stem + descriptor = create_responsive_variants(img_dir, date_iso) + if descriptor: + descriptors[date_iso] = descriptor + log(f"created variants for {date_iso}") + + patched = 0 + targets = [site_dir / "index.html", site_dir / "archiv" / "index.html"] + targets.extend(sorted((site_dir / "archiv").glob("2026-*.html"))) + for path in targets: + if path.exists(): + patched += patch_html_images(path, descriptors) + + log(f"done, {len(descriptors)} image set(s), patched {patched} html file(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/generator/generate.py b/generator/generate.py index 4bc306d..0694ae0 100644 --- a/generator/generate.py +++ b/generator/generate.py @@ -15,6 +15,7 @@ import requests from jinja2 import Environment, FileSystemLoader import topics +from images import create_responsive_variants, load_image_descriptor GERMAN_MONTHS = [ "Januar", @@ -205,7 +206,7 @@ def generate_hero_image( date_iso: str, prompt: str, negative_prompt: str, -) -> str | None: +) -> dict[str, str] | None: try: response = requests.post( "https://gateway.pixazo.ai/getImage/v1/getSDXLImage", @@ -233,12 +234,18 @@ def generate_hero_image( img_response = requests.get(image_url, timeout=120) img_response.raise_for_status() + if len(img_response.content) < 50_000: + log( + f"warning: hero image for {date_iso} too small " + f"({len(img_response.content)} bytes), discarding" + ) + return None img_dir = site_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) img_path = img_dir / f"{date_iso}.jpg" img_path.write_bytes(img_response.content) - return f"/img/{date_iso}.jpg" + return create_responsive_variants(img_dir, date_iso) except (requests.RequestException, OSError, KeyError, TypeError, ValueError) as exc: log(f"warning: hero image generation failed: {exc}") return None @@ -252,7 +259,8 @@ def render_template(name: str, context: dict) -> str: return env.get_template(name).render(**context) -def build_archiv_entries(archive: list[dict]) -> list[dict]: +def build_archiv_entries(archive: list[dict], site_dir: Path) -> list[dict]: + img_dir = site_dir / "img" entries = [] for item in sorted(archive, key=lambda e: e["date"], reverse=True): prompts = item.get("prompts") @@ -263,7 +271,9 @@ def build_archiv_entries(archive: list[dict]) -> list[dict]: "backronym": item["backronym"], "tagline": item["tagline"], "url": f"/archiv/{item['date']}.html", - "image": f"/img/{item['date']}.jpg" if item.get("has_image") else None, + "image": load_image_descriptor(img_dir, item["date"]) + if item.get("has_image") + else None, "topics": item.get("topics", []), "prompts": prompts if isinstance(prompts, dict) else None, } @@ -323,18 +333,26 @@ def main() -> int: log("PIXAZO_API_KEY not set, skipping hero image") human_date = date_human(today) - startup_context = { + startup_base = { "date": date_iso, "date_human": human_date, "theme": theme, "hero_image": hero_image, "s": llm_json, + "site_url": os.environ.get("SITE_URL", "https://mobea.de"), } - startup_html = render_template("startup.html.j2", startup_context) + startup_html = render_template( + "startup.html.j2", + {**startup_base, "canonical_path": "/"}, + ) + archiv_day_html = render_template( + "startup.html.j2", + {**startup_base, "canonical_path": f"/archiv/{date_iso}.html"}, + ) archiv_day_path = site_dir / "archiv" / f"{date_iso}.html" write_text_atomic(index_path, startup_html) - write_text_atomic(archiv_day_path, startup_html) + write_text_atomic(archiv_day_path, archiv_day_html) log(f"wrote {index_path} and {archiv_day_path}") archive_entry = { @@ -357,9 +375,21 @@ def main() -> int: save_json_atomic(data_dir / "archive.json", archive) log(f"updated {data_dir / 'archive.json'}") + latest_image = next( + ( + f"/img/{entry['date']}.jpg" + for entry in sorted(archive, key=lambda e: e["date"], reverse=True) + if entry.get("has_image") + ), + "/og-default.jpg", + ) archiv_html = render_template( "archiv.html.j2", - {"entries": build_archiv_entries(archive)}, + { + "entries": build_archiv_entries(archive, site_dir), + "site_url": os.environ.get("SITE_URL", "https://mobea.de"), + "og_image": latest_image, + }, ) write_text_atomic(site_dir / "archiv" / "index.html", archiv_html) log(f"wrote {site_dir / 'archiv' / 'index.html'}") diff --git a/generator/images.py b/generator/images.py new file mode 100644 index 0000000..73115ac --- /dev/null +++ b/generator/images.py @@ -0,0 +1,90 @@ +"""Responsive JPEG variants for hero images.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from PIL import Image + +VARIANT_WIDTHS = (360, 560, 800) +JPEG_QUALITY = 82 +DATE_IMAGE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})\.jpg$") + + +def image_public_path(date_iso: str, width: int | None = None) -> str: + if width is None: + return f"/img/{date_iso}.jpg" + return f"/img/{date_iso}-{width}w.jpg" + + +def build_srcset(date_iso: str, widths: list[int], full_width: int) -> str: + parts = [f"{image_public_path(date_iso, w)} {w}w" for w in widths] + parts.append(f"{image_public_path(date_iso)} {full_width}w") + return ", ".join(parts) + + +def create_responsive_variants(img_dir: Path, date_iso: str) -> dict[str, str] | None: + source = img_dir / f"{date_iso}.jpg" + if not source.exists(): + return None + + with Image.open(source) as im: + im = im.convert("RGB") + full_width, full_height = im.size + generated: list[int] = [] + + for width in VARIANT_WIDTHS: + if width >= full_width: + continue + height = round(full_height * width / full_width) + resized = im.resize((width, height), Image.Resampling.LANCZOS) + out = img_dir / f"{date_iso}-{width}w.jpg" + resized.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True) + generated.append(width) + + im.save(source, "JPEG", quality=JPEG_QUALITY, optimize=True) + + srcset = build_srcset(date_iso, generated, full_width) + default_src = image_public_path(date_iso, 560) if 560 in generated else image_public_path(date_iso) + thumb_src = image_public_path(date_iso, 360) if 360 in generated else image_public_path(date_iso) + + return { + "src": default_src, + "srcset": srcset, + "sizes": "(max-width: 768px) 100vw, 560px", + "og": image_public_path(date_iso), + "thumb_src": thumb_src, + "thumb_srcset": srcset, + "thumb_sizes": "(max-width: 640px) 50vw, 360px", + } + + +def load_image_descriptor(img_dir: Path, date_iso: str) -> dict[str, str] | None: + if not (img_dir / f"{date_iso}.jpg").exists(): + return None + if not (img_dir / f"{date_iso}-360w.jpg").exists(): + return create_responsive_variants(img_dir, date_iso) + widths = [w for w in VARIANT_WIDTHS if (img_dir / f"{date_iso}-{w}w.jpg").exists()] + with Image.open(img_dir / f"{date_iso}.jpg") as im: + full_width = im.size[0] + srcset = build_srcset(date_iso, widths, full_width) + return { + "src": image_public_path(date_iso, 560) if 560 in widths else image_public_path(date_iso), + "srcset": srcset, + "sizes": "(max-width: 768px) 100vw, 560px", + "og": image_public_path(date_iso), + "thumb_src": image_public_path(date_iso, 360) if 360 in widths else image_public_path(date_iso), + "thumb_srcset": srcset, + "thumb_sizes": "(max-width: 640px) 50vw, 360px", + } + + +def iter_source_images(img_dir: Path) -> list[Path]: + if not img_dir.exists(): + return [] + sources = [] + for path in sorted(img_dir.glob("*.jpg")): + if DATE_IMAGE_RE.match(path.name): + sources.append(path) + return sources diff --git a/generator/patch_head_meta.py b/generator/patch_head_meta.py new file mode 100644 index 0000000..98adaa2 --- /dev/null +++ b/generator/patch_head_meta.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Inject favicon and social meta tags into existing static HTML pages.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +from generate import build_archiv_entries, log, render_template, write_text_atomic + + +def extract_meta(html: str, prop: str) -> str | None: + match = re.search(rf'property="{re.escape(prop)}" content="([^"]*)"', html) + return match.group(1) if match else None + + +def extract_name_meta(html: str, name: str) -> str | None: + match = re.search(rf'name="{re.escape(name)}" content="([^"]*)"', html) + return match.group(1) if match else None + + +def extract_hero_image(html: str) -> str | None: + match = re.search(r' str: + return render_template( + "_head_common.html.j2", + { + "site_url": site_url, + "canonical_path": canonical_path, + "og_title": og_title, + "og_description": og_description, + "og_image": og_image, + }, + ).rstrip() + + +def inject_head_block(html: str, block: str) -> str: + if 'rel="icon"' in html: + return html + marker = ' bool: + html = path.read_text(encoding="utf-8") + og_title = extract_meta(html, "og:title") or extract_name_meta(html, "twitter:title") + og_description = extract_meta(html, "og:description") or extract_name_meta(html, "description") + if not og_title or not og_description: + log(f"warning: skipping {path}, missing title/description") + return False + block = render_head_block( + site_url=site_url, + canonical_path=canonical_path, + og_title=og_title, + og_description=og_description, + og_image=extract_hero_image(html), + ) + updated = inject_head_block(html, block) + if updated != html: + write_text_atomic(path, updated) + log(f"patched {path}") + return True + return False + + +def patch_archiv_index(path: Path, site_url: str, og_image: str) -> bool: + html = path.read_text(encoding="utf-8") + og_title = "MOBEA Archiv – Unsere bisherigen Pivots" + og_description = "Jeden Tag ein neues Startup. Hier ruhen die bisherigen." + if 'property="og:title"' not in html: + html = html.replace( + '', + '\n' + f' \n' + f' ', + 1, + ) + block = render_head_block( + site_url=site_url, + canonical_path="/archiv/", + og_title=og_title, + og_description=og_description, + og_image=og_image, + ) + updated = inject_head_block(html, block) + if updated != html: + write_text_atomic(path, updated) + log(f"patched {path}") + return True + return False + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + site_dir = Path(os.environ.get("SITE_DIR", repo_root / "public")) + data_dir = Path(os.environ.get("DATA_DIR", repo_root / "data")) + site_url = os.environ.get("SITE_URL", "https://mobea.de") + + archive = json.loads((data_dir / "archive.json").read_text(encoding="utf-8")) + latest_image = next( + ( + f"/img/{entry['date']}.jpg" + for entry in sorted(archive, key=lambda e: e["date"], reverse=True) + if entry.get("has_image") + ), + "/og-default.jpg", + ) + + patched = 0 + index_path = site_dir / "index.html" + if index_path.exists(): + patched += patch_startup_page(index_path, "/", site_url) + + archiv_dir = site_dir / "archiv" + for path in sorted(archiv_dir.glob("2026-*.html")): + patched += patch_startup_page(path, f"/archiv/{path.name}", site_url) + + archiv_index = archiv_dir / "index.html" + if archiv_index.exists(): + patched += patch_archiv_index(archiv_index, site_url, latest_image) + + log(f"done, patched {patched} file(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/generator/requirements.txt b/generator/requirements.txt index 7f541b1..06dff53 100644 --- a/generator/requirements.txt +++ b/generator/requirements.txt @@ -1,2 +1,3 @@ requests==2.32.3 jinja2==3.1.6 +pillow==11.2.1 diff --git a/generator/templates/_head_common.html.j2 b/generator/templates/_head_common.html.j2 new file mode 100644 index 0000000..559f88a --- /dev/null +++ b/generator/templates/_head_common.html.j2 @@ -0,0 +1,20 @@ +{% set _site_url = site_url | default('https://mobea.de') %} + + + + + + + + + +{% if og_image %} + + + + +{% else %} + +{% endif %} + + diff --git a/generator/templates/archiv.html.j2 b/generator/templates/archiv.html.j2 index b70ef9a..f6a71fa 100644 --- a/generator/templates/archiv.html.j2 +++ b/generator/templates/archiv.html.j2 @@ -5,6 +5,12 @@ MOBEA Archiv – Unsere bisherigen Pivots + + + {% set og_title = 'MOBEA Archiv – Unsere bisherigen Pivots' %} + {% set og_description = 'Jeden Tag ein neues Startup. Hier ruhen die bisherigen.' %} + {% set canonical_path = '/archiv/' %} + {% include '_head_common.html.j2' %}