Files
tsv08kulmbach-website/bin/matchcenter-sync.php
fs 3a591fe8d0 Matchcenter: Saison-Tabs, Vorsaison-Archiv & gehärteter Cache-Write
- Sync schreibt Cache jetzt fehlersicher: json_encode mit JSON_THROW_ON_ERROR
  + Rückgabeprüfung von file_put_contents/rename → ein defektes UTF-8-Zeichen
  aus der BFV-API kann den guten Cache nicht mehr leerschreiben (alter Cache
  bleibt via $fail unangetastet)
- Vorsaison wird bei erkanntem compoundId-Wechsel einmalig archiviert (previous),
  pro Team compound_id + abgeleitetes season-Label + stale-Flag persistiert
- Aktuelle Saison ist immer sichtbar (auch leer), Wechsel zur Vorsaison über
  wiederverwendete Stripe-Tabbar (group-tabs); neue Komponente matchcenter-season
- Leere aktuelle Saison zeigt Hinweis statt leerer Fläche

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:51:14 +02:00

510 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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.');
};
// --- Saison-Label (Fußball-Saison läuft JulJun): aus dem jüngsten Spiel ableiten,
// ersatzweise (leere Saison) aus dem heutigen Datum → liefert immer ein Label. ---
$seasonLabel = static function (DateTimeImmutable $d): string {
$y = (int) $d->format('Y');
$start = (int) $d->format('n') >= 7 ? $y : $y - 1;
return sprintf('Saison %d/%02d', $start, ($start + 1) % 100);
};
$seasonFromRows = static function (array ...$rowSets) use ($seasonLabel, $tz, $today): string {
$latest = null;
foreach ($rowSets as $rows) {
foreach ($rows as $r) {
if (empty($r['kickoff'])) {
continue;
}
$dt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $r['kickoff'], $tz);
if ($dt && ($latest === null || $dt > $latest)) {
$latest = $dt;
}
}
}
return $seasonLabel($latest ?? $today);
};
/**
* 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 {
$prevCompound = (string) ($prevTeam['compound_id'] ?? '');
$previous = $prevTeam['previous'] ?? null;
// 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'] ?? '');
// Saisonwechsel: liefert die API eine NEUE compoundId, archivieren wir die eben
// abgelaufene Saison einmalig (verschachteltes previous des Vorgängers wird nicht
// mitkopiert → keine unbegrenzte Schachtelung).
if ($apiCompound !== '' && $prevCompound !== '' && $apiCompound !== $prevCompound && $prevTeam !== null) {
$previous = [
'season' => $prevTeam['season'] ?? null,
'league' => $prevTeam['league'] ?? null,
'last_results' => $prevTeam['last_results'] ?? [],
'table' => $prevTeam['table'] ?? [],
];
}
// Beste bekannte compoundId: API > letzter Cache > Config-Fallback. So triggert ein
// vorübergehender Fetch-Ausfall (apiCompound leer) keinen falschen Saisonwechsel.
$compoundId = $apiCompound !== ''
? $apiCompound
: ($prevCompound !== '' ? $prevCompound : $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");
}
// Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
// sie noch leer ist) oder Daten/eine Vorsaison vorliegen. Nur überspringen, wenn der
// Fetch fehlschlug UND weder Cache noch Vorsaison existieren.
$hasCurrent = $upcoming !== [] || $results !== [] || $table !== [];
$hasPrevious = $previous !== null
&& (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
continue;
}
$season = $seasonFromRows($upcoming, $results);
$teamBlock = [
'key' => $key,
'name' => $name,
'league' => $league,
'slug' => $slugMap[$key] ?? '',
'compound_id' => $compoundId,
'season' => $season,
'next_matches' => $upcoming,
'last_results' => $results,
'table' => $table,
];
if ($hasPrevious) {
$teamBlock['previous'] = $previous;
$keepCrests($previous['last_results'] ?? []);
$keepCrests($previous['table'] ?? []);
}
$resultTeams[] = $teamBlock;
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'
. ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
} 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'] ?? []);
$keepCrests($prevTeam['previous']['last_results'] ?? []);
$keepCrests($prevTeam['previous']['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). Jeder Schritt ist abgesichert: ein
// defektes UTF-8-Zeichen aus der API (json_encode → Exception) oder ein
// Schreib-/Rename-Fehler darf den bestehenden guten Cache NICHT leerschreiben. ---
try {
$payload = json_encode([
'fetched_at' => time(),
'stale' => $cacheFallbackUsed, // true = mind. ein Teil kam aus dem alten Cache
'teams' => $resultTeams,
'upcoming' => $upcomingAll,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
$fail('Cache-Encode fehlgeschlagen: ' . $e->getMessage());
}
$tmp = $cacheFile . '.tmp';
if (file_put_contents($tmp, $payload) === false) {
$fail("Cache-Schreiben fehlgeschlagen (tmp: {$tmp})");
}
if (!rename($tmp, $cacheFile)) {
@unlink($tmp);
$fail("Cache-Rename fehlgeschlagen ({$tmp}{$cacheFile})");
}
$log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/');