Files
wm-projekt/lib/feeds.ts
2026-07-08 22:49:24 -05:00

830 lines
31 KiB
TypeScript

import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
import { TEAM_LOCALIZATION } from "./team-mappings";
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
// ----------------------------------------------------------------------------
// Caching: In-Memory-Cache mit TTL + stale-while-revalidate.
// Bei abgelaufenem Cache wird der alte Wert sofort zurückgegeben und die
// Erneuerung im Hintergrund angestoßen (keine Wartezeit für den Nutzer).
// ----------------------------------------------------------------------------
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
const cache = new Map<string, CacheEntry<unknown>>();
// Subscription-System: Benachrichtigt Listener (z.B. API-Routen) bei
// erfolgreichem Hintergrund-Refresh, sodass das Frontend nach dem
// nächsten Poll die aktuellen Daten erhält.
type CacheListener = (value: unknown) => void;
const cacheSubscriptions = new Map<string, Set<CacheListener>>();
function notifyCacheListeners(key: string, value: unknown): void {
const subs = cacheSubscriptions.get(key);
if (!subs) return;
for (const cb of subs) {
try { cb(value); } catch { /* silent */ }
}
}
export function onCacheRefresh<T>(key: string, cb: (value: T) => void): () => void {
if (!cacheSubscriptions.has(key)) cacheSubscriptions.set(key, new Set());
const set = cacheSubscriptions.get(key)!;
const wrapped = cb as CacheListener;
set.add(wrapped);
return () => { set.delete(wrapped); if (set.size === 0) cacheSubscriptions.delete(key); };
}
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const hit = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
// Frischer Cache → direkt zurück
if (hit && hit.expires > now) return hit.value;
// Abgelaufen, aber vorhanden → sofort alten Wert liefern, im Hintergrund erneuern
if (hit) {
if (!hit.refreshing) {
hit.refreshing = true;
fn()
.then((value) => {
cache.set(key, { value, expires: Date.now() + ttlMs });
notifyCacheListeners(key, value);
})
.catch((err) => { console.error(`[cache] Hintergrund-Refresh fehlgeschlagen (${key}):`, err); })
.finally(() => { const e = cache.get(key) as CacheEntry<T> | undefined; if (e) e.refreshing = false; });
}
return hit.value;
}
// Kaltstart: kein Cache → synchron laden
const value = await fn();
cache.set(key, { value, expires: now + ttlMs });
return value;
}
// ----------------------------------------------------------------------------
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc)
// ----------------------------------------------------------------------------
const PM_BASE = "https://gamma-api.polymarket.com";
const PM_SERIES = "11433"; // WM 2026 Serie
interface PmEvent {
id: string;
slug: string;
title: string;
markets?: PmMarket[];
}
interface PmMarket {
slug: string;
groupItemTitle: string;
outcomes: string; // JSON-String ["Yes","No"]
outcomePrices: string; // JSON-String ["0.385","0.615"]
sportsMarketType?: string;
closed: boolean;
}
export interface ParsedOdds {
slug: string;
homeCode: string;
awayCode: string;
homeName: string;
awayName: string;
pHome: number;
pDraw: number;
pAway: number;
startTime: string | null;
}
// Normalisiert Teamcodes zwischen Polymarket-Slug und Feed (3-Buchstaben).
// Bekannte Abweichungen: kr/kor (Südkorea), nld/ned (Niederlande), cdr/cod (DR Kongo).
const CODE_NORM: Record<string, string> = {
kor: "kr",
nld: "ned",
cdr: "cod",
cvi: "cpv",
rsa: "rsa", // ok
swe: "swe", // ok
};
function normCode(c: string): string {
return (CODE_NORM[c] ?? c).toLowerCase();
}
function safeParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}
// Holt alle WM-Spiele über den Series-Endpoint mit Pagination.
// Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts).
export async function fetchOdds(): Promise<ParsedOdds[]> {
return cached("pm:odds", 300_000, async () => {
const allEvents: PmEvent[] = [];
for (let offset = 0; ; offset += 100) {
const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
});
if (!res.ok) throw new Error(`polymarket ${res.status}`);
const page = (await res.json()) as PmEvent[];
allEvents.push(...page);
if (page.length < 100) break;
}
const parsed: ParsedOdds[] = [];
for (const ev of allEvents) {
// Slug: fifwc-{home}-{away}-{yyyy}-{mm}-{dd}
const slugParts = ev.slug.replace("fifwc-", "").split("-");
if (slugParts.length < 5) continue;
const homeCode = normCode(slugParts[0]);
const awayCode = normCode(slugParts[1]);
let pHome = 0, pDraw = 0, pAway = 0;
let homeName = "", awayName = "";
let marketGameStartTime: string | null = null;
for (const mk of ev.markets ?? []) {
if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue;
if (mk.closed) continue;
if (!marketGameStartTime) marketGameStartTime = (mk as any).gameStartTime ?? null;
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
if (prices.length < 2) continue;
const yes = prices[0]; // erster Preis = "Yes"-Wahrscheinlichkeit
const gti = mk.groupItemTitle.toLowerCase();
if (mk.slug.endsWith("-draw") || gti.startsWith("draw")) {
pDraw = yes;
} else {
// Heuristik: match gegen groupItemTitle (Teamname)
// Prüfe ob der Titel den Home-Code oder Away-Code enthält
// Besser: erster Markt = Home, dritter = Away; Draw in der Mitte.
// Da die Reihenfolge nicht garantiert ist: über slug-Suffix matchen.
if (mk.slug.endsWith("-" + slugParts[0].toLowerCase())) {
pHome = yes;
homeName = mk.groupItemTitle;
} else if (mk.slug.endsWith("-" + slugParts[1].toLowerCase())) {
pAway = yes;
awayName = mk.groupItemTitle;
}
}
}
if (pHome > 0 || pDraw > 0 || pAway > 0) {
const startTime = marketGameStartTime ?? (ev as any).gameStartTime ?? (ev as any).endDate ?? (ev as any).startDate;
parsed.push({ slug: ev.slug, homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway, startTime });
}
}
console.log("[polymarket] spiele:", parsed.length);
return parsed;
});
}
// Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase).
function normName(n: string): string {
return n
.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // diakritische Zeichen entfernen
.replace(/[^a-z0-9 ]/g, "")
.replace(/\s+/g, " ")
.trim();
}
// Verknüpft Polymarket-Odds mit Feed-Matches über Teamnamen (primär) oder Codes.
export function attachOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] {
// Map: Team-ID → normierter Name
const nameById = new Map(teams.map((t) => [t.id, normName(t.name)]));
// Map: normierter Name → Team-ID (für Lookup)
const idByName = new Map<string, string>();
for (const t of teams) {
const nn = normName(t.name);
if (!idByName.has(nn)) idByName.set(nn, t.id);
}
// Map: normierter Code → Team-ID
const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id]));
// Map: Polymarket ParsedOdds → { homeTeamId, awayTeamId }
const oddsMatch = new Map<ParsedOdds, { homeId: string; awayId: string } | null>();
for (const o of odds) {
const hn = normName(o.homeName);
const an = normName(o.awayName);
const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode);
const awayId = idByName.get(an) ?? idByCode.get(o.awayCode);
if (homeId && awayId) {
oddsMatch.set(o, { homeId, awayId });
} else {
oddsMatch.set(o, null);
}
}
const attachedOdds = new Set<ParsedOdds>();
const result = matches.map((m) => {
if (!m.homeTeamId || !m.awayTeamId) return m;
// Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht
for (const [o, ids] of oddsMatch) {
if (!ids) continue;
// Beide Richtungen prüfen
if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) ||
(ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) {
const swapped = ids.homeId === m.awayTeamId;
attachedOdds.add(o);
return {
...m,
prob: {
home: swapped ? o.pAway : o.pHome,
draw: o.pDraw,
away: swapped ? o.pHome : o.pAway,
},
};
}
}
return m;
});
return result;
}
// ----------------------------------------------------------------------------
// KO-Odds-Fallback: löst R32-Paarungen auf und verknüpft Polymarket-Events
// mit K.o.-Feed-Matches, die noch keine Team-IDs im Feed haben.
// ----------------------------------------------------------------------------
// Löst für jeden R32-Slot (73-88) die aktuellen Team-Paarungen auf Basis der
// Gruppentabellen und Annex-C-Zuordnung. Gibt eine Map matchNumber → Paarung
// zurück, NUR wenn beide Team-IDs eindeutig bekannt sind (kein Raten).
function resolveR32Pairings(matches: Match[], teams: Team[]): Map<number, { homeTeamId: string; awayTeamId: string }> {
const tables = computeGroupTables(teams, matches);
const thirds = computeThirdPlaceTable(tables);
const qGroups = qualifiedThirdGroups(thirds);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
const pairings = new Map<number, { homeTeamId: string; awayTeamId: string }>();
for (const slot of R32) {
const homeGroup = slot.home.type === "W" ? slot.home.group : undefined;
const awayGroup = slot.away.type === "W" ? slot.away.group : undefined;
const wg = (homeGroup ?? awayGroup) as GroupId | undefined;
let hid: string | null = null;
if (slot.home.type === "W" && slot.home.group) {
hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 1)?.teamId ?? null;
} else if (slot.home.type === "R" && slot.home.group) {
hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 2)?.teamId ?? null;
} else if (slot.home.type === "3" && annex && wg) {
const tg = annex[wg];
if (tg) hid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null;
}
let aid: string | null = null;
if (slot.away.type === "W" && slot.away.group) {
aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 1)?.teamId ?? null;
} else if (slot.away.type === "R" && slot.away.group) {
aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 2)?.teamId ?? null;
} else if (slot.away.type === "3" && annex && wg) {
const tg = annex[wg];
if (tg) aid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null;
}
if (hid && aid) {
pairings.set(slot.matchNumber, { homeTeamId: hid, awayTeamId: aid });
}
}
return pairings;
}
// Extrahiert das Datum (YYYY-MM-DD) aus einem Polymarket-Slug.
// Slug-Format: fifwc-{home}-{away}-{yyyy}-{mm}-{dd}
function slugToDate(slug: string): string | null {
const parts = slug.split("-");
if (parts.length < 3) return null;
const y = parts[parts.length - 3];
const m = parts[parts.length - 2];
const d = parts[parts.length - 1];
if (/^\d{4}$/.test(y) && /^\d{2}$/.test(m) && /^\d{2}$/.test(d)) {
return `${y}-${m}-${d}`;
}
return null;
}
// Zweiter Durchlauf für attachOdds: ordnet Polymarket-Events K.o.-Feed-Matches
// zu, die (noch) keine Team-IDs im Feed haben. Arbeitet direkt auf den
// aufgelösten R32-Paarungen (resolveR32Pairings). Identifiziert das Ziel-
// Feed-Match über die Slot-matchNumber (Stufe 1) oder Datums-Lookup aus dem
// Slug (Stufe 2) — KEINE chronologische Nummernvergabe.
// Mutiert matches in-place, setzt nur bei Matches OHNE bestehende prob.
export function attachKOOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] {
const pairings = resolveR32Pairings(matches, teams);
if (pairings.size === 0) return matches;
const idByName = new Map<string, string>();
for (const t of teams) {
const nn = normName(t.name);
if (!idByName.has(nn)) idByName.set(nn, t.id);
}
const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id]));
for (const [matchNum, pairing] of pairings) {
if (!pairing.homeTeamId || !pairing.awayTeamId) continue;
// Finde Polymarket-Event für dieses Team-Paar
let matchedEvent: ParsedOdds | null = null;
for (const o of odds) {
const hn = normName(o.homeName);
const an = normName(o.awayName);
const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode);
const awayId = idByName.get(an) ?? idByCode.get(o.awayCode);
if (!homeId || !awayId) continue;
if ((homeId === pairing.homeTeamId && awayId === pairing.awayTeamId) ||
(homeId === pairing.awayTeamId && awayId === pairing.homeTeamId)) {
matchedEvent = o;
break;
}
}
if (!matchedEvent) continue;
// Ziel-Feed-Match identifizieren
let targetMatch: Match | undefined;
// Stufe 1: bereits nummeriertes R32-Match (via assignKONumbersBySlots Pass 1)
targetMatch = matches.find(m =>
m.stage === "R32" && m.group == null && m.matchNumber === matchNum,
);
// Stufe 2: Datums-Lookup aus Polymarket-Slug für unnummerierte Matches
if (!targetMatch) {
const slugDate = slugToDate(matchedEvent.slug);
if (slugDate) {
const candidates = matches.filter(m =>
m.stage === "R32" && m.group == null && m.matchNumber === 0 &&
m.utcDate.slice(0, 10) === slugDate,
);
if (candidates.length === 1) {
targetMatch = candidates[0];
targetMatch.matchNumber = matchNum;
} else if (candidates.length > 1) {
// Tie-breaker: falls Polymarket-Event eine Startzeit hat, nächstgelegenes Feed-Match
if (matchedEvent.startTime) {
const eventTime = new Date(matchedEvent.startTime).getTime();
let best: Match | undefined;
let bestDiff = Infinity;
for (const c of candidates) {
const diff = Math.abs(new Date(c.utcDate).getTime() - eventTime);
if (diff < bestDiff) { bestDiff = diff; best = c; }
}
// Nur zuordnen, wenn Zeitdifferenz plausibel (<4h)
if (best && bestDiff < 4 * 60 * 60 * 1000) {
targetMatch = best;
targetMatch.matchNumber = matchNum;
}
}
}
}
}
if (!targetMatch) continue;
// prob nicht überschreiben, falls schon gesetzt
if (targetMatch.prob) continue;
const homeIdFromEvent = idByName.get(normName(matchedEvent.homeName)) ??
idByCode.get(matchedEvent.homeCode);
const swapped = homeIdFromEvent === pairing.awayTeamId;
targetMatch.prob = {
home: swapped ? matchedEvent.pAway : matchedEvent.pHome,
draw: matchedEvent.pDraw,
away: swapped ? matchedEvent.pHome : matchedEvent.pAway,
};
}
return matches;
}
// ----------------------------------------------------------------------------
// FIFA-API: Live-Scores, Elfmeterschießen, Spielminute
// ----------------------------------------------------------------------------
const FIFA_BASE = "https://api.fifa.com/api/v3";
const FIFA_SEASON = "285023";
// FIFA-Code → App-Code (nur Abweichungen; sonst identisch)
const FIFA_CODE_OVERRIDE: Record<string, string> = {
CRO: "HRV", // Kroatien
POR: "PRT", // Portugal
SUI: "CHE", // Schweiz
};
function fifaCodeToAppCode(fifaCode: string): string {
return FIFA_CODE_OVERRIDE[fifaCode] ?? fifaCode;
}
interface FifaTeamBlock {
IdTeam: string; // FIFA-Team-ID
Abbreviation: string; // FIFA-Code (z.B. PAR, CRO)
}
interface FifaMatch {
MatchNumber: number;
IdMatch?: string | null;
IdStage?: string | null;
Home: FifaTeamBlock | null;
Away: FifaTeamBlock | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
HomeTeamPenaltyScore: number | null;
AwayTeamPenaltyScore: number | null;
MatchStatus: number; // 0=finished, 1=scheduled, else=live
MatchTime: string | null; // z.B. "132'"
ResultType: number | null; // 1=regular, 2=penalties
Winner?: string | null; // Team-ID des Siegers
}
interface FifaScores {
homeScore: number | null;
awayScore: number | null;
homePenalty: number | null;
awayPenalty: number | null;
resultType: number | null;
status: MatchStatus;
matchTime: string | null;
winnerTeamId: string | null;
idMatch: string | null;
idStage: string | null;
}
// ----------------------------------------------------------------------------
// FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams
// ----------------------------------------------------------------------------
interface FifaCalendarTeamBlock {
IdTeam: string;
Abbreviation: string;
TeamName: Array<{ Locale: string; Description: string }>;
}
interface FifaCalendarMatch {
IdMatch: string;
IdStage: string;
IdGroup: string | null;
MatchNumber: number;
Date: string;
Home: FifaCalendarTeamBlock | null;
Away: FifaCalendarTeamBlock | null;
HomeTeamScore: number | null;
AwayTeamScore: number | null;
HomeTeamPenaltyScore: number | null;
AwayTeamPenaltyScore: number | null;
MatchStatus: number;
MatchTime: string | null;
ResultType: number | null;
Winner?: string | null;
Attendance: string | null;
Stadium?: {
Name: Array<{ Locale: string; Description: string }>;
CityName: Array<{ Locale: string; Description: string }>;
} | null;
}
// Holt alle Spiele + Teams von der FIFA-API (parallele Quelle, noch nicht aktiv).
export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ matches: Match[]; teams: Team[] }> {
return cached(`fifa:matches:${locale}`, 60_000, async () => {
const lang = locale === "en" ? "en" : "de";
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
});
if (!res.ok) throw new Error(`fifa-calendar ${res.status}`);
const data = (await res.json()) as { Results: FifaCalendarMatch[] };
console.log("[fifa-fetch] Kalender geladen, Result-Array Länge:", (data.Results ?? []).length);
const teamMap = new Map<string, Team>();
function teamFromBlock(tb: FifaCalendarTeamBlock, idGroup: string | null): Team | null {
const id = tb.IdTeam;
if (teamMap.has(id)) return teamMap.get(id)!;
const code = fifaCodeToAppCode(tb.Abbreviation);
const group = idGroup ? FIFA_GROUP_MAP[idGroup] : null;
if (!group) return null;
const loc = TEAM_LOCALIZATION[code.toUpperCase()];
const name = tb.TeamName?.[0]?.Description ?? "";
const t: Team = {
id,
name,
code,
group,
localisedName: loc ? loc[lang === "en" ? "en" : "de"] : name,
localisedNames: loc ? { de: loc.de, en: loc.en } : { de: name, en: name },
};
teamMap.set(id, t);
return t;
}
function mapFifaStatus(ms: number): MatchStatus {
switch (ms) {
case 0: return "FINISHED";
case 1: return "SCHEDULED";
case 10: return "POSTPONED";
default: return "LIVE";
}
}
const matches: Match[] = (data.Results ?? []).map((fm) => {
const group = fm.IdGroup ? FIFA_GROUP_MAP[fm.IdGroup] ?? null : null;
let homeTeam: Team | null = null;
let awayTeam: Team | null = null;
if (fm.Home) homeTeam = teamFromBlock(fm.Home, fm.IdGroup);
if (fm.Away) awayTeam = teamFromBlock(fm.Away, fm.IdGroup);
const attendance = fm.Attendance ? parseInt(fm.Attendance, 10) || null : null;
const minute = fm.MatchTime ? parseInt(fm.MatchTime, 10) || null : null;
const pref = lang === "en" ? "en-GB" : "de-DE";
const stadiumName = fm.Stadium?.Name?.find(n => n.Locale === pref)?.Description
?? fm.Stadium?.Name?.[0]?.Description ?? null;
const stadiumCity = fm.Stadium?.CityName?.find(n => n.Locale === pref)?.Description
?? fm.Stadium?.CityName?.[0]?.Description ?? null;
return {
id: fm.IdMatch,
group,
stage: FIFA_STAGE_MAP[fm.IdStage] ?? "GROUP",
matchNumber: fm.MatchNumber,
utcDate: fm.Date,
status: mapFifaStatus(fm.MatchStatus),
minute,
homeTeamId: fm.Home?.IdTeam ?? null,
awayTeamId: fm.Away?.IdTeam ?? null,
homeScore: fm.HomeTeamScore,
awayScore: fm.AwayTeamScore,
homePenalty: fm.HomeTeamPenaltyScore,
awayPenalty: fm.AwayTeamPenaltyScore,
winnerTeamId: fm.Winner ?? null,
venue: null,
stadiumName,
stadiumCity,
attendance,
};
});
const teams = [...teamMap.values()];
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
return { matches, teams };
});
}
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
// Scores/Status/Penalties/Winner sind sprachunabhängig → locale-freier Cache-Key.
export async function fetchFifaScores(): Promise<{
scores: Map<number, FifaScores>;
fifaIdToAppCode: Map<string, string>;
}> {
return cached("fifa:scores", 45_000, async () => {
const url = `${FIFA_BASE}/calendar/matches?language=en&count=500&idSeason=${FIFA_SEASON}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`fifa ${res.status}`);
const data = (await res.json()) as { Results: FifaMatch[] };
console.log("[fifa-fetch] Scores geladen, Result-Array Länge:", (data.Results ?? []).length);
const scores = new Map<number, FifaScores>();
const fifaIdToAppCode = new Map<string, string>();
for (const fm of data.Results ?? []) {
// Team-Mapping sammeln
for (const tb of [fm.Home, fm.Away]) {
if (tb && !fifaIdToAppCode.has(tb.IdTeam)) {
fifaIdToAppCode.set(tb.IdTeam, fifaCodeToAppCode(tb.Abbreviation));
}
}
let status: MatchStatus;
switch (fm.MatchStatus) {
case 0: status = "FINISHED"; break;
case 1: status = "SCHEDULED"; break;
case 10: status = "POSTPONED"; break;
default: status = "LIVE"; break;
}
scores.set(fm.MatchNumber, {
homeScore: fm.HomeTeamScore,
awayScore: fm.AwayTeamScore,
homePenalty: fm.HomeTeamPenaltyScore,
awayPenalty: fm.AwayTeamPenaltyScore,
resultType: fm.ResultType,
status,
matchTime: fm.MatchTime,
winnerTeamId: fm.Winner ?? null,
idMatch: fm.IdMatch ?? null,
idStage: fm.IdStage ?? null,
});
}
console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
return { scores, fifaIdToAppCode };
});
}
// Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel).
// Team-IDs sind jetzt FIFA-IDs → winnerTeamId kann direkt gesetzt werden.
export function applyFifaScores(
matches: Match[], teams: Team[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
): Match[] {
const { scores: fifaMap } = fifaData;
if (fifaMap.size === 0) return matches;
let applied = 0;
const result = matches.map((m) => {
const fs = fifaMap.get(m.matchNumber);
if (!fs) return m;
const r = { ...m };
if (fs.homeScore != null) r.homeScore = fs.homeScore;
if (fs.awayScore != null) r.awayScore = fs.awayScore;
if (fs.homePenalty != null) r.homePenalty = fs.homePenalty;
if (fs.awayPenalty != null) r.awayPenalty = fs.awayPenalty;
if (fs.winnerTeamId) r.winnerTeamId = fs.winnerTeamId;
if (fs.matchTime) {
const min = parseInt(fs.matchTime, 10);
if (!isNaN(min)) r.minute = min;
}
r.status = fs.status;
applied++;
return r;
});
if (applied > 0) console.log("[fifa] scores angewandt:", applied);
return result;
}
// Holt Tor-Details pro Spiel vom FIFA-Detail-Endpoint.
interface FifaGoalRaw { scorer: string; minute: string; team: "home" | "away"; type: number | null; }
function locName(arr: Array<{ Locale: string; Description: string }> | undefined): string {
if (!arr || arr.length === 0) return "";
return (arr.find(x => x.Locale === "de-DE")
?? arr.find(x => x.Locale === "en-GB")
?? arr[0])?.Description ?? "";
}
function normMinuteStr(min: string | null | undefined): string {
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
}
async function fetchFifaGoals(
idStage: string, idMatch: string, locale: string = "de",
ttlMs: number = 60_000,
): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${locale}:${idMatch}`, ttlMs, async () => {
const lang = locale === "en" ? "en" : "de";
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) { console.warn("[fifa-detail] status", res.status, idMatch); return []; }
const dj: any = await res.json();
const buildPlayerMap = (teamBlock: any): Map<string, string> => {
const map = new Map<string, string>();
for (const p of teamBlock?.Players ?? []) {
map.set(String(p.IdPlayer), locName(p.PlayerName) || locName(p.ShortName));
}
return map;
};
const homeBlock = dj.HomeTeam, awayBlock = dj.AwayTeam;
const homePlayers = buildPlayerMap(homeBlock);
const awayPlayers = buildPlayerMap(awayBlock);
const goals: FifaGoalRaw[] = [];
for (const [block, players, side] of [
[homeBlock, homePlayers, "home"] as const,
[awayBlock, awayPlayers, "away"] as const,
]) {
for (const g of block?.Goals ?? []) {
const min = normMinuteStr(g.Minute);
if (min === "" || isNaN(parseInt(min, 10))) continue;
goals.push({
scorer: players.get(String(g.IdPlayer)) || "?",
minute: normMinuteStr(g.Minute),
team: side,
type: g.Type ?? null,
});
}
}
goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10));
return goals;
});
}
// Lädt Tor-Details für laufende und alle beendeten Spiele mit Toren.
// Beendete Spiele werden über langlebiges Caching (24h) gespart, nicht über Ausschluss.
// Tore eines beendeten Spiels ändern sich nie mehr → einmal fetchen genügt.
export async function attachFifaGoals(
matches: Match[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
locale: string = "de",
): Promise<Match[]> {
const targets = matches.filter(m =>
(m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
);
if (targets.length === 0) return matches;
const goalsByMatchId = new Map<string, GoalEvent[]>();
const results = await Promise.allSettled(targets.map(async (m) => {
const fs = fifaData.scores.get(m.matchNumber);
if (!fs?.idMatch || !fs?.idStage) return;
const isFinished = m.status === "FINISHED";
const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000;
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl);
if (goals.length) goalsByMatchId.set(m.id, goals);
}));
const fetched = results.filter(r => r.status === "fulfilled").length;
const failed = results.filter(r => r.status === "rejected").length;
if (goalsByMatchId.size > 0 || failed > 0) {
console.log("[fifa-fetch] Goals: geladen für", goalsByMatchId.size, "Matches,", fetched, "OK,", failed, "Fehler");
}
if (goalsByMatchId.size === 0) return matches;
return matches.map(m => {
const g = goalsByMatchId.get(m.id);
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
});
}
// ----------------------------------------------------------------------------
// FIFA Live-Daten Pipeline: kombiniert Scores + Goals in gestufter Abfolge.
// Step 1: Scores von der FIFA-API holen (enthält idMatch/idStage-Mappings).
// Step 2: Scores auf die Matches anwenden (MatchNumber als Schlüssel).
// Step 3: Goal-Details NUR für live/beendete Matches mit Toren nachladen.
//
// Die Schritte werden sequentiell ausgeführt (kein Promise.all wie zuvor),
// damit fehlende ID-Mappings nicht zu sinnlosen Requests führen.
// Jeder Schritt loggt die Array-/Map-Länge, sodass auf der Vercel-Konsole
// sofort ersichtlich ist, ob der Upstream leer ist oder der Fehler im Mapping liegt.
// ----------------------------------------------------------------------------
export async function attachFifaLiveData(
matches: Match[],
teams: Team[],
locale: string = "de",
): Promise<Match[]> {
// --- Step 1: Scores von FIFA holen ---
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
try {
fifaData = await fetchFifaScores();
} catch (err) {
console.warn("[fifa-fetch] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
return matches;
}
// --- Step 2: Scores auf Matches anwenden ---
const updated = applyFifaScores(matches, teams, fifaData);
// --- Step 3: Goal-Details für alle Spiele mit Toren (live + beendet) ---
// Requests werden durch differenzierte Cache-TTL gespart:
// beendete Spiele → 24h (Tore ändern sich nie)
// laufende Spiele → 60s (neue Tore erscheinen)
const targets = updated.filter(m => {
const fs = fifaData.scores.get(m.matchNumber);
return (
(m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || m.status === "FINISHED") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0 &&
fs?.idMatch != null &&
fs?.idStage != null
);
});
if (targets.length === 0) {
console.log("[fifa-fetch] Pipeline: Scores aktualisiert, keine Goal-Requests nötig");
return updated;
}
const goalsByMatchId = new Map<string, GoalEvent[]>();
let fetched = 0;
let failed = 0;
for (const m of targets) {
const fs = fifaData.scores.get(m.matchNumber)!;
if (!fs.idMatch || !fs.idStage) continue;
const isFinished = m.status === "FINISHED";
const ttl = isFinished ? 24 * 60 * 60 * 1000 : 60_000; // beendet: 24h, live: 60s
try {
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale, ttl);
fetched++;
if (goals.length) goalsByMatchId.set(m.id, goals);
} catch {
failed++;
}
}
console.log("[fifa-fetch] Pipeline: Goals für", goalsByMatchId.size, "Matches geladen (", fetched, "OK,", failed, "Fehler )");
if (goalsByMatchId.size === 0) return updated;
return updated.map(m => {
const g = goalsByMatchId.get(m.id);
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
});
}