"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser (#106 / #160, ADR 0009). 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`` 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/``. ## Anchor-Sprache (verifiziert WP20 Sitzung 116) ``` ... – 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. ``` 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 ## Fraktions-Mapping WP20 (ab 2022) WP20 Konstellation: CDU + GRÜNE (Koalition), SPD + FDP + SSW (Opposition). SSW ist 5%-Huerden-befreit, daher fester Bestandteil. - ``CDU``, ``SPD``, ``FDP``, ``SSW`` - ``BÜNDNIS 90/DIE GRÜNEN`` / ``GRÜNEN`` / ``GRÜNE`` → GRÜNE """ from __future__ import annotations 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"(?PAntrag|Alternativantrag|Änderungsantrag|Gesetzentwurf|" r"Beschlussempfehlung|Tagesordnungspunkt|Anträge|Ausschussüberweisung|" r"trotzdem)?" r"[^.]{0,200}?(?Peinstimmig|mehrheitlich|mit\s+(?:großer\s+)?Mehrheit|" r"trotzdem mit Mehrheit)?\s*" r"(?Pangenommen|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[^?.]+?)(?=\.\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[^?.]+?)(?=\.\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[^?.]+?)(?=\.\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(), }) seen = set() deduped = [] for r in results: if r["anchor_pos"] in seen: continue seen.add(r["anchor_pos"]) deduped.append(r) return deduped