Phase G: BundestagAdapter via DIP-API (#56)

Schließt #56 (Bundespolitik überprüfbar machen). Neuer
``BundestagAdapter`` in ``app/parlamente.py``, neuer ``BUND``-Eintrag in
``app/bundeslaender.py`` als 17. Parlament-Slot.

API:

- DIP-Search-API auf ``search.dip.bundestag.de/api/v1/drucksache``
- API-Key aus ``dip-config.js`` gescraped (öffentlich, klartext)
- Auth via URL-Param ``?apikey=...`` plus ``Origin: https://dip.bundestag.de``-
  Header (Origin-Locking, server-to-server-tauglich)
- Pagination via ``cursor``-Parameter, 100 Hits pro Page
- ``f.drucksachetyp=Antrag`` und ``f.wahlperiode=21`` als Server-Filter

Mapping:

- ``dokumentnummer`` → ``Drucksache.drucksache``
- ``titel`` → ``title``
- ``urheber[*].titel`` → durch ``parteien.extract_fraktionen`` zu
  ``["AfD"]``/``["GRÜNE"]``/etc. — die ``"Fraktion der AfD"``-
  Schreibweise wird vom zentralen Mapper aus #55 bereits korrekt
  geparst, kein Adapter-spezifisches Pattern nötig
- ``fundstelle.pdf_url`` → ``link``
- ``datum`` → bereits ISO ``YYYY-MM-DD``

``get_document(drucksache)`` nutzt ``f.dokumentnummer`` als direkter
Server-Filter, kein linearer Pagination-Scan.

BUND-Eintrag in ``bundeslaender.py``:

- ``code="BUND"``, ``parlament_name="Deutscher Bundestag"``,
  ``wahlperiode=21``, ``wahlperiode_start="2025-03-25"`` (Konstituierung
  21. WP nach BTW 2025), ``regierungsfraktionen=["CDU", "CSU", "SPD"]``
  (Kabinett Merz)
- ``aktiv=True`` — taucht automatisch in ``alle_bundeslaender()`` und
  ``aktive_bundeslaender()`` auf, damit die UI- und
  Auswertungs-Pipelines BUND ohne zusätzliche Sonderpfade kennen
- 17 Einträge in ``BUNDESLAENDER`` statt 16 — Tests entsprechend
  aktualisiert (``test_sixteen_bundeslaender_plus_bund``,
  ``test_alle_bundeslaender_returns_all``,
  ``test_all_wahlperioden_lists_each_bl_twice``)

Live-Probe direkt im Repo:

```
adapter: Deutscher Bundestag (DIP), wahlperiode=21
search returned 5 docs
  21/5136 2026-03-31 | ['AfD'] | Transparenz, Wirtschaftlichkeit ...
  21/5064 2026-03-27 | ['GRÜNE'] | Ausverkauf der Energieinfrastruktur ...
  21/5059 2026-03-27 | ['AfD'] | Berufsfreiheit für Selbstständige ...
get_document('21/5136') -> drucksache=21/5136
```

176 Unit-Tests grün, Live-Verifikation Sub-A im Container nach Deploy.

Refs: #56, #59 (Phase G)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-09 14:04:11 +02:00
parent 15b9af8795
commit 0f7d35f20e
4 changed files with 254 additions and 7 deletions

View File

@ -66,7 +66,31 @@ class Bundesland:
# Hauptregister: code -> Bundesland-Instanz.
# Reihenfolge alphabetisch nach offiziellem Namen für stabile UI-Sortierung.
# Sonder-Eintrag "BUND" für den Deutschen Bundestag (technisch kein BL,
# aber teilt die gesamte Adapter/Analyzer-Pipeline mit den 16 BL).
BUNDESLAENDER: dict[str, Bundesland] = {
"BUND": Bundesland(
code="BUND",
name="Deutscher Bundestag",
parlament_name="Deutscher Bundestag",
wahlperiode=21,
wahlperiode_start="2025-03-25", # Konstituierung 21. WP nach BTW 2025
naechste_wahl="2029-09-30", # geschätzt
regierungsfraktionen=["CDU", "CSU", "SPD"], # Kabinett Merz, schwarz-rot
landtagsfraktionen=["CDU", "CSU", "AfD", "SPD", "GRÜNE", "LINKE", "BSW", "FDP"],
doku_system="DIP",
doku_base_url="https://search.dip.bundestag.de",
drucksache_format="21/12345",
dokukratie_scraper=None,
aktiv=True,
anmerkung=(
"DIP-API auf search.dip.bundestag.de mit öffentlichem "
"API-Key aus dip-config.js und Origin-Header-Locking auf "
"https://dip.bundestag.de. ~600 Anträge pro Wahlperiode. "
"Kabinett Merz seit Mai 2025 (CDU/CSU+SPD nach BSW-Aus). "
"BundestagAdapter implementiert in #56."
),
),
"BW": Bundesland(
code="BW",
name="Baden-Württemberg",

View File

@ -1845,8 +1845,221 @@ class PARLISAdapter(ParlamentAdapter):
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
# Registry of adapters
ADAPTERS = {
"BUND": BundestagAdapter(),
"NRW": NRWAdapter(),
"LSA": PortalaAdapter(
bundesland="LSA",

View File

@ -43,11 +43,13 @@ class TestWahlperiodeFor:
def test_all_wahlperioden_lists_each_bl_twice(self):
out = all_wahlperioden()
# 16 Bundesländer × 2 WPs = 32 Einträge
assert len(out) == 32
# 16 Bundesländer + BUND × 2 WPs = 34 Einträge (#56 fügt BUND hinzu)
assert len(out) == 34
# Aktuelle und vorherige WP für NRW
assert "NRW-WP18" in out
assert "NRW-WP17" in out
# BUND ist auch dabei
assert "BUND-WP21" in out
# ─────────────────────────────────────────────────────────────────────────────

View File

@ -1,4 +1,9 @@
"""Tests for bundeslaender.py — sanity over 16-state registry.
"""Tests for bundeslaender.py — sanity over the parliament registry.
Stand: 16 Bundesländer + Sondereintrag ``BUND`` für den Deutschen
Bundestag (siehe #56). Der ``Bundesland``-Dataclass-Name ist historisch
und wird inzwischen als generischer Parlament-Slot verwendet eine
Umbenennung ist als separates Refactoring-Item geplant.
Includes the #48 classification regression: TH must be ParlDok, HB must
be StarWeb, SN must be Eigensystem (not ParlDok).
@ -7,8 +12,10 @@ from app.bundeslaender import BUNDESLAENDER, get, aktive_bundeslaender, alle_bun
class TestRegistryStructure:
def test_sixteen_bundeslaender(self):
assert len(BUNDESLAENDER) == 16
def test_sixteen_bundeslaender_plus_bund(self):
# 16 echte Bundesländer + BUND-Sondereintrag (#56)
assert len(BUNDESLAENDER) == 17
assert "BUND" in BUNDESLAENDER
def test_codes_are_uppercase(self):
for code in BUNDESLAENDER:
@ -33,8 +40,9 @@ class TestActiveBundeslaender:
original = {"NRW", "LSA", "MV", "BE"}
assert original <= active_codes
def test_alle_bundeslaender_returns_all_sixteen(self):
assert len(alle_bundeslaender()) == 16
def test_alle_bundeslaender_returns_all(self):
# 16 BL + BUND
assert len(alle_bundeslaender()) == 17
def test_alle_bundeslaender_active_first(self):
out = alle_bundeslaender()