- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Ein Review über die Winterliga-Änderung förderte 13 Befunde zutage, davon drei ernste. Der schwerste lag in der Pipeline. GitLab bricht einen Job bei der ersten Zeile ab, die ungleich null endet — und `check` endet im Sommer regelmäßig mit 1, weil es 32 echte Abweichungen gibt. Die darunter stehende Winterprüfung lief damit kein einziges Mal. Im Job `json` dieselbe Falle: Ein unlesbares Sommer-PDF hätte auch die Winterliga gekostet. Beide Läufe stehen jetzt in einem Block und werden erst am Ende bewertet. Zwei Filterfehler, die stillschweigend Daten verloren hätten: - Die automatisch erkannte Wintersaison war das Maximum über *alle* Links. Ein einziges Sommer-PDF auf der Winterseite hätte den Filter auf dessen höheres Jahr gezogen und von der Winterliga nichts übrig gelassen — ohne Fehlermeldung, mit Rückgabewert 0. - Der Saisonfilter warf Links ohne Jahr im Namen weg. Damit lieferte --all-pdfs, ausdrücklich die Rückfallebene bei Schemawechseln, im Winter genau dann nichts, wenn man sie braucht. Dazu: merge_index und write_files überstehen jetzt eine index.json aus älteren Fassungen oder aus einem abgebrochenen Lauf, statt den ganzen Lauf nach 120 gelesenen PDFs mit einem KeyError zu beenden. Die Gruppennummer darf einstellig sein. Die Winter-Kopfzeile wird gefordert statt nur übersprungen — ohne sie wäre die Spaltenfolge bloß eine Annahme. Ein Winter-Ergebnisbericht wird am Inhalt erkannt und mit klarer Meldung abgelehnt. Winter-Tabellen zählen getrennt, weil "nachgerechnet" für sie schlicht nicht stimmt. Beim 404 auf die Saisonseite war die Toleranz zu weit: Auch ein Umzug des Verbands wäre als "Saison noch nicht veröffentlicht" durchgegangen, mit grüner Pipeline und verschwundenen Sommerligen. Jetzt wird nur ohne ausdrückliche Angabe ausgewichen, und zwar auf die Saison davor — damit bleiben die Sommertabellen über den Winter stehen, was die vorige Fassung verloren hätte. Bei --winter, --url oder --year ist ein 404 ein Fehler. docs/fehlerquellen.md ist neu: die Muster, die hier wirklich vorkamen, jeweils mit dem echten Fall, dem Erkennungsmerkmal und dem Befehl zum Nachprüfen. Dazu eine Checkliste und die Regel, die sich zweimal bewährt hat — eine Probe, die nur beim falschen Lesen fehlschlagen kann, gehört in den Parser; eine, die auch bei falschem Veröffentlichen fehlschlägt, in check.py. 70 Tests. Am Netz belegt: Sommer 107/107, Winter 8/8 und 11/11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| docs | ||
| src/boulepdfparser | ||
| tests | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| CLAUDE.md | ||
| pyproject.toml | ||
| README.md | ||
boulepdfparser
Liest die Liga-PDFs des Saarländischen Boule-Verbands — die Ergebnisse der Spieltage und die Tabellen — und gibt sie als JSON aus, damit die Vereinswebsite daraus Tabellen zeichnen kann. Kommandozeilenwerkzeug, kein Dienst: einmal aufrufen, JSON liegt da.
Dokumentation
| Datei | Inhalt |
|---|---|
| docs/einstieg.md | Ohne Vorwissen: Entstehung, Sprachwahl, jede Datei einzeln, Tests, erster eigener Lauf |
| docs/projekt.md | Technisch: Aufbau, Layout-Annahmen, CLI- und JSON-Referenz, Wartung |
| docs/gitlab.md | Pipeline, Pages, Zeitplan, Spiegelung von Forgejo — Schritt für Schritt |
| docs/wordpress.md | Die Tabellen auf der Vereinsseite zeigen: Plugin-Entwurf mit Code |
| docs/betrieb.md | Saisonwechsel, Abweichungen melden, Rücksicht auf die Quelle, Rechtliches |
| docs/fehlerquellen.md | Selbst prüfen: welche Fehlerarten hier vorkommen und wie man einen Verdacht belegt |
Was es liest
Der Verband spielt zwei Spielzeiten: die Sommersaison innerhalb eines Kalenderjahres und die Winterliga, die den Jahreswechsel überspannt.
Im Sommer erscheinen je Spieltag und Liga zwei PDFs — der Ergebnisbericht mit allen Begegnungen und die fortgeschriebene Tabelle. Beide werden gelesen; Spielpläne und Formulare bleiben liegen.
Oberliga Ost
1. Spieltag: 24.04.2026 Ergebnis:
Triplette 1 Triplette M Doublette 1 Doublette 2 Doublette M
BC Hüttigweiler 1 gegen PC Messidor 2 13 : 9 13 : 12 13 : 2 13 : 11 6 : 13
Ein Lauf über die Sommersaison 2026 findet 107 solcher PDFs in elf Ligen.
Die Winterliga spielt in Gruppen und hat eine eigene Tabellenform — Siege, Siegpunkte, Differenz- und Pluspunkte, den Platz am Ende:
WINTERLIGA 2025 / 2026
Sieg- Differenz- Plus-
Verein Siege Platz
punkte Pkt. punkte
FV Diefflen 1 3 8 57 107 1
Ihre Ergebnisberichte haben ein drittes Layout und werden noch nicht gelesen; seit 2025/26 veröffentlicht der Verband dazu ohnehin keine mehr.
Bedienung
pip install .
boulepdfparser list # was auf der Saisonseite verlinkt ist
boulepdfparser parse --out liga.json # alles einlesen, eine JSON-Datei
boulepdfparser parse --split web/data/ # eine JSON-Datei je Liga
boulepdfparser parse --winter --split web/data/ --merge # dazu die Winterliga
boulepdfparser check # Tabellen gegen die Ergebnisse rechnen
Ohne weitere Angabe gilt die Sommersaison des laufenden Kalenderjahres. Filter
für kleinere Läufe: --league oberliga-ost, --matchday 5,
--kind standings. Einzelne Dateien gehen auch ohne Netz:
boulepdfparser parse ~/pdfs/*.pdf.
Die beiden Spielzeiten
Gezählt wird nach dem Startjahr — im Sommer ist das die Saison selbst, im Winter das Jahr, in dem sie beginnt:
| Sommer | Winter | |
|---|---|---|
| Beispiel | 2026 | 2025/26 |
| Aufruf | --year 2026 |
--year 2025 --winter |
| Seite des Verbands | …/liga-2026/ |
…/winterliga/, ohne Jahr |
Rolle von --year |
füllt die Adresse | filtert, denn dort liegen alle Saisons nebeneinander |
| Eingeteilt in | elf Ligen | Gruppen |
| Ergebnisberichte | ja | bis 2024/25, danach keine mehr |
Ohne --year nimmt der Sommer das laufende Kalenderjahr und der Winter die
jüngste Saison, die auf der Seite liegt.
Zieht der Verband ganz um, hilft --url https://…/ für den einzelnen Lauf;
dauerhaft werden dann INDEX_URL_TEMPLATE bzw. WINTER_INDEX_URL in
src/boulepdfparser/source.py nachgezogen —
die einzige Stelle im Code, die Adressen kennt.
Zwischen Januar und dem Frühjahr gibt es die Seite des laufenden Jahres
noch nicht. Ohne --year weicht der Lauf dann von selbst auf die Saison davor
aus und sagt das — die Sommertabellen bleiben also stehen, statt über den
Winter von der Website zu verschwinden:
Saison 2027 ist noch nicht veröffentlicht — weiche auf 2026 aus.
Gibt es auch die nicht, endet der Lauf mit Rückgabewert 3 und einer lesbaren
Meldung statt mit einem Traceback. Bei --winter, --year oder --url wird
nicht ausgewichen: Wer ausdrücklich fragt, bekommt eine ausdrückliche
Antwort.
Das JSON
Standard ist die Form site: nach Liga gebündelt, Spieltage und Tabellen
aufsteigend sortiert, jede Angabe mit ihrer Quell-PDF.
{
"generated_at": "2026-08-23T20:37:12+00:00",
"season": 2026,
"season_label": "2026",
"season_kind": "sommer",
"index_url": "https://www.petanque-sbv.de/content/sportliches/sbv-liga/liga-2026/",
"leagues": [
{
"league": "Oberliga Ost",
"season": 2026,
"season_label": "2026",
"season_kind": "sommer",
"slug": "oberliga-ost-2026",
"latest_standings_matchday": 5,
"matchdays": [
{
"matchday": 1,
"date": "2026-04-24",
"formations": ["Triplette 1", "Triplette M", "Doublette 1", "Doublette 2", "Doublette M"],
"matches": [
{
"home": "BC Hüttigweiler 1",
"away": "PC Messidor 2",
"game_points": [4, 1],
"points": [58, 47],
"winner": "home",
"games": [{ "formation": "Triplette 1", "home": 13, "away": 9 }]
}
],
"source": "https://www.petanque-sbv.de/assets/…/2026-ligatabelle-oberliga-ost-1.spieltag.pdf"
}
],
"standings": [
{
"matchday": 5,
"rows": [
{
"rank": 1, "team": "PC Hanweiler 2",
"wins": 5, "losses": 0,
"games_won": 20, "games_lost": 5,
"points_for": 289, "points_against": 204,
"difference": 85
}
],
"source": "https://www.petanque-sbv.de/assets/…-5.spieltag-tabelle.pdf"
}
]
}
]
}
game_points sind die gewonnenen Einzelspiele einer Begegnung, points die
addierten Punkte — beide gerechnet, nicht aus dem PDF gelesen.
season ist immer das Startjahr und damit sortierbar; season_label ist die
Anzeigeform („2026“, „2025/26“) und season_kind sagt, welche Spalten die
Tabellenzeilen tragen:
season_kind |
Spalten in standings[].rows[] |
|---|---|
sommer |
rank, team, wins, losses, games_won, games_lost, points_for, points_against, difference |
winter |
rank, team, wins, win_points, difference, plus_points |
Mit --split web/data/ entsteht daraus eine Datei je Liga
(oberliga-ost-2026.json, winterliga-gruppe-01-2025-26.json) plus eine
index.json, die alle Ligen mit Dateinamen und vorhandenen Spieltagen
auflistet. Für eine Seite, die nur eine Tabelle zeigt, ist das der kleinere
Abruf.
Die Saison steht im Dateinamen, damit Sommer, Winter und Archiv nebeneinander
liegen können. Welche Datei die aktuelle ist, sagt index.json — die
Website sollte sie lesen, statt einen Namen fest einzutragen. Zwei Läufe in
dasselbe Verzeichnis brauchen --merge, sonst ersetzt der zweite die
index.json des ersten.
--shape documents gibt statt dessen ein Objekt je PDF aus, in
Lesereihenfolge — die Form fürs Archiv. --compact spart die Einrückung.
Auf der Website einbinden
const basis = "https://<gruppe>.gitlab.io/<projekt>";
const index = await fetch(`${basis}/index.json`).then((r) => r.json());
// Dateinamen nicht raten — index.json sagt, welche Datei gerade gilt.
const eintrag = index.leagues.find((l) => l.league === "Oberliga Ost");
const liga = await fetch(`${basis}/${eintrag.file}`).then((r) => r.json());
const tabelle = liga.standings.at(-1); // die jüngste Tabelle
for (const row of tabelle.rows) {
// Sommer: row.wins, row.games_won, row.points_for, row.difference
// Winter: row.wins, row.win_points, row.plus_points, row.difference
}
GitLab Pages liefert die Dateien mit Access-Control-Allow-Origin: * aus, der
Abruf von einer anderen Domain funktioniert also. Die tatsächliche Adresse
steht im Projekt unter Deploy → Pages — neue Projekte bekommen eine eigene
Domain je Projekt statt des Pfads unter der Gruppe.
Pipeline
.gitlab-ci.yml baut das JSON und veröffentlicht es über
GitLab Pages. Vier Jobs: test (pytest), json (PDFs holen und schreiben —
Sommer und Winter in dasselbe Verzeichnis), check (Gegenrechnung, darf
fehlschlagen) und pages.
Drei Dinge sind einzurichten: die Spiegelung von Forgejo nach GitLab (dort liegt das Repository, GitLab CI braucht es auf GitLab), Pages und ein Zeitplan — z. B. montags 6 Uhr, Spieltage sind samstags oder sonntags. Schritt für Schritt steht das in docs/gitlab.md, samt Fehlersuche, Abrufcode für die Website und den Alternativen ohne GitLab (Forgejo Actions, rsync auf den Vereinsserver, schlichter Cron).
Die PDFs bleiben im CI-Cache liegen, ein Lauf lädt also nur nach, was neu ist. Schlägt das Einlesen eines PDFs fehl, scheitert der Job — Pages behält dann die letzte vollständige Fassung, statt eine halbe zu veröffentlichen.
Die Tabellen des Verbands stimmen nicht immer
boulepdfparser check rechnet jede veröffentlichte Tabelle aus den
Ergebnisberichten derselben Liga nach: Siege aus den gewonnenen Einzelspielen,
Spiele und Punkte aufsummiert. Für die Saison 2026 stimmen 480 von 500
nachgerechneten Tabellenzeilen; in den übrigen 20 weichen 32 Einzelwerte ab:
Bezirksliga Nord 2026, 2. Spieltag · BV Dirmingen 2: points_for berechnet 95, im PDF 106
Verbandsliga Ost 2026, 4. Spieltag · Hokuta Bous: games_won berechnet 8, im PDF 9
Das sind Fehler in den PDFs des Verbands, keine Lesefehler: die Abweichungen betreffen drei Ligen, entstehen an einem Spieltag und werden von dort an mitgeschleppt. Wer die Tabellen auf der Vereinsseite zeigt, sollte das wissen — und die Fundstellen gegebenenfalls dem Sportwart melden.
Die Winterliga lässt sich nicht nachrechnen — es gibt keine Ergebnisberichte dazu, und ihre Tabelle führt Plus-, aber keine Minuspunkte. Eine Probe hat sie trotzdem: Jeder erzielte Punkt ist zugleich ein kassierter, also müssen sich die Differenzen einer Gruppe zu null addieren. 16 von 19 Tabellen tun das:
Winterliga Gruppe 01 2024/25, 3. Spieltag: Differenzen summieren sich auf -18 statt 0
Auch hier wächst der Rest von Spieltag zu Spieltag (−18, −71, −151) — einmal verrutscht, danach mitgeschleppt.
Verglichen wird nur, wo alles vorliegt: Fehlt zu einer Tabelle nach dem 5. Spieltag ein Ergebnisbericht, wird sie übersprungen statt falsch bemängelt.
Grenzen
- Nur Ergebnisse und Tabellen. Die Spielpläne der Saison sind zweispaltig gesetzt; ihr Text kommt Wort für Wort und ohne Zeilenbezug aus dem PDF. Das wäre eine eigene, positionsbasierte Auswertung.
- Winterliga: nur die Tabellen. Ihre Ergebnisberichte haben ein drittes Layout (Runden je Spieltag, drei Formationen, dazu Sieg- und Spielpunkte als Paare und drei nackte Platzziffern). Seit 2025/26 erscheinen sie nicht mehr.
- Frauenliga: gar nicht. Aus ihren PDFs kommt genau eine Textzeile; der Inhalt liegt nicht als Textebene vor.
- Streng statt still. Eine Zeile, die nicht ins erwartete Muster passt, ist ein Fehler und keine übersprungene Zeile. Ändert der Verband das Layout, fällt das auf, statt die halbe Datei zu verlieren.
- Textebene nötig. Ein eingescanntes PDF ohne Textebene ergibt nichts; bisher liefert der Verband durchweg erzeugte PDFs.
Aufbau
src/boulepdfparser/
source.py Adressen beider Saisonseiten — die eine Stelle mit Jahresbezug
discover.py Saisonseite → PDF-Adressen samt Hinweisen aus dem Dateinamen
fetch.py HTTP; PDFs mit Zwischenspeicher, die Übersichtsseite ohne
extract.py PDF → Textzeilen (pdfplumber)
parse.py Textzeilen → Ergebnisbericht oder Tabelle, ohne jedes I/O
models.py Datenmodell samt Saisonbegriff; Werte gerechnet, nie gelesen
check.py Tabelle gegen die Ergebnisse nachrechnen, Winter gegen sich selbst
render.py JSON in den Formen site und documents
cli.py list, parse, check
Tests
pip install ".[dev]"
pytest
61 Tests, alle ohne Netz. Die Fixtures in tests/fixtures/ sind der Text
echter PDFs aus dem Sommer 2026 und den Wintersaisons 2024/25 und 2025/26 —
darunter die beiden Spieltage, an denen die Bezirksliga Nord und die
Winterliga-Gruppe 01 aus dem Tritt geraten, damit die Gegenrechnung
nachweislich anschlägt.