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'
'
+ )
+
+ 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 @@