Bremen publiziert wie Hessen nur Beschlussprotokolle (TOPs + Status-Saetze), KEINE Wortprotokolle mit Vote-Block. Daher minimaler Parser: - Drucksache + Status (angenommen/abgelehnt/ueberwiesen) - Vote-Listen bleiben leer (HB hat keine Fraktions-Detail) Anchor-Regex: "Die Buergerschaft (Landtag|Stadtbuergerschaft) <verb> <rest> <terminator>" Verb-Mapping: - "lehnt ... ab" → abgelehnt - "stimmt ... zu" → angenommen - "beschliesst ..." → angenommen - "verabschiedet ..." → angenommen - "verweist|ueberweist|leitet" → ueberwiesen - "nimmt ... Kenntnis" → uebersprungen (kein Vote) Drucksachen-Aufloesung: erst Inline-Form "(21/N)", dann Block-Form "Drucksache 21/N" rueckwaerts vom Anchor. URL-Pattern (verifiziert WP21 Sitzung 33 Land): https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf Cron unterstuetzt jetzt {n4}-Platzhalter (4-stellig). HB Land WP21 ingestiert via direktes URL-Probing (b21l0001.pdf … b21l9999.pdf). Stadtbuergerschaft (b21s*) als Folge-Issue. Tests: 21 HB-Tests, Verifikation S33 → 20 Beschluesse extrahiert. Stand: 8 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH, HB). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
"""Bremen (HB) — Beschlussprotokoll-Parser (#106 / #153, ADR 0009).
|
|
|
|
**Limitierung:** Bremen publiziert wie Hessen nur kompakte
|
|
Beschlussprotokolle (TOPs + Status-Saetze), KEINE Wortprotokolle mit
|
|
Vote-Block. Daher liefert dieser Parser pro Drucksache:
|
|
- Ergebnis-Status (angenommen/abgelehnt/info)
|
|
- KEINE Fraktions-Vote-Detail (Vote-Listen bleiben leer)
|
|
|
|
URL-Pattern (verifiziert WP21 Sitzungen 33+):
|
|
``https://www.bremische-buergerschaft.de/dokumente/wp{wp:02}/{land|stadt}/protokoll/b{wp:02}{l|s}{n:04}.pdf``
|
|
|
|
WP21 Land-Sitzungen: ``b21l0001.pdf``, ``b21l0002.pdf``, ...
|
|
WP21 Stadt-Sitzungen: ``b21s0001.pdf``, ``b21s0002.pdf``, ...
|
|
|
|
Auto-Ingest-Cron: pure URL-Probing per Sitzungs-Index funktioniert.
|
|
|
|
## Status-Saetze (verifiziert WP21 Sitzung 33)
|
|
|
|
```
|
|
Die Bürgerschaft (Landtag) lehnt den Antrag ab.
|
|
Die Bürgerschaft (Landtag) lehnt den Änderungsantrag (21/1688) ab.
|
|
Die Bürgerschaft (Landtag) stimmt dem Änderungsantrag (21/1764) zu.
|
|
Die Bürgerschaft (Landtag) beschließt das Gesetz in erster Lesung.
|
|
Die Bürgerschaft (Landtag) nimmt von der Mitteilung Kenntnis. ← skip (kein Vote)
|
|
```
|
|
|
|
## Status-Mapping
|
|
|
|
- ``lehnt ... ab`` → ergebnis="abgelehnt"
|
|
- ``stimmt ... zu`` → ergebnis="angenommen"
|
|
- ``beschließt ...`` → ergebnis="angenommen"
|
|
- ``verabschiedet`` → ergebnis="angenommen"
|
|
- ``nimmt ... Kenntnis`` → ergebnis="kenntnis" (skip)
|
|
|
|
## Drucksachen-Lookup
|
|
|
|
- Inline-Form: ``(21/1234)`` direkt im Status-Satz
|
|
- Block-Form: ``(Drucksache 21/N)`` rueckwaerts vom Anchor (TOP-Block)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Optional
|
|
|
|
try:
|
|
import fitz
|
|
except ImportError:
|
|
fitz = None
|
|
|
|
|
|
ALLE_FRAKTIONEN_HB = ["SPD", "GRÜNE", "CDU", "DIE LINKE", "FDP", "BIW"]
|
|
|
|
|
|
# Anchor: "Die Bürgerschaft (Landtag|Stadtbürgerschaft) <verb> ... <terminator>"
|
|
ANCHOR_RE = re.compile(
|
|
r"Die\s+Bürgerschaft\s+\((?:Landtag|Stadtbürgerschaft)\)\s+"
|
|
r"(?P<verb>lehnt|stimmt|beschließt|nimmt|verabschiedet|verweist|"
|
|
r"überweist|leitet)\s+"
|
|
r"(?P<rest>[^.]{0,500}?)"
|
|
r"(?P<terminator>\sab\.|\szu\.|\sKenntnis\.|\.)",
|
|
re.DOTALL,
|
|
)
|
|
|
|
DS_INLINE_RE = re.compile(r"\((\d{1,2}/\d{2,5})\)")
|
|
DS_BLOCK_RE = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5})")
|
|
|
|
|
|
def _classify_status(verb: str, rest: str, terminator: str) -> str:
|
|
"""Map HB-Beschluss-Vokabular auf einheitliche ergebnis-Codes."""
|
|
verb_l = verb.lower()
|
|
rest_l = rest.lower()
|
|
term_l = terminator.lower().strip()
|
|
if "kenntnis" in rest_l or "kenntnis" in term_l:
|
|
return "kenntnis"
|
|
if verb_l == "lehnt" and "ab" in term_l:
|
|
return "abgelehnt"
|
|
if verb_l == "stimmt" and "zu" in term_l:
|
|
return "angenommen"
|
|
if verb_l in ("beschließt", "verabschiedet"):
|
|
return "angenommen"
|
|
if verb_l in ("verweist", "überweist", "leitet"):
|
|
return "überwiesen"
|
|
return "unbekannt"
|
|
|
|
|
|
def _resolve_drucksache_hb(text: str, anchor_start: int, rest: str) -> Optional[str]:
|
|
"""Drucksachen-Aufloesung: erst Inline-Form aus rest, dann Block-Form."""
|
|
inline = DS_INLINE_RE.search(rest)
|
|
if inline:
|
|
return inline.group(1)
|
|
window_start = max(0, anchor_start - 1500)
|
|
window = text[window_start:anchor_start]
|
|
matches = list(DS_BLOCK_RE.finditer(window))
|
|
if matches:
|
|
return matches[-1].group(1)
|
|
return None
|
|
|
|
|
|
def _normalize_text(text: str) -> str:
|
|
text = re.sub(r"(?<=[a-zäöüß])-\s+(?=[a-zäöüß])", "", text)
|
|
return re.sub(r"\s+", " ", text)
|
|
|
|
|
|
def parse_protocol(pdf_path: str) -> list[dict]:
|
|
"""Parst HB-Beschlussprotokoll, liefert Status pro Drucksache.
|
|
|
|
Vote-Listen bleiben leer (HB publiziert nur Status, keine Fraktions-Detail).
|
|
"""
|
|
if fitz is None:
|
|
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den HB-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 ANCHOR_RE.finditer(full):
|
|
verb = m.group("verb")
|
|
rest = m.group("rest")
|
|
terminator = m.group("terminator")
|
|
ergebnis = _classify_status(verb, rest, terminator)
|
|
if ergebnis in ("kenntnis", "unbekannt"):
|
|
continue
|
|
|
|
ds = _resolve_drucksache_hb(full, m.start(), rest)
|
|
if not ds:
|
|
continue
|
|
|
|
results.append({
|
|
"drucksache": ds,
|
|
"ergebnis": ergebnis,
|
|
"einstimmig": False, # Beschlussprotokoll → keine Vote-Detail
|
|
"kind": "direct",
|
|
"votes": {"ja": [], "nein": [], "enthaltung": []},
|
|
"anchor_pos": m.start(),
|
|
})
|
|
|
|
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
|