gwoe-antragspruefer/app/protokoll_parsers/sl.py
Dotty Dotter d0f7b9217c feat(#161): SL-Parser produktiv — Saarland HTML-Abstimmungsergebnisse
Saarland publiziert keine Wortprotokolle, sondern eigene HTML-Seiten
mit strukturierten Abstimmungsergebnissen pro Sitzung:

  <p>Drucksache 17/2076 ... in Erster Lesung mit Stimmenmehrheit
  angenommen ... [SPD: dafür; CDU und AfD: dagegen]</p>

Daher Input ist HTML, nicht PDF. Parser nutzt LI-Block-Iteration und
extrahiert pro Block:
- Drucksache aus "Drucksache N/M"
- Status aus "(einstimmig|mit Stimmenmehrheit)? (angenommen|abgelehnt)"
- Vote-Block aus "[SPD: dafür; CDU: dagegen; AfD: Enthaltung]"
- einstimmig=True falls Status enthaelt "einstimmig"

Vote-Bracket-Parser (eigenstaendig vs. Reden-Stil-Parser anderer BL):
- Splits per ; → "Phrase: Status"
- Phrase per Wortgrenzen-Regex auf {SPD,CDU,AfD} matchen
- Status-Map: dafür→ja, dagegen→nein, Enthaltung→enthaltung

URL-Pattern (nicht direkt vorhersagbar wegen Datums-Slug):
https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/

Auto-Ingest via Index-Scrape (analog HH/HE/SH):
- /aktuelles/mitteilungen/ scrape
- WP16-URLs (mit "wahlperiode-vom") ueberspringen
- Pro neue Sitzung: HTML herunterladen, ingest_pdf-API auf .html-Datei

Tests: 18 SL-Tests (Verifikation Sitzung 46 → 18 Votes mit korrekten
JA/NEIN/ENTH-Listen). Stand: 9 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:53:51 +02:00

153 lines
4.8 KiB
Python

"""Saarland (SL) — Abstimmungsergebnisse-Parser (#106 / #161, ADR 0009).
**Spezialfall:** Saarland publiziert keine Wortprotokolle, sondern eigene
Abstimmungsergebnisse-HTML-Seiten pro Sitzung mit strukturiertem Vote-Block:
```
<p>...Drucksache 17/2076...
in Erster Lesung mit Stimmenmehrheit angenommen und an den Ausschuss [...]
[SPD: dafür; CDU und AfD: dagegen]</p>
```
Daher Input ist HTML, nicht PDF. ``parse_protocol(html_path)`` liest die
HTML-Seite und extrahiert pro <li> einen Vote.
URL-Pattern (nicht direkt vorhersagbar, daher Index-Scrape):
``https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/``
Index-Seite: https://www.landtag-saar.de (Front-Listing der Mitteilungen).
## Vote-Block-Format
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]
## Ergebnis-Mapping
- ``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
ALLE_FRAKTIONEN_SL = ["SPD", "CDU", "AfD"]
# <li>...</li>-Block per Sitzung; jeder Block enthaelt typischerweise
# 1x Drucksache + 1x Status + 1x Vote-Klammer.
LI_BLOCK_RE = re.compile(
r"<li[^>]*>(.*?)</li>",
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"(?P<ergebnis>angenommen|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<inner>[^\[\]]+)\]")
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("&amp;", "&").replace("&nbsp;", " ")
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