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:
parent
278d74ff97
commit
19e5fe4691
@ -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(
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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")),
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user