Switch to Long-Polling, add Turniertabellen-Anzeige für ODJM D 2026

This commit is contained in:
2026-05-24 15:32:36 +02:00
parent 2ad3dab7f8
commit 7efa38c91a
4 changed files with 253 additions and 55 deletions

88
app.js
View File

@@ -3,37 +3,40 @@
* Haupt-Application * 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'; const PLAYER_NAME = 'Kiesewetter, Lara';
let board = null; let board = null;
let chess = null; let chess = null;
let currentGame = null; let currentGame = null;
let allLaraGames = []; let allLaraGames = [];
let refreshTimer = null;
let countdown = 0;
let serverLastFetch = null; let serverLastFetch = null;
let laraColor = null; let laraColor = null;
let currentMoveIndex = -1; let currentMoveIndex = -1;
let userSelectedGame = false; let userSelectedGame = false;
let userScrolledMoves = false; let userScrolledMoves = false;
let lastMtime = 0;
let pollId = 0;
/** /**
* Lädt die PGN-Datei und aktualisiert die Anzeige * Lädt die PGN-Datei und aktualisiert die Anzeige
*/ */
async function loadPGN(showOverlay = true) { async function loadPGN(showOverlay = true) {
const currentPollId = ++pollId;
if (showOverlay) showLoading(true); if (showOverlay) showLoading(true);
hideError(); hideError();
try { try {
const [pgnResponse, statusResponse] = await Promise.all([ 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) fetch('http://localhost:8111/status').catch(() => null)
]); ]);
if (currentPollId !== pollId) return;
if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`); 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) { if (statusResponse && statusResponse.ok) {
const status = await statusResponse.json(); const status = await statusResponse.json();
serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null; serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null;
@@ -62,10 +65,12 @@ async function loadPGN(showOverlay = true) {
updateMovesList(); updateMovesList();
updateAllGamesList(); updateAllGamesList();
updateTimestamp(); updateTimestamp();
updateStandings();
showLoading(false); showLoading(false);
} catch (error) { } catch (error) {
if (currentPollId !== pollId) return;
console.error('Fehler beim Laden:', error); console.error('Fehler beim Laden:', error);
showError(`Fehler: ${error.message}`); showError(`Fehler: ${error.message}`);
showLoading(false); 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 = '<div class="standings-loading">Daten nicht verfügbar</div>';
return;
}
container.innerHTML = `
<div class="standings-rank">${data.rank}.</div>
<div class="standings-rank-label">Tabellenplatz</div>
<div class="standings-header">${data.round_info || 'nach Runde 1'}</div>
<div class="standings-row">
<span class="standings-label">Punkte</span>
<span class="standings-value">${data.points}</span>
</div>
<div class="standings-row">
<span class="standings-label">Siege</span>
<span class="standings-value">${data.wins}</span>
</div>
<div class="standings-row">
<span class="standings-label">Unentschieden</span>
<span class="standings-value">${data.draws}</span>
</div>
<div class="standings-row">
<span class="standings-label">Niederlagen</span>
<span class="standings-value">${data.losses}</span>
</div>
`;
})
.catch(err => {
document.getElementById('standings-content').innerHTML =
'<div class="standings-loading">Daten nicht verfügbar</div>';
});
}
/** /**
* Format clock string * 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() { function startAutoRefresh() {
countdown = REFRESH_INTERVAL / 1000; document.getElementById('refresh-timer').textContent = '● Live';
document.getElementById('refresh-timer').style.color = '#4ade80';
if (refreshTimer) clearInterval(refreshTimer); async function poll() {
while (true) {
refreshTimer = setInterval(() => { await loadPGN(false);
countdown--; await new Promise(r => setTimeout(r, 30000));
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);
} }
}, 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', () => { document.getElementById('refresh-btn').addEventListener('click', () => {
countdown = REFRESH_INTERVAL / 1000; pollId++;
loadPGN(); loadPGN(true);
}); });
/** /**

View File

@@ -54,6 +54,12 @@
<h3>Alle Partien von Lara</h3> <h3>Alle Partien von Lara</h3>
<div id="all-games-list"></div> <div id="all-games-list"></div>
</div> </div>
<div id="standings-panel">
<h3>Turniertabelle ODJM D 2026</h3>
<div id="standings-content">
<div class="standings-loading">Lade Tabellenstand...</div>
</div>
</div>
</div> </div>
</div> </div>

157
server.py
View File

@@ -7,30 +7,38 @@ Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit.
import http.server import http.server
import socketserver import socketserver
import urllib.request import urllib.request
import urllib.parse
import sys import sys
import os import os
import threading import threading
import time import time
import json import json
import re
from datetime import datetime from datetime import datetime
PGN_URL = os.environ.get("PGN_URL", "https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn") 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)) PORT = int(os.environ.get("PORT", 8111))
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache") CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
CACHE_FILE = os.path.join(CACHE_DIR, "gesamt-utf8.pgn") 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) os.makedirs(CACHE_DIR, exist_ok=True)
last_fetch_time = None last_fetch_time = None
last_standings_fetch_time = None
def fetch_pgn(): def fetch_pgn():
"""Lädt die PGN-Datei von der URL als Bytes.""" """Lädt die PGN-Datei von der URL als Bytes."""
global last_fetch_time global last_fetch_time
try: try:
req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"}) headers = {"User-Agent": "Mozilla/5.0"}
with urllib.request.urlopen(req, timeout=30) as response: 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() data = response.read()
last_fetch_time = time.time() last_fetch_time = time.time()
return data return data
@@ -39,43 +47,117 @@ def fetch_pgn():
return None return None
def get_pgn_content(): def get_pgn_content_longpoll(since=0):
"""Gibt PGN-Inhalt als Bytes zurück, nutzt Cache wenn möglich.""" """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):
with open(CACHE_FILE, "rb") as f:
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"<tr[^>]*>(.*?)</tr>", html, re.DOTALL | re.IGNORECASE)
for row in rows:
if "Lara Kiesewetter" not in row:
continue
cells = re.findall(r"<td[^>]*>(.*?)</td>", 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() now = time.time()
# Prüfe Cache if os.path.exists(STANDINGS_CACHE_FILE):
if os.path.exists(CACHE_FILE): age = now - os.path.getmtime(STANDINGS_CACHE_FILE)
age = now - os.path.getmtime(CACHE_FILE) if age < 300: # 5 Minuten Cache
if age < CACHE_TTL: with open(STANDINGS_CACHE_FILE, "r") as f:
with open(CACHE_FILE, "rb") as f: return json.load(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()
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 return None
class PGNHandler(http.server.BaseHTTPRequestHandler): class PGNHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.path == "/pgn": parsed_path = urllib.parse.urlparse(self.path)
content = get_pgn_content()
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: if content:
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
self.send_header("X-Cache-Mtime", str(mtime))
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
else: else:
@@ -83,9 +165,9 @@ class PGNHandler(http.server.BaseHTTPRequestHandler):
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers() 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_response(200)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
@@ -99,6 +181,23 @@ class PGNHandler(http.server.BaseHTTPRequestHandler):
} }
self.wfile.write(json.dumps(status).encode()) 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: else:
# Statische Dateien aus dem Verzeichnis # Statische Dateien aus dem Verzeichnis
if self.path == "/": if self.path == "/":

View File

@@ -168,18 +168,69 @@ header h1 {
border-radius: 8px; border-radius: 8px;
} }
#moves-panel, #all-games-panel { #moves-panel, #all-games-panel, #standings-panel {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
} }
#moves-panel h3, #all-games-panel h3 { #moves-panel h3, #all-games-panel h3, #standings-panel h3 {
margin-bottom: 12px; margin-bottom: 12px;
color: #e94560; color: #e94560;
font-size: 1rem; 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 { #moves-list {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;