Saarland publiziert keine Wortprotokolle, sondern eigene HTML-Seiten
mit strukturierten Abstimmungsergebnissen pro Sitzung:
<p>Drucksache 17/2076 ... in Erster Lesung mit Stimmenmehrheit
angenommen ... [SPD: dafür; CDU und AfD: dagegen]</p>
Daher Input ist HTML, nicht PDF. Parser nutzt LI-Block-Iteration und
extrahiert pro Block:
- Drucksache aus "Drucksache N/M"
- Status aus "(einstimmig|mit Stimmenmehrheit)? (angenommen|abgelehnt)"
- Vote-Block aus "[SPD: dafür; CDU: dagegen; AfD: Enthaltung]"
- einstimmig=True falls Status enthaelt "einstimmig"
Vote-Bracket-Parser (eigenstaendig vs. Reden-Stil-Parser anderer BL):
- Splits per ; → "Phrase: Status"
- Phrase per Wortgrenzen-Regex auf {SPD,CDU,AfD} matchen
- Status-Map: dafür→ja, dagegen→nein, Enthaltung→enthaltung
URL-Pattern (nicht direkt vorhersagbar wegen Datums-Slug):
https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/
Auto-Ingest via Index-Scrape (analog HH/HE/SH):
- /aktuelles/mitteilungen/ scrape
- WP16-URLs (mit "wahlperiode-vom") ueberspringen
- Pro neue Sitzung: HTML herunterladen, ingest_pdf-API auf .html-Datei
Tests: 18 SL-Tests (Verifikation Sitzung 46 → 18 Votes mit korrekten
JA/NEIN/ENTH-Listen). Stand: 9 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
12 KiB
Bash
Executable File
335 lines
12 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
|
|
|
|
# ─── SL: HTML-Abstimmungsergebnisse-Index ─────────────────────────────
|
|
# SL publiziert keine Wortprotokolle, sondern HTML-Abstimmungsergebnisse-Seiten.
|
|
# Index-Scrape /aktuelles/mitteilungen/, jeden abstimmungsergebnisse-Link
|
|
# einzeln laden + parsen.
|
|
echo "--- SL WP17 (HTML-Index-Scrape) ---"
|
|
docker exec -i "$CONTAINER" python <<'EOF'
|
|
import re, sys
|
|
import urllib.request
|
|
import sqlite3
|
|
import asyncio
|
|
|
|
BASE = "https://www.landtag-saar.de"
|
|
req = urllib.request.Request(
|
|
f"{BASE}/aktuelles/mitteilungen/",
|
|
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)
|
|
|
|
# WP17 hat keinen "wahlperiode-vom"-Marker im URL-Slug — diesen Filter ausschliessen.
|
|
url_re = re.compile(
|
|
r'href="(/aktuelles/mitteilungen/abstimmungsergebnisse-der-(\d+)-landtagssitzung-vom-[^"]+?/)"'
|
|
)
|
|
matches = []
|
|
seen_pids = set()
|
|
for m in url_re.finditer(html):
|
|
href, sitzung = m.groups()
|
|
if "wahlperiode-vom" in href:
|
|
continue # WP16-URLs ueberspringen
|
|
pid = f"SL17-{sitzung}"
|
|
if pid in seen_pids:
|
|
continue
|
|
seen_pids.add(pid)
|
|
matches.append((pid, BASE + href))
|
|
print(f" {len(matches)} SL-Sitzungen WP17 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='SL'"
|
|
)}
|
|
|
|
from app.ingest_votes import ingest_pdf
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
new_count = 0
|
|
for pid, url in matches:
|
|
if pid in existing:
|
|
continue
|
|
print(f" → neu: {pid} ({url[:80]})")
|
|
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
urllib.request.urlretrieve(url, tmp_path)
|
|
stats = asyncio.run(ingest_pdf(
|
|
tmp_path, bundesland="SL", 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" SL: {new_count} neue Sitzungen 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) ==="
|