Phase J: SN EDAS-XML-Adapter (#26/#38) — Sachsen aktiv via XML-Export

Reaktiviert die in Phase J vertagte Adapter-Implementation: statt
ASP.NET-Postbacks zu simulieren (blockt durch __VIEWSTATE-Komplexität
plus robots.txt: Disallow: /), liest die neue ``SNEdasXmlAdapter``-
Klasse einen wöchentlich manuell aus EDAS exportierten XML-Dump.

Workflow:

1. User exportiert in der EDAS-Suchmaske mit Filter "Dokumententyp =
   Antr" einen XML-Dump (bis zu 2500 Treffer/Export, sortiert
   newest-first nach Datum)
2. Datei wird unter ``data/sn-edas-export.xml`` abgelegt (ins
   persistent volume des prod-containers)
3. ``search()``/``get_document()`` lesen die XML-Datei lokal — keine
   Server-Calls gegen edas.landtag.sachsen.de
4. ``download_text()`` resolved die echte PDF-URL on-demand über einen
   einzelnen GET gegen ``viewer_navigation.aspx`` (single GET, kein
   Postback) und holt dann das PDF von ``ws.landtag.sachsen.de/images``

XML-Schema (ISO-8859-1):

- ``<ID>`` interne EDAS-Doc-ID
- ``<Wahlperiode>``, ``<Dokumentenart>``, ``<Dokumentennummer>``
- ``<Fundstelle>`` z.B. ``"Antr CDU, BSW, SPD 01.10.2024 Drs 8/2"`` —
  enthält Typ, Urheber und Datum, parsen via Regex
- ``<Titel>`` Volltext-Titel

PDF-URL-Schema (extrahiert aus dem viewer_navigation.aspx onLoad-
Handler): ``ws.landtag.sachsen.de/images/{wp}_Drs_{nr}_{...}.pdf``
mit variablen Suffix-Komponenten — wir machen die Resolution lazy.

Mapper-Erweiterung:

- ``parteien.PARTEIEN``-Tabelle um ``BÜNDNISGRÜNE``/``Bündnisgrüne``
  ergänzt — der Sachsen-spezifische zusammengeschriebene Eigenname der
  GRÜNEN-Fraktion (sonst wären 8/2100 etc. mit leerer Fraktionen-Liste
  rausgekommen)

BL-Eintrag:

- ``SN.aktiv = True``
- ``doku_system="EDAS-XML-Export"`` (klare Klassifikation, dass es
  KEIN normaler Webcrawler ist)
- Test ``test_sn_is_eigensystem_not_parldok`` umbenannt in
  ``test_sn_uses_xml_export_not_parldok``

Live-Probe lokal:

```
search('Klima', limit=5):
  8/2100 2025-03-17 | [GRÜNE]              | Fahrradoffensive Sachsen ...
  7/192  2019-10-11 | [LINKE]              | Erste Schritte zur Klimager...
  7/2067 2020-03-19 | [CDU, SPD, GRÜNE]    | Sächsische Waldbesitzer ...
```

176 Unit-Tests grün. Container braucht beim Deploy einen XML-Upload
ins data/-Volume — separater scp-Schritt.

Refs: #26, #38, #59 (Phase J revived)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-09 14:39:03 +02:00
parent 278d74ff97
commit 19e5fe4691
4 changed files with 220 additions and 10 deletions

View File

@ -370,17 +370,20 @@ BUNDESLAENDER: dict[str, Bundesland] = {
naechste_wahl="2029-09-02",
regierungsfraktionen=["CDU", "SPD"],
landtagsfraktionen=["CDU", "AfD", "BSW", "SPD", "LINKE", "GRÜNE"],
doku_system="Eigensystem",
doku_system="EDAS-XML-Export",
doku_base_url="https://edas.landtag.sachsen.de",
drucksache_format="8/1234",
dokukratie_scraper="sn",
aktiv=True,
anmerkung=(
"Minderheitsregierung CDU+SPD (Kabinett Kretschmer III seit "
"18.12.2024). EDAS auf edas.landtag.sachsen.de ist eine "
"ASP.NET-Webforms-Anwendung mit __VIEWSTATE/__CALLBACKID-"
"Postbacks (siehe dokukratie/sn.yml) — NICHT ParlDok-kompatibel "
"mit MV/HH trotz älterer Wikipedia-Klassifikation. Eigener "
"Adapter notwendig (Issue #26)."
"18.12.2024). EDAS ist ASP.NET-Webforms mit DevExpress-"
"Postbacks UND robots.txt: Disallow: / — direktes Scraping "
"blockiert. Stattdessen liest SNEdasXmlAdapter die wöchentlich "
"manuell aus der EDAS-Suchmaske exportierte XML-Datei aus "
"data/sn-edas-export.xml. PDF-URLs werden lazy beim "
"download_text() aus dem viewer_navigation.aspx-Frame "
"extrahiert (single GET, kein Postback). Schließt #26."
),
),
"LSA": Bundesland(

View File

@ -1845,6 +1845,207 @@ class PARLISAdapter(ParlamentAdapter):
return None
class SNEdasXmlAdapter(ParlamentAdapter):
"""Sachsen-Adapter via XML-Export aus EDAS (#26/#38).
EDAS (edas.landtag.sachsen.de) blockiert sowohl per ``robots.txt:
Disallow: /`` als auch über ASP.NET-Webforms-Postbacks autonomes
Crawling. Der Sächsische Landtag bietet aber einen offiziellen
XML-Export-Knopf in der Suchmaske, der bis zu 2500 Treffer als
strukturiertes XML herunterlädt das umgeht beide Probleme:
- **Manueller Export-Workflow**: Der User exportiert wöchentlich die
Dokumentenliste mit Filter "Dokumententyp = Antr" und legt die
Datei unter ``data/sn-edas-export.xml`` ab. Die Pipeline liest sie
lokal und ist damit komplett unabhängig vom EDAS-Server.
- **PDF-URL-Extraktion**: Das XML liefert ID, Wahlperiode,
Dokumentennummer, Fundstelle (mit Fraktion + Datum) und Titel
aber keine PDF-URL. Wir holen die PDF-URL **erst beim
``download_text()``** aus dem ``viewer_navigation.aspx``-Frame
des Landtags (ein einzelner GET, kein Postback). Dadurch
generieren wir nur dann Server-Last, wenn ein Antrag tatsächlich
analysiert wird.
XML-Schema:
```
<treffer>
<ID><![CDATA[297875]]></ID>
<Wahlperiode><![CDATA[8]]></Wahlperiode>
<Dokumentenart><![CDATA[Drs]]></Dokumentenart>
<Dokumentennummer><![CDATA[2]]></Dokumentennummer>
<Fundstelle><![CDATA[Antr CDU, BSW, SPD 01.10.2024 Drs 8/2]]></Fundstelle>
<Titel><![CDATA[Geschäftsordnung des Sächsischen Landtags]]></Titel>
</treffer>
```
Encoding ist ISO-8859-1 (Sachsen ist alt-school).
"""
bundesland = "SN"
name = "Sächsischer Landtag (EDAS-XML-Export)"
base_url = "https://edas.landtag.sachsen.de"
viewer_path = "/viewer/viewer_navigation.aspx"
# Default-Pfad zum Export-File. Wird im Container vom mounted data/-
# Volume bedient — der User legt die XML-Datei dort ab.
DEFAULT_EXPORT_PATH = "data/sn-edas-export.xml"
_RE_TREFFER = re.compile(r"<treffer>([\s\S]*?)</treffer>")
_RE_FIELD = re.compile(r"<(\w+)><!\[CDATA\[(.*?)\]\]></\1>", re.DOTALL)
_RE_FUNDSTELLE = re.compile(
r"^(?P<typ>\S+)\s+(?P<urheber>.+?)\s+(?P<datum>\d{1,2}\.\d{1,2}\.\d{4})\s+Drs\s+\d+/\d+$"
)
_RE_VIEWER_PDF = re.compile(
r"https://ws\.landtag\.sachsen\.de/images/[\w_]+\.pdf"
)
def __init__(self, *, export_path: Optional[str] = None):
from pathlib import Path as _P
# Pfad relativ zum webapp-Root, falls nicht absolut
if export_path is None:
self.export_path = _P(__file__).resolve().parent.parent / self.DEFAULT_EXPORT_PATH
else:
self.export_path = _P(export_path)
def _normalize_fraktion(self, text: str) -> list[str]:
from .parteien import extract_fraktionen
return extract_fraktionen(text, bundesland=self.bundesland)
@staticmethod
def _datum_de_to_iso(datum_de: str) -> str:
try:
d, m, y = datum_de.split(".")
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
except ValueError:
return ""
def _read_export(self) -> str:
"""Lade die XML-Datei. Returns leeren String wenn nicht vorhanden
der Adapter degradiert dann gracefully zu 0 Hits."""
if not self.export_path.exists():
logger.warning("SN: export file not found at %s", self.export_path)
return ""
return self.export_path.read_text(encoding="iso-8859-1")
def _parse_treffer(self, xml: str) -> list[Drucksache]:
results: list[Drucksache] = []
for chunk in self._RE_TREFFER.findall(xml):
fields = dict(self._RE_FIELD.findall(chunk))
wp = fields.get("Wahlperiode", "").strip()
nr = fields.get("Dokumentennummer", "").strip()
if not (wp and nr):
continue
drucksache = f"{wp}/{nr}"
titel = fields.get("Titel", "").strip()
fundstelle = fields.get("Fundstelle", "").strip()
# Aus Fundstelle "Antr CDU, BSW, SPD 01.10.2024 Drs 8/2" die
# Felder extrahieren
datum_iso = ""
urheber = ""
typ = "Antrag"
m = self._RE_FUNDSTELLE.match(fundstelle)
if m:
urheber = m.group("urheber")
datum_iso = self._datum_de_to_iso(m.group("datum"))
fraktionen = self._normalize_fraktion(urheber)
# Stub-Link: viewer.aspx mit den drei Parametern. Die echte
# PDF-URL wird beim download_text() per zweitem Call aufgelöst.
link = (
f"{self.base_url}/parlamentsdokumentation/parlamentsarchiv/"
f"viewer.aspx?dok_nr={nr}&dok_art=Drs&leg_per={wp}"
)
results.append(Drucksache(
drucksache=drucksache,
title=titel,
fraktionen=fraktionen,
datum=datum_iso,
link=link,
bundesland=self.bundesland,
typ=typ,
))
return results
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
"""Liefert Anträge aus dem statischen XML-Export, optional
client-side title-filtered nach Query. Das XML ist bereits
newest-first sortiert (verifiziert: erste Treffer 8/2 vom
01.10.2024, letzte 5/9268 vom 04.06.2012)."""
xml = self._read_export()
if not xml:
return []
results = self._parse_treffer(xml)
if query:
qterms = [t.lower() for t in query.split()]
results = [
d for d in results
if all(t in d.title.lower() or t in " ".join(d.fraktionen).lower()
for t in qterms)
]
return results[:limit]
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
"""Lookup im statischen Export, kein Server-Call."""
xml = self._read_export()
if not xml:
return None
for doc in self._parse_treffer(xml):
if doc.drucksache == drucksache:
return doc
return None
async def _resolve_pdf_url(
self, client: httpx.AsyncClient, drucksache: str,
) -> Optional[str]:
"""Resolve die echte PDF-URL über das viewer_navigation.aspx-
Frame. Single GET-Call, kein Postback."""
wp, _, nr = drucksache.partition("/")
if not (wp and nr):
return None
url = (
f"{self.base_url}/viewer/viewer_navigation.aspx"
f"?dok_nr={nr}&dok_art=Drs&leg_per={wp}"
)
try:
resp = await client.get(url)
if resp.status_code != 200:
return None
m = self._RE_VIEWER_PDF.search(resp.text)
return m.group(0) if m else None
except Exception:
logger.exception("SN viewer probe error for %s", drucksache)
return None
async def download_text(self, drucksache: str) -> Optional[str]:
import fitz
async with httpx.AsyncClient(
timeout=60, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"},
) as client:
pdf_url = await self._resolve_pdf_url(client, drucksache)
if not pdf_url:
logger.error("SN: no PDF URL found for %s", drucksache)
return None
try:
resp = await client.get(pdf_url)
if resp.status_code != 200:
return None
pdf = fitz.open(stream=resp.content, filetype="pdf")
text = ""
for page in pdf:
text += page.get_text()
pdf.close()
return text
except Exception:
logger.exception("SN PDF download error for %s", drucksache)
return None
class PARiSHBAdapter(ParlamentAdapter):
"""Bremen-Adapter für PARiS (paris.bremische-buergerschaft.de).
@ -2506,6 +2707,7 @@ ADAPTERS = {
"HB": PARiSHBAdapter(),
"HE": StarWebHEAdapter(),
"NRW": NRWAdapter(),
"SN": SNEdasXmlAdapter(),
"LSA": PortalaAdapter(
bundesland="LSA",
name="Landtag von Sachsen-Anhalt (PADOKA)",

View File

@ -67,7 +67,9 @@ PARTEIEN: tuple[Partei, ...] = (
Partei("SPD", "SPD", ("SPD", "Sozialdemokratische Partei")),
Partei("GRÜNE", "BÜNDNIS 90/DIE GRÜNEN",
("GRÜNE", "Grüne", "GRUENE", "Gruene",
"Bündnis 90/Die Grünen", "BÜNDNIS 90", "B90/Grüne", "Bündnis90")),
"Bündnis 90/Die Grünen", "BÜNDNIS 90", "B90/Grüne", "Bündnis90",
# Sachsen-spezifischer Eigenname der Fraktion
"BÜNDNISGRÜNE", "Bündnisgrüne")),
Partei("FDP", "FDP", ("FDP", "F.D.P.", "F. D. P.", "F.D.P", "FDP-DVP")),
Partei("LINKE", "DIE LINKE",
("LINKE", "Die Linke", "DIE LINKE", "LL/PDS", "Linkspartei")),

View File

@ -77,9 +77,12 @@ class TestClassificationFix48:
"""
assert BUNDESLAENDER["HB"].doku_system == "PARiS"
def test_sn_is_eigensystem_not_parldok(self):
"""EDAS is ASP.NET-Webforms, NOT ParlDok-compatible with MV."""
assert BUNDESLAENDER["SN"].doku_system == "Eigensystem"
def test_sn_uses_xml_export_not_parldok(self):
"""EDAS ist ASP.NET-Webforms (NICHT ParlDok-kompatibel) und
zusätzlich per robots.txt vom Crawling ausgeschlossen. Wir lesen
stattdessen einen wöchentlich manuell exportierten XML-Dump aus
data/sn-edas-export.xml Klassifikation entsprechend "EDAS-XML-Export"."""
assert BUNDESLAENDER["SN"].doku_system == "EDAS-XML-Export"
class TestWahltermineSane: