gwoe-antragspruefer/app/wahlprogramm_fetch.py

291 lines
9.9 KiB
Python
Raw Permalink Normal View History

"""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()