feat(#160): SH-Parser produktiv — Schleswig-Holsteiner Plenarprotokolle
Verifiziert auf WP20 Sitzungen 115 + 116. Format ist TH-aehnlich: Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen|so beschlossen)" Vote-Block (Q+A im Reden-Stil): - JA: "Wer dem zustimmen will ... Das sind die Fraktionen von X" - NEIN: "Wer stimmt dagegen? ... Das sind die Fraktionen von Y" - ENTH: "Wer enthaelt sich? ... Z" Drucksachen-Lookup: rueckwaerts vom Anchor Besonderheiten: - SSW (5%-Huerden-befreit) als feste Fraktion - "Damit ist die Ausschussueberweisung einstimmig so beschlossen" → ergebnis="ueberwiesen" - "Das sind alle anderen Fraktionen" → NEIN als Komplement von JA inferiert - Soft-Hyphen-Reparatur (PDF-Zeilenumbruch "zustim- men" → "zustimmen") - _last_match-Helper, weil 1500-char-Window mehrere Vote-Bloecke enthalten kann (TH-Limitierung gefixed) URL-Pattern (verifiziert): https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf Datum-Anteile (YYYY-Pfad + MM-YY-Suffix) machen URL-Vorhersage unmoeglich → Auto-Ingest-Cron via Index-Scrape (analog HH/HE): https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/ Tests: 23 SH-Tests + Stub-Registry-Test angepasst. Stand: 7 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06918c71eb
commit
7ebdc78331
@ -34,6 +34,7 @@ from .be import parse_protocol as _parse_be
|
|||||||
from .hh import parse_protocol as _parse_hh
|
from .hh import parse_protocol as _parse_hh
|
||||||
from .th import parse_protocol as _parse_th
|
from .th import parse_protocol as _parse_th
|
||||||
from .he import parse_protocol as _parse_he
|
from .he import parse_protocol as _parse_he
|
||||||
|
from .sh import parse_protocol as _parse_sh
|
||||||
|
|
||||||
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
|
# Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal.
|
||||||
ProtokollParser = Callable[[str], list[dict]]
|
ProtokollParser = Callable[[str], list[dict]]
|
||||||
@ -45,6 +46,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = {
|
|||||||
"HH": _parse_hh,
|
"HH": _parse_hh,
|
||||||
"TH": _parse_th,
|
"TH": _parse_th,
|
||||||
"HE": _parse_he,
|
"HE": _parse_he,
|
||||||
|
"SH": _parse_sh,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,47 +1,220 @@
|
|||||||
"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009).
|
"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser (#106 / #160, ADR 0009).
|
||||||
|
|
||||||
**Status: noch nicht implementiert.** Dieser Modul-Stub enthaelt
|
URL-Pattern (verifiziert WP20 Sitzungen 115, 116):
|
||||||
Recherche-Findings vom 2026-04-28, sodass die Implementer-Session
|
``https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf``
|
||||||
direkt produktiv loslegen kann. Der Stub wird **nicht** in
|
|
||||||
``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert — der
|
|
||||||
Auto-Ingest-Cron ueberspringt SH solange.
|
|
||||||
|
|
||||||
## Recherche
|
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/``.
|
||||||
|
|
||||||
| Feld | Wert |
|
## Anchor-Sprache (verifiziert WP20 Sitzung 116)
|
||||||
|---|---|
|
|
||||||
| **Doku-System** | StarWeb |
|
|
||||||
| **Base-URL** | http://lissh.lvn.parlanet.de |
|
|
||||||
| **Familie** | HB/NI-Familie |
|
|
||||||
| **Format** | PDF erwartet |
|
|
||||||
|
|
||||||
## URL-Discovery
|
```
|
||||||
|
... – 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.
|
||||||
|
```
|
||||||
|
|
||||||
Starfinder-CGI-Backend; Plenum-Protokolle als PDF-Direktlinks moeglich
|
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
|
||||||
|
|
||||||
## Bezug
|
## Fraktions-Mapping WP20 (ab 2022)
|
||||||
|
|
||||||
- Architektur: ADR 0009 (Plenarprotokoll-Parser-Registry)
|
WP20 Konstellation: CDU + GRÜNE (Koalition), SPD + FDP + SSW (Opposition).
|
||||||
- Roadmap: ``docs/protokoll-parser-roadmap.md``
|
SSW ist 5%-Huerden-befreit, daher fester Bestandteil.
|
||||||
- Referenz-Implementation: ``app/protokoll_parsers/nrw.py``
|
|
||||||
(38 Tests, 19/19-Fixture-Garantie)
|
|
||||||
- Folge-Issue: https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160 (Titel: "protokoll-parser: SH (Schleswig-Holstein)")
|
|
||||||
|
|
||||||
## Aufwand
|
- ``CDU``, ``SPD``, ``FDP``, ``SSW``
|
||||||
|
- ``BÜNDNIS 90/DIE GRÜNEN`` / ``GRÜNEN`` / ``GRÜNE`` → GRÜNE
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
def parse_protocol(path: str) -> list[dict]:
|
try:
|
||||||
"""STUB — siehe Modul-Docstring."""
|
import fitz
|
||||||
raise NotImplementedError(
|
except ImportError:
|
||||||
"SH-Plenarprotokoll-Parser ist noch nicht implementiert. "
|
fitz = None
|
||||||
"Siehe app/protokoll_parsers/sh.py-Docstring fuer Recherche-Findings "
|
|
||||||
"und docs/protokoll-parser-roadmap.md."
|
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for r in results:
|
||||||
|
if r["anchor_pos"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(r["anchor_pos"])
|
||||||
|
deduped.append(r)
|
||||||
|
return deduped
|
||||||
|
|||||||
@ -23,15 +23,15 @@ Body und der Eintrag wird in `PROTOKOLL_PARSERS` ergaenzt.
|
|||||||
| BW | `bw.py` | [#151](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/151) | ⚠ Stub (Datenmodell-Inkompatibilitaet) |
|
| BW | `bw.py` | [#151](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/151) | ⚠ Stub (Datenmodell-Inkompatibilitaet) |
|
||||||
| BY | `by.py` | [#152](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/152) | 📋 Stub |
|
| BY | `by.py` | [#152](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/152) | 📋 Stub |
|
||||||
| HB | `hb.py` | [#153](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/153) | 📋 Stub |
|
| HB | `hb.py` | [#153](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/153) | 📋 Stub |
|
||||||
| HE | `he.py` | [#154](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/154) | 📋 Stub |
|
| **HE** | `he.py` | [#154](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/154) | ✅ produktiv (Status-Only, Beschlussprotokoll WP21, Cron via Index-Scrape) |
|
||||||
| LSA | `lsa.py` | [#156](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/156) | 📋 Stub |
|
| LSA | `lsa.py` | [#156](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/156) | 📋 Stub |
|
||||||
| MV | `mv.py` | [#157](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/157) | 📋 Stub |
|
| MV | `mv.py` | [#157](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/157) | 📋 Stub |
|
||||||
| NI | `ni.py` | [#158](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/158) | 📋 Stub |
|
| NI | `ni.py` | [#158](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/158) | 📋 Stub |
|
||||||
| RP | `rp.py` | [#159](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/159) | 📋 Stub |
|
| RP | `rp.py` | [#159](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/159) | 📋 Stub |
|
||||||
| SH | `sh.py` | [#160](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160) | 📋 Stub |
|
| **SH** | `sh.py` | [#160](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160) | ✅ produktiv (WP20 Sample-Tests S115/S116 grün, Cron via 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) | 📋 Stub |
|
||||||
| SN | `sn.py` | [#162](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/162) | 📋 Stub |
|
| SN | `sn.py` | [#162](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/162) | 📋 Stub |
|
||||||
| TH | `th.py` | [#163](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/163) | 📋 Stub |
|
| **TH** | `th.py` | [#163](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/163) | ✅ produktiv (WP8 Sample, Cron mit URL-Pattern) |
|
||||||
|
|
||||||
## Pattern fuer neue BL
|
## Pattern fuer neue BL
|
||||||
|
|
||||||
|
|||||||
@ -159,6 +159,66 @@ for m in matches:
|
|||||||
print(f" HE: {new_count} neue Protokolle ingestet")
|
print(f" HE: {new_count} neue Protokolle ingestet")
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# ─── SH: Index-Page-Scrape (URL enthaelt Jahr + MM-YY-Suffix) ─────────
|
||||||
|
# SH-URL hat Jahr-Pfad + MM-YY-Suffix, daher Index-Scrape.
|
||||||
|
echo "--- SH WP20 (Index-Scrape) ---"
|
||||||
|
docker exec "$CONTAINER" python <<'EOF'
|
||||||
|
import re, sys
|
||||||
|
import urllib.request
|
||||||
|
import sqlite3
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/",
|
||||||
|
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)
|
||||||
|
|
||||||
|
# href="/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf"
|
||||||
|
pdf_re = re.compile(
|
||||||
|
r'href="(/export/sites/ltsh/infothek/wahl20/plenum/plenprot/(\d{4})/'
|
||||||
|
r'20-(\d{3})_(\d{2}-\d{2})\.pdf)"'
|
||||||
|
)
|
||||||
|
matches = list(pdf_re.finditer(html))
|
||||||
|
print(f" {len(matches)} SH-Plenarprotokolle 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='SH'"
|
||||||
|
)}
|
||||||
|
|
||||||
|
from app.ingest_votes import ingest_pdf
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
for m in matches:
|
||||||
|
href, year, sitzung, suffix = m.groups()
|
||||||
|
pid = f"PlPr20-{int(sitzung)}"
|
||||||
|
if pid in existing:
|
||||||
|
continue
|
||||||
|
url = "https://www.landtag.ltsh.de" + href
|
||||||
|
print(f" → neu: {pid} ({year} {suffix})")
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, tmp_path)
|
||||||
|
stats = asyncio.run(ingest_pdf(
|
||||||
|
tmp_path, bundesland="SH", 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" SH: {new_count} neue Protokolle ingestet")
|
||||||
|
EOF
|
||||||
|
|
||||||
for entry in "${PROTO_TARGETS[@]}"; do
|
for entry in "${PROTO_TARGETS[@]}"; do
|
||||||
IFS='|' read -r bl wp prefix pattern <<< "$entry"
|
IFS='|' read -r bl wp prefix pattern <<< "$entry"
|
||||||
echo "--- ${bl} WP${wp} (prefix=${prefix}) ---"
|
echo "--- ${bl} WP${wp} (prefix=${prefix}) ---"
|
||||||
|
|||||||
158
tests/test_protokoll_parsers_sh.py
Normal file
158
tests/test_protokoll_parsers_sh.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""Tests fuer app/protokoll_parsers/sh.py — SH Plenarprotokoll-Parser (#160).
|
||||||
|
|
||||||
|
Stichprobe-getestet gegen WP20 Sitzungen 115 + 116 (Schleswig-Holstein).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.protokoll_parsers.sh import (
|
||||||
|
_normalize_fraktionen_sh,
|
||||||
|
_normalize_text,
|
||||||
|
_parse_vote_block_sh,
|
||||||
|
_resolve_drucksache_sh,
|
||||||
|
RESULT_ANCHOR_RE,
|
||||||
|
ALLE_FRAKTIONEN_SH,
|
||||||
|
FRAKTIONEN_MAP_SH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeFraktionenSh:
|
||||||
|
def test_simple_cdu(self):
|
||||||
|
assert _normalize_fraktionen_sh("die CDU") == ["CDU"]
|
||||||
|
|
||||||
|
def test_buendnis_normalizes_to_gruene(self):
|
||||||
|
assert _normalize_fraktionen_sh("BÜNDNIS 90/DIE GRÜNEN") == ["GRÜNE"]
|
||||||
|
|
||||||
|
def test_die_gruenen_normalizes(self):
|
||||||
|
assert _normalize_fraktionen_sh("DIE GRÜNEN") == ["GRÜNE"]
|
||||||
|
|
||||||
|
def test_ssw(self):
|
||||||
|
assert _normalize_fraktionen_sh("die SSW-Fraktion") == ["SSW"]
|
||||||
|
|
||||||
|
def test_combined_fraktionen(self):
|
||||||
|
result = _normalize_fraktionen_sh(
|
||||||
|
"die Fraktionen von SPD, FDP und SSW"
|
||||||
|
)
|
||||||
|
assert set(result) == {"SPD", "FDP", "SSW"}
|
||||||
|
|
||||||
|
def test_koalition_phrase(self):
|
||||||
|
result = _normalize_fraktionen_sh(
|
||||||
|
"die Fraktionen von CDU und BÜNDNIS 90/DIE GRÜNEN"
|
||||||
|
)
|
||||||
|
assert set(result) == {"CDU", "GRÜNE"}
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert _normalize_fraktionen_sh("") == []
|
||||||
|
|
||||||
|
def test_no_double_count_gruene(self):
|
||||||
|
# 'BÜNDNIS 90/DIE GRÜNEN' und 'GRÜNE' beide getroffen → nur 1× GRÜNE
|
||||||
|
result = _normalize_fraktionen_sh("BÜNDNIS 90/DIE GRÜNEN und GRÜNE")
|
||||||
|
assert result.count("GRÜNE") == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeText:
|
||||||
|
def test_collapses_whitespace(self):
|
||||||
|
assert _normalize_text("a b\n\tc") == "a b c"
|
||||||
|
|
||||||
|
def test_repairs_soft_hyphenation(self):
|
||||||
|
# Deutsche Silbentrennung am Zeilenumbruch
|
||||||
|
assert _normalize_text("zustim- men") == "zustimmen"
|
||||||
|
|
||||||
|
def test_preserves_legitimate_hyphens(self):
|
||||||
|
# Schulgeld-Kosten ist ein Kompositum, kein Trennstrich
|
||||||
|
# → "Schulgeld-Kosten" mit Großbuchstaben nach Bindestrich → bleibt
|
||||||
|
assert _normalize_text("Schulgeld-Kosten") == "Schulgeld-Kosten"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseVoteBlockSh:
|
||||||
|
def test_complete_qa_block(self):
|
||||||
|
block = (
|
||||||
|
"Wer dem zustimmen will, den bitte ich um das Handzeichen. "
|
||||||
|
"– 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."
|
||||||
|
)
|
||||||
|
votes = _parse_vote_block_sh(block)
|
||||||
|
assert set(votes["ja"]) == {"SPD", "FDP", "SSW"}
|
||||||
|
assert set(votes["nein"]) == {"CDU", "GRÜNE"}
|
||||||
|
assert votes["enthaltung"] == []
|
||||||
|
|
||||||
|
def test_block_with_enthaltung(self):
|
||||||
|
block = (
|
||||||
|
"Wer dem zustimmen will, den bitte ich um das Handzeichen. "
|
||||||
|
"– Das sind die Fraktionen von FDP und SSW. "
|
||||||
|
"Wer stimmt dagegen? – Das ist die CDU-Fraktion. "
|
||||||
|
"Wer enthält sich? – Das sind die Fraktionen von "
|
||||||
|
"BÜNDNIS 90/DIE GRÜNEN und SPD. Damit ist der Antrag abgelehnt."
|
||||||
|
)
|
||||||
|
votes = _parse_vote_block_sh(block)
|
||||||
|
assert set(votes["ja"]) == {"FDP", "SSW"}
|
||||||
|
assert set(votes["nein"]) == {"CDU"}
|
||||||
|
assert set(votes["enthaltung"]) == {"GRÜNE", "SPD"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDrucksacheSh:
|
||||||
|
def test_finds_drucksache_before_anchor(self):
|
||||||
|
text = (
|
||||||
|
"Drucksache 20/1234 ... Wer dem zustimmen will. – Das ist die SPD. "
|
||||||
|
"Damit ist der Antrag abgelehnt."
|
||||||
|
)
|
||||||
|
anchor = text.index("Damit")
|
||||||
|
assert _resolve_drucksache_sh(text, anchor) == "20/1234"
|
||||||
|
|
||||||
|
def test_picks_most_recent_drucksache(self):
|
||||||
|
text = (
|
||||||
|
"Drucksache 20/1000 ... Drucksache 20/2000 wird abgestimmt. "
|
||||||
|
"Damit ist der Antrag abgelehnt."
|
||||||
|
)
|
||||||
|
anchor = text.index("Damit")
|
||||||
|
assert _resolve_drucksache_sh(text, anchor) == "20/2000"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_ds(self):
|
||||||
|
assert _resolve_drucksache_sh("Damit ist abgelehnt.", 0) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResultAnchorRegex:
|
||||||
|
def test_matches_antrag_abgelehnt(self):
|
||||||
|
m = RESULT_ANCHOR_RE.search("Damit ist der Antrag abgelehnt.")
|
||||||
|
assert m and m.group("ergebnis") == "abgelehnt"
|
||||||
|
|
||||||
|
def test_matches_mehrheitlich_angenommen(self):
|
||||||
|
m = RESULT_ANCHOR_RE.search(
|
||||||
|
"Damit ist der Antrag mehrheitlich angenommen."
|
||||||
|
)
|
||||||
|
assert m and m.group("modus") == "mehrheitlich"
|
||||||
|
assert m.group("ergebnis") == "angenommen"
|
||||||
|
|
||||||
|
def test_matches_trotzdem_mit_mehrheit(self):
|
||||||
|
m = RESULT_ANCHOR_RE.search(
|
||||||
|
"Damit ist der Antrag trotzdem mit Mehrheit angenommen."
|
||||||
|
)
|
||||||
|
assert m and m.group("ergebnis") == "angenommen"
|
||||||
|
|
||||||
|
def test_matches_ausschussueberweisung_einstimmig(self):
|
||||||
|
m = RESULT_ANCHOR_RE.search(
|
||||||
|
"Damit ist die Ausschussüberweisung einstimmig so beschlossen."
|
||||||
|
)
|
||||||
|
assert m
|
||||||
|
assert m.group("modus") == "einstimmig"
|
||||||
|
assert "beschlossen" in m.group("ergebnis").lower()
|
||||||
|
|
||||||
|
def test_no_match_random_text(self):
|
||||||
|
m = RESULT_ANCHOR_RE.search("Der Antrag wurde abgelehnt.")
|
||||||
|
# 'Damit ist' fehlt
|
||||||
|
assert m is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
def test_all_fraktionen_complete(self):
|
||||||
|
# WP20-SH: CDU+GRÜNE Koalition, SPD+FDP+SSW Opposition
|
||||||
|
assert set(ALLE_FRAKTIONEN_SH) == {"CDU", "GRÜNE", "SPD", "FDP", "SSW"}
|
||||||
|
|
||||||
|
def test_mapping_covers_all_fraktionen(self):
|
||||||
|
all_codes = set()
|
||||||
|
for _phrase, codes in FRAKTIONEN_MAP_SH:
|
||||||
|
all_codes.update(codes)
|
||||||
|
for f in ALLE_FRAKTIONEN_SH:
|
||||||
|
assert f in all_codes, f"Fraktion {f} fehlt im FRAKTIONEN_MAP_SH"
|
||||||
@ -20,9 +20,9 @@ import pytest
|
|||||||
from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender
|
from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender
|
||||||
|
|
||||||
STUB_BL_CODES = [
|
STUB_BL_CODES = [
|
||||||
# BUND/BE/HH/TH/HE raus, weil seit 2026-04-28/29 produktive Parser
|
# BUND/BE/HH/TH/HE/SH raus, weil seit 2026-04-28/29 produktive Parser
|
||||||
"BB", "BW", "BY", "HB",
|
"BB", "BW", "BY", "HB",
|
||||||
"LSA", "MV", "NI", "RP", "SH", "SL", "SN",
|
"LSA", "MV", "NI", "RP", "SL", "SN",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -76,8 +76,8 @@ class TestRegistryDiscipline:
|
|||||||
|
|
||||||
def test_stubs_not_in_registry(self):
|
def test_stubs_not_in_registry(self):
|
||||||
registered = set(supported_bundeslaender())
|
registered = set(supported_bundeslaender())
|
||||||
# Aktuell: NRW + BUND + BE + HH + TH + HE produktiv
|
# Aktuell: NRW + BUND + BE + HH + TH + HE + SH produktiv
|
||||||
assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE"}, (
|
assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH"}, (
|
||||||
"Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, "
|
"Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, "
|
||||||
"diesen Test anpassen UND den Stub durch echten Parser ersetzen."
|
"diesen Test anpassen UND den Stub durch echten Parser ersetzen."
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user