No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Sebastian eaac818d12 Review-Befunde beheben, Anleitung zum Selbstprüfen
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>
2026-08-24 10:56:21 +02:00
docs Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00
src/boulepdfparser Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00
tests Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00
.gitignore Parser, Kommandozeile, Tests und Pipeline 2026-08-24 09:20:24 +02:00
.gitlab-ci.yml Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00
CLAUDE.md Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00
pyproject.toml Parser, Kommandozeile, Tests und Pipeline 2026-08-24 09:20:24 +02:00
README.md Review-Befunde beheben, Anleitung zum Selbstprüfen 2026-08-24 10:56:21 +02:00

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.