Files
tsv08kulmbach-website/bin/matchcenter-sync.php

436 lines
18 KiB
PHP
Raw Normal View History

Matchcenter: Spielplan, Ergebnisse & Tabellen (eigene Seite statt BFV-Widget) Neue Unterseite /matchcenter ersetzt die alten BFV-Widget-Embeds (verstoßen gegen CSP) durch eine self-hostete Lösung: Cron-Scraper holt die Daten aus der öffentlichen BFV-Widget-JSON-API, schreibt data/matchcenter.json und lädt die Vereinswappen lokal — Besucher kontaktieren nur unsere Domain. - bin/matchcenter-sync.php: CLI/Cron-Scraper (Muster wie instagram-sync), JSON-API team/{id}/matches + competition/{compoundId}/table, Wappen via getLogo lokal, atomarer Write, Fail-safe. Auto-Saison: Tabellen-compoundId wird aus der Matches-API abgeleitet (team_id ist saison-stabil). Per-Team-Cache-Fallback bei Fetch-Fehlern, damit Sektionen nie leeren. - Seite app/pages/matchcenter.php + Route + Nav-Eintrag (unter Fußball). - Komponenten: match-slider (nächste Spiele), match-card, match-row, league-table (mit Auf-/Abstiegszonen), matchcenter-section (kicker-Layout: Tabelle 2/3 + Spiele 1/3), matchcenter-empty, crest (weißes Chip + Initialen-Fallback). - carousel.js: banner.js zum generischen [data-carousel]-Antrieb verallgemeinert (Banner- und Match-Slider teilen ihn); crest.js: Logo-Fallback im Browser. - helpers.php: sportsevent_nodes() für SportsEvent-JSON-LD. - Icons calendar-event; config.example + .gitignore + CLAUDE.md ergänzt. - Responsive geprüft (kein Seiten-Overflow 360–1280, Tabelle scrollt in der Karte). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0192vTfGmGpoWeyUse4TuQFj
2026-06-19 10:57:14 +02:00
<?php
declare(strict_types=1);
/**
* Matchcenter-Sync (BFV-Widget-Ersatz): holt Spielplan, Ergebnisse und Tabelle der
* drei Aktivenmannschaften aus der öffentlichen BFV-Widget-JSON-API, lädt die
* Vereinswappen lokal nach public/assets/img/crests/ und schreibt data/matchcenter.json
* atomar. Bei JEDEM Fehler bleibt der letzte gute Cache unangetastet die Website
* degradiert nur, sie bricht nie. Reverse-engineert aus dem BFV-Widget (das alte
* <BFVWidget.HTML5…>-Embed lud genau diese Endpunkte client-seitig nach).
*
* Aufruf: php bin/matchcenter-sync.php [-v] [--dump]
* Cron: 37 7,19 * * * cd /pfad/zur/site && /usr/bin/php bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
* (zusätzlich am Wochenende häufiger, siehe config.example.php)
*
* Datenquelle (Host https://widget-prod.bfv.de, liefert sauberes JSON, kein HTML-Scraping):
* - Spiele/Ergebnisse: /api/service/widget/v1/team/{teamPermanentId}/matches
* - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table
* - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG)
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 der $fetch-Helfer
* setzt ihn (wie bin/instagram-sync.php).
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$dump = in_array('--dump', $argv, true);
$apiHost = 'https://widget-prod.bfv.de';
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
$referer = (string) config('matchcenter.referer', 'https://widget-prod.bfv.de/');
$maxUpcoming = (int) config('matchcenter.max_upcoming', 5);
$maxResults = (int) config('matchcenter.max_results', 5);
$teamsCfg = (array) config('matchcenter.teams', []);
$cacheFile = DATA_PATH . '/matchcenter.json';
$crestDir = PUBLIC_PATH . '/assets/img/crests';
// Anzeigename/Liga/Slug kommen aus der Single Source of Truth (data/teams.json),
// nicht aus der API — der Scraper erfindet keine kuratierten Namen.
$teamsData = json_load('teams');
$slugMap = [
'erste-mannschaft' => 'fussball/erste-mannschaft',
'zweite-mannschaft' => 'fussball/zweite-mannschaft',
'damen' => 'fussball/damen',
];
$tz = new DateTimeZone('Europe/Berlin');
$today = (new DateTimeImmutable('now', $tz))->setTime(0, 0);
$weekdaysDe = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// Wettbewerbsart → kurzes, sprechendes Badge-Label.
$competitionLabels = [
'Meisterschaften' => 'Liga',
'Freundschaftsspiele' => 'Freundschaft',
'Pokalspiele' => 'Pokal',
'Pokal' => 'Pokal',
'Hallenrunde' => 'Halle',
];
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/matchcenter.log', $line, FILE_APPEND);
if ($verbose) {
echo $line;
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
exit(1);
};
if ($teamsCfg === []) {
$fail('Keine Teams in config.matchcenter.teams konfiguriert');
}
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
}
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
$fetch = static function (string $url, bool $json = true) use ($referer): string|false {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
CURLOPT_HTTPHEADER => [
'Accept: ' . ($json ? 'application/json, text/plain, */*' : '*/*'),
'Accept-Language: de-DE,de;q=0.9',
'Referer: ' . $referer,
'Sec-Fetch-Dest: ' . ($json ? 'empty' : 'image'),
'Sec-Fetch-Mode: ' . ($json ? 'cors' : 'no-cors'),
'Sec-Fetch-Site: cross-site',
],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return ($body !== false && $status === 200) ? $body : false;
};
/** JSON-Endpoint holen und data-Knoten zurückgeben (oder null). */
$fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
$body = $fetch($url, true);
if ($body === false) {
return null;
}
if ($dump) {
$log("DUMP {$url}\n" . substr($body, 0, 4000));
}
$data = json_decode($body, true);
return is_array($data) ? $data : null;
};
// --- Wappen-Download: idempotent, normalisiert auf max 96px PNG (Transparenz erhalten) ---
if (!is_dir($crestDir)) {
mkdir($crestDir, 0755, true);
}
$crestKeep = [];
$downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $crestDir, &$crestKeep, $log): ?string {
$clubId = trim((string) $clubId);
if ($clubId === '' || !preg_match('/^[A-Z0-9-]+$/i', $clubId)) {
return null;
}
$rel = "img/crests/{$clubId}.png";
$file = "{$crestDir}/{$clubId}.png";
$crestKeep["{$clubId}.png"] = true;
if (is_file($file)) {
return $rel;
}
$bin = $fetch($crestBase . $clubId, false);
if ($bin === false) {
$log("Wappen-Download fehlgeschlagen: {$clubId}");
return null;
}
$src = @imagecreatefromstring($bin);
if ($src === false) {
$log("Wappen unlesbar: {$clubId}");
return null;
}
$w = imagesx($src);
$h = imagesy($src);
$scale = min(96 / $w, 96 / $h, 1.0);
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
$out = imagecreatetruecolor($nw, $nh);
imagealphablending($out, false);
imagesavealpha($out, true);
imagefilledrectangle($out, 0, 0, $nw, $nh, imagecolorallocatealpha($out, 0, 0, 0, 127));
imagecopyresampled($out, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagepng($out, $file);
return $rel;
};
// --- Datums-/Zeit-Helfer (deutsche Quellformate, Europe/Berlin) ---
$parseKickoff = static function (string $date, string $time) use ($tz): ?DateTimeImmutable {
$date = trim($date);
$time = trim($time) !== '' ? trim($time) : '00:00';
if ($date === '') {
return null;
}
$dt = DateTimeImmutable::createFromFormat('d.m.Y H:i', "{$date} {$time}", $tz);
return $dt ?: null;
};
$dateDisplay = static function (?DateTimeImmutable $dt, string $raw) use ($weekdaysDe): string {
if ($dt === null) {
return $raw; // Roh-String behalten — Anzeige bricht nie weg.
}
return $weekdaysDe[(int) $dt->format('w')] . ', ' . $dt->format('d.m.');
};
/**
* Ein Match aus der API in unser Anzeige-Format überführen.
* @param array<string,mixed> $m Rohes Match aus data.matches[]
* @param string $teamCfgId permanentId der eigenen Mannschaft (für is_home/outcome)
*/
$mapMatch = static function (array $m, string $teamCfgId) use ($parseKickoff, $dateDisplay, $downloadCrest, $competitionLabels, $tz): array {
$rawDate = (string) ($m['kickoffDate'] ?? '');
$rawTime = (string) ($m['kickoffTime'] ?? '');
$dt = $parseKickoff($rawDate, $rawTime);
$isHome = ($m['homeTeamPermanentId'] ?? null) === $teamCfgId;
$type = (string) ($m['competitionType'] ?? '');
$row = [
'kickoff' => $dt?->format('Y-m-d\TH:i'),
'date_display' => $dateDisplay($dt, $rawDate),
'time_display' => $rawTime !== '' ? $rawTime . ' Uhr' : '',
'competition' => $competitionLabels[$type] ?? ($type !== '' ? $type : 'Spiel'),
'home' => (string) ($m['homeTeamName'] ?? ''),
'away' => (string) ($m['guestTeamName'] ?? ''),
'home_crest' => $downloadCrest($m['homeClubId'] ?? null),
'away_crest' => $downloadCrest($m['guestClubId'] ?? null),
'is_home' => $isHome,
];
$result = trim((string) ($m['result'] ?? ''));
if ($result !== '' && preg_match('/^(\d+)\s*:\s*(\d+)/', $result, $mm)) {
$gh = (int) $mm[1];
$ga = (int) $mm[2];
$own = $isHome ? $gh : $ga;
$opp = $isHome ? $ga : $gh;
$row['goals_home'] = $gh;
$row['goals_away'] = $ga;
$row['result'] = $result;
$row['outcome'] = $own > $opp ? 'win' : ($own === $opp ? 'draw' : 'loss');
}
return $row;
};
// --- Vorheriger Cache: bei Teil-Ausfällen (BFV liefert 418/Fehler) behalten wir
// die alten Daten dieses Teams bzw. Teils, statt die Sektion zu leeren. ---
$prev = [];
if (is_file($cacheFile)) {
$decoded = json_decode((string) file_get_contents($cacheFile), true);
if (is_array($decoded)) {
$prev = $decoded;
}
}
$prevByKey = [];
foreach ($prev['teams'] ?? [] as $pt) {
if (!empty($pt['key'])) {
$prevByKey[$pt['key']] = $pt;
}
}
$cacheFallbackUsed = false;
// Wappen-Dateinamen aus (ggf. aus dem Cache übernommenen) Zeilen in die Keep-Liste
// aufnehmen, damit die GC unten sie nicht löscht.
$keepCrests = static function (array $rows) use (&$crestKeep): void {
foreach ($rows as $r) {
foreach (['home_crest', 'away_crest', 'crest'] as $k) {
if (!empty($r[$k]) && is_string($r[$k])) {
$crestKeep[basename($r[$k])] = true;
}
}
}
};
// --- Pro Team: Spiele + Tabelle holen, parsen, Wappen laden (isoliert) ---
$resultTeams = [];
$upcomingAll = [];
foreach ($teamsCfg as $cfg) {
$key = (string) ($cfg['key'] ?? '');
$teamCfgId = (string) ($cfg['team_id'] ?? '');
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
if ($key === '' || $teamCfgId === '') {
$log("Team-Eintrag ohne key/team_id übersprungen");
continue;
}
$prevTeam = $prevByKey[$key] ?? null;
try {
// Spiele zuerst — daraus leiten wir die aktuelle Wettbewerbs-ID (Tabelle) ab,
// damit eine neue Saison automatisch übernommen wird (die team_id ist stabil,
// die compound_id der Tabelle wechselt pro Saison).
$matchesData = $fetchJson("{$apiHost}/api/service/widget/v1/team/{$teamCfgId}/matches");
$apiCompound = (string) ($matchesData['data']['team']['compoundId'] ?? '');
$compoundId = $apiCompound !== '' ? $apiCompound : $cfgCompoundId;
$tableData = $compoundId !== '' ? $fetchJson("{$apiHost}/api/service/widget/v1/competition/{$compoundId}/table") : null;
// Name/Liga bevorzugt aus teams.json (Single Source), sonst API/alter Cache.
$apiCompetition = (string) ($matchesData['data']['team']['competitionName'] ?? '');
$name = $teamsData[$key]['name'] ?? ($matchesData['data']['team']['name'] ?? ($prevTeam['name'] ?? $key));
$league = $teamsData[$key]['league'] ?? null;
if (!$league) {
$league = $apiCompetition !== '' ? $apiCompetition : ($prevTeam['league'] ?? null);
}
// --- Spiele-Teil ---
if ($matchesData !== null) {
$matches = $matchesData['data']['matches'] ?? [];
$upcoming = [];
$results = [];
foreach ($matches as $m) {
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
$row = $mapMatch($m, $teamCfgId);
if ($hasResult) {
$results[] = $row;
} else {
// Ohne Ergebnis nur zeigen, wenn Anstoß heute/künftig.
$dt = $row['kickoff'] !== null ? new DateTimeImmutable($row['kickoff'], $tz) : null;
if ($dt === null || $dt->setTime(0, 0) >= $today) {
$upcoming[] = $row;
}
}
}
usort($upcoming, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
usort($results, static fn ($a, $b) => ($b['kickoff'] ?? '') <=> ($a['kickoff'] ?? ''));
$upcoming = array_slice($upcoming, 0, $maxUpcoming);
$results = array_slice($results, 0, $maxResults);
// permanentId → clubId-Karte (für Wappen in der Tabelle).
$clubIdByPermanent = [];
foreach ($matches as $m) {
if (!empty($m['homeTeamPermanentId']) && !empty($m['homeClubId'])) {
$clubIdByPermanent[$m['homeTeamPermanentId']] = $m['homeClubId'];
}
if (!empty($m['guestTeamPermanentId']) && !empty($m['guestClubId'])) {
$clubIdByPermanent[$m['guestTeamPermanentId']] = $m['guestClubId'];
}
}
} else {
// Spiele-Fetch fehlgeschlagen → alten Cache dieses Teams behalten.
$upcoming = $prevTeam['next_matches'] ?? [];
$results = $prevTeam['last_results'] ?? [];
$clubIdByPermanent = [];
$cacheFallbackUsed = true;
$keepCrests($upcoming);
$keepCrests($results);
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen");
}
// --- Tabellen-Teil ---
if ($tableData !== null) {
$tableRows = $tableData['data']['table'] ?? [];
$cfgZone = $tableData['data']['configuration'] ?? [];
$rowCount = count($tableRows);
$promo = (int) ($cfgZone['promotionTeamCount'] ?? 0);
$promoPlayoff = (int) ($cfgZone['promotionPlayoffTeamCount'] ?? 0);
$releg = (int) ($cfgZone['relegationTeamCount'] ?? 0);
$relegPlayoff = (int) ($cfgZone['relegationPlayoffTeamCount'] ?? 0);
$table = [];
foreach ($tableRows as $r) {
$pos = (int) ($r['position'] ?? 0);
$permanentId = (string) ($r['team']['permanentId'] ?? '');
$zone = null;
if ($pos >= 1 && $pos <= $promo) {
$zone = 'promotion';
} elseif ($pos <= $promo + $promoPlayoff) {
$zone = 'promotion-playoff';
} elseif ($releg > 0 && $pos > $rowCount - $releg) {
$zone = 'relegation';
} elseif ($relegPlayoff > 0 && $pos > $rowCount - $releg - $relegPlayoff) {
$zone = 'relegation-playoff';
}
$table[] = [
'rank' => $pos,
'club' => (string) ($r['team']['name'] ?? ''),
'crest' => $downloadCrest($clubIdByPermanent[$permanentId] ?? null),
'played' => (int) ($r['matches'] ?? 0),
'won' => (int) ($r['matchesWon'] ?? 0),
'drawn' => (int) ($r['matchesDrawn'] ?? 0),
'lost' => (int) ($r['matchesLost'] ?? 0),
'goal_diff' => (int) ($r['goalsDiff'] ?? 0),
'points' => (int) ($r['points'] ?? 0),
'zone' => $zone,
'is_own_club' => $permanentId === $teamCfgId,
];
}
} else {
// Tabellen-Fetch fehlgeschlagen → alte Tabelle dieses Teams behalten.
$table = $prevTeam['table'] ?? [];
$cacheFallbackUsed = true;
$keepCrests($table);
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen");
}
if ($upcoming === [] && $results === [] && $table === []) {
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
continue;
}
$resultTeams[] = [
'key' => $key,
'name' => $name,
'league' => $league,
'slug' => $slugMap[$key] ?? '',
'next_matches' => $upcoming,
'last_results' => $results,
'table' => $table,
];
foreach ($upcoming as $u) {
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $name]);
}
$log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze');
} catch (\Throwable $e) {
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
$log("Team {$key} übersprungen: " . $e->getMessage());
if ($prevTeam !== null) {
$resultTeams[] = $prevTeam;
$cacheFallbackUsed = true;
$keepCrests($prevTeam['next_matches'] ?? []);
$keepCrests($prevTeam['last_results'] ?? []);
$keepCrests($prevTeam['table'] ?? []);
foreach ($prevTeam['next_matches'] ?? [] as $u) {
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $prevTeam['name'] ?? $key]);
}
}
}
}
if ($resultTeams === []) {
$fail('Kein einziges Team lieferte Daten (API blockt evtl. die Server-IP oder Struktur geändert)');
}
// Globaler „nächste Spiele"-Slider: teamübergreifend, nach Anstoß sortiert.
usort($upcomingAll, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
$upcomingAll = array_slice($upcomingAll, 0, $maxUpcoming);
// --- Nicht mehr referenzierte Wappen aufräumen. Nur, wenn ALLE Teams frisch
// geladen wurden (sonst könnte ein transienter Ausfall ein noch genutztes
// Wappen löschen) und die Keep-Liste nicht leer ist. ---
if (!$cacheFallbackUsed && $crestKeep !== []) {
foreach (glob("{$crestDir}/*.png") ?: [] as $existing) {
if (!isset($crestKeep[basename($existing)])) {
unlink($existing);
}
}
}
// --- Cache atomar schreiben (tmp + rename) ---
$payload = json_encode([
'fetched_at' => time(),
'teams' => $resultTeams,
'upcoming' => $upcomingAll,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);
$log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/');