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
|
||||
|
||||
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'}")
|
||||
|
||||
@@ -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
|
||||
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">
|
||||
<title>MOBEA Archiv – Unsere bisherigen Pivots</title>
|
||||
<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">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -314,7 +320,7 @@
|
||||
<a href="{{ entry.url }}" class="entry-link">
|
||||
<div class="entry-thumb">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="entry-body">
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<meta name="description" content="{{ s.subtitle }}">
|
||||
<meta property="og:title" content="MOBEA – {{ s.tagline }}">
|
||||
<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 %}
|
||||
{% if t == 'corporate' %}
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
@@ -859,7 +863,7 @@
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
{% 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 %}
|
||||
<div class="hero-gradient" aria-hidden="true">✦</div>
|
||||
{% 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="robots" content="noindex">
|
||||
<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>
|
||||
*, *::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