gwoe-antragspruefer/app/ingest_votes.py
Dotty Dotter 22a2b63c35 feat(#148): BUND-Parser produktiv — Bundestags-XML-Plenarprotokolle
Erster vollwertiger Plenarprotokoll-Parser nach NRW. Quelle:
https://dserver.bundestag.de/btp/{wp}/{wp}{n:03}.xml

Anchor-Sprache (verifiziert WP20 Sitzungen 30 + 100):
  'Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen
   gegen die Stimmen der CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion
   angenommen.'

Pattern:
- Subjekt: Beschlussempfehlung | Ueberweisungsvorschlag | Antrag | Gesetzentwurf
- Vote-Block: 'mit den Stimmen X / gegen die Stimmen Y / bei Enthaltung Z'
- Ergebnis: 'angenommen' oder 'abgelehnt'
- Drucksache rueckwaerts vom Anchor (1500 chars Window)
- Kind 'ueberweisung' invertiert ergebnis zu 'ueberwiesen'

Fraktions-Mapping (WP20 = Ampel):
- 'Koalitionsfraktionen' → SPD + GRÜNE + FDP
- 'Oppositionsfraktionen' → CDU/CSU + AfD + LINKE
- 'CDU/CSU-Fraktion', 'Fraktion Bündnis 90/Die Grünen', etc.

WP21 (ab 2025) braucht eigenes Mapping-Update.

26 Tests in test_protokoll_parsers_bund.py (Vote-Block-Parsing, Anchor-
Erkennung, Drucksachen-Lookup, End-to-End mit Mock-XML).

Cron + Ingest-CLI:
- PROTO_TARGETS-Format erweitert um PROTOKOLL_ID_PREFIX und {n3}-
  Placeholder fuer 3-stellig zero-gepaddetes BT-Schema (BTP20-N)
- ingest_votes URL-Suffix dynamisch (PDF vs XML) statt hardcoded .pdf
- Eintrag in PROTOKOLL_PARSERS (NRW + BUND)
- Stub-Test angepasst: BUND raus aus STUB_BL_CODES

889 Tests gruen (787 → 889, +102 fuer Phase-2 Stubs+Tests+BUND).
2026-04-28 23:21:39 +02:00

176 lines
6.0 KiB
Python

"""BL-uebergreifende Ingest-CLI fuer Plenarprotokolle (#106 / #126).
Pipeline:
1. PDF laden (Pfad oder URL)
2. ``protokoll_parsers.parse_protocol(bundesland, pdf_path)`` waehlt den
BL-spezifischen Parser aus der Registry
3. ``upsert_plenum_vote()`` schreibt jede Abstimmung in die DB
CLI:
python -m app.ingest_votes --pdf MMP18-119.pdf
python -m app.ingest_votes --url https://landtag.nrw.de/.../MMP18-119.pdf
python -m app.ingest_votes --pdf x.pdf --bundesland NRW --protokoll-id MMP18-119
python -m app.ingest_votes --supported # Liste der BL mit Parser
Aktuell registriert: NRW. Folge-BL via app/protokoll_parsers/<bl>.py + Eintrag
in PROTOKOLL_PARSERS — siehe ADR 0009.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
import tempfile
import urllib.request
from pathlib import Path
from typing import Optional
from .protokoll_parsers import parse_protocol, supported_bundeslaender
from .database import upsert_plenum_vote
logger = logging.getLogger(__name__)
def _derive_protokoll_id(pdf_path: Path) -> str:
"""Ermittle Protokoll-ID aus dem Datei-Stem (z.B. 'MMP18-119.pdf''MMP18-119')."""
return pdf_path.stem
def _download_pdf(url: str, dest: Path) -> Path:
"""Lade ein PDF von einer URL in einen Pfad. Wirft bei HTTP-Fehlern."""
req = urllib.request.Request(
url,
headers={"User-Agent": "GWOeAntragspruefer/1.0 (+https://gwoe.toppyr.de)"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
dest.write_bytes(resp.read())
return dest
async def ingest_pdf(
pdf_path: Path,
*,
bundesland: str = "NRW",
protokoll_id: Optional[str] = None,
quelle_url: Optional[str] = None,
) -> dict:
"""Parse das PDF mit dem BL-Parser und schreibe alle Abstimmungen in die DB.
Returns:
Statistik-Dict ``{parsed, written, skipped_no_drucksache, errors,
protokoll_id, bundesland}``.
Raises:
NotImplementedError: wenn fuer ``bundesland`` kein Parser registriert ist.
"""
pid = protokoll_id or _derive_protokoll_id(pdf_path)
parsed = parse_protocol(bundesland, str(pdf_path))
written = 0
skipped_no_ds = 0
errors: list[str] = []
for entry in parsed:
ds = entry.get("drucksache")
if not ds:
skipped_no_ds += 1
continue
try:
await upsert_plenum_vote(
bundesland=bundesland,
drucksache=ds,
ergebnis=entry["ergebnis"],
einstimmig=bool(entry.get("einstimmig", False)),
fraktionen_ja=entry.get("votes", {}).get("ja", []),
fraktionen_nein=entry.get("votes", {}).get("nein", []),
fraktionen_enthaltung=entry.get("votes", {}).get("enthaltung", []),
quelle_protokoll=pid,
quelle_url=quelle_url,
)
written += 1
except Exception as exc:
logger.exception("Upsert fehlgeschlagen fuer %s", ds)
errors.append(f"{ds}: {exc}")
return {
"parsed": len(parsed),
"written": written,
"skipped_no_drucksache": skipped_no_ds,
"errors": errors,
"protokoll_id": pid,
"bundesland": bundesland,
}
def _cli() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(
description="Plenarprotokoll → plenum_vote_results (#106 / #126)",
)
src = parser.add_mutually_exclusive_group(required=False)
src.add_argument("--pdf", help="Pfad zu lokalem PDF")
src.add_argument("--url", help="HTTP(S)-URL zum PDF")
parser.add_argument("--bundesland", default="NRW",
help="Bundesland-Code (default: NRW)")
parser.add_argument("--protokoll-id",
help="Protokoll-ID (default: aus Datei-Stem)")
parser.add_argument("--supported", action="store_true",
help="Liste alle BL-Codes mit registriertem Parser")
args = parser.parse_args()
if args.supported:
for bl in supported_bundeslaender():
print(bl)
sys.exit(0)
if not args.pdf and not args.url:
parser.error("--pdf oder --url ist erforderlich")
if args.url:
# Tmpfile-Suffix aus der URL ableiten (PDF, XML, ...) — der BUND-Parser
# nutzt XML, der NRW-Parser nutzt PDF. Suffix beeinflusst nur den
# Dateinamen; Parser lesen Inhalt nach Format.
url_suffix = "." + args.url.rsplit(".", 1)[-1].split("?")[0]
if url_suffix not in (".pdf", ".xml", ".html"):
url_suffix = ".pdf"
with tempfile.NamedTemporaryFile(suffix=url_suffix, delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
print(f"Lade {args.url}{tmp_path}")
_download_pdf(args.url, tmp_path)
pid = args.protokoll_id or args.url.rsplit("/", 1)[-1].rsplit(".", 1)[0]
stats = asyncio.run(ingest_pdf(
tmp_path, bundesland=args.bundesland,
protokoll_id=pid, quelle_url=args.url,
))
finally:
tmp_path.unlink(missing_ok=True)
else:
pdf_path = Path(args.pdf)
if not pdf_path.exists():
print(f"FEHLER: PDF nicht gefunden: {pdf_path}", file=sys.stderr)
sys.exit(1)
stats = asyncio.run(ingest_pdf(
pdf_path, bundesland=args.bundesland,
protokoll_id=args.protokoll_id,
))
print()
print(f"Protokoll {stats['protokoll_id']} ({stats['bundesland']})")
print(f" parsed: {stats['parsed']}")
print(f" written: {stats['written']}")
if stats["skipped_no_drucksache"]:
print(f" ohne DS: {stats['skipped_no_drucksache']}")
if stats["errors"]:
print(f" errors: {len(stats['errors'])}")
for e in stats["errors"][:5]:
print(f" {e}")
if stats["written"] == 0 and not stats["errors"]:
sys.exit(2)
if __name__ == "__main__":
_cli()