2026-04-29 01:29:06 +02:00
|
|
|
|
"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser (#106 / #160, ADR 0009).
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
URL-Pattern (verifiziert WP20 Sitzungen 115, 116):
|
|
|
|
|
|
``https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf``
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
Datums-Anteil (YYYY-Pfad + MM-YY-Suffix) macht reine URL-Vorhersage
|
|
|
|
|
|
unmoeglich → Auto-Ingest scrapt Index-Seite
|
|
|
|
|
|
``https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/``.
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
## Anchor-Sprache (verifiziert WP20 Sitzung 116)
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
```
|
|
|
|
|
|
... – Das sind die Fraktionen von SPD, FDP und SSW. Wer stimmt dagegen?
|
|
|
|
|
|
– Das sind die Fraktionen von CDU und BÜNDNIS 90/DIE GRÜNEN. Damit
|
|
|
|
|
|
ist der Antrag abgelehnt.
|
|
|
|
|
|
```
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
Pattern (TH-aehnlich):
|
|
|
|
|
|
- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig|mit Mehrheit|trotzdem mit Mehrheit)? (angenommen|abgelehnt|überwiesen)``
|
|
|
|
|
|
- **Vote-Block:** Q+A im Reden-Stil
|
|
|
|
|
|
- JA: ``Wer dem zustimmt ... Das sind die Fraktionen von [PHRASE]`` /
|
|
|
|
|
|
``Das ist die [PHRASE]-Fraktion``
|
|
|
|
|
|
- NEIN: ``Wer stimmt dagegen? ... [PHRASE]`` / ``Gegenstimmen? ... [PHRASE]``
|
|
|
|
|
|
- ENTH: ``Wer enthaelt sich? ... [PHRASE]`` (oft fehlend)
|
|
|
|
|
|
- **Drucksachen-Lookup:** ``Drucksache 20/N`` rueckwaerts vom Anchor
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
## Fraktions-Mapping WP20 (ab 2022)
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
WP20 Konstellation: CDU + GRÜNE (Koalition), SPD + FDP + SSW (Opposition).
|
|
|
|
|
|
SSW ist 5%-Huerden-befreit, daher fester Bestandteil.
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
- ``CDU``, ``SPD``, ``FDP``, ``SSW``
|
|
|
|
|
|
- ``BÜNDNIS 90/DIE GRÜNEN`` / ``GRÜNEN`` / ``GRÜNE`` → GRÜNE
|
2026-04-28 23:09:07 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
import re
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
import fitz
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
fitz = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ALLE_FRAKTIONEN_SH = ["CDU", "GRÜNE", "SPD", "FDP", "SSW"]
|
|
|
|
|
|
|
|
|
|
|
|
FRAKTIONEN_MAP_SH = [
|
|
|
|
|
|
("BÜNDNIS 90/DIE GRÜNEN", ["GRÜNE"]),
|
|
|
|
|
|
("BÜNDNIS 90", ["GRÜNE"]),
|
|
|
|
|
|
("DIE GRÜNEN", ["GRÜNE"]),
|
|
|
|
|
|
("GRÜNEN", ["GRÜNE"]),
|
|
|
|
|
|
("GRÜNE", ["GRÜNE"]),
|
|
|
|
|
|
("CDU", ["CDU"]),
|
|
|
|
|
|
("SPD", ["SPD"]),
|
|
|
|
|
|
("FDP", ["FDP"]),
|
|
|
|
|
|
("SSW", ["SSW"]),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_fraktionen_sh(text: str) -> list[str]:
|
|
|
|
|
|
found = set()
|
|
|
|
|
|
remaining = text
|
|
|
|
|
|
for phrase, codes in FRAKTIONEN_MAP_SH:
|
|
|
|
|
|
if phrase in remaining:
|
|
|
|
|
|
for c in codes:
|
|
|
|
|
|
found.add(c)
|
|
|
|
|
|
remaining = remaining.replace(phrase, " ")
|
|
|
|
|
|
return sorted(found)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Result-Anchor: "Damit ist/sind [Subjekt] (modus)? (ergebnis)"
|
|
|
|
|
|
# Erfasst auch "Damit ist die Ausschussueberweisung einstimmig so beschlossen"
|
|
|
|
|
|
# als ergebnis="überwiesen".
|
|
|
|
|
|
RESULT_ANCHOR_RE = re.compile(
|
|
|
|
|
|
r"Damit\s+(?:ist|sind)\s+(?:der|die|das|dieser?|dieses|beide|alle|auch)?\s*"
|
|
|
|
|
|
r"(?P<subject>Antrag|Alternativantrag|Änderungsantrag|Gesetzentwurf|"
|
|
|
|
|
|
r"Beschlussempfehlung|Tagesordnungspunkt|Anträge|Ausschussüberweisung|"
|
|
|
|
|
|
r"trotzdem)?"
|
|
|
|
|
|
r"[^.]{0,200}?(?P<modus>einstimmig|mehrheitlich|mit\s+(?:großer\s+)?Mehrheit|"
|
|
|
|
|
|
r"trotzdem mit Mehrheit)?\s*"
|
|
|
|
|
|
r"(?P<ergebnis>angenommen|abgelehnt|überwiesen|so\s+beschlossen)",
|
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Vote-Sub-Patterns. SH-Form ist Q+A:
|
|
|
|
|
|
# Q: "Wer dem zustimmen will, den bitte ich um das Handzeichen."
|
|
|
|
|
|
# A: "– Das sind die Fraktionen von SPD, FDP und SSW."
|
|
|
|
|
|
# Wir matchen Q komplett, dann ab dem ersten `–` die Antwort.
|
|
|
|
|
|
JA_RE = re.compile(
|
|
|
|
|
|
r"Wer\s+(?:dem|dafür|so|der|den)?\s*"
|
|
|
|
|
|
r"(?:zustimmen|zustimmt|stimmen|verfahren|"
|
|
|
|
|
|
r"beschließen|so\s+beschließen|für\s+\S+|ist\s+dafür)"
|
|
|
|
|
|
r"\s*(?:will|möchte|kann|ist)?"
|
|
|
|
|
|
r"[^–]{0,200}–\s*"
|
|
|
|
|
|
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+|"
|
|
|
|
|
|
r"einstimmig)?)?"
|
|
|
|
|
|
r"(?P<ja>[^?.]+?)(?=\.\s|\?|Wer\s+(?:stimmt|enth|dagegen|ist\s+dagegen)|"
|
|
|
|
|
|
r"Gegenstimmen|Damit|Stimmenthaltungen?)",
|
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
NEIN_RE = re.compile(
|
|
|
|
|
|
r"(?:Wer\s+(?:stimmt\s+dagegen|ist\s+dagegen)|Gegenstimmen)\??[^–]{0,80}–\s*"
|
|
|
|
|
|
r"(?:Bei\s+Gegenstimmen\s+(?:der\s+)?(?:Fraktionen\s+von\s+)?)?"
|
|
|
|
|
|
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?"
|
|
|
|
|
|
r"(?P<nein>[^?.]+?)(?=\.\s|\?|Wer\s+enth|Damit|Stimmenthaltung)",
|
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
ENTH_RE = re.compile(
|
|
|
|
|
|
r"(?:Wer\s+enthält\s+sich|Stimmenthaltungen?)\??[^–]{0,80}–\s*"
|
|
|
|
|
|
r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?"
|
|
|
|
|
|
r"(?P<enth>[^?.]+?)(?=\.\s|\?|Damit|Wer)",
|
|
|
|
|
|
re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
DS_RE_SH = re.compile(r"Drucksache\s+20/(\d{2,5})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _last_match(pattern: re.Pattern, text: str) -> Optional[re.Match]:
|
|
|
|
|
|
"""Return the LAST match — vote-blocks vor einem Anchor sollen den
|
|
|
|
|
|
naechstliegenden Q+A-Block treffen, nicht den ersten."""
|
|
|
|
|
|
last = None
|
|
|
|
|
|
for m in pattern.finditer(text):
|
|
|
|
|
|
last = m
|
|
|
|
|
|
return last
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_vote_block_sh(block: str) -> dict:
|
|
|
|
|
|
votes = {"ja": [], "nein": [], "enthaltung": []}
|
|
|
|
|
|
|
|
|
|
|
|
ja_m = _last_match(JA_RE, block)
|
|
|
|
|
|
if ja_m:
|
|
|
|
|
|
votes["ja"] = _normalize_fraktionen_sh(ja_m.group("ja"))
|
|
|
|
|
|
|
|
|
|
|
|
nein_m = _last_match(NEIN_RE, block)
|
|
|
|
|
|
if nein_m:
|
|
|
|
|
|
votes["nein"] = _normalize_fraktionen_sh(nein_m.group("nein"))
|
|
|
|
|
|
|
|
|
|
|
|
enth_m = _last_match(ENTH_RE, block)
|
|
|
|
|
|
if enth_m:
|
|
|
|
|
|
enth_text = enth_m.group("enth")
|
|
|
|
|
|
low = enth_text.lower()
|
|
|
|
|
|
if "niemand" in low or "keine" in low:
|
|
|
|
|
|
votes["enthaltung"] = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
votes["enthaltung"] = _normalize_fraktionen_sh(enth_text)
|
|
|
|
|
|
|
|
|
|
|
|
return votes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_drucksache_sh(text: str, anchor_start: int) -> Optional[str]:
|
|
|
|
|
|
window_start = max(0, anchor_start - 1500)
|
|
|
|
|
|
window = text[window_start:anchor_start]
|
|
|
|
|
|
matches = list(DS_RE_SH.finditer(window))
|
|
|
|
|
|
if matches:
|
|
|
|
|
|
return f"20/{matches[-1].group(1)}"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_text(text: str) -> str:
|
|
|
|
|
|
# Soft-hyphenation reparieren: 'zustim- men' → 'zustimmen' (PDF-Zeilen-
|
|
|
|
|
|
# umbruch mit Trennstrich, deutsche Silbentrennung am Wortinneren).
|
|
|
|
|
|
text = re.sub(r"(?<=[a-zäöüß])-\s+(?=[a-zäöüß])", "", text)
|
|
|
|
|
|
return re.sub(r"\s+", " ", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_protocol(pdf_path: str) -> list[dict]:
|
|
|
|
|
|
if fitz is None:
|
|
|
|
|
|
raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den SH-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 RESULT_ANCHOR_RE.finditer(full):
|
|
|
|
|
|
modus = (m.group("modus") or "").lower()
|
|
|
|
|
|
ergebnis_raw = m.group("ergebnis").lower()
|
|
|
|
|
|
# "so beschlossen" tritt bei Ausschussueberweisung auf
|
|
|
|
|
|
if "beschlossen" in ergebnis_raw:
|
|
|
|
|
|
ergebnis = "überwiesen"
|
|
|
|
|
|
else:
|
|
|
|
|
|
ergebnis = ergebnis_raw
|
|
|
|
|
|
block_start = max(0, m.start() - 1500)
|
|
|
|
|
|
block = full[block_start:m.end()]
|
|
|
|
|
|
|
|
|
|
|
|
ds = _resolve_drucksache_sh(full, m.start())
|
|
|
|
|
|
if not ds:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
votes = _parse_vote_block_sh(block)
|
|
|
|
|
|
einstimmig = "einstimmig" in modus
|
|
|
|
|
|
|
|
|
|
|
|
if einstimmig and not votes["ja"]:
|
|
|
|
|
|
votes["ja"] = list(ALLE_FRAKTIONEN_SH)
|
|
|
|
|
|
|
|
|
|
|
|
# "Das sind alle anderen Fraktionen" → NEIN-Komplement von JA
|
|
|
|
|
|
if (votes["ja"] and not votes["nein"]
|
|
|
|
|
|
and "alle anderen" in block[-400:].lower()):
|
|
|
|
|
|
votes["nein"] = sorted(set(ALLE_FRAKTIONEN_SH) - set(votes["ja"]))
|
|
|
|
|
|
|
|
|
|
|
|
results.append({
|
|
|
|
|
|
"drucksache": ds,
|
|
|
|
|
|
"ergebnis": ergebnis,
|
|
|
|
|
|
"einstimmig": einstimmig,
|
|
|
|
|
|
"kind": "direct",
|
|
|
|
|
|
"votes": votes,
|
|
|
|
|
|
"anchor_pos": m.start(),
|
|
|
|
|
|
})
|
2026-04-28 23:09:07 +02:00
|
|
|
|
|
2026-04-29 01:29:06 +02:00
|
|
|
|
seen = set()
|
|
|
|
|
|
deduped = []
|
|
|
|
|
|
for r in results:
|
|
|
|
|
|
if r["anchor_pos"] in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(r["anchor_pos"])
|
|
|
|
|
|
deduped.append(r)
|
|
|
|
|
|
return deduped
|