import { GroupId, Match, MatchStatus, Team } from "./types"; import { venueFor } from "./venues"; import { localisedTeamName } from "./team-mappings"; // ---------------------------------------------------------------------------- // Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser- // Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit). // ---------------------------------------------------------------------------- interface CacheEntry { value: T; expires: number; } const cache = new Map>(); export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; const now = Date.now(); if (hit && hit.expires > now) return hit.value; try { const value = await fn(); cache.set(key, { value, expires: now + ttlMs }); return value; } catch (err) { // Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen. if (hit) return hit.value; throw err; } } // ---------------------------------------------------------------------------- // football-data.org: Teams, Spiele, Status // ---------------------------------------------------------------------------- const FD_BASE = "https://api.football-data.org/v4"; const FD_COMP = "WC"; // FIFA World Cup function fdHeaders(): Record { const token = process.env.FOOTBALL_DATA_TOKEN; return token ? { "X-Auth-Token": token } : {}; } function mapStatus(s: string): MatchStatus { switch (s) { case "LIVE": return "LIVE"; case "IN_PLAY": return "IN_PLAY"; case "PAUSED": return "PAUSED"; case "FINISHED": return "FINISHED"; default: return "SCHEDULED"; } } // Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId. function parseGroup(g: string | null | undefined): GroupId | null { if (!g) return null; const m = /GROUP_([A-L])/.exec(g); return m ? (m[1] as GroupId) : null; } interface FdMatchesResponse { matches: Array<{ id: number; utcDate: string; status: string; minute?: number | null; matchday?: number | null; stage: string; group?: string | null; venue?: string | null; attendance?: number | null; homeTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null }; awayTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null }; score: { fullTime: { home: number | null; away: number | null } }; }>; } function stageFor(stage: string, group: GroupId | null): Match["stage"] { if (group) return "GROUP"; switch (stage) { case "LAST_32": return "R32"; case "LAST_16": return "R16"; case "QUARTER_FINALS": return "QF"; case "SEMI_FINALS": return "SF"; case "THIRD_PLACE": return "3RD"; case "FINAL": return "FINAL"; default: return "GROUP"; } } // Phasen-Reihenfolge für die K.o.-Nummerierung. const STAGE_ORDER: Record = { GROUP: 0, R32: 1, R16: 2, QF: 3, SF: 4, "3RD": 5, FINAL: 6, }; // Setzt Spielnummern und Stadien. // - K.o.-Spiele: chronologisch ab 73 (Anstöße dort eindeutig) -> für Bracket nötig. // - Venue: Gruppenspiele über die Teampaarung, K.o.-Spiele über die Spielnummer. function assignNumbersAndVenues(matches: Match[], teams: Team[]): void { // K.o.-Spiele eindeutig durchnummerieren (73..104). const ko = matches .filter((m) => m.group == null) .sort((a, b) => { const sa = STAGE_ORDER[a.stage], sb = STAGE_ORDER[b.stage]; if (sa !== sb) return sa - sb; const t = +new Date(a.utcDate) - +new Date(b.utcDate); return t !== 0 ? t : Number(a.id) - Number(b.id); }); ko.forEach((m, i) => { m.matchNumber = 73 + i; }); // Stadien setzen (Gruppenphase per Paarung, K.o. per Nummer). for (const m of matches) { m.venue = venueFor(m, teams); } } // Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call). export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: Team[] }> { return cached("fd:matches", 60_000, async () => { const res = await fetch(`${FD_BASE}/competitions/${FD_COMP}/matches`, { headers: fdHeaders(), // Next.js: kein eigenes Caching, wir cachen selbst cache: "no-store", }); if (!res.ok) throw new Error(`football-data ${res.status}`); const data = (await res.json()) as FdMatchesResponse; const teamMap = new Map(); const matches: Match[] = data.matches.map((m) => { const group = parseGroup(m.group); // Teams registrieren (nur wenn Gruppenspiel und ID vorhanden) for (const side of [m.homeTeam, m.awayTeam]) { if (side.id != null && side.name && group) { const id = String(side.id); if (!teamMap.has(id)) { teamMap.set(id, { id, name: side.name, code: side.tla ?? "", group, crest: side.crest ?? null, localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""), }); } } } return { id: String(m.id), group, stage: stageFor(m.stage, group), // Vorläufig 0 — die echte FIFA-Spielnummer wird unten gesetzt. matchNumber: 0, utcDate: m.utcDate, status: mapStatus(m.status), minute: m.minute ?? null, homeTeamId: m.homeTeam.id != null ? String(m.homeTeam.id) : null, awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null, homeScore: m.score.fullTime.home, awayScore: m.score.fullTime.away, venue: null, // wird unten aus der Map gesetzt attendance: m.attendance ?? null, }; }); const teams = [...teamMap.values()]; assignNumbersAndVenues(matches, teams); return { matches, teams }; }); } // ---------------------------------------------------------------------------- // 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 { homeCode: string; awayCode: string; homeName: string; awayName: string; pHome: number; pDraw: number; pAway: number; } // 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 = { 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(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 { return cached("pm:odds", 120_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; } //console.log("[polymarket] events fetched:", allEvents.length); 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 = ""; for (const mk of ev.markets ?? []) { if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue; if (mk.closed) continue; const prices = safeParse(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) { parsed.push({ homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway }); } } 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(); 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(); 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); } } return 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; return { ...m, prob: { home: swapped ? o.pAway : o.pHome, draw: o.pDraw, away: swapped ? o.pHome : o.pAway, }, }; } } return m; }); }