Vollständige Pipeline zur Analyse kommunaler Vorlagen aus ALLRIS: - OParl-Import: 20.149 Vorlagen - PDF-Extraktion: 10.045 Volltexte (adaptives Throttling) - KI-Zusammenfassungen: 10.026 via Qwen Plus (parallelisiert) - Beratungsfolge-Scraper: Beschlusstexte + Wortprotokolle - Abstimmungs-Analyse mit Koalitionsmatrix - Georeferenzierung (Nominatim) Stack: FastAPI + SvelteKit + SQLite Deployment: Docker + Traefik auf VServer Daten (DB, Logs) nicht im Repo — siehe Restic-Backup. Repo-Setup: scripts/setup.sh für Neuaufbau aus OParl-API.
342 lines
11 KiB
Python
342 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ALLRIS Abstimmungs-Scraper für Antragstracker Hagen.
|
|
Lädt Abstimmungsergebnisse von to020-Seiten und extrahiert fraktionsweises Stimmverhalten.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from bs4 import BeautifulSoup
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
DB_PATH = PROJECT_ROOT / "data" / "tracker_remote.db"
|
|
|
|
# DashScope API für Qwen
|
|
DASHSCOPE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
|
DASHSCOPE_KEY = os.environ.get("QWEN_API_KEY") or os.popen("security find-generic-password -s qwen-api -w 2>/dev/null").read().strip()
|
|
|
|
|
|
EXTRACTION_PROMPT = """Analysiere dieses Abstimmungsergebnis aus einem kommunalen Ratsinformationssystem.
|
|
|
|
TEXT:
|
|
{text}
|
|
|
|
---
|
|
|
|
Extrahiere das Stimmverhalten aller Fraktionen. Gib NUR valides JSON zurück:
|
|
|
|
{{
|
|
"ergebnis": "beschlossen/abgelehnt/vertagt/zurückgezogen",
|
|
"ergebnis_typ": "einstimmig/mehrheitlich/mit_gegenstimmen",
|
|
"fraktionen": [
|
|
{{"name": "SPD", "stimme": "ja", "anzahl": null, "bemerkung": null}},
|
|
{{"name": "AfD", "stimme": "enthaltung", "anzahl": null, "bemerkung": null}},
|
|
{{"name": "Hagen Aktiv", "stimme": "enthaltung", "anzahl": 1, "bemerkung": "1 Mitglied"}}
|
|
]
|
|
}}
|
|
|
|
Regeln:
|
|
- stimme: "ja", "nein", oder "enthaltung"
|
|
- anzahl: nur wenn explizit Teilmenge genannt ("1 Mitglied"), sonst null
|
|
- Bei "einstimmig" oder "ungeändert beschlossen" ohne Gegenstimmen: alle bekannten Fraktionen als "ja"
|
|
- Bekannte Hagener Fraktionen: SPD, CDU, Grüne, FDP, AfD, HAK/Die Linke, Hagen Aktiv, Freie Wähler
|
|
|
|
NUR JSON, keine Erklärungen."""
|
|
|
|
|
|
def get_db():
|
|
conn = sqlite3.connect(str(DB_PATH))
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def fetch_to020_page(client: httpx.Client, tolfdnr: int) -> str | None:
|
|
"""Lädt die to020-Seite (Tagesordnungspunkt-Detail)."""
|
|
url = f"https://allris.hagen.de/public/to020?TOLFDNR={tolfdnr}"
|
|
try:
|
|
resp = client.get(url, timeout=30, follow_redirects=True)
|
|
resp.raise_for_status()
|
|
return resp.text
|
|
except Exception as e:
|
|
print(f" Fehler beim Laden von {url}: {e}")
|
|
return None
|
|
|
|
|
|
def extract_abstimmung_text(html: str) -> tuple[str | None, str | None]:
|
|
"""Extrahiert Abstimmungsergebnis-Block aus HTML.
|
|
Returns: (ergebnis_kurz, volltext)
|
|
"""
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
|
|
# Kurzform: "ungeändert beschlossen" etc.
|
|
ergebnis_kurz = None
|
|
beschluss_span = soup.find('span', id='toBeschlussart')
|
|
if beschluss_span:
|
|
ergebnis_kurz = beschluss_span.get_text(strip=True)
|
|
|
|
# Volltext: Abstimmungsergebnis-Block
|
|
volltext = None
|
|
|
|
# Suche nach docPart mit "Abstimmungsergebnis"
|
|
for div in soup.find_all('div', class_='docPart'):
|
|
text = div.get_text()
|
|
if 'Abstimmungsergebnis' in text or 'einstimmig' in text.lower() or 'Enthaltung' in text:
|
|
volltext = text.strip()
|
|
break
|
|
|
|
# Fallback: Suche im Wortprotokoll nach Abstimmungsinfo
|
|
if not volltext:
|
|
for div in soup.find_all('div', class_='docPart'):
|
|
text = div.get_text()
|
|
if any(kw in text.lower() for kw in ['abstimmung', 'ja-stimm', 'nein-stimm', 'enthält sich', 'mehrheitlich', 'einstimmig']):
|
|
volltext = text.strip()
|
|
break
|
|
|
|
return ergebnis_kurz, volltext
|
|
|
|
|
|
def call_qwen_turbo(prompt: str) -> dict | None:
|
|
"""Ruft Qwen-Turbo API auf (schnell & günstig)."""
|
|
if not DASHSCOPE_KEY:
|
|
print(" FEHLER: Kein QWEN_API_KEY gefunden")
|
|
return None
|
|
|
|
try:
|
|
resp = httpx.post(
|
|
DASHSCOPE_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {DASHSCOPE_KEY}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
json={
|
|
"model": "qwen-turbo-latest",
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"temperature": 0.1
|
|
},
|
|
timeout=30
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
content = resp.json()["choices"][0]["message"]["content"]
|
|
|
|
# JSON extrahieren
|
|
if "```json" in content:
|
|
content = content.split("```json")[1].split("```")[0]
|
|
elif "```" in content:
|
|
content = content.split("```")[1].split("```")[0]
|
|
|
|
return json.loads(content.strip())
|
|
|
|
except json.JSONDecodeError as e:
|
|
print(f" JSON-Parse-Fehler: {e}")
|
|
return None
|
|
except Exception as e:
|
|
print(f" API-Fehler: {e}")
|
|
return None
|
|
|
|
|
|
def get_tolfdnrs_from_vorlage(client: httpx.Client, volfdnr: int) -> list[tuple[int, str, str]]:
|
|
"""Extrahiert TOLFDNRs aus der vo020-Seite.
|
|
Returns: List of (tolfdnr, datum, gremium_name)
|
|
"""
|
|
url = f"https://allris.hagen.de/public/vo020?VOLFDNR={volfdnr}"
|
|
try:
|
|
resp = client.get(url, timeout=30, follow_redirects=True)
|
|
resp.raise_for_status()
|
|
html = resp.text
|
|
|
|
results = []
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
|
|
# Suche nach Links zu to020
|
|
for link in soup.find_all('a', href=re.compile(r'TOLFDNR=\d+')):
|
|
match = re.search(r'TOLFDNR=(\d+)', link.get('href', ''))
|
|
if match:
|
|
tolfdnr = int(match.group(1))
|
|
# Versuche Datum und Gremium aus Kontext zu extrahieren
|
|
row = link.find_parent('tr')
|
|
datum = ''
|
|
gremium = ''
|
|
if row:
|
|
cells = row.find_all('td')
|
|
for cell in cells:
|
|
text = cell.get_text(strip=True)
|
|
if re.match(r'\d{2}\.\d{2}\.\d{4}', text):
|
|
datum = text
|
|
elif 'Sitzung' in text or 'Rat' in text or 'Ausschuss' in text:
|
|
gremium = text
|
|
results.append((tolfdnr, datum, gremium))
|
|
|
|
return list(set(results)) # Deduplizieren
|
|
except Exception as e:
|
|
print(f" Fehler beim Laden von vo020: {e}")
|
|
return []
|
|
|
|
|
|
def process_vorlage(conn: sqlite3.Connection, client: httpx.Client, vorlage: dict) -> int:
|
|
"""Verarbeitet alle TOLFDNRs einer Vorlage.
|
|
Returns: Anzahl erfolgreicher Abstimmungen
|
|
"""
|
|
vid = vorlage['id']
|
|
volfdnr = vorlage.get('volfdnr')
|
|
|
|
if not volfdnr:
|
|
# VOLFDNR aus web_url extrahieren
|
|
web_url = vorlage.get('web_url', '')
|
|
match = re.search(r'VOLFDNR=(\d+)', web_url)
|
|
if match:
|
|
volfdnr = int(match.group(1))
|
|
else:
|
|
return 0
|
|
|
|
# TOLFDNRs von Vorlagen-Seite holen
|
|
tolfdnrs = get_tolfdnrs_from_vorlage(client, volfdnr)
|
|
if not tolfdnrs:
|
|
return 0
|
|
|
|
success = 0
|
|
for tolfdnr, datum, gremium in tolfdnrs:
|
|
# Prüfen ob schon verarbeitet
|
|
existing = conn.execute(
|
|
"SELECT id FROM abstimmungen WHERE vorlage_id = ? AND ergebnis_detail LIKE ?",
|
|
(vid, f'%{tolfdnr}%')
|
|
).fetchone()
|
|
if existing:
|
|
continue
|
|
|
|
if process_tolfdnr(conn, client, vid, tolfdnr, datum, gremium):
|
|
success += 1
|
|
time.sleep(0.3)
|
|
|
|
return success
|
|
|
|
|
|
def process_tolfdnr(conn: sqlite3.Connection, client: httpx.Client,
|
|
vorlage_id: int, tolfdnr: int, datum: str, gremium: str) -> bool:
|
|
"""Verarbeitet eine einzelne TOLFDNR."""
|
|
|
|
# Seite laden
|
|
html = fetch_to020_page(client, tolfdnr)
|
|
if not html:
|
|
return False
|
|
|
|
# Abstimmungstext extrahieren
|
|
ergebnis_kurz, volltext = extract_abstimmung_text(html)
|
|
|
|
if not ergebnis_kurz and not volltext:
|
|
return False
|
|
|
|
# KI-Analyse wenn Volltext vorhanden
|
|
fraktionen_data = []
|
|
if volltext and len(volltext) > 20:
|
|
prompt = EXTRACTION_PROMPT.format(text=volltext[:3000])
|
|
result = call_qwen_turbo(prompt)
|
|
if result and 'fraktionen' in result:
|
|
fraktionen_data = result['fraktionen']
|
|
if result.get('ergebnis'):
|
|
ergebnis_kurz = result['ergebnis']
|
|
|
|
# Gremium-ID finden
|
|
gremium_id = None
|
|
if gremium:
|
|
row = conn.execute("SELECT id FROM gremien WHERE name LIKE ?", (f"%{gremium[:20]}%",)).fetchone()
|
|
if row:
|
|
gremium_id = row['id']
|
|
|
|
# Datum parsen
|
|
sitzung_datum = None
|
|
if datum:
|
|
try:
|
|
parts = datum.split('.')
|
|
if len(parts) == 3:
|
|
sitzung_datum = f"{parts[2]}-{parts[1]}-{parts[0]}"
|
|
except:
|
|
pass
|
|
|
|
# In DB speichern
|
|
detail_json = json.dumps({
|
|
"tolfdnr": tolfdnr,
|
|
"fraktionen": fraktionen_data
|
|
}, ensure_ascii=False) if fraktionen_data else json.dumps({"tolfdnr": tolfdnr})
|
|
|
|
cursor = conn.execute("""
|
|
INSERT INTO abstimmungen (beratung_id, vorlage_id, gremium_id, sitzung_datum, ergebnis, ergebnis_detail, volltext)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (None, vorlage_id, gremium_id, sitzung_datum, ergebnis_kurz, detail_json, volltext))
|
|
|
|
abstimmung_id = cursor.lastrowid
|
|
|
|
# Fraktionen speichern
|
|
for f in fraktionen_data:
|
|
conn.execute("""
|
|
INSERT INTO abstimmungen_fraktionen (abstimmung_id, fraktion, stimme, anzahl, bemerkung)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (abstimmung_id, f.get('name'), f.get('stimme'), f.get('anzahl'), f.get('bemerkung')))
|
|
|
|
conn.commit()
|
|
|
|
print(f" ✓ TOLFDNR {tolfdnr}: {ergebnis_kurz or 'OK'} ({len(fraktionen_data)} Fraktionen)")
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="ALLRIS Abstimmungs-Scraper")
|
|
parser.add_argument("--limit", type=int, default=10, help="Max. Anzahl Vorlagen")
|
|
parser.add_argument("--typ", type=str, default="antrag", help="Vorlagen-Typ (antrag/anfrage)")
|
|
parser.add_argument("--vorlage", type=int, help="Nur bestimmte Vorlage-ID")
|
|
args = parser.parse_args()
|
|
|
|
print(f"=== ALLRIS Abstimmungs-Scraper ===\n")
|
|
|
|
conn = get_db()
|
|
client = httpx.Client()
|
|
|
|
# Vorlagen mit web_url finden, die noch keine Abstimmung haben
|
|
query = """
|
|
SELECT v.id, v.aktenzeichen, v.web_url
|
|
FROM vorlagen v
|
|
LEFT JOIN abstimmungen a ON v.id = a.vorlage_id
|
|
WHERE v.web_url IS NOT NULL
|
|
AND a.id IS NULL
|
|
"""
|
|
params = []
|
|
|
|
if args.typ:
|
|
query += " AND v.typ = ?"
|
|
params.append(args.typ)
|
|
|
|
if args.vorlage:
|
|
query += " AND v.id = ?"
|
|
params.append(args.vorlage)
|
|
|
|
query += f" ORDER BY v.datum_eingang DESC LIMIT {args.limit}"
|
|
|
|
vorlagen = conn.execute(query, params).fetchall()
|
|
print(f"Verarbeite {len(vorlagen)} Vorlagen\n")
|
|
|
|
total_success = 0
|
|
for v in vorlagen:
|
|
vd = dict(v)
|
|
print(f"Vorlage {vd.get('aktenzeichen', vd['id'])}...")
|
|
success = process_vorlage(conn, client, vd)
|
|
total_success += success
|
|
if success == 0:
|
|
print(f" (keine Abstimmungsdaten)")
|
|
time.sleep(0.5)
|
|
|
|
client.close()
|
|
conn.close()
|
|
|
|
print(f"\n=== Fertig: {total_success} Abstimmungen extrahiert ===")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|