#23 BayernAdapter — TYPO3-Solr HTML scraping (Anträge in WP19)

Stub durch echten Adapter ersetzt. Recon + Implementierung in einem
Wurf, weil das Backend deutlich freundlicher ist als bei SL/NI:

- Server-side rendered HTML, keine SPA, keine Auth, keine Cookies
- TYPO3 mit ext-solr unter /parlament/dokumente/drucksachen
- Filter direkt als URL-Query-Params (q, dokumentenart, wahlperiodeid[],
  sort, anzahl_treffer, page)
- 17.598 Drucksachen in WP19, davon ~10-15% Anträge — wir holen pro
  Page 100 Hits, paginieren bis 3 Pages und filtern client-seitig auf
  <p>Antrag …</p> (analog zu SL/HE)

Pattern-Extraktion über drei Regexen aus dem stabilen result-block:

  <div class="row result">
    <h4><a href="…pdf">Drucksache Nr. 19/<NR> vom DD.MM.YYYY</a></h4>
    <p>Antrag <FRAKTION>[, <FRAKTION2>]</p>
    <h5><strong>TITLE</strong></h5>
  </div>

Drucksachen-Lookup: q=<drucksache> matched die Nummer im Volltext und
liefert sie als einzigen Hit — wie bei SL und HB, kein dedizierter
GetById-Endpoint nötig.

Smoke-Test im Container:

  search("Schule", 5) → 5 Anträge in WP19 (SPD/FW-BAYERN+CSU/GRÜNE/AfD/AfD)
  get_document(19/11388) → match
  download_text(19/11388) → 4694 chars echter Antrags-Volltext
  search("", 5) → 5 newest Anträge mit korrektem date-DESC sort

Free-Voters-Disambiguation funktioniert über den #55 Parteinamen-Mapper:
"FREIE WÄHLER" auf Bayerns Liste wird zu "FW-BAYERN" canonicalized
(separat von "FREIE WÄHLER" in RP und "BVB-FW" in BB).

Tests: 185/185 grün.

UI-Aktivierung erfolgt separat in #35 (blockiert auf diesem Commit
und auf den BY-WP19-Wahlprogrammen — CSU, GRÜNE, AfD, SPD, FDP, FW).

Refs: #23, #49 (Roadmap Phase 3)
This commit is contained in:
Dotty Dotter 2026-04-10 01:00:47 +02:00
parent 6dfcd69979
commit 27ae82a758

View File

@ -1446,22 +1446,238 @@ class StarFinderCGIAdapter(ParlamentAdapter):
class BayernAdapter(ParlamentAdapter):
"""Adapter for Bayerischer Landtag."""
"""Adapter for Bayerischer Landtag (#23) — TYPO3-Solr HTML scraping.
Backend ist eine TYPO3-Site mit ext-solr-Suche unter
``/parlament/dokumente/drucksachen``. Server-side rendering, keine
SPA, keine API. Reverse-engineering ist trivial die Drucksachen-
Liste hat ein stabiles HTML-Pattern und der Server akzeptiert die
Filter direkt als URL-Query-Parameter.
Search-URL:
GET /parlament/dokumente/drucksachen?dokumentenart=Drucksache
&wahlperiodeid[]=19
&q=<volltext>
&sort=date
&anzahl_treffer=100
&page=<n>
Response-Pattern (HTML):
<div class="row result">
<div class="col-12">
<h4>
<a href="https://www.bayern.landtag.de/www/ElanTextAblage_WP19/Drucksachen/Basisdrucksachen/0000009000/0000009107.pdf">
Drucksache Nr. 19/11407 vom 08.04.2026
</a>
</h4>
<p> Antrag AfD </p>
<h5><strong>Kostenloses Parken für E-Fahrzeuge</strong></h5>
</div>
</div>
Felder pro Eintrag:
* ``Drucksache Nr. 19/<NUM> vom DD.MM.YYYY`` drucksache + datum
* ``<a href="…Basisdrucksachen/…NUM.pdf">`` PDF-Link (Anträge)
oder ``Schriftliche Anfragen/pdf`` für Anfragen Anträge
werden client-seitig über ``<p>Antrag `` gefiltert
* ``<p>Antrag <FRAKTION>[, <FRAKTION2>]</p>`` typ + Fraktionen
* ``<h5><strong>TITLE</strong></h5>`` title
Drucksachen-Lookup nutzt denselben Endpoint mit ``q=<drucksache>``;
die Solr-Suche matcht die Nummer im Volltext und liefert sie als
einzigen oder ersten Treffer.
Pagination: 100 pro Page (Maximum), max 17.598 Drucksachen in WP19
Stand 2026-04-10. Wir holen client-side max ``limit*5`` Anträge nach
Filterung.
"""
bundesland = "BY"
name = "Bayerischer Landtag"
base_url = "https://www.bayern.landtag.de"
_RE_RESULT_BLOCK = re.compile(
r'<div class="row result">(.*?)</div>\s*</div>', re.DOTALL,
)
_RE_DRUCKSACHE_HEADER = re.compile(
r'Drucksache\s+Nr\.\s*(\d+/\d+)\s*vom\s*(\d{2}\.\d{2}\.\d{4})',
re.IGNORECASE,
)
_RE_PDF_HREF = re.compile(r'href="([^"]+\.pdf)"')
_RE_TYP_FRAKTION = re.compile(r'<p>\s*([^<]+?)\s*</p>')
_RE_TITLE = re.compile(r'<h5>\s*<strong>([^<]+)</strong>\s*</h5>')
def __init__(self, *, wahlperiode: int = 19):
self.wahlperiode = wahlperiode
@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 ""
def _parse_results(self, html: str) -> list[Drucksache]:
"""Extrahiere alle Drucksachen-Einträge aus einer Result-Page.
Filtert client-seitig auf ``<p>Antrag </p>`` die Page enthält
Anträge, Schriftliche Anfragen, Mündliche Anfragen, Berichte und
Gesetzentwürfe gemischt.
"""
from .parteien import extract_fraktionen
results: list[Drucksache] = []
for block in self._RE_RESULT_BLOCK.findall(html):
m_header = self._RE_DRUCKSACHE_HEADER.search(block)
if not m_header:
continue
drucksache = m_header.group(1)
datum_iso = self._datum_de_to_iso(m_header.group(2))
m_typ = self._RE_TYP_FRAKTION.search(block)
typ_frak = m_typ.group(1).strip() if m_typ else ""
# Format ist "<TYP> <FRAKTIONEN>" — Typ ist das erste Token,
# Rest ist Fraktion(en) komma-separiert.
parts = typ_frak.split(None, 1)
typ = parts[0] if parts else ""
fraktionen_text = parts[1] if len(parts) > 1 else ""
# Bayern listet auch Schriftliche Anfragen, Berichte etc. in
# derselben Liste — wir wollen nur Anträge.
if typ.lower() != "antrag":
continue
fraktionen = extract_fraktionen(
fraktionen_text, bundesland="BY",
)
m_title = self._RE_TITLE.search(block)
title = m_title.group(1).strip() if m_title else f"Drucksache {drucksache}"
# Kollabieren von Mehrfach-Whitespace innerhalb des Titels
title = re.sub(r"\s+", " ", title)
m_pdf = self._RE_PDF_HREF.search(block)
pdf_url = m_pdf.group(1) if m_pdf else ""
results.append(Drucksache(
drucksache=drucksache,
title=title,
fraktionen=fraktionen,
datum=datum_iso,
link=pdf_url,
bundesland="BY",
typ=typ,
))
return results
def _build_search_params(self, query: str, page: int = 1) -> dict:
# Bayern nutzt PHP-Style-Array-Suffix ``wahlperiodeid[]`` —
# httpx codiert Listen als wiederholte Keys, wir bauen den
# Param-Namen mit ``[]`` direkt in den dict-Key ein.
return {
"dokumentenart": "Drucksache",
"wahlperiodeid[]": str(self.wahlperiode),
"q": query or "",
"sort": "date",
"anzahl_treffer": "100",
"page": str(page),
}
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
# TODO: Implement Bayern search
return []
"""Volltext-Suche über die aktuelle Wahlperiode, gefiltert auf Anträge.
Sortiert newest-first (``sort=date``). Holt 1-3 Pages, je 100
Hits (Antrags-Anteil ist ~10-15% des Drucksachen-Mix), client-
seitig nach ``Antrag``-Typ gefiltert.
"""
url = f"{self.base_url}/parlament/dokumente/drucksachen"
results: list[Drucksache] = []
seen: set[str] = set()
async with httpx.AsyncClient(
timeout=30,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"},
) as client:
for page in range(1, 4): # max 300 raw hits → ~30-50 Anträge
try:
resp = await client.get(url, params=self._build_search_params(query, page=page))
except Exception:
logger.exception("BY search request error page=%d", page)
break
if resp.status_code != 200:
logger.error("BY search HTTP %s page=%d", resp.status_code, page)
break
page_results = self._parse_results(resp.text)
if not page_results:
break
for d in page_results:
if d.drucksache in seen:
continue
seen.add(d.drucksache)
results.append(d)
if len(results) >= limit:
return results
return results
async def get_document(self, drucksache: str) -> Optional[Drucksache]:
# TODO: Implement
"""Direktes Lookup via ``q=<drucksache>``. Solr-Volltext matcht
die Drucksachen-Nummer und liefert sie als einzigen Hit zurück."""
url = f"{self.base_url}/parlament/dokumente/drucksachen"
params = self._build_search_params(drucksache, page=1)
async with httpx.AsyncClient(
timeout=30, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 GWOE-Antragspruefer"},
) as client:
try:
resp = await client.get(url, params=params)
except Exception:
logger.exception("BY get_document request error for %s", drucksache)
return None
if resp.status_code != 200:
return None
for d in self._parse_results(resp.text):
if d.drucksache == drucksache:
return d
return None
async def download_text(self, drucksache: str) -> Optional[str]:
return None
"""Download das Antrags-PDF 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("BY 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("BY download error for %s", drucksache)
return None
class PARLISAdapter(ParlamentAdapter):