v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024): - app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs) - app/templates/v2/: base.html + 11 Screens + 8 Component-Macros - AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores) - v2 ist jetzt Default unter / — classic unter /classic - Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129) - Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle - Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie, Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze Backend-Erweiterungen: - main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout}, /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.) - og_card.py + og_template: Open-Graph-Bilder via Playwright (#141) - wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138) - auswertungen.py: BL-Filter + get_wahlperioden Helper (#137) - auth.py: Direct-Access-Grant + Refresh-Token-Cookie Classic-Updates: - Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
"""Open-Graph-Bild-Rendering via Playwright (#141).
|
||
|
||
Rendert /v2/og-template?drucksache=X als PNG 1200×630.
|
||
Cache in data/og-cache/ mit Key SHA256(drucksache + updated_at).
|
||
|
||
Öffentliche API:
|
||
``render_og_card(drucksache, updated_at, base_url)``
|
||
→ PNG-Bytes oder None bei Fehler
|
||
|
||
``cache_key(drucksache, updated_at)``
|
||
→ Hex-String (SHA-256 Kurzform, 16 Zeichen)
|
||
|
||
``get_cached(drucksache, updated_at, cache_dir)``
|
||
→ Path der gecacheten Datei oder None
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_DEFAULT_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "og-cache"
|
||
|
||
|
||
def cache_key(drucksache: str, updated_at: str) -> str:
|
||
"""Berechnet den Cache-Schlüssel als 16-stelligen SHA-256-Präfix.
|
||
|
||
Args:
|
||
drucksache: Drucksachen-ID (z.B. "NRW-18/1234").
|
||
updated_at: ISO-Zeitstempel des letzten Updates aus der Datenbank.
|
||
|
||
Returns:
|
||
16 Hex-Zeichen (64-Bit-Präfix des SHA-256).
|
||
"""
|
||
raw = f"{drucksache}|{updated_at}"
|
||
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
||
|
||
|
||
def _cache_path(drucksache: str, updated_at: str, cache_dir: Path) -> Path:
|
||
key = cache_key(drucksache, updated_at)
|
||
safe_name = drucksache.replace("/", "_").replace(" ", "_")
|
||
return cache_dir / f"{safe_name}_{key}.png"
|
||
|
||
|
||
def get_cached(
|
||
drucksache: str,
|
||
updated_at: str,
|
||
cache_dir: Optional[Path] = None,
|
||
) -> Optional[Path]:
|
||
"""Gibt den Pfad der gecacheten PNG-Datei zurück, wenn sie existiert.
|
||
|
||
Args:
|
||
drucksache: Drucksachen-ID.
|
||
updated_at: ISO-Zeitstempel — ändert sich dieser, ist der Cache ungültig.
|
||
cache_dir: Verzeichnis für den Cache. Standard: data/og-cache/.
|
||
|
||
Returns:
|
||
Path-Objekt wenn Treffer, sonst None.
|
||
"""
|
||
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
|
||
path = _cache_path(drucksache, updated_at, cache_dir)
|
||
return path if path.exists() else None
|
||
|
||
|
||
def render_og_card(
|
||
drucksache: str,
|
||
updated_at: str,
|
||
base_url: str = "http://127.0.0.1:8000",
|
||
cache_dir: Optional[Path] = None,
|
||
) -> Optional[bytes]:
|
||
"""Rendert die OG-Karte als PNG via Playwright und legt sie im Cache ab.
|
||
|
||
Bei Cache-Hit wird das Rendering übersprungen.
|
||
|
||
Args:
|
||
drucksache: Drucksachen-ID (URL-kodierbar).
|
||
updated_at: ISO-Zeitstempel für den Cache-Key.
|
||
base_url: Interne Basis-URL der App (Playwright greift darauf zu).
|
||
cache_dir: Cache-Verzeichnis. Standard: data/og-cache/.
|
||
|
||
Returns:
|
||
PNG-Bytes bei Erfolg, None bei Fehler.
|
||
"""
|
||
cache_dir = cache_dir or _DEFAULT_CACHE_DIR
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
cached = get_cached(drucksache, updated_at, cache_dir)
|
||
if cached:
|
||
logger.debug("OG-Cache-Hit für %s", drucksache)
|
||
return cached.read_bytes()
|
||
|
||
dest = _cache_path(drucksache, updated_at, cache_dir)
|
||
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
import urllib.parse
|
||
|
||
encoded = urllib.parse.quote(drucksache, safe="")
|
||
url = f"{base_url}/v2/og-template?drucksache={encoded}"
|
||
|
||
with sync_playwright() as pw:
|
||
browser = pw.chromium.launch(args=["--no-sandbox"])
|
||
page = browser.new_page(viewport={"width": 1200, "height": 630})
|
||
page.goto(url, wait_until="networkidle", timeout=15000)
|
||
png_bytes = page.screenshot(
|
||
clip={"x": 0, "y": 0, "width": 1200, "height": 630},
|
||
type="png",
|
||
)
|
||
browser.close()
|
||
|
||
dest.write_bytes(png_bytes)
|
||
logger.info("OG-Karte gerendert: %s → %s", drucksache, dest.name)
|
||
return png_bytes
|
||
|
||
except Exception:
|
||
logger.exception("Playwright-Render fehlgeschlagen für %s", drucksache)
|
||
return None
|