Files
wm-projekt/lib/feeds.ts

781 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GroupId, Match, MatchStatus, Team } from "./types";
import { venueFor } from "./venues";
import { localisedTeamName } from "./team-mappings";
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
// ----------------------------------------------------------------------------
// 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: Nummerierung über Slot-Auflösung (assignKONumbersBySlots),
// NICHT mehr chronologisch — die FIFA-Nummern folgen der Bracket-Topologie.
// Venue: Gruppenspiele über die Teampaarung, K.o.-Spiele über die Spielnummer.
function assignNumbersAndVenues(matches: Match[], teams: Team[]): void {
// Stadien setzen (Gruppenphase per Paarung, K.o. per Nummer).
for (const m of matches) {
m.venue = venueFor(m, teams);
}
}
// Vergibt K.o.-Match-Nummern über Slot-Auflösung (FIFA-Topologie)
// statt chronologisch. Für R32-Matches mit Team-IDs wird der Slot gesucht,
// dessen aufgelöste Teams dem Feed-Paar entsprechen.
export function assignKONumbersBySlots(matches: Match[], teams: Team[]): void {
const tables = computeGroupTables(teams, matches);
const thirds = computeThirdPlaceTable(tables);
const qGroups = qualifiedThirdGroups(thirds);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
const byPair = new Map<string, Match>();
for (const m of matches) {
if (m.group != null || !m.homeTeamId || !m.awayTeamId) continue;
byPair.set(`${m.homeTeamId}::${m.awayTeamId}`, m);
byPair.set(`${m.awayTeamId}::${m.homeTeamId}`, m);
}
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) {
const fm = byPair.get(`${hid}::${aid}`);
if (fm) fm.matchNumber = slot.matchNumber;
}
}
// R16Finale: per Stage + Datum den LATER_ROUNDS-Slots zuordnen
for (const stage of ["R16", "QF", "SF", "3RD", "FINAL"] as const) {
const sm = matches.filter(m => m.stage === stage && m.group == null && m.matchNumber === 0)
.sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate));
const ls = LATER_ROUNDS.filter(k => k.stage === (stage === "3RD" ? "3RD" : stage));
for (let i = 0; i < sm.length && i < ls.length; i++) {
sm[i].matchNumber = ls[i].matchNumber;
}
}
}
// 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 {
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", 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;
}
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 rawStartDate = (ev as any).startDate ?? null;
const rawEndDate = (ev as any).endDate ?? null;
const rawGameStartTime = (ev as any).gameStartTime ?? null;
const startTime = marketGameStartTime ?? rawGameStartTime ?? rawEndDate ?? rawStartDate;
console.log(" [PM-RAW-ML]", ev.slug,
"| active:", (ev as any).active, "| closed:", (ev as any).closed,
"| startDate:", rawStartDate,
"| gameStartTime:", rawGameStartTime,
"| mkGameStartTime:", marketGameStartTime,
"| endDate:", rawEndDate);
parsed.push({ slug: ev.slug, homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway, startTime });
}
}
console.log("[PM-RAW]", new Date().toISOString(), "| events gesamt:", allEvents.length);
console.log("[PM-LOAD]", new Date().toISOString(), "| moneyline-events:", parsed.length, "| events gesamt:", allEvents.length);
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 feedMatchesWithTheseTeams = matches.filter(m =>
m.homeTeamId && m.awayTeamId &&
((m.homeTeamId === homeId && m.awayTeamId === awayId) ||
(m.homeTeamId === awayId && m.awayTeamId === homeId)),
);
console.log("[PM-NOMATCH]", o.homeName, "vs", o.awayName, "| slug:", o.slug);
console.log("[PM-NOMATCH-WHY]", o.homeName, "vs", o.awayName,
"| homeId gefunden:", !!homeId,
"| awayId gefunden:", !!awayId,
"| homeCode:", o.homeCode, "| awayCode:", o.awayCode,
"| feed-matches mit diesen teams:", feedMatchesWithTheseTeams.length);
}
}
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);
console.log("[PM-ATTACH]", o.homeName, "vs", o.awayName,
"| match gefunden:", true,
"| match.status:", m.status,
"| prob gesetzt:", true);
return {
...m,
prob: {
home: swapped ? o.pAway : o.pHome,
draw: o.pDraw,
away: swapped ? o.pHome : o.pAway,
},
};
}
}
return m;
});
for (const [o, ids] of oddsMatch) {
if (ids && !attachedOdds.has(o)) {
const matchingFeedMatch = matches.find(m =>
m.homeTeamId && m.awayTeamId &&
((m.homeTeamId === ids.homeId && m.awayTeamId === ids.awayId) ||
(m.homeTeamId === ids.awayId && m.awayTeamId === ids.homeId)),
);
console.log("[PM-NOMATCH]", o.homeName, "vs", o.awayName, "| slug:", o.slug);
console.log("[PM-NOMATCH-WHY]", o.homeName, "vs", o.awayName,
"| team-ids gematcht:", ids.homeId, "vs", ids.awayId,
"| feed-match mit diesen teams existiert:", !!matchingFeedMatch,
"| feed-match status:", matchingFeedMatch?.status ?? "—",
"| prob bereits gesetzt:", matchingFeedMatch?.prob ? "ja" : "nein");
}
}
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 });
}
}
console.log("[R32-PAIRS] aufgelöste Paarungen:", pairings.size);
for (const [num, p] of pairings) {
const h = teams.find(t=>t.id===p.homeTeamId)?.name ?? p.homeTeamId;
const a = teams.find(t=>t.id===p.awayTeamId)?.name ?? p.awayTeamId;
console.log(" [R32-PAIR]", "matchNumber:", num, "|", h, "vs", a);
}
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 koOhneProb = matches.filter(m => m.group == null && m.prob == null).length;
console.log("[KO-ODDS] start | odds-events:", odds?.length ?? "undefined",
"| ko-matches ohne prob:", koOhneProb);
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]));
console.log("[KO-LOOP] start | pairings:", pairings.size, "| odds-events:", odds?.length ?? "undef");
for (const [matchNum, pairing] of pairings) {
if (!pairing.homeTeamId || !pairing.awayTeamId) continue;
const hName = teams.find(t => t.id === pairing.homeTeamId)?.name ?? "?";
const aName = teams.find(t => t.id === pairing.awayTeamId)?.name ?? "?";
// 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) {
console.log("[KO-TRY]", "matchNumber:", matchNum,
"| teams:", `${hName} vs ${aName}`,
"| polymarket-event gefunden:", false,
"| ziel-feedmatch gefunden:", false,
"| stufe:", "keine");
continue;
}
// Ziel-Feed-Match identifizieren
let targetMatch: Match | undefined;
let stufe: string | undefined;
// Stufe 1: bereits nummeriertes R32-Match (via assignKONumbersBySlots Pass 1)
targetMatch = matches.find(m =>
m.stage === "R32" && m.group == null && m.matchNumber === matchNum,
);
if (targetMatch) stufe = "1 (nummeriert)";
// 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;
stufe = "2 (datum)";
} 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;
stufe = "2 (datum+tie)";
}
}
console.log("[KO-TIME]", "slot:", matchNum,
"| teams:", `${hName} vs ${aName}`,
"| pm-gameStartTime(UTC):", matchedEvent.startTime,
"| feed-kandidaten:", candidates.map(c =>
`${c.id}(${c.utcDate})`,
).join(", "),
"| gewählt:", targetMatch?.id ?? "keins");
if (!targetMatch) {
console.log("[KO-TIE]", "matchNumber:", matchNum,
"| slug:", matchedEvent.slug,
"| slugDate:", slugDate,
"| startTime:", matchedEvent.startTime,
"| candidates:", candidates.map(c =>
`${c.id}(${c.utcDate})`,
).join(", "));
}
}
}
}
if (!targetMatch) {
console.log("[KO-TRY]", "matchNumber:", matchNum,
"| teams:", `${hName} vs ${aName}`,
"| polymarket-event gefunden:", true,
"| ziel-feedmatch gefunden:", false,
"| stufe:", stufe ?? "keine");
continue;
}
// prob nicht überschreiben, falls schon gesetzt
if (targetMatch.prob) {
console.log("[KO-TRY]", "matchNumber:", matchNum,
"| teams:", `${hName} vs ${aName}`,
"| polymarket-event gefunden:", true,
"| ziel-feedmatch gefunden:", true,
"| stufe:", stufe,
"| prob bereits gesetzt:", true);
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,
};
console.log("[PM-ATTACH]", matchedEvent.homeName, "vs", matchedEvent.awayName,
"| match gefunden via R32:", true,
"| match.status:", targetMatch.status,
"| matchNumber:", targetMatch.matchNumber,
"| prob gesetzt:", true);
console.log("[KO-TRY]", "matchNumber:", matchNum,
"| teams:", `${hName} vs ${aName}`,
"| polymarket-event gefunden:", !!matchedEvent,
"| ziel-feedmatch gefunden:", !!targetMatch,
"| stufe:", stufe);
}
return matches;
}
// ----------------------------------------------------------------------------
// worldcup26.ir: schnelle Live-Scores (additiv, Fallback auf football-data)
// ----------------------------------------------------------------------------
const WC26_BASE = "https://worldcup26.ir";
interface Wc26Game {
home_team_name_en?: string;
away_team_name_en?: string;
group?: string;
matchday?: string;
home_score?: string;
away_score?: string;
time_elapsed?: string;
}
interface LiveScore {
homeName: string;
awayName: string;
group: string;
matchday: string;
homeScore: number | null;
awayScore: number | null;
status: string; // "IN_PLAY" | "FINISHED"
}
// Ruft alle Spiele von worldcup26.ir ab.
export async function fetchLiveScores(): Promise<LiveScore[]> {
const res = await fetch(`${WC26_BASE}/get/games`, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`worldcup26 ${res.status}`);
const data = (await res.json()) as { games: Wc26Game[] };
const games = data.games ?? [];
const scores: LiveScore[] = [];
for (const g of games) {
if (!g.home_team_name_en || !g.away_team_name_en) continue;
const hScore = parseScore(g.home_score);
const aScore = parseScore(g.away_score);
let status = "";
switch (g.time_elapsed) {
case "live": status = "IN_PLAY"; break;
case "finished": status = "FINISHED"; break;
default: continue; // notstarted → überspringen
}
// Nur anwenden, wenn mindestens ein Score vorhanden ist
if (hScore == null && aScore == null) continue;
scores.push({
homeName: g.home_team_name_en,
awayName: g.away_team_name_en,
group: g.group ?? "",
matchday: String(g.matchday ?? ""),
homeScore: hScore,
awayScore: aScore,
status,
});
}
console.log("[worldcup26] spiele:", scores.length);
return scores;
}
function parseScore(s: string | undefined): number | null {
if (!s || s === "null") return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
// Wendet worldcup26-Live-Scores auf football-data-Matches an.
// Matching über normalisierte Teamnamen + group + matchday.
export function applyLiveScores(
matches: Match[], teams: Team[], liveScores: LiveScore[],
): Match[] {
// Build lookup: normName(home)::normName(away)::group::matchday → match
// Für Group-Phase ist die Paarung eindeutig (jedes Paar spielt 1x).
const byPair = new Map<string, Match>();
for (const m of matches) {
if (m.group == null) continue; // nur Gruppenphase
if (!m.homeTeamId || !m.awayTeamId) continue;
const ht = teams.find((t) => t.id === m.homeTeamId);
const at = teams.find((t) => t.id === m.awayTeamId);
if (!ht || !at) continue;
const hn = normName(ht.name);
const an = normName(at.name);
// Beide Richtungen
byPair.set(`${hn}::${an}::${m.group}`, m);
byPair.set(`${an}::${hn}::${m.group}`, m);
}
const result = [...matches];
let applied = 0;
for (const ls of liveScores) {
const hn = normName(ls.homeName);
const an = normName(ls.awayName);
const key = `${hn}::${an}::${ls.group}`;
const fdMatch = byPair.get(key);
if (!fdMatch) continue;
const idx = result.findIndex((m) => m.id === fdMatch.id);
if (idx < 0) continue;
result[idx] = {
...result[idx],
homeScore: ls.homeScore ?? result[idx].homeScore,
awayScore: ls.awayScore ?? result[idx].awayScore,
status: ls.status as Match["status"],
};
applied++;
}
if (applied > 0) console.log("[worldcup26] auf matches angewandt:", applied);
return result;
}