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
436 lines
18 KiB
PHP
436 lines
18 KiB
PHP
<?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/');
|