antragstracker/scripts/scrape_abstimmungen.py

342 lines
11 KiB
Python
Raw Permalink Normal View History

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