Hessen publiziert nur Beschlussprotokolle (Tagesordnung + Status), KEINE Wortprotokolle mit Vote-Block. Daher minimaler Parser: - Drucksache + Status (angenommen/abgelehnt/ueberwiesen) - Vote-Listen bleiben leer (HE hat keine Fraktions-Detail) URL-Pattern (verifiziert WP21 Sitzungen 61-63): http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf Datum-Teil DD-MM-YYYY → URL-Vorhersage unmoeglich, Auto-Ingest braucht Index-Scrape via starweb.hessen.de/starweb/LIS/Pd_Eingang.htm (analog HH). Status-Mapping: - "angenommen" → ergebnis="angenommen" - "Abgelehnt" → ergebnis="abgelehnt" - "Nach (Aussprache|Lesung) an [Ausschuss]" → ergebnis="ueberwiesen" - "Entgegengenommen", "Abgehalten", "Zur Kenntnis genommen" → uebersprungen Tests: PROTOKOLL_PARSERS-Set jetzt {NRW, BUND, BE, HH, TH, HE}. STUB_BL_CODES auf 11 BL reduziert (BB, BW, BY, HB, LSA, MV, NI, RP, SH, SL, SN bleiben). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
4.2 KiB
Python
129 lines
4.2 KiB
Python
"""Hessen (HE) — Beschlussprotokoll-Parser (#106 / #154, ADR 0009).
|
||
|
||
**Limitierung:** Hessen publiziert nur kompakte Beschlussprotokolle
|
||
(Tagesordnung + Status), KEINE Wortprotokolle mit Vote-Block. Daher
|
||
liefert dieser Parser pro Drucksache:
|
||
- Ergebnis-Status (angenommen/abgelehnt/ueberwiesen/...)
|
||
- KEINE Fraktions-Vote-Detail (Vote-Listen bleiben leer)
|
||
|
||
URL-Pattern (verifiziert WP21 Sitzungen 61, 62, 63):
|
||
``http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf``
|
||
|
||
Datum-Teil ``DD-MM-YYYY`` macht direkte URL-Vorhersage unmoeglich. Der
|
||
Auto-Ingest-Cron muss die Index-Seite scrapen:
|
||
``https://starweb.hessen.de/starweb/LIS/Pd_Eingang.htm`` (analog HH).
|
||
|
||
## Format-Beispiel (verifiziert WP21 Sitzung 62)
|
||
|
||
```
|
||
13. Dritte Lesung Gesetzentwurf Landesregierung
|
||
Gesetz ueber die Feststellung des Haushaltsplans...
|
||
– Drucks. 21/4047 zu Drucks. 21/3503 zu Drucks. 21/2971 –
|
||
In dritter Lesung angenommen: Gesetz beschlossen
|
||
```
|
||
|
||
Pattern:
|
||
- ``Drucks. {wp}/N`` (mit Punkt nach Drucks)
|
||
- Naechstes Status-Wort: ``angenommen``, ``Abgelehnt``, ``Beschlussempfehlung angenommen``
|
||
- Vorangehende Lesungs-Phrase: ``In erster/zweiter/dritter Lesung``
|
||
|
||
## Ergebnis-Mapping
|
||
|
||
- ``angenommen`` → ergebnis="angenommen"
|
||
- ``Abgelehnt`` → ergebnis="abgelehnt"
|
||
- ``Nach (Aussprache|erster Lesung|zweiter Lesung) an [Ausschuss]`` → ergebnis="überwiesen"
|
||
- ``Entgegengenommen``, ``Abgehalten`` → kind="info" (kein Vote-Beschluss)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Optional
|
||
|
||
try:
|
||
import fitz
|
||
except ImportError:
|
||
fitz = None
|
||
|
||
|
||
ALLE_FRAKTIONEN_HE = ["CDU", "SPD", "GRÜNE", "AfD", "FDP", "LINKE"]
|
||
|
||
|
||
# Anchor-Pattern: Drucks. {wp}/N + Status (mit beliebigem Text dazwischen)
|
||
ENTRY_RE = re.compile(
|
||
r"Drucks\.\s+(?P<ds>\d{1,2}/\d{2,5})"
|
||
r"(?P<between>[^–]{0,800}?)"
|
||
r"–\s*(?P<status>"
|
||
r"(?:In (?:erster|zweiter|dritter) Lesung\s+)?angenommen"
|
||
r"|(?:In (?:erster|zweiter|dritter) Lesung\s+)?Abgelehnt"
|
||
r"|Beschlussempfehlung angenommen"
|
||
r"|Beschlussempfehlung abgelehnt"
|
||
r"|Nach (?:Aussprache|erster Lesung|zweiter Lesung|dritter Lesung) an [A-Z]+(?:\s*,\s*[A-Z]+)*"
|
||
r"|Entgegengenommen(?:\s+und\s+besprochen)?"
|
||
r"|Abgehalten"
|
||
r"|Zur Kenntnis genommen"
|
||
r")",
|
||
re.DOTALL | re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _classify_status(status: str) -> str:
|
||
"""Map HE-Status auf einheitliche ergebnis-Codes."""
|
||
s = status.lower()
|
||
if "abgelehnt" in s:
|
||
return "abgelehnt"
|
||
if "nach" in s and "an " in s:
|
||
return "überwiesen"
|
||
if "angenommen" in s:
|
||
return "angenommen"
|
||
if "abgehalten" in s or "entgegengenommen" in s or "zur kenntnis genommen" in s:
|
||
return "kenntnis" # eigene Kategorie ohne Vote-Bedeutung
|
||
return "unbekannt"
|
||
|
||
|
||
def _normalize_text(text: str) -> str:
|
||
return re.sub(r"\s+", " ", text)
|
||
|
||
|
||
def parse_protocol(pdf_path: str) -> list[dict]:
|
||
"""Parst HE-Beschlussprotokoll und liefert Status pro Drucksache.
|
||
|
||
Vote-Listen bleiben leer (HE publiziert nur Status, kein Vote-Detail).
|
||
"""
|
||
if fitz is None:
|
||
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den HE-Parser")
|
||
|
||
doc = fitz.open(pdf_path)
|
||
full = "".join(p.get_text() for p in doc)
|
||
doc.close()
|
||
full = _normalize_text(full)
|
||
|
||
results = []
|
||
for m in ENTRY_RE.finditer(full):
|
||
ds = m.group("ds")
|
||
status = m.group("status").strip()
|
||
ergebnis = _classify_status(status)
|
||
if ergebnis in ("kenntnis", "unbekannt"):
|
||
# Kein Vote-Beschluss — uebersprungen, sonst muellt es die DB voll
|
||
continue
|
||
|
||
results.append({
|
||
"drucksache": ds,
|
||
"ergebnis": ergebnis,
|
||
"einstimmig": False, # Beschlussprotokoll hat keine Vote-Detail
|
||
"kind": "direct",
|
||
"votes": {"ja": [], "nein": [], "enthaltung": []},
|
||
"anchor_pos": m.start(),
|
||
"_he_raw_status": status, # debug-info, nicht persistiert
|
||
})
|
||
|
||
# Dedup ueber (drucksache, anchor_pos)
|
||
seen = set()
|
||
deduped = []
|
||
for r in results:
|
||
key = (r["drucksache"], r["anchor_pos"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append(r)
|
||
return deduped
|