"""Parliament search adapters for different German states.""" import json import logging import httpx import re from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional from bs4 import BeautifulSoup logger = logging.getLogger(__name__) @dataclass class Drucksache: """A parliamentary document.""" drucksache: str # e.g. "18/8125" title: str fraktionen: list[str] datum: str # ISO date link: str # PDF URL bundesland: str typ: str = "Antrag" # Antrag, Anfrage, Beschlussempfehlung, etc. class ParlamentAdapter(ABC): """Base adapter for searching parliament documents.""" bundesland: str name: str @abstractmethod async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Search for documents matching query.""" pass @abstractmethod async def get_document(self, drucksache: str) -> Optional[Drucksache]: """Get a specific document by ID.""" pass @abstractmethod async def download_text(self, drucksache: str) -> Optional[str]: """Download and extract text from a document.""" pass class NRWAdapter(ParlamentAdapter): """Adapter for NRW Landtag (opal.landtag.nrw.de).""" bundesland = "NRW" name = "Landtag Nordrhein-Westfalen" base_url = "https://opal.landtag.nrw.de" search_url = "https://opal.landtag.nrw.de/home/dokumente/dokumentensuche/parlamentsdokumente/aktuelle-dokumente.html" def _parse_query(self, query: str) -> tuple[str, list[str], bool]: """ Parse search query for AND logic and exact phrases. Returns: (search_term_for_api, filter_terms, is_exact) Examples: - 'Klimaschutz Energie' -> ('Klimaschutz', ['klimaschutz', 'energie'], False) - '"Grüner Stahl"' -> ('Grüner Stahl', ['grüner stahl'], True) - 'Klimaschutz "erneuerbare Energie"' -> ('Klimaschutz', ['klimaschutz', 'erneuerbare energie'], False) """ query = query.strip() # Check for exact phrase (entire query in quotes) if query.startswith('"') and query.endswith('"') and query.count('"') == 2: exact = query[1:-1].strip() return (exact, [exact.lower()], True) # Extract quoted phrases and regular terms import shlex try: parts = shlex.split(query) except ValueError: # Fallback for unbalanced quotes parts = query.split() if not parts: return (query, [query.lower()], False) # Use first term for API search, all terms for filtering filter_terms = [p.lower() for p in parts] return (parts[0], filter_terms, False) def _matches_all_terms(self, doc: 'Drucksache', terms: list[str], is_exact: bool) -> bool: """Check if document matches all search terms (AND logic).""" searchable = f"{doc.title} {doc.drucksache} {' '.join(doc.fraktionen)} {doc.typ}".lower() if is_exact: # Exact phrase must appear return terms[0] in searchable else: # All terms must appear (AND) return all(term in searchable for term in terms) async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Search NRW Landtag documents via OPAL portal.""" results = [] # Parse query for AND logic api_query, filter_terms, is_exact = self._parse_query(query) async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: try: # First, get the page to establish session initial = await client.get(self.search_url) if initial.status_code != 200: logger.error("NRW search initial request failed: %s", initial.status_code) return [] # Parse for webflow token from pagination links soup = BeautifulSoup(initial.text, 'html.parser') # Find a pagination link to extract the webflow token pagination_link = soup.select_one('a[href*="webflowexecution"]') webflow_token = "" webflow_execution = "" if pagination_link: href = pagination_link.get('href', '') # Extract webflowToken and webflowexecution from URL token_match = re.search(r'webflowToken=([^&]*)', href) exec_match = re.search(r'(webflowexecution[^=]+)=([^&]+)', href) if token_match: webflow_token = token_match.group(1) if exec_match: webflow_execution = f"{exec_match.group(1)}={exec_match.group(2)}" # Now perform the search with POST # Find the form action URL with webflow token form = soup.select_one('form#docSearchByItem') form_action = self.search_url if form and form.get('action'): action = form.get('action') if action.startswith('/'): form_action = f"{self.base_url}{action}" elif action.startswith('http'): form_action = action else: form_action = f"{self.search_url}?{action}" # Build form data for "Einfache Suche" (searchByItem form) form_data = { '_eventId_sendform': '1', 'dokNum': api_query, # This is the text search field 'formId': 'searchByItem', 'dokTyp': '', # All types 'wp': '18', # Wahlperiode 18 } # POST request with form data to the form action URL search_resp = await client.post( form_action, data=form_data, cookies=initial.cookies, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) if search_resp.status_code != 200: logger.error("NRW search request failed: %s", search_resp.status_code) return [] # Parse results soup = BeautifulSoup(search_resp.text, 'html.parser') # Find all document result items (li elements containing articles) items = soup.select('li:has(article)') for item in items[:limit]: try: # Extract drucksache number from first link num_link = item.select_one('a[href*="MMD"]') if not num_link: continue href = num_link.get('href', '') # Extract number: MMD18-12345.pdf -> 18/12345 match = re.search(r'MMD(\d+)-(\d+)\.pdf', href) if not match: continue legislatur, nummer = match.groups() drucksache = f"{legislatur}/{nummer}" pdf_url = f"https://www.landtag.nrw.de{href}" if href.startswith('/') else href # Extract title from the title link (class e-document-result-item__title) title_elem = item.select_one('a.e-document-result-item__title') if title_elem: # Get text content, clean it up title = title_elem.get_text(strip=True) # Remove SVG icon text and clean title = re.sub(r'\s* Optional[Drucksache]: """Get document metadata by drucksache ID (e.g. '18/8125'). Nutzt ``search(drucksache)`` um den echten Titel und die Fraktionen von OPAL zu bekommen. Fallback auf generischen PDF-Link wenn die Suche nichts findet. """ # Versuch 1: über die Suche den echten Eintrag finden results = await self.search(drucksache, limit=10) for doc in results: if doc.drucksache == drucksache: return doc # Fallback: PDF-Link konstruieren ohne Titel/Fraktionen match = re.match(r"(\d+)/(\d+)", drucksache) if not match: return None legislatur, nummer = match.groups() pdf_url = f"https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMD{legislatur}-{nummer}.pdf" async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: try: resp = await client.head(pdf_url) if resp.status_code == 200: return Drucksache( drucksache=drucksache, title="", # Leer statt generisch, damit LLM-Titel nicht überschrieben wird fraktionen=[], datum="", link=pdf_url, bundesland="NRW", ) except: pass return None async def download_text(self, drucksache: str) -> Optional[str]: """Download PDF and extract text.""" import fitz # PyMuPDF doc = await self.get_document(drucksache) if not doc: return None async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: try: resp = await client.get(doc.link) if resp.status_code != 200: return None # Extract text with PyMuPDF pdf = fitz.open(stream=resp.content, filetype="pdf") text = "" for page in pdf: text += page.get_text() pdf.close() return text except Exception as e: logger.exception("NRW download error for %s", drucksache) return None class PortalaAdapter(ParlamentAdapter): """Adapter for portala/eUI-based parliament documentation systems. Used by parliaments running the proprietary "esearch" / portala framework (originally developed for STAR/StarFinder backends, now wrapped in a Single-Page App with Template Toolkit on the server side): - **LSA** (Sachsen-Anhalt) — PADOKA at ``padoka.landtag.sachsen-anhalt.de`` under ``/portal/`` (singular) - **BE** (Berlin) — PARDOK at ``pardok.parlament-berlin.de`` under ``/portala/`` (with the trailing 'a') Both instances share the same JSON action schema, only the base URL, the data source ID, the application path prefix and a few minor quirks differ — those are constructor parameters so that the same class can serve both states (and any future portala-based parliament). The search workflow is two-stage: 1. ``POST {base}{path}/browse.tt.json`` with a complex JSON ``action`` body that contains an Elasticsearch-style query tree under ``search.json``. The server returns a ``report_id`` plus hit count. 2. ``POST {base}{path}/report.tt.html`` with ``{report_id, start, chunksize}`` to fetch the HTML hit list. Each hit carries a Perl Data::Dumper block in a ``
`` tag with the canonical metadata.

    The query body schema was reverse-engineered from
    https://github.com/okfde/dokukratie/blob/main/dokukratie/scrapers/portala.query.json
    (GPL-3.0 — only structure/selectors are reused, not Python code).

    Full-text search is **not** implemented in the MVP: the adapter
    returns documents of the current Wahlperiode in the given date
    window, and the search query is applied as a client-side
    title/Urheber filter. The server-side full-text path requires
    state-specific ``sf`` index names that are not yet known.
    """

    def __init__(
        self,
        *,
        bundesland: str,
        name: str,
        base_url: str,
        db_id: str,
        wahlperiode: int,
        portala_path: str = "/portal",
        document_type: Optional[str] = "Antrag",
        pdf_url_prefix: str = "/files/",
        date_window_days: int = 730,
        typ_filter: Optional[str] = "DOKDBE",
        omit_date_filter: bool = False,
    ) -> None:
        """Configure a portala/eUI adapter for one specific parliament.

        Args:
            bundesland: state code (e.g. ``"LSA"``, ``"BE"``).
            name: human-readable adapter label (used in logs/UI).
            base_url: ``https://...`` of the portal host without trailing slash.
            db_id: data source identifier the eUI server expects in
                ``action.sources``, e.g. ``"lsa.lissh"`` or ``"lah.lissh"``.
            wahlperiode: current legislative period — fed into the WP
                term of the search tree.
            portala_path: path prefix where the portala app lives. ``/portal``
                for LSA, ``/portala`` for Berlin.
            document_type: optional filter applied via ETYPF/DTYPF/DART
                terms. ``"Antrag"`` works for LSA; for instances where
                the index uses different document_type values (e.g. Berlin),
                pass ``None`` to drop the document_type subtree entirely
                — the user can still filter client-side by title.
            pdf_url_prefix: URL fragment between ``base_url`` and the
                relative PDF path returned by the server.
            date_window_days: how many days back ``search()`` looks by
                default.
            typ_filter: ``TYP=`` term in the parsed string and
                JSON tree. ``DOKDBE`` works for LSA/BE/BB/BW (the
                lissh-style instances). For Hessen (``hlt.lis``) and
                similar instances the value is different or absent —
                pass ``None`` to drop the term entirely.
        """
        self.bundesland = bundesland
        self.name = name
        self.base_url = base_url.rstrip("/")
        self.db_id = db_id
        self.wahlperiode = wahlperiode
        self.portala_path = "/" + portala_path.strip("/")
        self.document_type = document_type
        self.pdf_url_prefix = "/" + pdf_url_prefix.strip("/") + "/"
        self.date_window_days = date_window_days
        self.typ_filter = typ_filter
        self.omit_date_filter = omit_date_filter

    # ── LSA-style hit list (Perl Data::Dumper inside 
 blocks) ──
    # Reverse-engineered "WEV*" record fields:
    # WEV06.main = title
    # WEV32.5    = relative PDF path
    # WEV32.main = "Antrag   Drucksache X/YYYY ..."
    _RE_TITLE = re.compile(r"'WEV06'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"']([^\"']+)[\"']")
    _RE_PDF = re.compile(r"'5'\s*=>\s*'([^']*\.pdf)'")
    _RE_DRUCKSACHE = re.compile(r"Drucksache\s*(\d+/\d+)")
    _RE_URHEBER_DATUM = re.compile(
        r"'WEV32'\s*=>\s*\[\s*\{[^}]*'main'\s*=>\s*[\"']Antrag\s+(.+?)\s+(\d{1,2}\.\d{1,2}\.\d{4})\s+Drucksache",
    )
    _RE_PRE_BLOCK = re.compile(r'
\$VAR1 = (.*?)
', re.DOTALL) # ── Berlin-style hit list (production HTML cards, no Perl dump) ── # The whole div for one record: _RE_BE_RECORD = re.compile( r']*class="[^"]*efxRecordRepeater[^"]*"[^>]*data-efx-rec="[^"]*"[^>]*>(.*?)(?=]*efxRecordRepeater|]*id="efxResultsEnd"||$)', re.DOTALL, ) _RE_BE_TITLE = re.compile(r']*class="h5[^"]*"[^>]*>\s*([^<]+)') _RE_BE_LINK = re.compile(r']*href="([^"]+\.pdf)"[^>]*>') # The metadata h6 looks like: # Antrag (Eilantrag)  Drucksache 19/3104 S. 1 bis 24 vom 31.03.2026 _RE_BE_DRUCKSACHE = re.compile(r'Drucksache\s+(\d+/\d+)') # BE has "Drucksache 19/3104 S. 1 bis 24 vom 31.03.2026" — date is # marked by ``vom``. BB has the BE card format too but writes the # date BEFORE the Drucksachen-Nummer with no marker: # "Antrag Reinhard Simon (BSW) 17.10.2024 Drucksache 8/2 (1 S.)". # Try ``vom``-prefix first; fall back to the first plain date. _RE_BE_DATUM_VOM = re.compile(r'vom\s+(\d{1,2}\.\d{1,2}\.\d{4})') _RE_BE_DATUM_PLAIN = re.compile(r'(\d{1,2}\.\d{1,2}\.\d{4})') _RE_BE_DOCTYPE = re.compile(r'\s*([^<&]+?)(?: |<)') @staticmethod def _decode_perl_hex(s: str) -> str: """Decode \\x{abcd} escape sequences from Perl Data::Dumper output.""" return re.sub(r'\\x\{([0-9a-f]+)\}', lambda m: chr(int(m.group(1), 16)), s) _RE_JSON_COMMENT = re.compile(r'', re.DOTALL) _RE_DRUCKSACHE_IN_META = re.compile(r'Drucksache\s+(\d+/\d+)') _RE_DATUM_IN_META = re.compile(r'(\d{2}\.\d{2}\.\d{4})') def _parse_hit_list_json_comments(self, html: str, query_filter: str) -> list[Drucksache]: """Parse NI-style JSON-in-HTML-Comment records (#22). Niedersachsen's NILAS uses efxRecordRepeater cards like Berlin, but embeds structured data as JSON objects in HTML comments (````) instead of Perl dumps. Field mapping (from HAR-Analyse 2026-04-10): - WEV01[0].main → Titel - WEV03[0].main → Typ (z.B. "Kleine Anfrage zur schriftlichen Beantwortung") - WEV05[0].main → Metadata-Zeile (Typ + Urheber + Datum + "Drucksache XX/YYYY") - WEV05[0].1 → PDF-URL - WEV08[0].1 → PDF-URL (alternativ) """ results: list[Drucksache] = [] seen: set[str] = set() for m in self._RE_JSON_COMMENT.finditer(html): try: data = json.loads(m.group(1)) except (json.JSONDecodeError, ValueError): continue # Titel wev01 = data.get("WEV01", [{}]) title = wev01[0].get("main", "") if wev01 else "" # Typ wev03 = data.get("WEV03", [{}]) typ = wev03[0].get("main", "") if wev03 else "" # Metadata-Zeile (Urheber, Datum, Drucksache-Nr) wev05 = data.get("WEV05", [{}]) meta = wev05[0].get("main", "") if wev05 else "" # PDF-URL: WEV05.1 bevorzugt, WEV08.1 als Fallback pdf_url = "" if wev05 and wev05[0].get("1"): pdf_url = wev05[0]["1"] elif data.get("WEV08", [{}]): wev08 = data["WEV08"] if wev08 and wev08[0].get("1"): pdf_url = wev08[0]["1"] if pdf_url.startswith("http://"): pdf_url = "https://" + pdf_url[len("http://"):] # Drucksache-Nr aus Metadata m_ds = self._RE_DRUCKSACHE_IN_META.search(meta) if not m_ds: continue drucksache = m_ds.group(1) # Datum aus Metadata m_dat = self._RE_DATUM_IN_META.search(meta) datum_iso = self._datum_de_to_iso(m_dat.group(1)) if m_dat else "" # Fraktionen aus Metadata fraktionen = self._normalize_fraktion(meta) doc = Drucksache( drucksache=drucksache, title=title or f"Drucksache {drucksache}", fraktionen=fraktionen, datum=datum_iso, link=pdf_url, bundesland=self.bundesland, typ=typ or "Antrag", ) # Deduplizierung (NI liefert manche Treffer doppelt) if drucksache in seen: continue seen.add(drucksache) # Client-seitig Antrag-Filter (wie bei allen Adaptern) if "antrag" not in (doc.typ or "").lower(): continue if query_filter: hay = f"{title} {meta}".lower() if not all(t in hay for t in query_filter.lower().split()): continue results.append(doc) return results def _normalize_fraktion(self, urheber: str) -> list[str]: """Thin shim — die ganze Regex-Logik lebt jetzt zentral in ``app.parteien.extract_fraktionen`` (siehe #55). ``self.bundesland`` wird mitgegeben, damit FW-Familien-Aliase korrekt disambiguiert werden. """ from .parteien import extract_fraktionen return extract_fraktionen(urheber, bundesland=self.bundesland) def _build_search_body( self, wahlperiode: int, start_date: str, end_date: str, ) -> dict: """Build the action JSON body for browse.tt.json. The schema is taken from dokukratie's portala.query.json template and only differs in the data source and the variable substitutions. When ``self.document_type`` is None, the ETYPF/DTYPF/DART subtree is dropped — useful for parliaments whose ETYPF index uses different value strings than ``"Antrag"``. """ document_type = self.document_type date_range_text = f"{start_date} THRU {end_date}" date_term = lambda sf, num: { # noqa: E731 — local helper "tn": "trange", "sf": sf, "op": "eq", "num": num, "idx": 119, "l": 3, "p1": start_date, "t1": start_date, "p2": end_date, "t2": end_date, "t": date_range_text, } # Build the search.lines (form-state mirror) and the json tree lines: dict = { "2": str(wahlperiode), "10": start_date, "11": end_date, "20.1": "alWEBBI", "20.2": "alWEBBI", "20.3": "alWEBBI", "90.1": "AND", "90.2": "AND", "90.3": "AND", } if document_type is not None: lines["3"] = document_type lines["4"] = "D" # Top-level AND tree top_terms: list = [ {"tn": "term", "t": str(wahlperiode), "idx": 6, "l": 3, "sf": "WP", "op": "eq", "num": 5}, ] if document_type is not None: top_terms.append({"tn": "or", "num": 3, "terms": [ {"tn": "or", "num": 4, "terms": [ {"tn": "term", "t": f'"{document_type}"', "idx": 50, "l": 4, "sf": "ETYPF", "op": "eq", "num": 10}, {"tn": "term", "t": f'"{document_type}"', "idx": 50, "l": 4, "sf": "ETYP2F", "op": "eq", "num": 11}, {"tn": "term", "t": f'"{document_type}"', "idx": 50, "l": 4, "sf": "DTYPF", "op": "eq", "num": 12}, {"tn": "term", "t": f'"{document_type}"', "idx": 50, "l": 4, "sf": "DTYP2F", "op": "eq", "num": 13}, {"tn": "term", "t": f'"{document_type}"', "idx": 50, "l": 4, "sf": "1VTYPF", "op": "eq", "num": 14}, ]}, {"tn": "or", "num": 15, "terms": [ {"tn": "term", "t": '"D"', "idx": 93, "l": 4, "sf": "DART", "op": "eq", "num": 16}, {"tn": "term", "t": '"D"', "idx": 93, "l": 4, "sf": "DARTS", "op": "eq", "num": 17}, ]}, ]}) if not self.omit_date_filter: top_terms.append({"tn": "or", "num": 18, "terms": [ {"tn": "or", "num": 19, "terms": [ date_term("DAT", 20), date_term("DDAT", 21), ]}, date_term("SDAT", 22), ]}) if self.typ_filter is not None: top_terms.append({"tn": "term", "t": self.typ_filter, "idx": 156, "l": 1, "sf": "TYP", "op": "eq", "num": 23}) # Mirror the same shape into the parsed/sref display strings typ_clause = f" AND TYP={self.typ_filter}" if self.typ_filter is not None else "" date_clause = ( f" AND (DAT,DDAT,SDAT= {date_range_text})" if not self.omit_date_filter else "" ) if document_type is not None: parsed = ( f"((/WP {wahlperiode}) AND " f"(/ETYPF,ETYP2F,DTYPF,DTYP2F,1VTYPF (\"{document_type}\")) " f"AND (/DART,DARTS (\"D\")){date_clause}){typ_clause}" ) else: parsed = f"((/WP {wahlperiode}){date_clause}){typ_clause}" return { "action": "SearchAndDisplay", "sources": [self.db_id], "report": { "rhl": "main", "rhlmode": "add", "format": "generic1-full", "mime": "html", "sort": "WEVSO1/D WEVSO2 WEVSO3", }, "search": { "lines": lines, "serverrecordname": "sr_generic1", "parsed": parsed, "sref": parsed, "json": [{ "tn": "and", "num": 1, "terms": top_terms, }], }, "dataSet": "1", } @staticmethod def _datum_de_to_iso(datum_de: str) -> str: """Convert DD.MM.YYYY → YYYY-MM-DD; return '' for empty input.""" if not datum_de: return "" d, m, y = datum_de.split(".") return f"{y}-{m.zfill(2)}-{d.zfill(2)}" def _parse_hit_list_html(self, html: str, query_filter: str = "") -> list[Drucksache]: """Extract Drucksachen from a report.tt.html response. Two formats are supported and auto-detected: - **LSA-style:** the records are embedded as Perl Data::Dumper dumps inside ``
$VAR1 = …
`` blocks. WEV06 → title, WEV32 → metadata + PDF path. Used by Sachsen-Anhalt's PADOKA template. - **Berlin-style:** standard production HTML cards with ``efxRecordRepeater`` divs. Title in an ``

``, metadata + PDF link in an ````. Used by Berlin's PARDOK template. """ if self._RE_PRE_BLOCK.search(html): return self._parse_hit_list_dump(html, query_filter) # NI-style: JSON-in-HTML-Comments statt Perl-Dumps (#22). # Auto-detect: NI's efxRecordRepeater-Cards enthalten JSON- # Objekte in ```` Kommentaren statt Perl-Dumps. if " Der Parser zieht die Comments raw raus und mappt die WMV/EWBV- Felder auf das ``Drucksache``-Dataclass. Reverse-Engineering-Quelle: ``dokukratie/scrapers/portala.query.bw.json`` + Live-HAR gegen ``parlis.landtag-bw.de`` (Issue #29). """ # Reverse-engineered field map for the JSON records that come embedded # in HTML comments inside report.tt.html responses. # # Records look like ```` and may contain # nested ``...`` highlight tags inside the JSON values. # Non-greedy match against the literal closing ``}-->`` because that # delimiter does not appear inside the JSON payload itself. _RE_RECORD = re.compile(r"", re.DOTALL) _RE_DRUCKSACHE = re.compile(r"Drucksache\s+(\d+/\d+)") _RE_DATUM = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{4})") def __init__( self, *, bundesland: str, name: str, base_url: str, wahlperiode: int, prefix: str = "/parlis", document_typ: str = "Antrag", date_window_days: int = 730, poll_attempts: int = 15, poll_interval_seconds: float = 2.0, ) -> None: """Configure a PARLIS adapter for one specific parliament instance. Args: bundesland: state code, e.g. ``"BW"``. name: human-readable label. base_url: ``https://parlis.landtag-bw.de`` (no trailing slash). wahlperiode: legislative period — feeds into ``lines.l1``. prefix: app prefix where PARLIS lives. ``/parlis`` for BW. document_typ: feeds into ``lines.l4``. The server interprets this as a German document type label like ``"Antrag"``. date_window_days: look-back window for the search range, quick-win against title-only filtering — same approach as the PortalaAdapter for LSA/BE. poll_attempts: how many times to poll for ``report_id`` before giving up. ~15 × 2s = 30s upper bound. poll_interval_seconds: sleep between poll attempts. """ self.bundesland = bundesland self.name = name self.base_url = base_url.rstrip("/") self.prefix = "/" + prefix.strip("/") self.wahlperiode = wahlperiode self.document_typ = document_typ self.date_window_days = date_window_days self.poll_attempts = poll_attempts self.poll_interval_seconds = poll_interval_seconds @staticmethod def _datum_de_to_iso(datum_de: str) -> str: """DD.MM.YYYY → YYYY-MM-DD; '' for empty input.""" if not datum_de: return "" try: d, m, y = datum_de.split(".") return f"{y}-{m.zfill(2)}-{d.zfill(2)}" except ValueError: return "" def _normalize_fraktion(self, text: str) -> list[str]: """Thin shim — siehe ``app.parteien.extract_fraktionen``. #55. PARLIS packt den Originator in ``EWBV23`` wie ``"Antrag Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)..."``. """ from .parteien import extract_fraktionen return extract_fraktionen(text, bundesland=self.bundesland) def _build_initial_body(self, start_date: str, end_date: str) -> dict: """Build the first ``SearchAndDisplay`` body with the search component. The schema follows ``dokukratie/scrapers/portala.query.bw.json`` verbatim — only the placeholder values are substituted. """ return { "action": "SearchAndDisplay", "report": { "rhl": "main", "rhlmode": "add", "format": "suchergebnis-vorgang-full", "mime": "html", "sort": "SORT01/D SORT02/D SORT03", }, "search": { "lines": { "l1": str(self.wahlperiode), "l2": start_date, "l3": end_date, "l4": self.document_typ, }, "serverrecordname": "vorgang", }, "sources": ["Star"], } def _build_poll_body(self, search_id: str) -> dict: """Build the polling body — same action, but with the search_id instead of a fresh search component.""" return { "action": "SearchAndDisplay", "report": { "rhl": "main", "rhlmode": "add", "format": "suchergebnis-vorgang-full", "mime": "html", "sort": "SORT01/D SORT02/D SORT03", }, "id": search_id, "sources": ["Star"], } def _hit_record_to_drucksache(self, record: dict) -> Optional[Drucksache]: """Map a single JSON-in-comment record to a ``Drucksache``. PARLIS-record schema (reverse-engineered, all values are arrays of ``{"main": ...}`` dicts): - ``EWBV22``: "Drucksache 17/10323" - ``EWBD05``: direct PDF URL - ``EWBV23``: "Antrag " — single combined line - ``WMV30``: short Urheber summary ("Felix Herkens (GRÜNE) u. a.") - ``WMV33``: subject keywords (Schlagworte) - ``EWBD01``: "Drucksache " """ def first(field: str) -> str: block = record.get(field) if isinstance(block, list) and block: return (block[0].get("main") or "").strip() return "" ds_text = first("EWBV22") or first("EWBD01") m_ds = self._RE_DRUCKSACHE.search(ds_text) if not m_ds: return None drucksache = m_ds.group(1) # The "title" we want is the Schlagworte/topic, not the # Drucksachen-Header. PARLIS keeps the human-readable subject # in WMV33 (Schlagworte joined by semicolons) — that's the # closest equivalent to "title" the LSA/BE adapters expose. # Fallback to the EWBV23 line if WMV33 is empty. schlagworte = first("WMV33") # Strip embedded ... highlight tags schlagworte_clean = re.sub(r"", "", schlagworte).strip() title = schlagworte_clean or first("EWBV23") or f"Drucksache {drucksache}" # Date + Urheber out of EWBV23 ("Antrag ") ewbv23 = first("EWBV23") m_dat = self._RE_DATUM.search(ewbv23) datum_iso = self._datum_de_to_iso(m_dat.group(1) if m_dat else "") urheber_short = first("WMV30") fraktionen = self._normalize_fraktion(urheber_short or ewbv23) pdf_url = first("EWBD05") return Drucksache( drucksache=drucksache, title=title, fraktionen=fraktionen, datum=datum_iso, link=pdf_url, bundesland=self.bundesland, typ=self.document_typ, ) async def _initial_search_and_poll( self, client: httpx.AsyncClient, start_date: str, end_date: str, ) -> Optional[str]: """Run the initial search + poll until ``report_id`` arrives.""" import asyncio browse_html = f"{self.base_url}{self.prefix}/browse.tt.html" browse_json = f"{self.base_url}{self.prefix}/browse.tt.json" # Step 1: warm cookies await client.get(browse_html) # Step 2: initial search try: resp = await client.post( browse_json, json=self._build_initial_body(start_date, end_date), headers={"Referer": browse_html}, ) except Exception: logger.exception("%s initial search request error", self.bundesland) return None if resp.status_code != 200: logger.error("%s initial search HTTP %s", self.bundesland, resp.status_code) return None data = resp.json() if data.get("report_id"): return data["report_id"] search_id = data.get("search_id") if not search_id: logger.error("%s no search_id in initial response: %s", self.bundesland, data) return None # Step 3: poll until report_id appears or we run out of attempts for _ in range(self.poll_attempts): await asyncio.sleep(self.poll_interval_seconds) try: resp = await client.post( browse_json, json=self._build_poll_body(search_id), headers={"Referer": browse_html}, ) except Exception: logger.exception("%s poll request error", self.bundesland) return None if resp.status_code != 200: logger.error("%s poll HTTP %s", self.bundesland, resp.status_code) return None data = resp.json() if data.get("report_id"): return data["report_id"] star = data.get("sources", {}).get("Star", {}) if star.get("status") == "stopped" and not data.get("report_id"): # Search finished but no report — empty result return None logger.warning("%s gave up polling after %d attempts", self.bundesland, self.poll_attempts) return None def _parse_report_html(self, html: str) -> list[Drucksache]: """Extract Drucksachen from a report.tt.html response. Records are JSON objects embedded in HTML comments. We pull each comment block via regex, parse it as JSON, and map the WMV/EWBV fields to a Drucksache. """ results: list[Drucksache] = [] for m in self._RE_RECORD.finditer(html): json_text = m.group(1) try: record = json.loads(json_text) except json.JSONDecodeError: continue doc = self._hit_record_to_drucksache(record) if doc: results.append(doc) return results async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Search recent BW Anträge with optional client-side title filter. Server-side full-text is not used (#18 — einheitliches Verhalten ohne Volltext bis alle Adapter es können). The client filter looks at title (Schlagworte) + Urheber. """ from datetime import date, timedelta end = date.today() start = end - timedelta(days=self.date_window_days) async with httpx.AsyncClient( timeout=60, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"}, ) as client: try: report_id = await self._initial_search_and_poll( client, start.isoformat(), end.isoformat(), ) if not report_id: return [] # Pull a generous chunk so the client-side filter has # enough material to work with. chunksize = max(limit * 10, 200) if query else max(limit * 2, 50) report_url = ( f"{self.base_url}{self.prefix}/report.tt.html" f"?report_id={report_id}&start=0&chunksize={chunksize}" ) resp = await client.get( report_url, headers={"Referer": f"{self.base_url}{self.prefix}/browse.tt.html"}, ) if resp.status_code != 200: logger.error("%s report HTTP %s", self.bundesland, resp.status_code) return [] results = self._parse_report_html(resp.text) except Exception: logger.exception("%s search error", self.bundesland) return [] # Client-side filter if query: terms = [t.lower() for t in query.split() if t] results = [ d for d in results if all(t in f"{d.title} {' '.join(d.fraktionen)}".lower() for t in terms) ] return results[:limit] async def get_document(self, drucksache: str) -> Optional[Drucksache]: """Look up a single Drucksache by ID via a broad browse.""" results = await self.search(query="", limit=200) for doc in results: if doc.drucksache == drucksache: return doc return None async def download_text(self, drucksache: str) -> Optional[str]: """Download the PDF for a Drucksache and extract its text.""" import fitz # PyMuPDF doc = await self.get_document(drucksache) if not doc 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( "%s PDF HTTP %s for %s (%s)", self.bundesland, resp.status_code, drucksache, doc.link, ) 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("%s PDF download error for %s", self.bundesland, drucksache) 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+)>", 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). PARiS ist die alte Java-Servlet-Variante von StarWeb (anders als HE/starweb.hessen.de, das auf dem moderneren eUI läuft). Die Suche geht über genau einen POST-Call gegen ``/starweb/paris/servlet.starweb`` mit form-urlencoded Body. Response ist ein vollständiges HTML- Ergebnis-Page mit ````-Hits. Hit-Format pro ````: - ``S`` oder ``L`` als Indikator - ``

TITEL

`` - Stichworte (Thesaurus-Links, ignoriert) - ``Drs 21/730 S`` (Drucksachen-Nr mit S/L-Suffix) - ``Änderungsantrag vom 23.02.2026`` (Typ + Datum) - ``SPD, BÜNDNIS 90/DIE GRÜNEN, Die Linke`` (Fraktionen) - ```` Bremen hat zwei parallele Parlamente: Bürgerschaft (Landtag) für landespolitische Anträge und Stadtbürgerschaft für Bremens kommunale Sachen. Wir lassen beide durch (``PARL=S OR L``) — der Stadtbürgerschafts-Anteil ist für die GWÖ-Bilanzierung sogar interessanter, weil viele Entscheidungen auf kommunaler Ebene laufen. """ bundesland = "HB" name = "Bremische Bürgerschaft (PARiS)" base_url = "https://paris.bremische-buergerschaft.de" servlet_path = "/starweb/paris/servlet.starweb" wahlperiode = 21 # Pro-Hit-Regex über das ``-Pattern _RE_TR = re.compile( r']*>([\s\S]*?)', re.IGNORECASE, ) _RE_TITLE = re.compile(r']*>\s*]*>(.*?)', re.DOTALL) _RE_DRUCKSACHE = re.compile(r'Drs\s*\s*(\d+/\d+)\s*([SL]?)\s*') _RE_TYP_DATUM = re.compile(r'\s*,\s*([^,<\n]+?)\s+vom\s+(\d{1,2}\.\d{1,2}\.\d{4})') _RE_FRAKTIONEN_AFTER_DATUM = re.compile(r'vom\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\s*([^<]+)') _RE_PDF_LINK = re.compile( r']*target="new"', re.IGNORECASE, ) 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 "" @staticmethod def _strip_html(s: str) -> str: """Entferne HTML-Tags und entities aus einem Snippet.""" s = re.sub(r"<[^>]+>", "", s) s = s.replace("–", "–").replace(" ", " ") s = re.sub(r"&[a-zA-Z]+;", " ", s) return re.sub(r"\s+", " ", s).strip() def _parse_record_html(self, chunk: str) -> Optional[Drucksache]: m_ds = self._RE_DRUCKSACHE.search(chunk) if not m_ds: return None nr_only = m_ds.group(1) # "21/730" suffix = m_ds.group(2) or "" # "S" oder "L" # Drucksachen-ID: ohne Whitespace, mit Suffix dahinter wenn vorhanden drucksache = f"{nr_only}{suffix}" if suffix else nr_only m_t = self._RE_TITLE.search(chunk) title = self._strip_html(m_t.group(1)) if m_t else f"Drucksache {drucksache}" m_pdf = self._RE_PDF_LINK.search(chunk) pdf_url = m_pdf.group(1) if m_pdf else "" m_td = self._RE_TYP_DATUM.search(chunk) if m_td: typ = self._strip_html(m_td.group(1)) datum = self._datum_de_to_iso(m_td.group(2)) else: typ = "Drucksache" datum = "" m_fr = self._RE_FRAKTIONEN_AFTER_DATUM.search(chunk) urheber = self._strip_html(m_fr.group(1)) if m_fr else "" fraktionen = self._normalize_fraktion(urheber) return Drucksache( drucksache=drucksache, title=title, fraktionen=fraktionen, datum=datum, link=pdf_url, bundesland=self.bundesland, typ=typ, ) def _build_form_body(self, query: str) -> dict: """Form-Body für PARiS Suche. - ``path=paris/LISSHFL.web``: die LISSH-Vorgangsdatenbank - ``format=LISSH_BrowseVorgang_Report``: Browse-Format mit allen Hits in einer Page (kein Pagination) - ``01_LISSHFL_Themen``: Thesaurus-Volltext-Suche. Der Server akzeptiert kein ``*``-Wildcard und timeout-t bei leerem Wert, deshalb verwenden wir bei leerer Query ein hochfrequentes Stoppwort als Catch-all. - ``02_LISSHFL_PARL=S OR L``: Stadtbürgerschaft + Landtag - ``03_LISSHFL_WP``: aktuelle Wahlperiode (kein Range — ein Multi-WP-Range hat im Test 60s+ gebraucht) """ return { "path": "paris/LISSHFL.web", "format": "LISSH_BrowseVorgang_Report", "01_LISSHFL_Themen": query or "der", # häufiges Stoppwort "02_LISSHFL_PARL": "S OR L", "03_LISSHFL_WP": str(self.wahlperiode), } async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Single-POST-Search gegen den PARiS-Servlet.""" body = self._build_form_body(query) async with httpx.AsyncClient( timeout=60, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"}, ) as client: try: resp = await client.post( f"{self.base_url}{self.servlet_path}", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if resp.status_code != 200: logger.error("HB PARiS HTTP %s", resp.status_code) return [] results: list[Drucksache] = [] for chunk in self._RE_TR.findall(resp.text): doc = self._parse_record_html(chunk) if not doc: continue if "antrag" not in (doc.typ or "").lower(): continue results.append(doc) if len(results) >= limit: break return results except Exception: logger.exception("HB PARiS search error") return [] async def get_document(self, drucksache: str) -> Optional[Drucksache]: """Linearer Lookup über die search()-Resultate.""" # Bei Drucksachen-IDs mit Suffix (21/730S) zerlegen wir die, # damit die Volltext-Suche den nackten Drucksachen-Anteil findet m = re.match(r"(\d+/\d+)([SL]?)$", drucksache) if not m: return None results = await self.search("*", limit=200) for d in results: if d.drucksache == drucksache: return d return None async def download_text(self, drucksache: str) -> Optional[str]: import fitz doc = await self.get_document(drucksache) if not doc 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: 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("HB PARiS PDF download error for %s", drucksache) return None class StarWebHEAdapter(ParlamentAdapter): """Hessen-spezifischer eUI-Adapter (#24/#30). starweb.hessen.de läuft auf einem eUI-Backend mit synchronem 2-Step- Flow (anders als BW PARLIS, das asynchron pollt): 1. POST ``/portal/browse.tt.json`` mit ``action=SearchAndDisplay`` → Response enthält ``report_id`` direkt 2. GET ``/portal/report.tt.html?report_id=...`` → HTML mit den Hits Hit-Format: Cards mit ``efxRecordRepeater``-divs, Daten in HTML- Kommentar-Perl-Dumps (````). Field-Mapping: - ``WEV01`` → Title - ``WEV02`` → Datum - ``WEV03`` → Typ - ``WEV07`` → PDF-URL - ``WEV08`` → Drucksachen-Nummer - ``WEV12`` → Urheber/Fraktion Source: ``hlt.lis`` (Hessischer Landtag), Wahlperiode 21. """ _RE_HE_COMMENT_DUMP = re.compile( r'', re.DOTALL, ) _RE_HE_WEV01 = re.compile(r"'WEV01'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"']([^\"']+)[\"']") _RE_HE_WEV02 = re.compile(r"'WEV02'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"'](\d{1,2}\.\d{1,2}\.\d{4})[\"']") _RE_HE_WEV03 = re.compile(r"'WEV03'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"']([^\"']+)[\"']") _RE_HE_WEV07 = re.compile(r"'WEV07'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"']([^\"']+)[\"']") _RE_HE_WEV08 = re.compile(r"'WEV08'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"'](\d+/\d+)[\"']") _RE_HE_WEV12 = re.compile(r"'WEV12'\s*=>\s*\[\s*\{\s*'main'\s*=>\s*[\"']([^\"']+)[\"']") bundesland = "HE" name = "Hessischer Landtag (StarWeb)" base_url = "https://starweb.hessen.de" portal_path = "/portal" wahlperiode = 21 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: if not datum_de: return "" try: d, m, y = datum_de.split(".") return f"{y}-{m.zfill(2)}-{d.zfill(2)}" except ValueError: return "" @staticmethod def _decode_perl_hex(text: str) -> str: """Wandle ``\\x{e9}`` → ``é`` etc. um. Robuste Hex-Substitution.""" return re.sub( r"\\x\{([0-9a-fA-F]+)\}", lambda m: chr(int(m.group(1), 16)), text, ) def _build_initial_body(self, query: str = "") -> dict: """HE-Server-Body. Aktuelle WP, optional Volltext-Filter. Der Server verlangt ZWINGEND einen ``search.json``-Term-Tree mit einer ``not(query, NOWEB=X)``-Wurzel. ``parsed``/``sref`` allein reichen nicht — der Server ignoriert sie und liefert nur ``facets`` zurück. """ wp_str = str(self.wahlperiode) wp_term = { "tn": "term", "t": wp_str, "sf": "WP", "op": "eq", "idx": 45, "l": 3, "num": 1, } # Bauen den Top-NOT-Tree: NOT(query_subtree, NOWEB=X) if query: vtdrs_term = { "tn": "term", "t": f"\"(/VT ('\\\"{query}\\\"'))\"", "sf": "VTDRS", "op": "eq", "idx": 9, "l": 3, "num": 3, } inner = {"tn": "and", "terms": [vtdrs_term, wp_term], "num": 4} parsed = ( f"((/VTDRS \"(/VT ('\\\"{query}\\\"'))\") " f"AND (/WP {wp_str})) AND NOT NOWEB=X" ) else: inner = wp_term parsed = f"(/WP {wp_str}) AND NOT NOWEB=X" json_tree = [{ "tn": "not", "terms": [ inner, {"tn": "term", "t": "X", "sf": "NOWEB", "op": "eq", "idx": 100, "l": 3, "num": 2}, ], }] return { "action": "SearchAndDisplay", "sources": ["hlt.lis"], "report": { "rhl": "main", "rhlmode": "add", "format": "generic2-short", "mime": "html", "sort": "WPSORT/D DRSORT/D", }, "search": { "lines": {"1": query, "2": wp_str}, "serverrecordname": "generic2Search", "parsed": parsed, "sref": parsed, "json": json_tree, }, } async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Synchroner 2-Step gegen starweb.hessen.de.""" from .parteien import extract_fraktionen body = self._build_initial_body(query) browse_url = f"{self.base_url}{self.portal_path}/browse.tt.json" report_url = f"{self.base_url}{self.portal_path}/report.tt.html" async with httpx.AsyncClient( timeout=60, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"}, ) as client: try: resp = await client.post(browse_url, json=body) if resp.status_code != 200: logger.error("HE browse HTTP %s", resp.status_code) return [] data = resp.json() report_id = data.get("report_id") if not report_id: logger.error("HE: no report_id in browse response keys=%s", sorted(data.keys())) return [] # Step 2: report.tt.html mit chunksize — ohne den Parameter # liefert der Server nur den allerersten Hit (8 KB HTML). # Wir nehmen 1500 als Floor, analog #61 PortalaAdapter, weil # nach dem client-side Antrag-Filter die Hit-Dichte gering # ist (HE hat ~1:30 Antrag/Anfrage). chunksize = max(limit * 30, 1500) rep = await client.get( report_url, params={ "report_id": report_id, "start": 0, "chunksize": chunksize, }, ) if rep.status_code != 200: logger.error("HE report HTTP %s", rep.status_code) return [] results = self._parse_report_html(rep.text) # Client-side Antrag-Filter (analog #61 Bug 2/3 für portala) results = [d for d in results if "antrag" in (d.typ or "").lower()] # Optional Query-Filter client-side if query: qterms = query.lower().split() results = [ d for d in results if all(t in (d.title.lower() + " " + " ".join(d.fraktionen).lower()) for t in qterms) ] return results[:limit] except Exception: logger.exception("HE search error") return [] def _parse_report_html(self, html: str) -> list[Drucksache]: """Zieht Daten aus den ````- Kommentaren. WEV01–WEV12 → Drucksache-Felder.""" from .parteien import extract_fraktionen results: list[Drucksache] = [] for dump in self._RE_HE_COMMENT_DUMP.findall(html): m_ds = self._RE_HE_WEV08.search(dump) if not m_ds: continue drucksache = m_ds.group(1) m_t = self._RE_HE_WEV01.search(dump) title = self._decode_perl_hex(m_t.group(1)) if m_t else f"Drucksache {drucksache}" m_pdf = self._RE_HE_WEV07.search(dump) pdf_url = m_pdf.group(1) if m_pdf else "" if pdf_url.startswith("http://"): pdf_url = "https://" + pdf_url[len("http://"):] m_dat = self._RE_HE_WEV02.search(dump) datum_iso = self._datum_de_to_iso(m_dat.group(1)) if m_dat else "" m_typ = self._RE_HE_WEV03.search(dump) typ = self._decode_perl_hex(m_typ.group(1)) if m_typ else "Drucksache" m_urheber = self._RE_HE_WEV12.search(dump) urheber = self._decode_perl_hex(m_urheber.group(1)) if m_urheber else "" fraktionen = extract_fraktionen(urheber, bundesland=self.bundesland) results.append(Drucksache( drucksache=drucksache, title=title, fraktionen=fraktionen, datum=datum_iso, link=pdf_url, bundesland=self.bundesland, typ=typ, )) return results async def get_document(self, drucksache: str) -> Optional[Drucksache]: """Linearer Lookup über search() — wie die anderen Adapter, kein Direkt-ID-Filter.""" results = await self.search("", limit=200) for d in results: if d.drucksache == drucksache: return d return None async def download_text(self, drucksache: str) -> Optional[str]: import fitz doc = await self.get_document(drucksache) if not doc 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: 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("HE PDF download error for %s", drucksache) return None class BundestagAdapter(ParlamentAdapter): """Adapter für den Deutschen Bundestag via DIP-API. Quelle: ``search.dip.bundestag.de/api/v1`` — die offizielle REST-API des Dokumentations- und Informationssystems (DIP). Schema dokumentiert unter https://dip.bundestag.de/über-dip/hilfe/api (SPA, Inhalt im Bundle ``main.*.chunk.js``). Auth via URL-Parameter ``apikey=...`` PLUS einem ``Origin: https://dip.bundestag.de``-Header — der Server macht Origin-Locking auf seine eigene Single-Page-App. Der API-Key liegt offen in ``dip-config.js`` und wird vom DIP-Frontend bei jedem Request als URL-Parameter mitgeschickt. Solange wir den Origin-Header setzen, akzeptiert die API das auch von server-to- server-Calls. Doc-Mapping (``/api/v1/drucksache``): - ``dokumentnummer`` → ``drucksache`` (z.B. ``"21/5136"``) - ``titel`` → ``title`` - ``urheber[*].bezeichnung``/``titel`` → ``fraktionen`` (durch ``parteien.extract_fraktionen`` normalisiert, deckt ``"Fraktion der AfD"`` → ``"AfD"`` ab) - ``datum`` → ``datum`` (bereits ISO YYYY-MM-DD) - ``fundstelle.pdf_url`` → ``link`` - ``drucksachetyp`` → ``typ`` (Filter auf ``"Antrag"``) Pagination via ``cursor``-Parameter — der Server gibt nach jedem Result einen neuen Cursor zurück, den wir als nächsten Request mitschicken. 100 Hits pro Page, pro Wahlperiode ~600 Anträge. """ bundesland = "BUND" name = "Deutscher Bundestag (DIP)" base_url = "https://search.dip.bundestag.de/api/v1" # Aus dip-config.js gescraped (öffentlich, klartext, von der DIP-SPA # bei jedem Request mitgesendet). Origin-Locking macht den Key # nicht-trivial weiterzugeben, aber für server-to-server-Calls mit # gesetztem Origin-Header voll funktional. DEFAULT_APIKEY = "SbGXhWA.3cpnNdb8rkht7iWpvSgTP8XIG88LoCrGd4" ORIGIN = "https://dip.bundestag.de" def __init__( self, *, apikey: Optional[str] = None, wahlperiode: int = 21, document_typ: str = "Antrag", ): self.apikey = apikey or self.DEFAULT_APIKEY self.wahlperiode = wahlperiode self.document_typ = document_typ def _make_client(self) -> httpx.AsyncClient: return httpx.AsyncClient( timeout=30, follow_redirects=True, headers={ "Origin": self.ORIGIN, "Referer": f"{self.ORIGIN}/", "User-Agent": "Mozilla/5.0 GWOE-Antragspruefer", "Accept": "application/json", }, ) def _doc_to_drucksache(self, doc: dict) -> Optional[Drucksache]: """Map ein DIP-/drucksache-JSON auf unser ``Drucksache``-dataclass. ``None`` wenn essentielle Felder fehlen.""" from .parteien import extract_fraktionen nummer = doc.get("dokumentnummer") if not nummer: return None # PDF-URL aus fundstelle ziehen — ist die zuverlässige Adresse fundstelle = doc.get("fundstelle") or {} pdf_url = fundstelle.get("pdf_url") or "" if not pdf_url: return None # Fraktionen aus urheber-Liste extrahieren. DIP listet sie als # "Fraktion der AfD" o.ä. — extract_fraktionen kennt das Pattern # bereits aus den Landtags-Adaptern. urheber_strs: list[str] = [] for u in (doc.get("urheber") or []): if isinstance(u, dict): urheber_strs.append(u.get("titel") or u.get("bezeichnung") or "") urheber_combined = ", ".join(filter(None, urheber_strs)) fraktionen = extract_fraktionen(urheber_combined, bundesland=self.bundesland) return Drucksache( drucksache=nummer, title=doc.get("titel", ""), fraktionen=fraktionen, datum=doc.get("datum", ""), link=pdf_url, bundesland=self.bundesland, typ=doc.get("drucksachetyp", "Antrag"), ) async def _fetch_page( self, client: httpx.AsyncClient, *, cursor: Optional[str] = None, ) -> tuple[list[dict], Optional[str]]: """Lade eine Page vom /drucksache-Endpoint. Returns (docs, next_cursor).""" params = { "apikey": self.apikey, "f.drucksachetyp": self.document_typ, "f.wahlperiode": str(self.wahlperiode), } if cursor: params["cursor"] = cursor try: resp = await client.get(f"{self.base_url}/drucksache", params=params) if resp.status_code != 200: logger.error("BUND DIP HTTP %s: %s", resp.status_code, resp.text[:200]) return [], None data = resp.json() return data.get("documents", []), data.get("cursor") except Exception: logger.exception("BUND DIP request error") return [], None async def search(self, query: str, limit: int = 20) -> list[Drucksache]: """Liste die neuesten Anträge der konfigurierten Wahlperiode. Server liefert Antrags-gefiltert + nach Aktualität sortiert; wir paginieren über cursor bis ``limit`` (oder das Ende der Periode) erreicht ist. Query wird client-side als Title-Substring-Filter angewandt — die DIP-API hat einen ``f.titel``-Filter, aber für Konsistenz mit den Landtags-Adaptern (alle nutzen client-side Filter wegen Schema-Drift) machen wir es hier auch so. """ results: list[Drucksache] = [] seen: set[str] = set() query_terms = [t.lower() for t in query.split() if t] if query else [] async with self._make_client() as client: cursor: Optional[str] = None for _ in range(20): # max 20 pages = 2000 docs als Hard-Cap docs, next_cursor = await self._fetch_page(client, cursor=cursor) if not docs: break for raw in docs: doc = self._doc_to_drucksache(raw) if not doc: continue if doc.drucksache in seen: continue seen.add(doc.drucksache) if query_terms: hay = doc.title.lower() if not all(t in hay for t in query_terms): continue results.append(doc) if len(results) >= limit: return results # Cursor unverändert → letzte Page erreicht if not next_cursor or next_cursor == cursor: break cursor = next_cursor return results async def get_document(self, drucksache: str) -> Optional[Drucksache]: """Look up a single Drucksache by ID. Nutzt den f.dokumentnummer- Filter — direkter Treffer ohne Pagination.""" async with self._make_client() as client: try: resp = await client.get( f"{self.base_url}/drucksache", params={ "apikey": self.apikey, "f.dokumentnummer": drucksache, "f.wahlperiode": str(self.wahlperiode), }, ) if resp.status_code != 200: return None docs = resp.json().get("documents", []) for raw in docs: if raw.get("dokumentnummer") == drucksache: return self._doc_to_drucksache(raw) except Exception: logger.exception("BUND get_document error for %s", drucksache) return None async def download_text(self, drucksache: str) -> Optional[str]: """Download das Drucksachen-PDF und extrahiere Volltext.""" import fitz doc = await self.get_document(drucksache) if not doc 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: 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("BUND download error for %s", drucksache) 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(), "HB": PARiSHBAdapter(), "HE": StarWebHEAdapter(), "NRW": NRWAdapter(), "SN": SNEdasXmlAdapter(), "LSA": PortalaAdapter( bundesland="LSA", name="Landtag von Sachsen-Anhalt (PADOKA)", base_url="https://padoka.landtag.sachsen-anhalt.de", db_id="lsa.lissh", wahlperiode=8, portala_path="/portal", document_type="Antrag", pdf_url_prefix="/files/", ), "BE": PortalaAdapter( bundesland="BE", name="Abgeordnetenhaus von Berlin (PARDOK)", base_url="https://pardok.parlament-berlin.de", db_id="lah.lissh", wahlperiode=19, portala_path="/portala", # Berlin's ETYPF index uses different value strings — drop the # document_type subtree, fall back to client-side title filter. document_type=None, # Quick-win for #13: pulled the date window from the original # 180-day MVP up to 730 days so client-side title-filter searches # ("Schule" etc.) reach back across more of the WP19 corpus until # the eUI fulltext-sf is reverse-engineered. The chunksize bump # in PortalaAdapter.search() means the per-request payload stays # bounded. date_window_days=730, pdf_url_prefix="/files/", ), "MV": ParLDokAdapter( bundesland="MV", name="Landtag Mecklenburg-Vorpommern (ParlDok)", base_url="https://www.dokumentation.landtag-mv.de", wahlperiode=8, prefix="/parldok", document_typ="Antrag", ), "HH": ParLDokAdapter( bundesland="HH", name="Hamburgische Bürgerschaft (ParlDok)", base_url="https://www.buergerschaft-hh.de", wahlperiode=23, prefix="/parldok", document_typ="Antrag", ), "TH": ParLDokAdapter( bundesland="TH", name="Thüringer Landtag (ParlDok)", base_url="https://parldok.thueringer-landtag.de", wahlperiode=8, prefix="/parldok", # TH packs Anträge under composite type strings like # "Antrag gemäß § 79 GO" with kind="Vorlage", not the # MV-style kind="Drucksache"/type="Antrag". Substring-match # on "Antrag" plus widened kind list catches them all. document_typ="Antrag", document_typ_substring=True, kinds=["Drucksache", "Vorlage"], ), "SH": StarFinderCGIAdapter( bundesland="SH", name="Schleswig-Holsteinischer Landtag (LIS-SH)", base_url="http://lissh.lvn.parlanet.de", wahlperiode=20, db_path="lisshfl.txt", document_typ_code="antrag", ), "BB": PortalaAdapter( bundesland="BB", name="Landtag Brandenburg (parladoku)", base_url="https://www.parlamentsdokumentation.brandenburg.de", db_id="lbb.lissh", wahlperiode=8, portala_path="/portal", document_type="Antrag", # BB packs the date BEFORE the Drucksachen-Nummer in the h6 # line and uses the BE-style efxRecordRepeater HTML cards; # the auto-detect picks the card path automatically. ), "RP": PortalaAdapter( bundesland="RP", name="Landtag Rheinland-Pfalz (OPAL)", base_url="https://opal.rlp.de", db_id="rlp.lissh", wahlperiode=18, portala_path="/portal", document_type="Antrag", ), "BY": BayernAdapter(), "SL": SaarlandAdapter(), "BW": PARLISAdapter( bundesland="BW", name="Landtag von Baden-Württemberg (PARLIS)", base_url="https://parlis.landtag-bw.de", wahlperiode=17, prefix="/parlis", document_typ="Antrag", ), "NI": PortalaAdapter( bundesland="NI", name="Niedersächsischer Landtag (NILAS)", base_url="https://www.nilas.niedersachsen.de", db_id="lns.lissh", wahlperiode=19, portala_path="/portala", document_type="Antrag", # NI nutzt JSON-in-HTML-Comments statt Perl-Dumps (auto-detected # in _parse_hit_list_html via "