diff --git a/app/parlamente.py b/app/parlamente.py index 7512b7a..fe8a7b5 100644 --- a/app/parlamente.py +++ b/app/parlamente.py @@ -2701,6 +2701,227 @@ class BundestagAdapter(ParlamentAdapter): return None +class SaarlandAdapter(ParlamentAdapter): + """Adapter für den Landtag des Saarlandes via Umbraco JSON-API (#19). + + Backend ist eine Umbraco/.NET-SurfaceController-Schicht hinter + ``www.landtag-saar.de``. Die Suchseite ``/suche?searchValue=…`` lädt + ihre Ergebnisse via XHR-POST gegen + ``/umbraco/aawSearchSurfaceController/SearchSurface/GetSearchResults/``. + + Schema reverse-engineered aus einem HAR-Capture (User-Browser, gegen + ``Schule``-Suche). Wichtig: + + - Content-Type ist ``application/x-www-form-urlencoded; charset=UTF-8``, + aber der Body ist trotzdem **rohes JSON** (Kendo-Konvention von + ``$.ajax`` ohne explizites ``contentType``). Ein + ``application/json``-Header funktioniert auch, aber nur mit der + minimalen Body-Form unten — sobald ``Sections.{Print,Operations,…}`` + gesetzt sind, antwortet der Server mit HTTP 500. Mit ``Sections:{}`` + ist alles OK und der Server liefert die Hits sektionsübergreifend. + - Body-Schema: + + ```json + { + "Filter": {"Periods": [17]}, + "Pageination": {"Skip": 0, "Take": 10}, + "Sections": {}, + "Sort": {}, + "OnlyTitle": false, + "Value": "Schule", + "CurrentSearchTab": 0 + } + ``` + + - Response: ``FilteredResult[]`` mit pro Item ``DocumentNumber`` + (``"17/11"``), ``Legislative`` (Wahlperiode int), ``DocumentType`` + (``"Antrag"``/``"Anfrage"``/``"Gesetzentwurf"``/…), ``Title``, + ``PublicDate``, ``DocumentAuthor`` (Liste mit ``Name (Partei);…``), + ``Publisher`` (Fraktion bei kollektiven Anträgen), ``FilePath`` + (relativ, ``/file.ashx?FileId=…&FileName=…``). + + Der Filter auf ``DocumentType=="Antrag"`` läuft client-side, weil die + Server-Sections-Struktur die Filter-Granularität nicht hat (Print + enthält Anfragen + Anträge + Gesetzentwürfe gemischt). + + Drucksachen-Lookup: ``Value="17/11"`` matched die Drucksachen-Nummer + direkt an erster Position — ein dedizierter ``GetById``-Endpoint + existiert nicht. + """ + + bundesland = "SL" + name = "Landtag des Saarlandes" + base_url = "https://www.landtag-saar.de" + + def __init__(self, *, wahlperiode: int = 17): + self.wahlperiode = wahlperiode + + def _make_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + timeout=30, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 GWOE-Antragspruefer", + "Accept": "application/json, text/javascript, */*; q=0.01", + "X-Requested-With": "XMLHttpRequest", + "Origin": self.base_url, + "Referer": f"{self.base_url}/suche?searchValue=&ActiveTab=0", + }, + ) + + def _build_body(self, query: str, *, skip: int = 0, take: int = 50) -> str: + """Bauen den minimalen Body, der vom Server akzeptiert wird. + + Beachte: ``Sections={}`` und ``Sort={}`` sind PFLICHT als leere + Objekte (nicht weglassen, nicht ausfüllen — ausgefüllte Sections + triggern HTTP 500). + """ + return json.dumps({ + "Filter": {"Periods": [self.wahlperiode]}, + "Pageination": {"Skip": skip, "Take": take}, + "Sections": {}, + "Sort": {}, + "OnlyTitle": False, + "Value": query or "", + "CurrentSearchTab": 0, + }) + + @staticmethod + def _doc_to_drucksache(item: dict) -> Optional[Drucksache]: + from .parteien import extract_fraktionen + + nummer = item.get("DocumentNumber") + if not nummer: + return None + + # Fraktionen aus Publisher (kollektive Anträge: "CDU", "SPD") oder + # DocumentAuthor (individuelle MdL: "Schmitt-Lang, Jutta (CDU)"). + # Beides via extract_fraktionen normalisiert. + publisher = item.get("Publisher") or "" + author = item.get("DocumentAuthor") or "" + fraktionen = extract_fraktionen( + f"{publisher} {author}".strip(), bundesland="SL", + ) + + # PublicDate ist im Format ``2022-05-12T00:00:00`` — ISO-Date abschneiden. + public_date = (item.get("PublicDate") or "")[:10] + + # ``FilePath`` ist ``/file.ashx?FileId=…&FileName=…`` — der gibt + # aber HTML mit einem Iframe-Wrapper zurück, nicht das PDF selbst. + # Der echte Binär-Endpoint ist ``/Downloadfile.ashx`` (Großbuchstabe!) + # mit denselben Query-Parametern. Server liefert dort + # ``Content-Type: application/pdf``. + file_path = item.get("FilePath") or "" + if file_path.startswith("/file.ashx"): + file_path = file_path.replace("/file.ashx", "/Downloadfile.ashx", 1) + link = ( + f"https://www.landtag-saar.de{file_path}" + if file_path.startswith("/") else file_path + ) + + return Drucksache( + drucksache=nummer, + title=item.get("Title", ""), + fraktionen=fraktionen, + datum=public_date, + link=link, + bundesland="SL", + typ=item.get("DocumentType", ""), + ) + + async def _post_search( + self, client: httpx.AsyncClient, query: str, *, skip: int = 0, take: int = 50, + ) -> list[dict]: + url = ( + f"{self.base_url}/umbraco/aawSearchSurfaceController/" + "SearchSurface/GetSearchResults/" + ) + body = self._build_body(query, skip=skip, take=take) + try: + resp = await client.post( + url, + content=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + ) + if resp.status_code != 200: + logger.error("SL HTTP %s: %s", resp.status_code, resp.text[:200]) + return [] + data = resp.json() + return data.get("FilteredResult", []) or [] + except Exception: + logger.exception("SL search request error") + return [] + + async def search(self, query: str, limit: int = 20) -> list[Drucksache]: + """Volltextsuche über die aktuelle Wahlperiode, gefiltert auf Anträge. + + Holt 5*limit Hits in einer Page, filtert client-side auf + ``DocumentType=="Antrag"`` (Print-Section enthält auch Anfragen + und Gesetzentwürfe), und kürzt auf ``limit``. Sortierung kommt + relevance-based vom Server — für die UI ist Relevanz zu einer + Query meist wertvoller als Date-DESC. + """ + async with self._make_client() as client: + # Take großzügig, weil der Antrag-Filter ~30-50% der Hits drosselt + take = max(limit * 5, 30) + items = await self._post_search(client, query, skip=0, take=take) + + results: list[Drucksache] = [] + seen: set[str] = set() + for item in items: + if (item.get("DocumentType") or "").lower() != "antrag": + continue + doc = self._doc_to_drucksache(item) + if doc is None or doc.drucksache in seen: + continue + seen.add(doc.drucksache) + results.append(doc) + if len(results) >= limit: + break + return results + + async def get_document(self, drucksache: str) -> Optional[Drucksache]: + """Direktes Lookup via ``Value=`` — die Server-Suche + matcht die Drucksachen-Nummer im Dokument selbst und liefert sie + zuverlässig als ersten Treffer.""" + async with self._make_client() as client: + items = await self._post_search(client, drucksache, take=20) + + for item in items: + if item.get("DocumentNumber") == drucksache: + return self._doc_to_drucksache(item) + return None + + async def download_text(self, drucksache: str) -> Optional[str]: + """Hole das Antrags-PDF via ``/file.ashx`` und extrahiere Volltext.""" + import fitz + + doc = await self.get_document(drucksache) + if doc is None or not doc.link: + return None + + async with httpx.AsyncClient( + timeout=60, follow_redirects=True, + headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"}, + ) as client: + try: + resp = await client.get(doc.link) + if resp.status_code != 200: + logger.error("SL PDF HTTP %s for %s", resp.status_code, drucksache) + 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("SL download error for %s", drucksache) + return None + + # Registry of adapters ADAPTERS = { "BUND": BundestagAdapter(), @@ -2797,6 +3018,7 @@ ADAPTERS = { document_type="Antrag", ), "BY": BayernAdapter(), + "SL": SaarlandAdapter(), "BW": PARLISAdapter( bundesland="BW", name="Landtag von Baden-Württemberg (PARLIS)",