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)
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
# {n4} — Sitzungs-Nr 4-stellig zero-gepadded (z.B. HB: b21l0033.pdf)
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"
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
"HB|21|HB21l-|https://www.bremische-buergerschaft.de/dokumente/wp21/land/protokoll/b21l{n4}.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) ---"
2026-04-29 01:37:42 +02:00
docker exec -i " $CONTAINER " python <<EOF
2026-04-29 01:01:52 +02:00
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-29 01:19:58 +02:00
# ─── 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) ---"
2026-04-29 01:37:42 +02:00
docker exec -i " $CONTAINER " python <<'EOF'
2026-04-29 01:19:58 +02:00
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
2026-04-29 01:29:06 +02:00
# ─── 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) ---"
2026-04-29 01:37:42 +02:00
docker exec -i " $CONTAINER " python <<'EOF'
2026-04-29 01:29:06 +02:00
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
feat(#161): SL-Parser produktiv — Saarland HTML-Abstimmungsergebnisse
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>
2026-04-29 01:53:51 +02:00
# ─── 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
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 " )
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
n4 = $( printf "%04d" " $n " )
2026-04-28 23:21:39 +02:00
url = " ${ pattern // \{ n3 \} / $n3 } "
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
url = " ${ url // \{ n4 \} / $n4 } "
2026-04-28 23:21:39 +02:00
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) === "