feat: Archiv-Backfill, responsive Bilder und Site-Meta
Ergänzt Backfill- und Hilfsskripte, erzeugt nach der Pixazo-Generierung komprimierte srcset-Varianten, validiert leere Bilder und fügt Favicon, Web Manifest sowie Open-Graph-/Twitter-Tags in die Templates ein. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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())
|
||||||
@@ -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'<img src="/img/(\d{4}-\d{2}-\d{2})\.jpg" alt="([^"]*)" width="560" height="420">'
|
||||||
|
)
|
||||||
|
THUMB_IMG_RE = re.compile(
|
||||||
|
r'<img src="/img/(\d{4}-\d{2}-\d{2})\.jpg" alt="([^"]*)" width="360" height="203" loading="lazy">'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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'<img src="{image["src"]}" srcset="{image["srcset"]}" sizes="{image["sizes"]}" '
|
||||||
|
f'alt="{alt}" width="560" height="420">'
|
||||||
|
)
|
||||||
|
|
||||||
|
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'<img src="{image["thumb_src"]}" srcset="{image["thumb_srcset"]}" '
|
||||||
|
f'sizes="{image["thumb_sizes"]}" alt="{alt}" width="360" height="203" loading="lazy">'
|
||||||
|
)
|
||||||
|
|
||||||
|
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())
|
||||||
+38
-8
@@ -15,6 +15,7 @@ import requests
|
|||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
import topics
|
import topics
|
||||||
|
from images import create_responsive_variants, load_image_descriptor
|
||||||
|
|
||||||
GERMAN_MONTHS = [
|
GERMAN_MONTHS = [
|
||||||
"Januar",
|
"Januar",
|
||||||
@@ -205,7 +206,7 @@ def generate_hero_image(
|
|||||||
date_iso: str,
|
date_iso: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
negative_prompt: str,
|
negative_prompt: str,
|
||||||
) -> str | None:
|
) -> dict[str, str] | None:
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
"https://gateway.pixazo.ai/getImage/v1/getSDXLImage",
|
"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 = requests.get(image_url, timeout=120)
|
||||||
img_response.raise_for_status()
|
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 = site_dir / "img"
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
img_path = img_dir / f"{date_iso}.jpg"
|
img_path = img_dir / f"{date_iso}.jpg"
|
||||||
img_path.write_bytes(img_response.content)
|
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:
|
except (requests.RequestException, OSError, KeyError, TypeError, ValueError) as exc:
|
||||||
log(f"warning: hero image generation failed: {exc}")
|
log(f"warning: hero image generation failed: {exc}")
|
||||||
return None
|
return None
|
||||||
@@ -252,7 +259,8 @@ def render_template(name: str, context: dict) -> str:
|
|||||||
return env.get_template(name).render(**context)
|
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 = []
|
entries = []
|
||||||
for item in sorted(archive, key=lambda e: e["date"], reverse=True):
|
for item in sorted(archive, key=lambda e: e["date"], reverse=True):
|
||||||
prompts = item.get("prompts")
|
prompts = item.get("prompts")
|
||||||
@@ -263,7 +271,9 @@ def build_archiv_entries(archive: list[dict]) -> list[dict]:
|
|||||||
"backronym": item["backronym"],
|
"backronym": item["backronym"],
|
||||||
"tagline": item["tagline"],
|
"tagline": item["tagline"],
|
||||||
"url": f"/archiv/{item['date']}.html",
|
"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", []),
|
"topics": item.get("topics", []),
|
||||||
"prompts": prompts if isinstance(prompts, dict) else None,
|
"prompts": prompts if isinstance(prompts, dict) else None,
|
||||||
}
|
}
|
||||||
@@ -323,18 +333,26 @@ def main() -> int:
|
|||||||
log("PIXAZO_API_KEY not set, skipping hero image")
|
log("PIXAZO_API_KEY not set, skipping hero image")
|
||||||
|
|
||||||
human_date = date_human(today)
|
human_date = date_human(today)
|
||||||
startup_context = {
|
startup_base = {
|
||||||
"date": date_iso,
|
"date": date_iso,
|
||||||
"date_human": human_date,
|
"date_human": human_date,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
"hero_image": hero_image,
|
"hero_image": hero_image,
|
||||||
"s": llm_json,
|
"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"
|
archiv_day_path = site_dir / "archiv" / f"{date_iso}.html"
|
||||||
write_text_atomic(index_path, startup_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}")
|
log(f"wrote {index_path} and {archiv_day_path}")
|
||||||
|
|
||||||
archive_entry = {
|
archive_entry = {
|
||||||
@@ -357,9 +375,21 @@ def main() -> int:
|
|||||||
save_json_atomic(data_dir / "archive.json", archive)
|
save_json_atomic(data_dir / "archive.json", archive)
|
||||||
log(f"updated {data_dir / 'archive.json'}")
|
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 = render_template(
|
||||||
"archiv.html.j2",
|
"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)
|
write_text_atomic(site_dir / "archiv" / "index.html", archiv_html)
|
||||||
log(f"wrote {site_dir / 'archiv' / 'index.html'}")
|
log(f"wrote {site_dir / 'archiv' / 'index.html'}")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'<img src="/img/(\d{4}-\d{2}-\d{2})(?:-\d+w)?\.jpg"', html)
|
||||||
|
return f"/img/{match.group(1)}.jpg" if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_head_block(
|
||||||
|
*,
|
||||||
|
site_url: str,
|
||||||
|
canonical_path: str,
|
||||||
|
og_title: str,
|
||||||
|
og_description: str,
|
||||||
|
og_image: str | None,
|
||||||
|
) -> 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 = '<meta property="og:description"'
|
||||||
|
idx = html.find(marker)
|
||||||
|
if idx == -1:
|
||||||
|
marker = '<meta name="description"'
|
||||||
|
idx = html.find(marker)
|
||||||
|
if idx == -1:
|
||||||
|
raise ValueError("could not find description meta tag")
|
||||||
|
line_end = html.find("\n", idx)
|
||||||
|
if line_end == -1:
|
||||||
|
raise ValueError("unexpected HTML structure")
|
||||||
|
return html[: line_end + 1] + block + "\n" + html[line_end + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_startup_page(path: Path, canonical_path: str, site_url: str) -> 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(
|
||||||
|
'<meta name="description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">',
|
||||||
|
'<meta name="description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">\n'
|
||||||
|
f' <meta property="og:title" content="{og_title}">\n'
|
||||||
|
f' <meta property="og:description" content="{og_description}">',
|
||||||
|
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())
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
|
pillow==11.2.1
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% set _site_url = site_url | default('https://mobea.de') %}
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
|
<link rel="canonical" href="{{ _site_url }}{{ canonical_path }}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="MOBEA">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
<meta property="og:url" content="{{ _site_url }}{{ canonical_path }}">
|
||||||
|
{% if og_image %}
|
||||||
|
<meta property="og:image" content="{{ _site_url }}{{ og_image }}">
|
||||||
|
<meta property="og:image:alt" content="{{ og_title }}">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="{{ _site_url }}{{ og_image }}">
|
||||||
|
{% else %}
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
{% endif %}
|
||||||
|
<meta name="twitter:title" content="{{ og_title }}">
|
||||||
|
<meta name="twitter:description" content="{{ og_description }}">
|
||||||
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>MOBEA Archiv – Unsere bisherigen Pivots</title>
|
<title>MOBEA Archiv – Unsere bisherigen Pivots</title>
|
||||||
<meta name="description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">
|
<meta name="description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">
|
||||||
|
<meta property="og:title" content="MOBEA Archiv – Unsere bisherigen Pivots">
|
||||||
|
<meta property="og:description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">
|
||||||
|
{% 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' %}
|
||||||
<meta name="theme-color" content="#111118">
|
<meta name="theme-color" content="#111118">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -314,7 +320,7 @@
|
|||||||
<a href="{{ entry.url }}" class="entry-link">
|
<a href="{{ entry.url }}" class="entry-link">
|
||||||
<div class="entry-thumb">
|
<div class="entry-thumb">
|
||||||
{% if entry.image %}
|
{% if entry.image %}
|
||||||
<img src="{{ entry.image }}" alt="{{ entry.backronym }}" width="360" height="203" loading="lazy">
|
<img src="{{ entry.image.thumb_src }}" srcset="{{ entry.image.thumb_srcset }}" sizes="{{ entry.image.thumb_sizes }}" alt="{{ entry.backronym }}" width="360" height="203" loading="lazy">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="entry-body">
|
<div class="entry-body">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<meta name="description" content="{{ s.subtitle }}">
|
<meta name="description" content="{{ s.subtitle }}">
|
||||||
<meta property="og:title" content="MOBEA – {{ s.tagline }}">
|
<meta property="og:title" content="MOBEA – {{ s.tagline }}">
|
||||||
<meta property="og:description" content="{{ s.subtitle }}">
|
<meta property="og:description" content="{{ s.subtitle }}">
|
||||||
|
{% set og_title = 'MOBEA – ' ~ s.tagline %}
|
||||||
|
{% set og_description = s.subtitle %}
|
||||||
|
{% set og_image = hero_image.og if hero_image else none %}
|
||||||
|
{% include '_head_common.html.j2' %}
|
||||||
{% set t = theme %}
|
{% set t = theme %}
|
||||||
{% if t == 'corporate' %}
|
{% if t == 'corporate' %}
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
@@ -859,7 +863,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-visual">
|
<div class="hero-visual">
|
||||||
{% if hero_image %}
|
{% if hero_image %}
|
||||||
<img src="{{ hero_image }}" alt="MOBEA – {{ s.tagline }}" width="560" height="420">
|
<img src="{{ hero_image.src }}" srcset="{{ hero_image.srcset }}" sizes="{{ hero_image.sizes }}" alt="MOBEA – {{ s.tagline }}" width="560" height="420">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="hero-gradient" aria-hidden="true">✦</div>
|
<div class="hero-gradient" aria-hidden="true">✦</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -5,6 +5,24 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<title>Echt jetzt? – MOBEA</title>
|
<title>Echt jetzt? – MOBEA</title>
|
||||||
|
<meta name="description" content="MOBEA ist Satire. Jeden Tag ein neues fiktives Startup.">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
|
<link rel="canonical" href="https://mobea.de/echt-jetzt/">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="MOBEA">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
<meta property="og:url" content="https://mobea.de/echt-jetzt/">
|
||||||
|
<meta property="og:title" content="Echt jetzt? – MOBEA">
|
||||||
|
<meta property="og:description" content="MOBEA ist Satire. Jeden Tag ein neues fiktives Startup.">
|
||||||
|
<meta property="og:image" content="https://mobea.de/og-default.jpg">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Echt jetzt? – MOBEA">
|
||||||
|
<meta name="twitter:description" content="MOBEA ist Satire. Jeden Tag ein neues fiktives Startup.">
|
||||||
|
<meta name="twitter:image" content="https://mobea.de/og-default.jpg">
|
||||||
|
<meta name="theme-color" content="#12141a">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="MOBEA">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#2563eb"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" fill="#ffffff" font-family="system-ui, sans-serif" font-size="15" font-weight="800">M</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "MOBEA",
|
||||||
|
"short_name": "MOBEA",
|
||||||
|
"description": "Das Startup, das jeden Tag ein anderes Startup ist.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111118",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user