Files
politics-tracking/backend/politrack/sources/parlament/persons.py
T
Simon DiesenreiterandClaude Opus 5.5 9da5a1dc48 Politrack: track Austrian politicians from public sources
Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:47:14 +01:00

240 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Parliament: people, mandates, government functions, side-job disclosures, social links, speeches.
Flow: list 409 (all MPs since 1918) -> PersonRecord + mandate roles
-> detail /person/<id> for members of the configured periods
detail -> photo, social accounts, functions, side jobs (§6 Unv-Transparenz-G)
-> list 251 (speeches of that person)
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterator
from typing import Any
from politrack.config import get_settings
from politrack.core import Adapter, Request, Response, RunContext, register
from politrack.core.records import Claim, Evidence, OrgRef, PersonRecord, PersonRef, Role, SocialAccount, Statement
from politrack.sources.parlament.common import (
BASE,
CHAMBER_NAME_DE,
CHAMBER_SLUG,
GOVERNMENT_RE,
SPEC,
detail_request,
list_request,
list_rows,
parse_date,
walk_dicts,
)
from politrack.text import firmenbuch_number, html_to_text, strip_titles
SOCIAL_PLATFORMS = {"TWITTER": "twitter", "FACEBOOK": "facebook", "INSTAGRAM": "instagram", "TIKTOK": "tiktok",
"YOUTUBE": "youtube", "LINKEDIN": "linkedin", "WEBSITE": "website", "BLUESKY": "bluesky",
"MASTODON": "mastodon", "TELEGRAM": "telegram", "THREADS": "threads"}
FN_IN_TEXT = re.compile(r"\bFN\s*\d{1,6}\s?[a-zA-Z]\b")
POSTCODE_CITY = re.compile(r"(?:(?<=\d)|\b)\d{4}\s+[A-ZÄÖÜ][\wäöüß .\-]*$")
LEGAL_FORM_END = re.compile(r"(?i:gesmbh|gmbh|ges\.\s?m\.\s?b\.\s?h\.?|m\.\s?b\.\s?h\.?|aktiengesellschaft|\bag|\bkg|\bog|"
r"e\.\s?u\.|\bse|privatstiftung|genossenschaft|sparkasse)(?=[A-ZÄÖÜ\s,]|$)")
STREET_WORD = r"\S*(?:straße|strasse|str\.|gasse|platz|weg|kai|ring|allee|zeile|lände|markt|steig)"
def strip_street(text: str) -> str:
"""'Verein Musterhilfe Hauptstraße 12' -> 'Verein Musterhilfe' (greedy name, shortest street suffix)."""
m = (re.match(rf"^(.*\S)\s+{STREET_WORD}\s*\d+[a-z]?(?:/\d+)*\s*$", text, re.IGNORECASE)
or re.match(r"^(.*\S)\s+\S+\s*\d+[a-z]?(?:/\d+)*\s*$", text))
return m.group(1) if m else text
def employer(cell: str) -> tuple[str | None, str | None]:
"""Employer name without business address, plus its company register number.
'Hypo Tirol Bank AG, Meraner Straße 8, 6020 Innsbruck' -> ('Hypo Tirol Bank AG', None)
'25 PersonaldienstleistungsgesmbHFranz Josef Kai 95020 Salzburg FN306527a' (parliament's JSON glues
the lines together) -> ('25 PersonaldienstleistungsgesmbH', '306527a')
"""
cell = re.sub(r"^lit\.\s*\w\s*:\s*", "", cell).strip()
fn_match = FN_IN_TEXT.search(cell)
fn = firmenbuch_number(fn_match.group(0)) if fn_match else None
name = FN_IN_TEXT.sub("", cell).split(",")[0].strip(" *")
address = POSTCODE_CITY.search(name)
if address:
head = name[:address.start()]
ends = [m.end() for m in LEGAL_FORM_END.finditer(head)]
# cut after the last legal form; otherwise drop "<street> <number>" before the postcode
name = head[:ends[-1]] if ends else strip_street(head)
name = name.strip(" ,-*")
return (name if len(name) >= 3 and not re.fullmatch(r"[\d\s.€-]+", name) else None), fn
@register
class ParlamentPersons(Adapter):
spec = SPEC
name = "parlament.persons"
description = "Members of Nationalrat and Bundesrat since 1918, mandates, functions, side jobs, speeches"
schedule = "15 3 * * *"
creates_persons = True
parser_version = 3
def start(self, ctx: RunContext) -> Iterator[Request]:
yield list_request(409, {"NRBR": ["NR", "BR"]}, kind="persons")
def parse(self, ctx: RunContext, response: Response) -> Iterator[Any]:
kind = response.request.kind
if kind == "persons":
yield from self.parse_persons(ctx, response)
elif kind == "person":
yield from self.parse_person(ctx, response)
elif kind == "speeches":
yield from self.parse_speeches(ctx, response)
# -- persons & mandates -------------------------------------------------------------
def parse_persons(self, ctx: RunContext, response: Response) -> Iterator[Any]:
periods = set(ctx.params.get("periods") or get_settings().parlament_periods)
for row in list_rows(response.json()):
attr = row.get("Attribute") or {}
if isinstance(attr, str):
attr = json.loads(attr)
m = re.search(r"/person/(\d+)", attr.get("uri") or "")
if not m:
continue
pad = m.group(1)
ref = PersonRef(ids={"parlament": pad})
display = attr.get("zit") or strip_titles(row.get("Name") or "")
yield PersonRecord(ref=ref, full_name=display, given_name=row.get("vorname"), family_name=row.get("nachname"),
gender={"M": "male", "W": "female"}.get(row.get("GESCHL_CODE") or attr.get("geschlecht")))
mandates = attr.get("mandate_detail") or []
for mandate in mandates:
claim = self.mandate_claim(pad, display, mandate)
if claim:
yield claim
if any(md.get("gp_code") in periods for md in mandates):
yield detail_request(f"/person/{pad}", kind="person", meta={"pad": pad, "name": display})
def mandate_claim(self, pad: str, name: str, m: dict) -> Claim | None:
code = m.get("mand_code") or "NR"
if code not in CHAMBER_SLUG:
return None
party_code = m.get("wahlpartei_code")
party_name = m.get("wahlpartei_full_txt") or m.get("wahlpartei_txt") or party_code
party = OrgRef(name=party_name, short_name=party_code, kind="party") if party_name else None
start, end = parse_date(m.get("mandat_von")), parse_date(m.get("mandat_bis"))
gp = m.get("gp_code") or ""
region = m.get("wahlkreis_bundesland")
label = party_code or party_name or "?"
body = OrgRef(slug=CHAMBER_SLUG[code])
return Claim(
claim_type="role",
person=PersonRef(ids={"parlament": pad}),
org=body,
counterpart_org=party,
valid_from=start,
valid_to=end,
summary_de=f"{name}: Mandat im {CHAMBER_NAME_DE[code]} ({gp}. GP) für {label}"
+ (f", Wahlkreis {region}" if region else ""),
summary_en=f"{name}: member of the {CHAMBER_NAME_DE[code]} ({gp} legislative period) for {label}",
data={"gp": gp, "party_code": party_code, "faction_code": m.get("fraktionscode"), "region": region},
evidence=[Evidence(url=f"{BASE}/person/{pad}", doc_type="api_record", title=f"Parlament: {name}",
locator=f"mandat:{code}:{gp}:{start}")],
facet=Role(role="mp", level="federal", body=body, party=party, region=region, start=start, end=end),
)
# -- person detail ----------------------------------------------------------------
def parse_person(self, ctx: RunContext, response: Response) -> Iterator[Any]:
data = response.json()
content = data.get("content") or {}
pad, name = response.meta["pad"], response.meta["name"]
ref = PersonRef(ids={"parlament": pad})
url = f"{BASE}/person/{pad}"
photo = ((data.get("meta") or {}).get("openGraph") or {}).get("image")
if photo:
yield PersonRecord(ref=ref, full_name=name, photo_url=photo)
for account in (content.get("kontakt") or {}).get("socialMedia") or []:
platform = SOCIAL_PLATFORMS.get((account.get("type") or "").upper(), (account.get("type") or "website").lower())
yield SocialAccount(platform=platform, url=account["url"], person=ref, verified=True)
for fn in walk_dicts(content.get("biografie") or {}, "funktion_text"):
if fn.get("gremium") in CHAMBER_SLUG:
continue
text = fn.get("funktion_text") or fn.get("bez")
if not text:
continue
start, end = parse_date(fn.get("funktion_von")), parse_date(fn.get("funktion_bis"))
is_gov = bool(GOVERNMENT_RE.search(text))
body = OrgRef(slug="bundesregierung") if is_gov else None
yield Claim(
claim_type="role", person=ref, org=body, valid_from=start, valid_to=end,
summary_de=f"{name}: {text}",
evidence=[Evidence(url=url, doc_type="api_record", title=f"Parlament: {name}",
locator=f"funktion:{text}:{start}")],
facet=Role(role=text, level="federal" if is_gov else "other", body=body, start=start, end=end),
)
yield from self.side_jobs(ref, pad, name, content.get("unvtrans") or {})
yield list_request(251, {"PAD_INTERN": [int(pad)]}, kind="speeches", meta={"pad": pad, "name": name})
def side_jobs(self, ref: PersonRef, pad: str, name: str, unv: dict) -> Iterator[Claim]:
"""Disclosures under §6 Unv-Transparenz-G: leading positions, paid side jobs, honorary positions."""
tables = unv.get("unvtransData") or {}
pdf = next((d["link"] for group in unv.get("linkliste") or [] for d in group.get("documents") or []
if d.get("link", "").endswith(f"/person/{pad}/BezBegrBVGPar9.pdf")), None)
for key, table in tables.items():
m = re.fullmatch(r"(z\d+)Table", key)
if not m or not isinstance(table, dict):
continue
header = html_to_text(tables.get(f"{m.group(1)}Header")) or key
for row in (table.get("data") or {}).get("rows") or []:
cells = [c for c in (html_to_text(c) for c in row if isinstance(c, str)) if c]
if not cells:
continue
name_, fn = employer(cells[0]) if m.group(1) in ("z1", "z2", "z3") else (None, None)
if name_: # never keep the (business) address that parliament publishes with the employer
cells = [name_ + (f" (FN {fn})" if fn else ""), *cells[1:]]
text = " – ".join(cells)
yield Claim(
claim_type="side_job", person=ref, key=("unvtrans", pad, m.group(1), text),
counterpart_org=OrgRef(name=name_, kind="company" if LEGAL_FORM_END.search(name_) else "other",
ids={"firmenbuch": fn} if fn else {}) if name_ else None,
summary_de=f"{name} – {header.split(' - ')[0]}: {text}",
data={"category": header, "cells": cells, "unpaid": "*" in "".join(str(c) for c in row),
"employer": name_, "firmenbuch": fn},
evidence=[Evidence(url=f"{BASE}{pdf}" if pdf else f"{BASE}/person/{pad}", doc_type="register_entry",
title=f"Unvereinbarkeits-/Transparenzliste {name}", quote=text,
locator=f"unvtrans:{m.group(1)}")],
topics=("Transparenz und Korruption",),
)
# -- speeches -----------------------------------------------------------------------
def parse_speeches(self, ctx: RunContext, response: Response) -> Iterator[Claim]:
periods = set(ctx.params.get("periods") or get_settings().parlament_periods)
pad, name = response.meta["pad"], response.meta["name"]
for row in list_rows(response.json()):
if row.get("GP") not in periods and row.get("GREMIUM") != "B":
continue
html_cells = " ".join(str(v) for v in row.values() if isinstance(v, str))
protocol = re.search(r'href="(/dokument/[^"]+)"', html_cells)
session = html_to_text(row.get("Sitzung"))
topic = html_to_text(row.get("Rede"))
said_at = row.get("fromDate")
url = f"{BASE}{protocol.group(1)}" if protocol else f"{BASE}/person/{pad}"
chamber = "Bundesrat" if row.get("GREMIUM") == "B" else "Nationalrat"
yield Claim(
claim_type="statement", person=PersonRef(ids={"parlament": pad}),
key=("parl-speech", pad, row.get("Sortier"), topic), occurred_at=said_at,
summary_de=f"{name} sprach im {chamber} zu: {topic}",
summary_en=f"{name} spoke in the {chamber} on: {topic}",
data={"session": session, "gp": row.get("GP"), "item_title": topic},
evidence=[Evidence(url=url, doc_type="protocol", title=session, published_at=parse_date(said_at),
locator=url.split("#", 1)[-1] if "#" in url else "")],
facet=Statement(context="parliament_speech", url=url, said_at=said_at, medium=chamber, title=topic),
)