#19 SaarlandAdapter — Umbraco JSON-API mit Iframe-Unwrap
Reverse-Engineering aus HAR-Capture (User-Browser, /suche?searchValue=Schule):
- Endpoint: POST /umbraco/aawSearchSurfaceController/SearchSurface/GetSearchResults/
- Content-Type: application/x-www-form-urlencoded; charset=UTF-8 mit rohem
JSON im Body (Kendo-Konvention von $.ajax ohne expliziten contentType)
- Body MUSS Sections={} und Sort={} als leere Dicts haben — sobald
Sections.Print/etc. gesetzt sind, antwortet der Server mit HTTP 500
(eigene Stunden in der Sackgasse, bis HAR den minimalen Body zeigte)
- Body-Schema: {Filter:{Periods:[17]}, Pageination:{Skip,Take}, Sections:{},
Sort:{}, OnlyTitle:false, Value:<query>, CurrentSearchTab:0}
Response-Mapping (FilteredResult[*]):
- DocumentNumber → drucksache (e.g. "17/11")
- Title → title
- DocumentType → typ; client-side gefiltert auf "Antrag" (Print-Section
enthält Anfragen + Anträge + Gesetzentwürfe gemischt, ~30-50% sind Anträge)
- Publisher (kollektive Anträge: "CDU"/"SPD") + DocumentAuthor
(individuelle MdL: "Name, Vorname (CDU);…") via parteien.extract_fraktionen
- PublicDate (ISO mit T-Suffix) → datum (auf 10 Zeichen abgeschnitten)
- FilePath: ``/file.ashx?FileId=…&FileName=…`` ist ein HTML-Iframe-Wrapper
(455 Bytes), nicht das PDF! Echter Binär-Endpoint ist
``/Downloadfile.ashx`` (Großbuchstabe!) mit denselben Query-Parametern.
Der Wrapper hat mich beim ersten Smoke-Test mit "no objects found"
angeschmissen, der Iframe-Hint im HTML hat den Trick verraten.
Drucksachen-Lookup nutzt ``Value=<drucksache>``: der Server matcht die
Nummer im Volltext und liefert sie zuverlässig als ersten Hit. Kein
dedizierter GetById-Endpoint vorhanden.
Smoke-Test gegen prod (im Container):
- search("Schule", limit=5) → 2 Anträge in WP17 (140 Print-Hits gesamt,
Antrag-Filter auf 2/140 — der Rest sind Anfragen/Gesetzentwürfe):
17/11 [CDU] "Schule als Lern- und Bildungsort weiter stärken …"
17/419 [AfD] "Eine gute Bildungspolitik als wesentlicher Bestandteil …"
- get_document("17/11") → match
- download_text("17/11") → 3520 chars echter Antrags-Volltext (Header,
Fraktion, Resolutionstext)
Tests: 185/185 grün (keine Regression).
UI-Aktivierung erfolgt separat in #31 (blockiert auf diesem Commit).
Refs: #19, #49 (Roadmap Phase 3)
This commit is contained in:
parent
6ced7ae018
commit
6dfcd69979
@ -2701,6 +2701,227 @@ class BundestagAdapter(ParlamentAdapter):
|
|||||||
return None
|
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=<drucksache>`` — 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
|
# Registry of adapters
|
||||||
ADAPTERS = {
|
ADAPTERS = {
|
||||||
"BUND": BundestagAdapter(),
|
"BUND": BundestagAdapter(),
|
||||||
@ -2797,6 +3018,7 @@ ADAPTERS = {
|
|||||||
document_type="Antrag",
|
document_type="Antrag",
|
||||||
),
|
),
|
||||||
"BY": BayernAdapter(),
|
"BY": BayernAdapter(),
|
||||||
|
"SL": SaarlandAdapter(),
|
||||||
"BW": PARLISAdapter(
|
"BW": PARLISAdapter(
|
||||||
bundesland="BW",
|
bundesland="BW",
|
||||||
name="Landtag von Baden-Württemberg (PARLIS)",
|
name="Landtag von Baden-Württemberg (PARLIS)",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user