From 7efa38c91ad502450ea88abe15bcb9c45c6ee97d Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 24 May 2026 15:32:36 +0200 Subject: [PATCH] =?UTF-8?q?Switch=20to=20Long-Polling,=20add=20Turniertabe?= =?UTF-8?q?llen-Anzeige=20f=C3=BCr=20ODJM=20D=202026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 88 +++++++++++++++++++++-------- index.html | 6 ++ server.py | 159 +++++++++++++++++++++++++++++++++++++++++++---------- style.css | 55 +++++++++++++++++- 4 files changed, 253 insertions(+), 55 deletions(-) diff --git a/app.js b/app.js index fe4e881..6e6d637 100644 --- a/app.js +++ b/app.js @@ -3,37 +3,40 @@ * Haupt-Application */ -const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn'; -const REFRESH_INTERVAL = 10000; // 10 Sekunden const PLAYER_NAME = 'Kiesewetter, Lara'; let board = null; let chess = null; let currentGame = null; let allLaraGames = []; -let refreshTimer = null; -let countdown = 0; let serverLastFetch = null; let laraColor = null; let currentMoveIndex = -1; let userSelectedGame = false; let userScrolledMoves = false; +let lastMtime = 0; +let pollId = 0; /** * Lädt die PGN-Datei und aktualisiert die Anzeige */ async function loadPGN(showOverlay = true) { + const currentPollId = ++pollId; if (showOverlay) showLoading(true); hideError(); try { const [pgnResponse, statusResponse] = await Promise.all([ - fetch('http://localhost:8111/pgn'), + fetch(`http://localhost:8111/pgn?since=${lastMtime}`), fetch('http://localhost:8111/status').catch(() => null) ]); + if (currentPollId !== pollId) return; if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`); + const mtimeHeader = pgnResponse.headers.get('X-Cache-Mtime'); + if (mtimeHeader) lastMtime = parseFloat(mtimeHeader); + if (statusResponse && statusResponse.ok) { const status = await statusResponse.json(); serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null; @@ -62,10 +65,12 @@ async function loadPGN(showOverlay = true) { updateMovesList(); updateAllGamesList(); updateTimestamp(); + updateStandings(); showLoading(false); } catch (error) { + if (currentPollId !== pollId) return; console.error('Fehler beim Laden:', error); showError(`Fehler: ${error.message}`); showLoading(false); @@ -367,6 +372,49 @@ function updateAllGamesList() { } } +/** + * Lädt die Turniertabelle vom Proxy und zeigt Laras Platzierung an + */ +function updateStandings() { + fetch('http://localhost:8111/standings') + .then(res => { + if (!res.ok) throw new Error('Fehler beim Laden'); + return res.json(); + }) + .then(data => { + const container = document.getElementById('standings-content'); + if (!data || data.error) { + container.innerHTML = '
Daten nicht verfügbar
'; + return; + } + container.innerHTML = ` +
${data.rank}.
+
Tabellenplatz
+
${data.round_info || 'nach Runde 1'}
+
+ Punkte + ${data.points} +
+
+ Siege + ${data.wins} +
+
+ Unentschieden + ${data.draws} +
+
+ Niederlagen + ${data.losses} +
+ `; + }) + .catch(err => { + document.getElementById('standings-content').innerHTML = + '
Daten nicht verfügbar
'; + }); +} + /** * Format clock string */ @@ -386,25 +434,19 @@ function updateTimestamp() { } /** - * Start auto-refresh + * Start long-polling: nach jeder Antwort sofort die nächste Anfrage stellen */ function startAutoRefresh() { - countdown = REFRESH_INTERVAL / 1000; + document.getElementById('refresh-timer').textContent = '● Live'; + document.getElementById('refresh-timer').style.color = '#4ade80'; - if (refreshTimer) clearInterval(refreshTimer); - - refreshTimer = setInterval(() => { - countdown--; - const mins = Math.floor(countdown / 60); - const secs = countdown % 60; - document.getElementById('refresh-timer').textContent = - `Nächstes Update in: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; - - if (countdown <= 0) { - countdown = REFRESH_INTERVAL / 1000; - loadPGN(false); + async function poll() { + while (true) { + await loadPGN(false); + await new Promise(r => setTimeout(r, 30000)); } - }, 1000); + } + poll(); } /** @@ -424,11 +466,11 @@ function hideError() { } /** - * Manual refresh button + * Manual refresh button – startet neuen Long-Poll-Zyklus */ document.getElementById('refresh-btn').addEventListener('click', () => { - countdown = REFRESH_INTERVAL / 1000; - loadPGN(); + pollId++; + loadPGN(true); }); /** diff --git a/index.html b/index.html index ead35b9..bb57fed 100644 --- a/index.html +++ b/index.html @@ -54,6 +54,12 @@

Alle Partien von Lara

+
+

Turniertabelle ODJM D 2026

+
+
Lade Tabellenstand...
+
+
diff --git a/server.py b/server.py index 0f99bec..0e2a8d8 100644 --- a/server.py +++ b/server.py @@ -7,30 +7,38 @@ Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit. import http.server import socketserver import urllib.request +import urllib.parse import sys import os import threading import time import json +import re from datetime import datetime PGN_URL = os.environ.get("PGN_URL", "https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn") +STANDINGS_URL = "https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/" PORT = int(os.environ.get("PORT", 8111)) CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache") CACHE_FILE = os.path.join(CACHE_DIR, "gesamt-utf8.pgn") -CACHE_TTL = int(os.environ.get("CACHE_TTL", 30)) # Sekunden +STANDINGS_CACHE_FILE = os.path.join(CACHE_DIR, "standings.json") +CACHE_TTL = int(os.environ.get("CACHE_TTL", 31)) # Sekunden os.makedirs(CACHE_DIR, exist_ok=True) last_fetch_time = None +last_standings_fetch_time = None def fetch_pgn(): """Lädt die PGN-Datei von der URL als Bytes.""" global last_fetch_time try: - req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"}) - with urllib.request.urlopen(req, timeout=30) as response: + headers = {"User-Agent": "Mozilla/5.0"} + if last_fetch_time: + headers["modified_since"] = str(last_fetch_time) + req = urllib.request.Request(PGN_URL, headers=headers) + with urllib.request.urlopen(req, timeout=31) as response: data = response.read() last_fetch_time = time.time() return data @@ -39,43 +47,117 @@ def fetch_pgn(): return None -def get_pgn_content(): - """Gibt PGN-Inhalt als Bytes zurück, nutzt Cache wenn möglich.""" - now = time.time() - - # Prüfe Cache +def get_pgn_content_longpoll(since=0): + """Long-Poll: Gibt PGN-Inhalt + mtime zurück. + Wenn Cache seit `since` unverändert ist, wird bis zu 31s gewartet.""" + deadline = time.time() + 31 + + while time.time() < deadline: + if os.path.exists(CACHE_FILE): + mtime = os.path.getmtime(CACHE_FILE) + if mtime > since: + with open(CACHE_FILE, "rb") as f: + return f.read(), mtime + age = time.time() - mtime + if age >= CACHE_TTL: + content = fetch_pgn() + if content: + with open(CACHE_FILE, "wb") as f: + f.write(content) + new_mtime = os.path.getmtime(CACHE_FILE) + if new_mtime > since: + print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN via Long-Poll aktualisiert ({len(content)} Bytes)") + return content, new_mtime + else: + content = fetch_pgn() + if content: + with open(CACHE_FILE, "wb") as f: + f.write(content) + return content, os.path.getmtime(CACHE_FILE) + + time.sleep(2) + + # Timeout – aktuellen Stand zurückgeben if os.path.exists(CACHE_FILE): - age = now - os.path.getmtime(CACHE_FILE) - if age < CACHE_TTL: - with open(CACHE_FILE, "rb") as f: - return f.read() - - # Cache verfallen oder nicht vorhanden -> neu laden - content = fetch_pgn() - if content: - with open(CACHE_FILE, "wb") as f: - f.write(content) - print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN aktualisiert ({len(content)} Bytes)") - return content - - # Fallback: alter Cache - if os.path.exists(CACHE_FILE): - print(f"[{datetime.now().strftime('%H:%M:%S')}] Verwende alten Cache") with open(CACHE_FILE, "rb") as f: - return f.read() - + return f.read(), os.path.getmtime(CACHE_FILE) + return None, 0 + + +def fetch_standings(): + """Lädt die Tabellenseite und parst Laras Platzierung.""" + global last_standings_fetch_time + try: + headers = {"User-Agent": "Mozilla/5.0"} + if last_standings_fetch_time: + headers["modified_since"] = str(last_standings_fetch_time) + req = urllib.request.Request(STANDINGS_URL, headers=headers) + with urllib.request.urlopen(req, timeout=31) as response: + html = response.read().decode("utf-8", errors="replace") + last_standings_fetch_time = time.time() + + # Rundeninfo extrahieren + round_info = "" + m = re.search(r"Tabellenstand\s+nach\s+der\s+(\d+)\.\s*Runde", html) + if m: + round_info = f"nach der {m.group(1)}. Runde" + + # Tabellenzeilen suchen + rows = re.findall(r"]*>(.*?)", html, re.DOTALL | re.IGNORECASE) + for row in rows: + if "Lara Kiesewetter" not in row: + continue + cells = re.findall(r"]*>(.*?)", row, re.DOTALL) + clean = [re.sub(r"<[^>]+>", "", c).strip() for c in cells] + if len(clean) >= 9: + return { + "rank": clean[0], + "player": "Lara Kiesewetter", + "wins": clean[5], + "draws": clean[6], + "losses": clean[7], + "points": clean[8], + "round_info": round_info, + } + return None + except Exception as e: + print(f"Fehler beim Laden der Tabelle: {e}") + return None + + +def get_standings_content(): + """Gibt Tabellenstand als Dict zurück, nutzt Cache.""" + now = time.time() + + if os.path.exists(STANDINGS_CACHE_FILE): + age = now - os.path.getmtime(STANDINGS_CACHE_FILE) + if age < 300: # 5 Minuten Cache + with open(STANDINGS_CACHE_FILE, "r") as f: + return json.load(f) + + data = fetch_standings() + if data: + with open(STANDINGS_CACHE_FILE, "w") as f: + json.dump(data, f) + print(f"[{datetime.now().strftime('%H:%M:%S')}] Tabellenstand aktualisiert") + return data return None class PGNHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): - if self.path == "/pgn": - content = get_pgn_content() + parsed_path = urllib.parse.urlparse(self.path) + + if parsed_path.path == "/pgn": + params = urllib.parse.parse_qs(parsed_path.query) + since = float(params.get("since", [0])[0]) + content, mtime = get_pgn_content_longpoll(since) if content: self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Cache-Control", "no-cache") + self.send_header("X-Cache-Mtime", str(mtime)) self.end_headers() self.wfile.write(content) else: @@ -83,9 +165,9 @@ class PGNHandler(http.server.BaseHTTPRequestHandler): self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - self.wfile.write(b'{"error": "Konnte PGN nicht laden"}') + self.wfile.write(json.dumps({"error": "Konnte PGN nicht laden"}).encode()) - elif self.path == "/status": + elif parsed_path.path == "/status": self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") @@ -99,6 +181,23 @@ class PGNHandler(http.server.BaseHTTPRequestHandler): } self.wfile.write(json.dumps(status).encode()) + elif parsed_path.path == "/standings": + data = get_standings_content() + if data: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "max-age=300") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + else: + self.send_response(502) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + err = {"error": "Tabellendaten nicht verf\u00fcgbar"} + self.wfile.write(json.dumps(err).encode()) + else: # Statische Dateien aus dem Verzeichnis if self.path == "/": diff --git a/style.css b/style.css index a229922..46a071a 100644 --- a/style.css +++ b/style.css @@ -168,18 +168,69 @@ header h1 { border-radius: 8px; } -#moves-panel, #all-games-panel { +#moves-panel, #all-games-panel, #standings-panel { background: rgba(0, 0, 0, 0.3); border-radius: 12px; padding: 16px; } -#moves-panel h3, #all-games-panel h3 { +#moves-panel h3, #all-games-panel h3, #standings-panel h3 { margin-bottom: 12px; color: #e94560; font-size: 1rem; } +#standings-content { + font-size: 0.9rem; + line-height: 1.6; +} + +#standings-content .standings-loading { + color: #888; + font-style: italic; +} + +.standings-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.standings-row:last-child { + border-bottom: none; +} + +.standings-label { + color: #aaa; +} + +.standings-value { + color: #fff; + font-weight: 600; +} + +.standings-rank { + font-size: 2rem; + font-weight: bold; + color: #e94560; + text-align: center; + padding: 8px 0; +} + +.standings-rank-label { + font-size: 0.8rem; + color: #888; + text-align: center; +} + +.standings-header { + text-align: center; + margin-bottom: 8px; + color: #ffd700; + font-size: 0.85rem; +} + #moves-list { max-height: 300px; overflow-y: auto;