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+)>\1>", 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: