From 19e5fe469127441b6d1f99d364de5200e18aee2c Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Thu, 9 Apr 2026 14:39:03 +0200 Subject: [PATCH] =?UTF-8?q?Phase=20J:=20SN=20EDAS-XML-Adapter=20(#26/#38)?= =?UTF-8?q?=20=E2=80=94=20Sachsen=20aktiv=20via=20XML-Export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): - ```` interne EDAS-Doc-ID - ````, ````, ```` - ```` z.B. ``"Antr CDU, BSW, SPD 01.10.2024 Drs 8/2"`` — enthält Typ, Urheber und Datum, parsen via Regex - ```` 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) --- app/bundeslaender.py | 15 +-- app/parlamente.py | 202 ++++++++++++++++++++++++++++++++++++ app/parteien.py | 4 +- tests/test_bundeslaender.py | 9 +- 4 files changed, 220 insertions(+), 10 deletions(-) diff --git a/app/bundeslaender.py b/app/bundeslaender.py index ccbb6dc..36b8a0a 100644 --- a/app/bundeslaender.py +++ b/app/bundeslaender.py @@ -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( diff --git a/app/parlamente.py b/app/parlamente.py index 60180c3..7512b7a 100644 --- a/app/parlamente.py +++ b/app/parlamente.py @@ -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: + + ``` + + + + + + + + + ``` + + 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"([\s\S]*?)") + _RE_FIELD = re.compile(r"<(\w+)>", re.DOTALL) + _RE_FUNDSTELLE = re.compile( + r"^(?P\S+)\s+(?P.+?)\s+(?P\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)", diff --git a/app/parteien.py b/app/parteien.py index 0ea077b..efa3b2c 100644 --- a/app/parteien.py +++ b/app/parteien.py @@ -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")), diff --git a/tests/test_bundeslaender.py b/tests/test_bundeslaender.py index f49fe12..b19b0f7 100644 --- a/tests/test_bundeslaender.py +++ b/tests/test_bundeslaender.py @@ -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: