237 lines
8.6 KiB
TypeScript
237 lines
8.6 KiB
TypeScript
import { GroupId, Match, MatchStatus, Team } from "./types";
|
|
import { venueFor } from "./venues";
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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<T> { value: T; expires: number; }
|
|
const cache = new Map<string, CacheEntry<unknown>>();
|
|
|
|
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();
|
|
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<string, string> {
|
|
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<Match["stage"], number> = {
|
|
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<string, Team>();
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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
|
|
// ----------------------------------------------------------------------------
|
|
const PM_BASE = "https://gamma-api.polymarket.com";
|
|
|
|
interface PmMarket {
|
|
question: string;
|
|
outcomes: string; // JSON-String, z.B. "[\"Germany\",\"Draw\",\"Mexico\"]"
|
|
outcomePrices: string; // JSON-String, z.B. "[\"0.55\",\"0.25\",\"0.20\"]"
|
|
slug: string;
|
|
closed: boolean;
|
|
}
|
|
|
|
// Map: normalisierter Teamname-Schlüssel -> Wahrscheinlichkeit.
|
|
export interface OddsEntry {
|
|
outcomes: string[];
|
|
prices: number[];
|
|
question: string;
|
|
}
|
|
|
|
function safeParse<T>(s: string, fallback: T): T {
|
|
try { return JSON.parse(s) as T; } catch { return fallback; }
|
|
}
|
|
|
|
// Holt WM-bezogene Märkte über das Tag/Slug der Polymarket-WM-Kollektion.
|
|
// Hinweis: Der exakte Slug kann sich ändern; per ENV überschreibbar.
|
|
export async function fetchOdds(): Promise<OddsEntry[]> {
|
|
const slug = process.env.POLYMARKET_WC_SLUG || "world-cup-2026";
|
|
return cached("pm:odds", 120_000, async () => {
|
|
const res = await fetch(
|
|
`${PM_BASE}/events?slug=${encodeURIComponent(slug)}`,
|
|
{ cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" } },
|
|
);
|
|
if (!res.ok) throw new Error(`polymarket ${res.status}`);
|
|
const events = (await res.json()) as Array<{ markets?: PmMarket[] }>;
|
|
const entries: OddsEntry[] = [];
|
|
for (const ev of events) {
|
|
for (const mk of ev.markets ?? []) {
|
|
if (mk.closed) continue;
|
|
const outcomes = safeParse<string[]>(mk.outcomes, []);
|
|
const prices = safeParse<string[]>(mk.outcomePrices, []).map(Number);
|
|
if (outcomes.length && outcomes.length === prices.length) {
|
|
entries.push({ outcomes, prices, question: mk.question });
|
|
}
|
|
}
|
|
}
|
|
return entries;
|
|
});
|
|
}
|
|
|
|
// Verknüpft Polymarket-Märkte mit Spielen anhand der Teamnamen im Markttitel.
|
|
export function attachOdds(matches: Match[], teams: Team[], odds: OddsEntry[]): Match[] {
|
|
const nameById = new Map(teams.map((t) => [t.id, t.name.toLowerCase()]));
|
|
return matches.map((m) => {
|
|
if (!m.homeTeamId || !m.awayTeamId) return m;
|
|
const hn = nameById.get(m.homeTeamId);
|
|
const an = nameById.get(m.awayTeamId);
|
|
if (!hn || !an) return m;
|
|
const hit = odds.find(
|
|
(o) => o.question.toLowerCase().includes(hn) && o.question.toLowerCase().includes(an),
|
|
);
|
|
if (!hit) return m;
|
|
// Heuristik: 3 Outcomes = Heim/Unentschieden/Auswärts; 2 = Heim/Auswärts.
|
|
const idxHome = hit.outcomes.findIndex((o) => o.toLowerCase().includes(hn));
|
|
const idxAway = hit.outcomes.findIndex((o) => o.toLowerCase().includes(an));
|
|
const idxDraw = hit.outcomes.findIndex((o) => /draw|unentschieden|tie/i.test(o));
|
|
if (idxHome < 0 || idxAway < 0) return m;
|
|
return {
|
|
...m,
|
|
prob: {
|
|
home: hit.prices[idxHome] ?? 0,
|
|
draw: idxDraw >= 0 ? (hit.prices[idxDraw] ?? null) : null,
|
|
away: hit.prices[idxAway] ?? 0,
|
|
},
|
|
};
|
|
});
|
|
} |