336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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<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: `/crests/${id}.svg`,
|
|
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<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", 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<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) {
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
} |