Files
wm-projekt/lib/feeds.ts
2026-06-22 15:44:54 -05:00

207 lines
7.5 KiB
TypeScript

import { GroupId, Match, MatchStatus, Team } from "./types";
// ----------------------------------------------------------------------------
// 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";
}
}
// 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),
matchNumber: m.matchday ?? 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: m.venue ?? null,
attendance: m.attendance ?? null,
};
});
return { matches, teams: [...teamMap.values()] };
});
}
// ----------------------------------------------------------------------------
// 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,
},
};
});
}