gwoe-antragspruefer/scripts/auto-ingest-protocols.sh
Dotty Dotter d9ae0b0db8 feat(#153): HB-Parser produktiv — Bremer Beschlussprotokolle (Status-Only)
Bremen publiziert wie Hessen nur Beschlussprotokolle (TOPs + Status-Saetze),
KEINE Wortprotokolle mit Vote-Block. Daher minimaler Parser:
- Drucksache + Status (angenommen/abgelehnt/ueberwiesen)
- Vote-Listen bleiben leer (HB hat keine Fraktions-Detail)

Anchor-Regex: "Die Buergerschaft (Landtag|Stadtbuergerschaft) <verb> <rest> <terminator>"
Verb-Mapping:
- "lehnt ... ab" → abgelehnt
- "stimmt ... zu" → angenommen
- "beschliesst ..." → angenommen
- "verabschiedet ..." → angenommen
- "verweist|ueberweist|leitet" → ueberwiesen
- "nimmt ... Kenntnis" → uebersprungen (kein Vote)

Drucksachen-Aufloesung: erst Inline-Form "(21/N)", dann Block-Form
"Drucksache 21/N" rueckwaerts vom Anchor.

URL-Pattern (verifiziert WP21 Sitzung 33 Land):
https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf

Cron unterstuetzt jetzt {n4}-Platzhalter (4-stellig). HB Land WP21
ingestiert via direktes URL-Probing (b21l0001.pdf … b21l9999.pdf).
Stadtbuergerschaft (b21s*) als Folge-Issue.

Tests: 21 HB-Tests, Verifikation S33 → 20 Beschluesse extrahiert.
Stand: 8 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH, HB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:41:40 +02:00

266 lines
9.8 KiB
Bash
Executable File

#!/bin/bash
# BL-uebergreifender Auto-Ingest fuer Plenarprotokolle (#106 / #126 Phase 3).
#
# Pro registriertem BL: liest letztes ingestetes Protokoll, probiert das
# naechste, ingestet bei 200, wiederholt bis 404 (mit GAP_TOLERANCE).
# Idempotent (Compound-PK in plenum_vote_results), kein State ausser DB.
#
# Wird via Cron taeglich morgens aufgerufen. Ausgabe nach
# /var/log/gwoe-ingest-protocols.log.
#
# Usage:
# auto-ingest-protocols.sh [CONTAINER]
set -euo pipefail
CONTAINER="${1:-gwoe-antragspruefer}"
GAP_TOLERANCE=3 # 3 aufeinanderfolgende 404 → fertig fuer dieses BL
# Pro BL: URL-Pattern + Wahlperiode-Auflistung.
# WP-Liste ergibt sich aus aktiven Wahlperioden in BUNDESLAENDER. Hier
# aktuell + Vorgaenger-WP, weil Plenum noch in der laufenden WP arbeitet
# und alte Sitzungen gelegentlich nachtraeglich digitalisiert werden.
#
# Format: BL_CODE|WAHLPERIODE|PROTOKOLL_ID_PREFIX|URL_PATTERN
# URL-Pattern unterstuetzt zwei Platzhalter:
# {n} — Sitzungs-Nr unkpaddet (z.B. NRW: MMP18-1.pdf)
# {n3} — Sitzungs-Nr 3-stellig zero-gepadded (z.B. BUND: 20001.xml)
# {n4} — Sitzungs-Nr 4-stellig zero-gepadded (z.B. HB: b21l0033.pdf)
PROTO_TARGETS=(
"NRW|18|MMP18-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP18-{n}.pdf"
"NRW|17|MMP17-|https://www.landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP17-{n}.pdf"
"BUND|20|BTP20-|https://dserver.bundestag.de/btp/20/20{n3}.xml"
"BUND|19|BTP19-|https://dserver.bundestag.de/btp/19/19{n3}.xml"
"BE|19|PlPr19-|https://www.parlament-berlin.de/ados/19/IIIPlen/protokoll/plen19-{n3}-pp.pdf"
"BE|18|PlPr18-|https://www.parlament-berlin.de/ados/18/IIIPlen/protokoll/plen18-{n3}-pp.pdf"
"TH|8|PlPr8-|https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf"
"HB|21|HB21l-|https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.pdf"
)
echo "=== auto-ingest-protocols $(date -Iseconds) ==="
# ─── HH: Index-Page-Scrape statt URL-Pattern ──────────────────────────
# Hamburg hat keine vorhersagbare URL-Pattern (Blob-IDs + Hashes).
# Stattdessen: Index-Seite scrapen, jedes gefundene PDF einzeln ingesten.
echo "--- HH WP23 (Index-Scrape) ---"
docker exec -i "$CONTAINER" python <<EOF
import re, sys
import urllib.request
import sqlite3
import asyncio
# Index-Seite scrapen
req = urllib.request.Request(
"https://www.hamburgische-buergerschaft.de/recherche-info/protokolle",
headers={"User-Agent": "Mozilla/5.0 GWOeAntragspruefer"},
)
try:
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", errors="replace")
except Exception as e:
print(f" Index-Scrape fehlgeschlagen: {e}")
sys.exit(0)
# PDFs extrahieren: /resource/blob/{ID}/{HASH}/{N}-vorl-beschlussprotokoll-DD-MM-YYYY-data.pdf
pdf_re = re.compile(
r'href="(/resource/blob/(\d+)/([a-f0-9]+)/(\d+)-vorl-beschlussprotokoll-(\d{2}-\d{2}-\d{4})[^"]*\.pdf)"'
)
matches = list(pdf_re.finditer(html))
print(f" {len(matches)} HH-Protokolle in Index gefunden")
# Bereits ingestete Protokolle holen
db = sqlite3.connect("/app/data/gwoe-antraege.db")
existing = {row[0] for row in db.execute(
"SELECT quelle_protokoll FROM plenum_vote_results WHERE bundesland='HH'"
)}
from app.ingest_votes import ingest_pdf
from pathlib import Path
import tempfile
new_count = 0
for m in matches:
href, blob_id, h, sitzung, datum = m.groups()
pid = f"PlPr23-{sitzung}"
if pid in existing:
continue
url = "https://www.hamburgische-buergerschaft.de" + href
print(f" → neu: {pid} ({datum})")
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
urllib.request.urlretrieve(url, tmp_path)
stats = asyncio.run(ingest_pdf(
tmp_path, bundesland="HH", protokoll_id=pid, quelle_url=url,
))
print(f" parsed: {stats['parsed']}, written: {stats['written']}")
new_count += 1
except Exception as e:
print(f" Fehler: {e}")
finally:
tmp_path.unlink(missing_ok=True)
print(f" HH: {new_count} neue Protokolle ingestet")
EOF
# ─── HE: Index-Page-Scrape (URL enthaelt Datum, nicht vorhersagbar) ───
# Hessen-Beschlussprotokoll-URL-Pattern hat Datum-Anteil DD-MM-YYYY,
# daher Pattern via starweb-Index extrahieren statt vorzuhersagen.
echo "--- HE WP21 (Index-Scrape) ---"
docker exec -i "$CONTAINER" python <<'EOF'
import re, sys
import urllib.request
import sqlite3
import asyncio
req = urllib.request.Request(
"https://starweb.hessen.de/starweb/LIS/Pd_Eingang.htm",
headers={"User-Agent": "Mozilla/5.0 GWOeAntragspruefer"},
)
try:
# 302 → portal/browse.tt.html, urllib folgt automatisch
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", errors="replace")
except Exception as e:
print(f" Index-Scrape fehlgeschlagen: {e}")
sys.exit(0)
# href="http://starweb.hessen.de/cache/hessen/landtag/Plenum/{wp}/Beschlussprotokoll_PL_{n}_{datum}.pdf"
pdf_re = re.compile(
r'href="(https?://starweb\.hessen\.de/cache/hessen/landtag/Plenum/(\d+)/'
r'Beschlussprotokoll_PL_(\d+)_(\d{2}-\d{2}-\d{4})\.pdf)"'
)
matches = list(pdf_re.finditer(html))
print(f" {len(matches)} HE-Beschlussprotokolle in Index gefunden")
db = sqlite3.connect("/app/data/gwoe-antraege.db")
existing = {row[0] for row in db.execute(
"SELECT quelle_protokoll FROM plenum_vote_results WHERE bundesland='HE'"
)}
from app.ingest_votes import ingest_pdf
from pathlib import Path
import tempfile
new_count = 0
for m in matches:
url, wp, sitzung, datum = m.groups()
pid = f"PlPr{wp}-{sitzung}"
if pid in existing:
continue
print(f" → neu: {pid} ({datum})")
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
urllib.request.urlretrieve(url, tmp_path)
stats = asyncio.run(ingest_pdf(
tmp_path, bundesland="HE", protokoll_id=pid, quelle_url=url,
))
print(f" parsed: {stats['parsed']}, written: {stats['written']}")
new_count += 1
except Exception as e:
print(f" Fehler: {e}")
finally:
tmp_path.unlink(missing_ok=True)
print(f" HE: {new_count} neue Protokolle ingestet")
EOF
# ─── SH: Index-Page-Scrape (URL enthaelt Jahr + MM-YY-Suffix) ─────────
# SH-URL hat Jahr-Pfad + MM-YY-Suffix, daher Index-Scrape.
echo "--- SH WP20 (Index-Scrape) ---"
docker exec -i "$CONTAINER" python <<'EOF'
import re, sys
import urllib.request
import sqlite3
import asyncio
req = urllib.request.Request(
"https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/",
headers={"User-Agent": "Mozilla/5.0 GWOeAntragspruefer"},
)
try:
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", errors="replace")
except Exception as e:
print(f" Index-Scrape fehlgeschlagen: {e}")
sys.exit(0)
# href="/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf"
pdf_re = re.compile(
r'href="(/export/sites/ltsh/infothek/wahl20/plenum/plenprot/(\d{4})/'
r'20-(\d{3})_(\d{2}-\d{2})\.pdf)"'
)
matches = list(pdf_re.finditer(html))
print(f" {len(matches)} SH-Plenarprotokolle in Index gefunden")
db = sqlite3.connect("/app/data/gwoe-antraege.db")
existing = {row[0] for row in db.execute(
"SELECT quelle_protokoll FROM plenum_vote_results WHERE bundesland='SH'"
)}
from app.ingest_votes import ingest_pdf
from pathlib import Path
import tempfile
new_count = 0
for m in matches:
href, year, sitzung, suffix = m.groups()
pid = f"PlPr20-{int(sitzung)}"
if pid in existing:
continue
url = "https://www.landtag.ltsh.de" + href
print(f" → neu: {pid} ({year} {suffix})")
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
urllib.request.urlretrieve(url, tmp_path)
stats = asyncio.run(ingest_pdf(
tmp_path, bundesland="SH", protokoll_id=pid, quelle_url=url,
))
print(f" parsed: {stats['parsed']}, written: {stats['written']}")
new_count += 1
except Exception as e:
print(f" Fehler: {e}")
finally:
tmp_path.unlink(missing_ok=True)
print(f" SH: {new_count} neue Protokolle ingestet")
EOF
for entry in "${PROTO_TARGETS[@]}"; do
IFS='|' read -r bl wp prefix pattern <<< "$entry"
echo "--- ${bl} WP${wp} (prefix=${prefix}) ---"
# Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/Prefix
last_n=$(docker exec "$CONTAINER" python -c "
import sqlite3
c = sqlite3.connect('/app/data/gwoe-antraege.db').cursor()
c.execute(\"SELECT COALESCE(MAX(CAST(SUBSTR(quelle_protokoll, ${#prefix}+1) AS INTEGER)), 0) FROM plenum_vote_results WHERE bundesland=? AND quelle_protokoll LIKE ?\", ('${bl}', '${prefix}%'))
print(c.fetchone()[0])
" 2>/dev/null || echo "0")
# Sanity: numeric check
if ! [[ "$last_n" =~ ^[0-9]+$ ]]; then last_n=0; fi
start_n=$((last_n + 1))
echo "Letztes ingestes ${prefix}: ${last_n}, probiere ab ${start_n}"
consecutive_404=0
for n in $(seq $start_n $((last_n + 50))); do
n3=$(printf "%03d" "$n")
n4=$(printf "%04d" "$n")
url="${pattern//\{n3\}/$n3}"
url="${url//\{n4\}/$n4}"
url="${url//\{n\}/$n}"
http=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 15 "$url" || echo "000")
if [ "$http" = "200" ]; then
consecutive_404=0
pid="${prefix}${n}"
echo " → ingest ${pid}"
docker exec "$CONTAINER" python -m app.ingest_votes \
--url "$url" --bundesland "$bl" --protokoll-id "$pid" 2>&1 \
| tail -3 | sed 's/^/ /' || echo " !! ingest fehlgeschlagen"
elif [ "$http" = "404" ]; then
consecutive_404=$((consecutive_404 + 1))
if [ $consecutive_404 -ge $GAP_TOLERANCE ]; then
break
fi
fi
done
done
echo "=== auto-ingest done $(date -Iseconds) ==="