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>
291 lines
9.9 KiB
Python
291 lines
9.9 KiB
Python
"""Halbautomatische Wahlprogramm-Beschaffung (#138).
|
|
|
|
Workflow:
|
|
1. ``check_missing_programmes(bl, fraktionen)`` liefert Lücken
|
|
2. ``suggest_candidates(bl, partei)`` schlägt URL-Kandidaten vor
|
|
(aus wahlprogramm-links.yaml im selben Verzeichnis)
|
|
3. ``fetch_and_verify(url, dest_path, expected_sha)`` lädt, prüft SHA-256,
|
|
speichert in app/static/referenzen/ — oder bricht bei SHA-Abweichung ab
|
|
4. Re-Indexing via ``reindex_embeddings`` muss danach manuell ausgelöst werden
|
|
|
|
CLI:
|
|
python -m app.wahlprogramm_fetch --check [--bl BL]
|
|
python -m app.wahlprogramm_fetch --fetch BL PARTEI [--yes]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LINKS_FILE = Path(__file__).parent / "wahlprogramm-links.yaml"
|
|
_REFERENZEN_DIR = Path(__file__).parent / "static" / "referenzen"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# YAML-Quelle laden
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_links() -> dict:
|
|
"""Lädt wahlprogramm-links.yaml. Gibt leeres Dict zurück, wenn Datei fehlt."""
|
|
if not _LINKS_FILE.exists():
|
|
logger.warning("wahlprogramm-links.yaml nicht gefunden: %s", _LINKS_FILE)
|
|
return {}
|
|
with _LINKS_FILE.open(encoding="utf-8") as fh:
|
|
return yaml.safe_load(fh) or {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Öffentliche API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def suggest_candidates(bundesland: str, partei: str) -> list[dict]:
|
|
"""Gibt URL-Kandidaten aus wahlprogramm-links.yaml für BL+Partei zurück.
|
|
|
|
Args:
|
|
bundesland: Bundesland-Code (z.B. "NRW").
|
|
partei: Partei-Kürzel (z.B. "BSW").
|
|
|
|
Returns:
|
|
Liste von Dicts mit mindestens ``url`` und ``titel``. Leer, wenn
|
|
keine Einträge vorhanden.
|
|
"""
|
|
data = _load_links()
|
|
bl_block = data.get(bundesland, {})
|
|
partei_block = bl_block.get(partei, [])
|
|
if isinstance(partei_block, dict):
|
|
partei_block = [partei_block]
|
|
return list(partei_block)
|
|
|
|
|
|
def sha256_of_file(path: Path) -> str:
|
|
"""Berechnet den SHA-256-Hash einer Datei als Hex-String."""
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def fetch_and_verify(
|
|
url: str,
|
|
dest_path: Path,
|
|
expected_sha: Optional[str] = None,
|
|
) -> dict:
|
|
"""Lädt eine Datei herunter und prüft optional den SHA-256-Hash.
|
|
|
|
SHA-Gate-Logik:
|
|
- Existiert ``dest_path`` bereits, wird der bisherige Hash gespeichert.
|
|
- Nach dem Download wird der neue Hash verglichen.
|
|
- Bei Abweichung wird die temporäre Datei gelöscht und ein Fehler zurückgegeben
|
|
(niemals stillschweigend überschreiben).
|
|
|
|
Args:
|
|
url: Download-URL der PDF-Datei.
|
|
dest_path: Ziel-Pfad (typischerweise in app/static/referenzen/).
|
|
expected_sha: Wenn angegeben, muss der Download-Hash übereinstimmen.
|
|
|
|
Returns:
|
|
Dict mit den Schlüsseln:
|
|
- ``ok`` (bool): True bei Erfolg.
|
|
- ``sha256`` (str): SHA-256 der heruntergeladenen Datei.
|
|
- ``prev_sha256`` (str|None): SHA-256 der bisherigen Datei, falls vorhanden.
|
|
- ``error`` (str|None): Fehlermeldung bei Misserfolg.
|
|
- ``changed`` (bool): True, wenn sich die Datei gegenüber der bisherigen Version geändert hat.
|
|
"""
|
|
prev_sha: Optional[str] = None
|
|
if dest_path.exists():
|
|
prev_sha = sha256_of_file(dest_path)
|
|
|
|
tmp_path = dest_path.with_suffix(".tmp")
|
|
try:
|
|
logger.info("Lade %s → %s", url, tmp_path)
|
|
_referenzen_dir = dest_path.parent
|
|
_referenzen_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "GWOeAntragspruefer/1.0 (+https://gwoe.toppyr.de)"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
tmp_path.write_bytes(resp.read())
|
|
|
|
new_sha = sha256_of_file(tmp_path)
|
|
|
|
# SHA-Gate gegen expected_sha
|
|
if expected_sha and new_sha != expected_sha:
|
|
tmp_path.unlink(missing_ok=True)
|
|
return {
|
|
"ok": False,
|
|
"sha256": new_sha,
|
|
"prev_sha256": prev_sha,
|
|
"changed": False,
|
|
"error": (
|
|
f"SHA-Prüfung fehlgeschlagen: erwartet {expected_sha[:12]}…, "
|
|
f"erhalten {new_sha[:12]}…"
|
|
),
|
|
}
|
|
|
|
# SHA-Gate gegen bisherige Datei
|
|
if prev_sha and new_sha == prev_sha:
|
|
tmp_path.unlink(missing_ok=True)
|
|
logger.info("Datei unverändert (SHA %s…), kein Überschreiben.", new_sha[:12])
|
|
return {
|
|
"ok": True,
|
|
"sha256": new_sha,
|
|
"prev_sha256": prev_sha,
|
|
"changed": False,
|
|
"error": None,
|
|
}
|
|
|
|
tmp_path.rename(dest_path)
|
|
logger.info("Gespeichert: %s (SHA %s…)", dest_path.name, new_sha[:12])
|
|
return {
|
|
"ok": True,
|
|
"sha256": new_sha,
|
|
"prev_sha256": prev_sha,
|
|
"changed": True,
|
|
"error": None,
|
|
}
|
|
|
|
except Exception as exc:
|
|
tmp_path.unlink(missing_ok=True)
|
|
logger.exception("Fehler beim Download von %s", url)
|
|
return {
|
|
"ok": False,
|
|
"sha256": "",
|
|
"prev_sha256": prev_sha,
|
|
"changed": False,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def get_missing_programmes(bundesland: Optional[str] = None) -> list[dict]:
|
|
"""Liefert alle BL/Partei-Kombinationen mit Kandidaten-URL, aber fehlender Datei.
|
|
|
|
Args:
|
|
bundesland: Wenn angegeben, nur dieses Bundesland prüfen.
|
|
|
|
Returns:
|
|
Liste von Dicts mit ``bl``, ``partei``, ``dateiname``, ``kandidaten``.
|
|
"""
|
|
from .wahlprogramme import WAHLPROGRAMME
|
|
|
|
missing: list[dict] = []
|
|
data = _load_links()
|
|
|
|
bl_keys = [bundesland] if bundesland else list(data.keys())
|
|
for bl in bl_keys:
|
|
bl_block = data.get(bl, {})
|
|
for partei, kandidaten in bl_block.items():
|
|
if isinstance(kandidaten, dict):
|
|
kandidaten = [kandidaten]
|
|
|
|
wp_info = WAHLPROGRAMME.get(bl, {}).get(partei)
|
|
if wp_info:
|
|
dateiname = wp_info["file"]
|
|
dest = _REFERENZEN_DIR / dateiname
|
|
if dest.exists():
|
|
continue # Datei liegt bereits vor
|
|
else:
|
|
dateiname = None # noch nicht in WAHLPROGRAMME registriert
|
|
|
|
missing.append({
|
|
"bl": bl,
|
|
"partei": partei,
|
|
"dateiname": dateiname,
|
|
"kandidaten": kandidaten,
|
|
})
|
|
|
|
return missing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _cli() -> None:
|
|
import argparse
|
|
import sys
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Halbautomatische Wahlprogramm-Beschaffung (#138)",
|
|
)
|
|
parser.add_argument("--check", action="store_true", help="Lücken auflisten")
|
|
parser.add_argument("--bl", help="Bundesland-Filter für --check")
|
|
parser.add_argument("--fetch", nargs=2, metavar=("BL", "PARTEI"),
|
|
help="Wahlprogramm für BL/PARTEI herunterladen")
|
|
parser.add_argument("--url", help="URL überschreiben (statt erster Kandidat aus YAML)")
|
|
parser.add_argument("--yes", action="store_true",
|
|
help="Nicht interaktiv bestätigen (gefährlich)")
|
|
args = parser.parse_args()
|
|
|
|
if args.check:
|
|
missing = get_missing_programmes(args.bl)
|
|
if not missing:
|
|
print("Keine Lücken gefunden.")
|
|
for entry in missing:
|
|
cands = entry["kandidaten"]
|
|
cand_str = cands[0]["url"] if cands else "(keine URL hinterlegt)"
|
|
print(
|
|
f" {entry['bl']:6} {entry['partei']:15} "
|
|
f"{'(noch nicht registriert)' if not entry['dateiname'] else entry['dateiname']:35} "
|
|
f"→ {cand_str}"
|
|
)
|
|
sys.exit(0)
|
|
|
|
if args.fetch:
|
|
bl, partei = args.fetch
|
|
candidates = suggest_candidates(bl, partei)
|
|
if args.url:
|
|
url = args.url
|
|
elif candidates:
|
|
url = candidates[0]["url"]
|
|
print(f"Kandidat: {url}")
|
|
else:
|
|
print(f"Keine URL-Kandidaten für {bl}/{partei} in wahlprogramm-links.yaml.")
|
|
sys.exit(1)
|
|
|
|
from .wahlprogramme import WAHLPROGRAMME
|
|
wp_info = WAHLPROGRAMME.get(bl, {}).get(partei)
|
|
if not wp_info:
|
|
print(
|
|
f"WARNUNG: {bl}/{partei} ist noch nicht in wahlprogramme.py eingetragen.\n"
|
|
"Die Datei wird heruntergeladen, muss aber manuell registriert werden."
|
|
)
|
|
dateiname = f"{partei.lower()}-{bl.lower()}-neu.pdf"
|
|
else:
|
|
dateiname = wp_info["file"]
|
|
|
|
dest = _REFERENZEN_DIR / dateiname
|
|
|
|
if not args.yes:
|
|
confirm = input(f"Download {url} → {dest}? [j/N] ").strip().lower()
|
|
if confirm not in ("j", "ja", "y", "yes"):
|
|
print("Abgebrochen.")
|
|
sys.exit(0)
|
|
|
|
result = fetch_and_verify(url, dest)
|
|
if result["ok"]:
|
|
change_note = "geändert" if result["changed"] else "unverändert"
|
|
print(f"OK ({change_note}) — SHA-256: {result['sha256'][:16]}…")
|
|
if result["changed"]:
|
|
print("Hinweis: Embeddings müssen neu indexiert werden (python -m app.reindex_embeddings).")
|
|
else:
|
|
print(f"FEHLER: {result['error']}")
|
|
sys.exit(1)
|
|
sys.exit(0)
|
|
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_cli()
|