diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index a4d080c..d1becd1 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -36,6 +36,7 @@ from .th import parse_protocol as _parse_th from .he import parse_protocol as _parse_he from .sh import parse_protocol as _parse_sh from .hb import parse_protocol as _parse_hb +from .sl import parse_protocol as _parse_sl # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -49,6 +50,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "HE": _parse_he, "SH": _parse_sh, "HB": _parse_hb, + "SL": _parse_sl, } diff --git a/app/protokoll_parsers/sl.py b/app/protokoll_parsers/sl.py index f930945..c173f64 100644 --- a/app/protokoll_parsers/sl.py +++ b/app/protokoll_parsers/sl.py @@ -1,47 +1,152 @@ -"""Saarland (SL) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Saarland (SL) — Abstimmungsergebnisse-Parser (#106 / #161, ADR 0009). -**Status: noch nicht implementiert.** Dieser Modul-Stub enthaelt -Recherche-Findings vom 2026-04-28, sodass die Implementer-Session -direkt produktiv loslegen kann. Der Stub wird **nicht** in -``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert — der -Auto-Ingest-Cron ueberspringt SL solange. +**Spezialfall:** Saarland publiziert keine Wortprotokolle, sondern eigene +Abstimmungsergebnisse-HTML-Seiten pro Sitzung mit strukturiertem Vote-Block: -## Recherche +``` +

...Drucksache 17/2076... +in Erster Lesung mit Stimmenmehrheit angenommen und an den Ausschuss [...] +[SPD: dafür; CDU und AfD: dagegen]

+``` -| Feld | Wert | -|---|---| -| **Doku-System** | Eigensystem | -| **Base-URL** | https://www.landtag-saar.de | -| **Familie** | eigenstaendig | -| **Format** | PDF erwartet ueber Umbraco-Filterm | +Daher Input ist HTML, nicht PDF. ``parse_protocol(html_path)`` liest die +HTML-Seite und extrahiert pro
  • einen Vote. -## URL-Discovery +URL-Pattern (nicht direkt vorhersagbar, daher Index-Scrape): +``https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/`` -Umbraco-Backend; siehe SaarlandAdapter — Plenum-Protokolle ggf. analog Drucksachen via aawSearchSurfaceController-Pattern +Index-Seite: https://www.landtag-saar.de (Front-Listing der Mitteilungen). -## Bezug +## Vote-Block-Format -- Architektur: ADR 0009 (Plenarprotokoll-Parser-Registry) -- Roadmap: ``docs/protokoll-parser-roadmap.md`` -- Referenz-Implementation: ``app/protokoll_parsers/nrw.py`` - (38 Tests, 19/19-Fixture-Garantie) -- Folge-Issue: https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/161 (Titel: "protokoll-parser: SL (Saarland)") +Strukturierte Klammer-Notation pro Drucksache: +- ``[SPD: dafür; CDU und AfD: dagegen]`` → JA=[SPD], NEIN=[CDU,AfD] +- ``[SPD: dafür; CDU: dagegen; AfD: Enthaltung]`` → JA=[SPD], NEIN=[CDU], ENTH=[AfD] +- ``[SPD und CDU: dafür; AfD: Enthaltung]`` → JA=[SPD,CDU], NEIN=[], ENTH=[AfD] -## Aufwand +## Ergebnis-Mapping -Geschaetzt 1-3 Tage konzentrierte Arbeit: -- 2-4h URL-Discovery + Format-Inspektion (Sample-Protokoll inhaltlich anschauen) -- 4-8h Anchor-Phrasen-Reverse-Engineering + Parser-Implementierung -- 4h Tests mit Fixture-Pinning -- 1h Eintrag in PROTOKOLL_PARSERS + auto-ingest-protocols.sh +- ``angenommen`` (mit oder ohne ``mit Stimmenmehrheit|einstimmig``) → angenommen +- ``abgelehnt`` → abgelehnt +- ``zur Kenntnis genommen`` → uebersprungen (kein Vote) + +## Fraktions-Mapping WP17 (ab 2022) + +WP17 Konstellation: SPD-Alleinregierung (43 Sitze), CDU + AfD Opposition. +- ``SPD``, ``CDU``, ``AfD`` """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "SL-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/sl.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) + +ALLE_FRAKTIONEN_SL = ["SPD", "CDU", "AfD"] + + +#
  • ...
  • -Block per Sitzung; jeder Block enthaelt typischerweise +# 1x Drucksache + 1x Status + 1x Vote-Klammer. +LI_BLOCK_RE = re.compile( + r"]*>(.*?)", + re.DOTALL, +) + +DS_RE_SL = re.compile(r"Drucksache\s+(\d{1,2}/\d{2,5})") + +STATUS_RE = re.compile( + r"(?:in\s+\w+\s+Lesung\s+)?" + r"(?:mit\s+Stimmenmehrheit|einstimmig|mit\s+Mehrheit)?\s*" + r"(?Pangenommen|abgelehnt|abgesetzt|zur\s+Kenntnis\s+genommen)", + re.IGNORECASE, +) + +# Vote-Klammer: [SPD: dafür; CDU und AfD: dagegen] +VOTE_BRACKET_RE = re.compile(r"\[(?P[^\[\]]+)\]") + + +def _normalize_fraktionen_sl(phrase: str) -> list[str]: + """SPD und CDU → ['CDU', 'SPD']; CDU → ['CDU'].""" + found = set() + for fr in ALLE_FRAKTIONEN_SL: + if re.search(rf"\b{re.escape(fr)}\b", phrase, re.IGNORECASE): + found.add(fr) + return sorted(found) + + +def _parse_vote_bracket(bracket_inner: str) -> dict: + """Parst '[SPD: dafür; CDU und AfD: dagegen]' (innen ohne Klammern).""" + votes = {"ja": [], "nein": [], "enthaltung": []} + for segment in bracket_inner.split(";"): + if ":" not in segment: + continue + fraktionen_phrase, _, status = segment.rpartition(":") + status = status.strip().lower() + fraktionen = _normalize_fraktionen_sl(fraktionen_phrase) + if "dafür" in status or "ja" in status or "zustimm" in status: + votes["ja"].extend(fraktionen) + elif "dagegen" in status or "nein" in status or "ablehn" in status: + votes["nein"].extend(fraktionen) + elif "enthalt" in status: + votes["enthaltung"].extend(fraktionen) + for key in votes: + votes[key] = sorted(set(votes[key])) + return votes + + +def _strip_html(text: str) -> str: + text = re.sub(r"<[^>]+>", " ", text) + text = text.replace("&", "&").replace(" ", " ") + return re.sub(r"\s+", " ", text).strip() + + +def parse_protocol(html_path: str) -> list[dict]: + """Parst SL-Abstimmungsergebnisse-HTML, liefert Status + Votes.""" + with open(html_path, "r", encoding="utf-8", errors="replace") as f: + html = f.read() + + results = [] + for m in LI_BLOCK_RE.finditer(html): + block_html = m.group(1) + block_text = _strip_html(block_html) + + ds_m = DS_RE_SL.search(block_text) + if not ds_m: + continue + ds = ds_m.group(1) + + status_m = STATUS_RE.search(block_text) + if not status_m: + continue + ergebnis = status_m.group("ergebnis").lower() + if "kenntnis" in ergebnis: + continue + + modus_match = re.search(r"einstimmig", block_text, re.IGNORECASE) + einstimmig = bool(modus_match) + + vote_m = VOTE_BRACKET_RE.search(block_text) + votes = {"ja": [], "nein": [], "enthaltung": []} + if vote_m: + votes = _parse_vote_bracket(vote_m.group("inner")) + + if einstimmig and not votes["ja"]: + votes["ja"] = list(ALLE_FRAKTIONEN_SL) + + results.append({ + "drucksache": ds, + "ergebnis": ergebnis, + "einstimmig": einstimmig, + "kind": "direct", + "votes": votes, + "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 diff --git a/docs/protokoll-parser-roadmap.md b/docs/protokoll-parser-roadmap.md index 475fc5f..85419c3 100644 --- a/docs/protokoll-parser-roadmap.md +++ b/docs/protokoll-parser-roadmap.md @@ -29,7 +29,7 @@ Body und der Eintrag wird in `PROTOKOLL_PARSERS` ergaenzt. | NI | `ni.py` | [#158](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/158) | 📋 Stub (NILAS Login) | | RP | `rp.py` | [#159](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/159) | 📋 Stub (OPAL extern, kein direktes URL-Pattern) | | **SH** | `sh.py` | [#160](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160) | ✅ produktiv (575 Votes 110 Protokolle WP20, Index-Scrape) | -| SL | `sl.py` | [#161](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/161) | 📋 Stub | +| **SL** | `sl.py` | [#161](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/161) | ✅ produktiv (HTML-Abstimmungsergebnisse, WP17, Index-Scrape) | | SN | `sn.py` | [#162](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/162) | 📋 Stub (XML-Manuell-Export) | | **TH** | `th.py` | [#163](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/163) | ✅ produktiv (459 Votes 96 Protokolle WP8, URL-Pattern) | diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index 5a32ae2..a030f34 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -221,6 +221,75 @@ for m in matches: print(f" SH: {new_count} neue Protokolle ingestet") EOF +# ─── SL: HTML-Abstimmungsergebnisse-Index ───────────────────────────── +# SL publiziert keine Wortprotokolle, sondern HTML-Abstimmungsergebnisse-Seiten. +# Index-Scrape /aktuelles/mitteilungen/, jeden abstimmungsergebnisse-Link +# einzeln laden + parsen. +echo "--- SL WP17 (HTML-Index-Scrape) ---" +docker exec -i "$CONTAINER" python <<'EOF' +import re, sys +import urllib.request +import sqlite3 +import asyncio + +BASE = "https://www.landtag-saar.de" +req = urllib.request.Request( + f"{BASE}/aktuelles/mitteilungen/", + headers={"User-Agent": "Mozilla/5.0 GWOeAntragspruefer"}, +) +try: + html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", errors="replace") +except Exception as e: + print(f" Index-Scrape fehlgeschlagen: {e}") + sys.exit(0) + +# WP17 hat keinen "wahlperiode-vom"-Marker im URL-Slug — diesen Filter ausschliessen. +url_re = re.compile( + r'href="(/aktuelles/mitteilungen/abstimmungsergebnisse-der-(\d+)-landtagssitzung-vom-[^"]+?/)"' +) +matches = [] +seen_pids = set() +for m in url_re.finditer(html): + href, sitzung = m.groups() + if "wahlperiode-vom" in href: + continue # WP16-URLs ueberspringen + pid = f"SL17-{sitzung}" + if pid in seen_pids: + continue + seen_pids.add(pid) + matches.append((pid, BASE + href)) +print(f" {len(matches)} SL-Sitzungen WP17 in Index gefunden") + +db = sqlite3.connect("/app/data/gwoe-antraege.db") +existing = {row[0] for row in db.execute( + "SELECT quelle_protokoll FROM plenum_vote_results WHERE bundesland='SL'" +)} + +from app.ingest_votes import ingest_pdf +from pathlib import Path +import tempfile + +new_count = 0 +for pid, url in matches: + if pid in existing: + continue + print(f" → neu: {pid} ({url[:80]})") + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + urllib.request.urlretrieve(url, tmp_path) + stats = asyncio.run(ingest_pdf( + tmp_path, bundesland="SL", protokoll_id=pid, quelle_url=url, + )) + print(f" parsed: {stats['parsed']}, written: {stats['written']}") + new_count += 1 + except Exception as e: + print(f" Fehler: {e}") + finally: + tmp_path.unlink(missing_ok=True) +print(f" SL: {new_count} neue Sitzungen ingestet") +EOF + for entry in "${PROTO_TARGETS[@]}"; do IFS='|' read -r bl wp prefix pattern <<< "$entry" echo "--- ${bl} WP${wp} (prefix=${prefix}) ---" diff --git a/tests/test_protokoll_parsers_sl.py b/tests/test_protokoll_parsers_sl.py new file mode 100644 index 0000000..122a3de --- /dev/null +++ b/tests/test_protokoll_parsers_sl.py @@ -0,0 +1,113 @@ +"""Tests fuer app/protokoll_parsers/sl.py — SL Abstimmungsergebnisse-Parser (#161). + +SL ist HTML-basiert (nicht PDF) — eigene Abstimmungsergebnisse-Seite pro +Sitzung mit strukturiertem [SPD: dafür; CDU und AfD: dagegen]-Format. +""" +from __future__ import annotations + +import pytest + +from app.protokoll_parsers.sl import ( + _normalize_fraktionen_sl, + _parse_vote_bracket, + _strip_html, + DS_RE_SL, + STATUS_RE, + VOTE_BRACKET_RE, + LI_BLOCK_RE, + ALLE_FRAKTIONEN_SL, +) + + +class TestNormalizeFraktionenSl: + def test_simple_spd(self): + assert _normalize_fraktionen_sl("SPD") == ["SPD"] + + def test_spd_und_cdu(self): + assert _normalize_fraktionen_sl("SPD und CDU") == ["CDU", "SPD"] + + def test_alle_drei(self): + assert _normalize_fraktionen_sl("SPD, CDU und AfD") == ["AfD", "CDU", "SPD"] + + def test_empty(self): + assert _normalize_fraktionen_sl("") == [] + + def test_word_boundary(self): + # Kein Match auf 'SP' oder Substrings + assert _normalize_fraktionen_sl("SP-Partei") == [] + + +class TestParseVoteBracket: + def test_klassisches_pattern(self): + votes = _parse_vote_bracket("SPD: dafür; CDU und AfD: dagegen") + assert set(votes["ja"]) == {"SPD"} + assert set(votes["nein"]) == {"CDU", "AfD"} + assert votes["enthaltung"] == [] + + def test_mit_enthaltung(self): + votes = _parse_vote_bracket("SPD: dafür; CDU: dagegen; AfD: Enthaltung") + assert set(votes["ja"]) == {"SPD"} + assert set(votes["nein"]) == {"CDU"} + assert set(votes["enthaltung"]) == {"AfD"} + + def test_alle_dafuer(self): + votes = _parse_vote_bracket("SPD und CDU: dafür; AfD: Enthaltung") + assert set(votes["ja"]) == {"SPD", "CDU"} + assert votes["nein"] == [] + assert set(votes["enthaltung"]) == {"AfD"} + + def test_alle_dagegen(self): + votes = _parse_vote_bracket("AfD: dafür; SPD und CDU: dagegen") + assert set(votes["ja"]) == {"AfD"} + assert set(votes["nein"]) == {"SPD", "CDU"} + + +class TestStripHtml: + def test_removes_tags(self): + assert _strip_html("

    Hello world

    ") == "Hello world" + + def test_decodes_entities(self): + assert _strip_html("a & b") == "a & b" + + +class TestDrucksacheRegex: + def test_matches_drucksache(self): + m = DS_RE_SL.search("Drucksache 17/2076") + assert m and m.group(1) == "17/2076" + + def test_matches_with_spaces(self): + m = DS_RE_SL.search("(Drucksache 17/2074)") + assert m and m.group(1) == "17/2074" + + +class TestStatusRegex: + def test_matches_einstimmig_angenommen(self): + m = STATUS_RE.search("in Erster Lesung einstimmig angenommen") + assert m and m.group("ergebnis").lower() == "angenommen" + + def test_matches_mit_stimmenmehrheit(self): + m = STATUS_RE.search("mit Stimmenmehrheit angenommen") + assert m and m.group("ergebnis").lower() == "angenommen" + + def test_matches_abgelehnt(self): + m = STATUS_RE.search("in Erster Lesung abgelehnt") + assert m and m.group("ergebnis").lower() == "abgelehnt" + + +class TestVoteBracketRegex: + def test_matches_full_bracket(self): + m = VOTE_BRACKET_RE.search("[SPD: dafür; CDU und AfD: dagegen]") + assert m and "SPD" in m.group("inner") + + +class TestLiBlockRegex: + def test_extracts_li_content(self): + html = "
    • foo
    • bar
    " + matches = list(LI_BLOCK_RE.finditer(html)) + assert len(matches) == 2 + assert matches[0].group(1) == "foo" + + +class TestConstants: + def test_all_fraktionen_set(self): + assert ALLE_FRAKTIONEN_SL == ["SPD", "CDU", "AfD"] diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index ee7e485..e49a72d 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,9 +20,9 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender STUB_BL_CODES = [ - # BUND/BE/HH/TH/HE/SH/HB raus, weil seit 2026-04-28/29 produktive Parser + # BUND/BE/HH/TH/HE/SH/HB/SL raus, weil seit 2026-04-28/29 produktive Parser "BB", "BW", "BY", - "LSA", "MV", "NI", "RP", "SL", "SN", + "LSA", "MV", "NI", "RP", "SN", ] @@ -76,8 +76,8 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell: NRW + BUND + BE + HH + TH + HE + SH + HB produktiv - assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH", "HB"}, ( + # Aktuell: NRW + BUND + BE + HH + TH + HE + SH + HB + SL produktiv + assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH", "HB", "SL"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )