No description
  • Python 70.8%
  • PHP 15.7%
  • HTML 9.4%
  • CSS 3.4%
  • JavaScript 0.4%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Sebastian 9ed67843d2 Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m
- 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>
2026-10-03 12:30:07 +02:00
assets Dienst für den Vereinsserver: Zeitplan, Admin-Oberfläche, Daten für die Website 2026-10-03 12:06:39 +02:00
deploy Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
docs Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
src/boulepdfparser Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
tests Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
wordpress Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
.gitignore Dienst für den Vereinsserver: Zeitplan, Admin-Oberfläche, Daten für die Website 2026-10-03 12:06:39 +02:00
.gitlab-ci.yml Archiv: Gedächtnis zwischen den Läufen, Veröffentlichung je Platz 2026-09-26 19:45:32 +02:00
CLAUDE.md Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
freigaben.toml Archiv: Gedächtnis zwischen den Läufen, Veröffentlichung je Platz 2026-09-26 19:45:32 +02:00
pyproject.toml Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +02:00
README.md Ligen eines Vereins dynamisch, Update-Anleitung, Dienst über python -m 2026-10-03 12:30:07 +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. 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.