#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:
parent
6dfcd69979
commit
27ae82a758
@ -1446,21 +1446,237 @@ class StarFinderCGIAdapter(ParlamentAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class BayernAdapter(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"
|
bundesland = "BY"
|
||||||
name = "Bayerischer Landtag"
|
name = "Bayerischer Landtag"
|
||||||
base_url = "https://www.bayern.landtag.de"
|
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]:
|
async def search(self, query: str, limit: int = 20) -> list[Drucksache]:
|
||||||
# TODO: Implement Bayern search
|
"""Volltext-Suche über die aktuelle Wahlperiode, gefiltert auf Anträge.
|
||||||
return []
|
|
||||||
|
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]:
|
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
|
return None
|
||||||
|
|
||||||
async def download_text(self, drucksache: str) -> Optional[str]:
|
async def download_text(self, drucksache: str) -> Optional[str]:
|
||||||
|
"""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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user