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>
153 lines
4.8 KiB
Python
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("&", "&").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
|