#!/usr/bin/env python3 """ Scraped Beratungsfolge und Beschlüsse von ALLRIS Vorlagen-Seiten. Extrahiert: Sitzungen, Beschlussart, Beschlusstext aus verlinkten TOs. """ import argparse 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" LOG_FILE = PROJECT_ROOT / "data" / "beratungsfolge.log" # Rate Limiting DELAY_SECONDS = 1.0 def log(msg: str): timestamp = time.strftime("%H:%M:%S") line = f"[{timestamp}] {msg}" print(line) with open(LOG_FILE, "a") as f: f.write(line + "\n") def get_db(): conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row return conn def init_tables(conn): """Erweitert beratungen-Tabelle um neue Spalten falls nötig.""" existing = [c[1] for c in conn.execute('PRAGMA table_info(beratungen)').fetchall()] needed = ['to_url', 'tolfdnr', 'beschlussart', 'beschlusstext', 'wortprotokoll', 'scraped_at'] for col in needed: if col not in existing: conn.execute(f'ALTER TABLE beratungen ADD COLUMN {col} TEXT') log(f" Schema: +{col}") conn.commit() def scrape_vorlage_page(url: str) -> list[dict]: """Scraped Beratungsfolge von einer Vorlagen-Seite.""" try: resp = httpx.get(url, timeout=30, follow_redirects=True) resp.raise_for_status() soup = BeautifulSoup(resp.text, 'html.parser') beratungen = [] # Finde Beratungsfolge-Tabelle # Links zu to020 (Tagesordnungspunkt) oder to010 (Sitzung) for link in soup.find_all('a', href=True): href = link['href'] # TO-Links finden (Beschluss) if 'to020' in href and 'TOLFDNR=' in href: tolfdnr_match = re.search(r'TOLFDNR=(\d+)', href) if tolfdnr_match: tolfdnr = tolfdnr_match.group(1) beschlussart = link.get_text(strip=True) # Sitzungsinfo aus vorherigem Link holen sitzung_name = None sitzung_url = None # Suche vorherigen to010 Link (Sitzung) prev = link.find_previous('a', href=re.compile(r'to010.*SILFDNR=')) if prev: sitzung_name = prev.get_text(strip=True) sitzung_url = prev['href'] if not sitzung_url.startswith('http'): sitzung_url = 'https://allris.hagen.de' + sitzung_url to_url = href if href.startswith('http') else 'https://allris.hagen.de' + href beratungen.append({ 'tolfdnr': tolfdnr, 'beschlussart': beschlussart, 'sitzung_name': sitzung_name, 'sitzung_url': sitzung_url, 'to_url': to_url, }) return beratungen except Exception as e: log(f" Fehler beim Scrapen: {e}") return [] def scrape_to_page(url: str) -> dict: """Scraped Beschlusstext und Wortprotokoll von TO-Seite.""" try: resp = httpx.get(url, timeout=30, follow_redirects=True) resp.raise_for_status() soup = BeautifulSoup(resp.text, 'html.parser') result = { 'beschlusstext': None, 'wortprotokoll': None, 'sitzung_datum': None, } # Datum aus Titel extrahieren (z.B. "30.01.2025 - 6.4 Mündlicher...") title = soup.find('h1', class_='title') if title: date_match = re.search(r'(\d{2}\.\d{2}\.\d{4})', title.get_text()) if date_match: result['sitzung_datum'] = date_match.group(1) # Beschlusstext und Wortprotokoll finden # Die sind in Tags text_spans = soup.find_all('span', style=re.compile(r'font-family.*Arial')) texts = [s.get_text(strip=True) for s in text_spans if s.get_text(strip=True)] if texts: # Letzter Text ist oft der Beschluss result['beschlusstext'] = texts[-1] if len(texts) > 0 else None # Vorherige Texte sind Wortprotokoll if len(texts) > 1: result['wortprotokoll'] = '\n\n'.join(texts[:-1]) return result except Exception as e: log(f" TO-Fehler: {e}") return {} def process_vorlage(conn, vorlage: dict) -> int: """Verarbeitet eine Vorlage und speichert Beratungsfolge.""" vorlage_id = vorlage['id'] web_url = vorlage['web_url'] if not web_url: return 0 # Beratungsfolge von Vorlagen-Seite scrapen beratungen = scrape_vorlage_page(web_url) if not beratungen: return 0 saved = 0 for b in beratungen: time.sleep(DELAY_SECONDS) # TO-Seite für Details scrapen to_details = scrape_to_page(b['to_url']) try: # Update existierende Zeile oder insert neue conn.execute(""" UPDATE beratungen SET to_url = ?, tolfdnr = ?, beschlussart = ?, beschlusstext = ?, wortprotokoll = ?, scraped_at = CURRENT_TIMESTAMP WHERE vorlage_id = ? AND (tolfdnr = ? OR tolfdnr IS NULL) """, ( b['to_url'], b['tolfdnr'], b['beschlussart'], to_details.get('beschlusstext'), to_details.get('wortprotokoll'), vorlage_id, b['tolfdnr'], )) if conn.total_changes == 0: # Neue Zeile conn.execute(""" INSERT INTO beratungen (vorlage_id, to_url, tolfdnr, beschlussart, beschlusstext, wortprotokoll, scraped_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( vorlage_id, b['to_url'], b['tolfdnr'], b['beschlussart'], to_details.get('beschlusstext'), to_details.get('wortprotokoll'), )) conn.commit() saved += 1 except Exception as e: log(f" DB-Fehler: {e}") return saved def main(): parser = argparse.ArgumentParser(description="Beratungsfolge scrapen") parser.add_argument("--limit", type=int, default=50, help="Max. Vorlagen") args = parser.parse_args() log(f"=== Beratungsfolge-Scraper ===") log(f"Limit: {args.limit}") conn = get_db() init_tables(conn) # Vorlagen mit web_url die noch nicht gescraped sind (nutze beratung_status) vorlagen = conn.execute(""" SELECT id, aktenzeichen, web_url FROM vorlagen WHERE web_url IS NOT NULL AND beratung_status IS NULL ORDER BY datum_eingang DESC LIMIT ? """, (args.limit,)).fetchall() log(f"Zu verarbeiten: {len(vorlagen)}") total_beratungen = 0 for i, v in enumerate(vorlagen): log(f"[{i+1}/{len(vorlagen)}] {v['aktenzeichen']}...") time.sleep(DELAY_SECONDS) count = process_vorlage(conn, dict(v)) total_beratungen += count # Status setzen (auch wenn keine Beratung gefunden) conn.execute("UPDATE vorlagen SET beratung_status = 'done' WHERE id = ?", (v['id'],)) conn.commit() if count > 0: log(f" ✓ {count} Beratungen") else: log(f" - Keine Beratungsfolge") conn.close() log(f"\n=== Fertig ===") log(f"Beratungen gespeichert: {total_beratungen}") # Exit-Code für Batch-Runner remaining = conn.execute(""" SELECT COUNT(*) FROM vorlagen WHERE web_url IS NOT NULL AND id NOT IN (SELECT DISTINCT vorlage_id FROM beratungen) """).fetchone()[0] if False else 0 # Vereinfacht return 0 if __name__ == "__main__": import sys sys.exit(main())