init
This commit is contained in:
199
lib/feeds.ts
Normal file
199
lib/feeds.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
homeTeam: { id: number | null; name: string | null; tla?: string | null };
|
||||
awayTeam: { id: number | null; name: string | null; tla?: 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user