- Python 70.8%
- PHP 15.7%
- HTML 9.4%
- CSS 3.4%
- JavaScript 0.4%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- index.json und Ligadateien nennen je Liga ihre Mannschaften (teams), 0.2.1 - Plugin 1.1.0: [boule-tabellen verein="…"] zeigt je Mannschaft ihre jüngste Liga, Saisonwechsel ohne Pflege der Seite - docs/anleitung-update.md: Update und Deployment auf YunoHost Schritt für Schritt - Dienst startet über venv/bin/python -m, damit das Umbenennen des venv beim Update die Startzeile nicht bricht Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| assets | ||
| deploy | ||
| docs | ||
| src/boulepdfparser | ||
| tests | ||
| wordpress | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| CLAUDE.md | ||
| freigaben.toml | ||
| 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. Im Kern ein
Kommandozeilenwerkzeug: einmal aufrufen, JSON liegt da. Dazu kommt ein
kleiner Dienst für den Vereinsserver (boulepdfparser-admin), der es jede
Woche aufruft, die Daten ausliefert und eine Admin-Oberfläche für Befunde und
Freigaben hat.
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/anleitung-server.md | Betrieb auf dem Vereinsserver (YunoHost): Dienst, Zeitplan, Admin-Oberfläche — Schritt für Schritt |
| docs/anleitung-update.md | Einen neuen Stand auf den Server bringen: neue Umgebung neben der alten, umschalten, zurück |
| docs/anleitung-website.md | Die Tabellen auf pc-messidor.de: Plugin, Seite „Ligen“ mit [boule-tabellen verein="Messidor"] |
| docs/gitlab.md | Der frühere Weg: Pipeline, Pages, Zeitplan auf GitLab |
| docs/wordpress.md | Das WordPress-Plugin: Installation, Shortcode, Spieltagsleiste |
| 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 |
| docs/robustheit-und-verlauf.md | Robust gegen Fehler der Quelle (Stufe 1 umgesetzt: Einordnen nach Inhalt, Befunde), Archiv und Spieltagsverlauf (geplant) |
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
# mit Gedächtnis zwischen den Läufen, wie in der Pipeline
boulepdfparser parse --split public/ --archiv public/archiv.json --archiv-neu
boulepdfparser parse --winter --split public/ --archiv public/archiv.json
boulepdfparser befunde public/befunde.json # was aussortiert wurde
boulepdfparser ansehen public/ # alle Stände im Browser ansehen
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. Ist die Seite da, verlinkt aber kein
einziges Liga-PDF (Umbau, Wartung), ist es Rückgabewert 4, und es wird nichts
geschrieben. 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.
{
"schema": 2,
"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",
"current_matchday": 5,
"timeline": [{ "matchday": 1, "date": "2026-04-24", "standings": true, "results": true }],
"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 — außer mit --archiv.
Das Archiv
Mit --archiv ORT merkt sich der Lauf je Platz (Saison, Liga, Art,
Spieltag) den letzten geprüften Stand und baut die Ausgabe daraus, nicht aus
dem, was der Verband gerade verlinkt. Ein falsch verlinktes PDF kommt in
Quarantäne, ein unlesbares kostet nur seinen eigenen Platz, und eine Saison,
die von der Seite verschwindet, bleibt erhalten. Daneben liegen archiv.json
und befunde.json; boulepdfparser befunde zeigt, was aussortiert wurde, und
endet mit 1 bei Neuem. Von Hand annehmen oder sperren lässt sich ein Dokument
in freigaben.toml. Die Regeln stehen in
docs/robustheit-und-verlauf.md.
| Rückgabewert | mit --archiv |
|---|---|
5 |
Archiv nicht ladbar, nichts geschrieben (beim allerersten Lauf --archiv-neu) |
6 |
geschrieben, aber ein PDF war unlesbar; sein Platz behält den alten Stand |
4 |
nur noch, wenn die Saisonseite leer und das Archiv leer ist |
--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
Für WordPress gibt es ein fertiges Plugin in
wordpress/boule-liga/: Tabelle mit Liganamen und
Spieltagsleiste, [boule-tabelle liga="Oberliga Ost"] für eine Liga oder
[boule-tabellen verein="Messidor"] für alle Ligen eines Vereins, die sich
damit von Saison zu Saison selbst einstellen. Installation und
Attribute in docs/wordpress.md. Für andere Seiten
genügen ein paar Zeilen JavaScript:
const basis = "https://pcmtabellen.dompasero.de";
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());
// Nicht „die letzte der Liste“ nehmen — current_matchday sagt, welche gilt.
const tabelle = liga.standings.find((s) => s.matchday === liga.current_matchday);
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
}
Der Dienst liefert die Dateien mit Access-Control-Allow-Origin: * aus (GitLab
Pages tat es auch), der Abruf von einer anderen Domain funktioniert also.
Auf dem Vereinsserver
boulepdfparser-admin serve ist ein Dienst mit zwei Ports: die
Admin-Oberfläche (hinter der YunoHost-Anmeldung) und die JSON-Dateien für die
Website (öffentlich, nur die Dateien). Jeden Montag um 6 Uhr läuft er wie die
Pipeline unten — Sommer, Winter, Befunde, Gegenrechnung — baut in ein eigenes
Verzeichnis und tauscht erst, wenn alles taugt. In der Oberfläche stehen die
Befunde mit Knöpfen zum Annehmen und Sperren (statt eines Commits an
freigaben.toml), jeder Lauf mit seinem Protokoll, und die Tabellen im
Betrachter. Einrichtung: docs/anleitung-server.md, Updates: docs/anleitung-update.md.
pip install -e '.[admin]'
BOULE_DATEN=./daten boulepdfparser-admin lauf --archiv-neu # ein Lauf von Hand
BOULE_DATEN=./daten boulepdfparser-admin serve # http://127.0.0.1:8096
Pipeline (früherer Weg)
.gitlab-ci.yml baut das JSON und veröffentlicht es über
GitLab Pages. Fünf Jobs: test (pytest), json (Archiv von Pages holen,
PDFs lesen, fortschreiben — Sommer und Winter in dasselbe Verzeichnis),
befunde (rot, einmal, wenn etwas neu aussortiert wurde), 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
oder höchstens drei Wochen alt (Korrekturen). Schlägt das Einlesen eines PDFs
fehl, behält sein Platz den Stand aus dem Archiv. Ist das Archiv selbst nicht
ladbar, scheitert der Job — Pages behält dann die letzte Fassung. Beim
allerersten Lauf muss die Variable ARCHIV_NEU=1 gesetzt sein.
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
plausi.py jedes Dokument nach Inhalt einordnen, Unplausibles aussortieren
archiv.py Gedächtnis zwischen den Läufen, je Platz der letzte gute Stand
render.py JSON in den Formen site und documents
ansehen.py lokaler Betrachter für die Ausgabe
cli.py list, parse, check, befunde, ansehen
wordpress/
boule-liga/ WordPress-Plugin, liest die Ausgabe
tests/ Probe in WordPress Playground (probe.sh)
Tests
pip install ".[dev]"
pytest
149 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.