#!/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()