-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 $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/');