2026-04-28 22:23:51 +02:00
#!/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.
#
2026-04-28 23:21:39 +02:00
# 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)
2026-04-28 22:23:51 +02:00
PROTO_TARGETS = (
2026-04-28 23:21:39 +02:00
"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"
2026-04-29 01:09:07 +02:00
"BUND|19|BTP19-|https://dserver.bundestag.de/btp/19/19{n3}.xml"
feat(#150): BE-Parser produktiv — Berliner Abgeordnetenhaus-Plenarprotokolle
Dritter vollwertiger Plenarprotokoll-Parser nach NRW + BUND.
URL-Pattern verifiziert (WP19 Sitzungen 1, 10, 50, 80, 100):
https://www.parlament-berlin.de/ados/{wp}/IIIPlen/protokoll/plen{wp}-{n:03}-pp.pdf
Anchor-Sprache (NRW-aehnlich, mit Berliner-Eigenheit 'pro forma'):
Wer den Antrag auf Drucksache 19/X annehmen moechte, ... – Das sind
die Fraktionen Buendnis 90/Die Gruenen und Die Linke.
Wer stimmt dagegen? – Das sind die Fraktionen der CDU, SPD und AfD.
Wer enthaelt sich, pro forma? – Das ist niemand.
Damit ist der Antrag abgelehnt.
Pattern:
- Result-Anchor: Damit ist [Antrag/Aenderungsantrag/Gesetzentwurf/...]
(angenommen|abgelehnt)
- Vote-Block: 3 Q+A-Paare im Reden-Stil (annehmen moechte / dagegen /
enthaelt sich)
- Drucksachen-Lookup: 'Drucksache 19/N(-suffix)' rueckwaerts (1500-char Fenster)
Fraktions-Mapping WP19:
- Buendnis 90/Die Gruenen → GRÜNE
- Die Linke → LINKE
- CDU, SPD, AfD, FDP
21 Tests in test_protokoll_parsers_be.py.
Cron-PROTO_TARGETS erweitert um BE WP19 (~80 Sitzungen).
Stub-Test angepasst.
905 Tests gruen (889 → 905, +16 fuer BE).
2026-04-29 00:37:47 +02:00
"BE|19|PlPr19-|https://www.parlament-berlin.de/ados/19/IIIPlen/protokoll/plen19-{n3}-pp.pdf"
2026-04-29 01:09:07 +02:00
"BE|18|PlPr18-|https://www.parlament-berlin.de/ados/18/IIIPlen/protokoll/plen18-{n3}-pp.pdf"
feat(#163): TH-Parser produktiv — Thueringer Plenarprotokolle
Fuenfter produktiver Parser nach NRW + BUND + BE + HH.
URL-Pattern verifiziert (WP8 Sitzungen 1, 10, 20, 30, 40, 42):
https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf
Anchor-Sprache (BE-aehnlich):
Wer dem zustimmt, ... Das sind die Stimmen aus den Fraktionen der
CDU, BSW, SPD und Die Linke. Wer stimmt gegen ...? Das sind die
Stimmen aus der Fraktion der AfD. Damit ist [...] mehrheitlich
angenommen.
Pattern:
- Result-Anchor: Damit ist [Subjekt] (mehrheitlich|einstimmig)?
(angenommen|abgelehnt)
- Vote-Block: Wer dem zustimmt / Wer stimmt gegen / Wer enthaelt sich
- Drucksachen-Lookup: 'Drucksache 8/N' rueckwaerts
Fraktions-Mapping WP8 (ab Mai 2024): CDU, AfD, BSW, Linke, SPD
(WP7-Faktionen GRUENE/FDP fuer Backfill ebenfalls im Mapping).
Cron-PROTO_TARGETS um TH-WP8 erweitert. Stub-Test angepasst.
2026-04-29 01:11:58 +02:00
"TH|8|PlPr8-|https://www.thueringer-landtag.de/uploads/tx_tltcalendar/protocols/Arbeitsfassung{n}.pdf"
2026-04-28 22:23:51 +02:00
)
echo " === auto-ingest-protocols $( date -Iseconds) === "
2026-04-29 01:01:52 +02:00
# ─── 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 " $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
2026-04-28 22:23:51 +02:00
for entry in " ${ PROTO_TARGETS [@] } " ; do
2026-04-28 23:21:39 +02:00
IFS = '|' read -r bl wp prefix pattern <<< " $entry "
echo " --- ${ bl } WP ${ wp } (prefix= ${ prefix } ) --- "
2026-04-28 22:23:51 +02:00
2026-04-28 23:21:39 +02:00
# Hoechste bisher ingestete Sitzungs-Nr fuer diesen BL/Prefix
2026-04-28 22:29:36 +02:00
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
2026-04-28 22:23:51 +02:00
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
2026-04-28 23:21:39 +02:00
n3 = $( printf "%03d" " $n " )
url = " ${ pattern // \{ n3 \} / $n3 } "
url = " ${ url // \{ n \} / $n } "
2026-04-28 22:23:51 +02:00
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) === "