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",
|
naechste_wahl="2029-09-02",
|
||||||
regierungsfraktionen=["CDU", "SPD"],
|
regierungsfraktionen=["CDU", "SPD"],
|
||||||
landtagsfraktionen=["CDU", "AfD", "BSW", "SPD", "LINKE", "GRÜNE"],
|
landtagsfraktionen=["CDU", "AfD", "BSW", "SPD", "LINKE", "GRÜNE"],
|
||||||
doku_system="Eigensystem",
|
doku_system="EDAS-XML-Export",
|
||||||
doku_base_url="https://edas.landtag.sachsen.de",
|
doku_base_url="https://edas.landtag.sachsen.de",
|
||||||
drucksache_format="8/1234",
|
drucksache_format="8/1234",
|
||||||
dokukratie_scraper="sn",
|
dokukratie_scraper="sn",
|
||||||
|
aktiv=True,
|
||||||
anmerkung=(
|
anmerkung=(
|
||||||
"Minderheitsregierung CDU+SPD (Kabinett Kretschmer III seit "
|
"Minderheitsregierung CDU+SPD (Kabinett Kretschmer III seit "
|
||||||
"18.12.2024). EDAS auf edas.landtag.sachsen.de ist eine "
|
"18.12.2024). EDAS ist ASP.NET-Webforms mit DevExpress-"
|
||||||
"ASP.NET-Webforms-Anwendung mit __VIEWSTATE/__CALLBACKID-"
|
"Postbacks UND robots.txt: Disallow: / — direktes Scraping "
|
||||||
"Postbacks (siehe dokukratie/sn.yml) — NICHT ParlDok-kompatibel "
|
"blockiert. Stattdessen liest SNEdasXmlAdapter die wöchentlich "
|
||||||
"mit MV/HH trotz älterer Wikipedia-Klassifikation. Eigener "
|
"manuell aus der EDAS-Suchmaske exportierte XML-Datei aus "
|
||||||
"Adapter notwendig (Issue #26)."
|
"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(
|
"LSA": Bundesland(
|
||||||
|
|||||||
@ -1845,6 +1845,207 @@ class PARLISAdapter(ParlamentAdapter):
|
|||||||
return None
|
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):
|
class PARiSHBAdapter(ParlamentAdapter):
|
||||||
"""Bremen-Adapter für PARiS (paris.bremische-buergerschaft.de).
|
"""Bremen-Adapter für PARiS (paris.bremische-buergerschaft.de).
|
||||||
|
|
||||||
@ -2506,6 +2707,7 @@ ADAPTERS = {
|
|||||||
"HB": PARiSHBAdapter(),
|
"HB": PARiSHBAdapter(),
|
||||||
"HE": StarWebHEAdapter(),
|
"HE": StarWebHEAdapter(),
|
||||||
"NRW": NRWAdapter(),
|
"NRW": NRWAdapter(),
|
||||||
|
"SN": SNEdasXmlAdapter(),
|
||||||
"LSA": PortalaAdapter(
|
"LSA": PortalaAdapter(
|
||||||
bundesland="LSA",
|
bundesland="LSA",
|
||||||
name="Landtag von Sachsen-Anhalt (PADOKA)",
|
name="Landtag von Sachsen-Anhalt (PADOKA)",
|
||||||
|
|||||||
@ -67,7 +67,9 @@ PARTEIEN: tuple[Partei, ...] = (
|
|||||||
Partei("SPD", "SPD", ("SPD", "Sozialdemokratische Partei")),
|
Partei("SPD", "SPD", ("SPD", "Sozialdemokratische Partei")),
|
||||||
Partei("GRÜNE", "BÜNDNIS 90/DIE GRÜNEN",
|
Partei("GRÜNE", "BÜNDNIS 90/DIE GRÜNEN",
|
||||||
("GRÜNE", "Grüne", "GRUENE", "Gruene",
|
("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("FDP", "FDP", ("FDP", "F.D.P.", "F. D. P.", "F.D.P", "FDP-DVP")),
|
||||||
Partei("LINKE", "DIE LINKE",
|
Partei("LINKE", "DIE LINKE",
|
||||||
("LINKE", "Die Linke", "DIE LINKE", "LL/PDS", "Linkspartei")),
|
("LINKE", "Die Linke", "DIE LINKE", "LL/PDS", "Linkspartei")),
|
||||||
|
|||||||
@ -77,9 +77,12 @@ class TestClassificationFix48:
|
|||||||
"""
|
"""
|
||||||
assert BUNDESLAENDER["HB"].doku_system == "PARiS"
|
assert BUNDESLAENDER["HB"].doku_system == "PARiS"
|
||||||
|
|
||||||
def test_sn_is_eigensystem_not_parldok(self):
|
def test_sn_uses_xml_export_not_parldok(self):
|
||||||
"""EDAS is ASP.NET-Webforms, NOT ParlDok-compatible with MV."""
|
"""EDAS ist ASP.NET-Webforms (NICHT ParlDok-kompatibel) und
|
||||||
assert BUNDESLAENDER["SN"].doku_system == "Eigensystem"
|
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:
|
class TestWahltermineSane:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user