Compare commits
1 Commits
b2e4b63729
...
fifa-prima
| Author | SHA1 | Date | |
|---|---|---|---|
| 202d4460e9 |
@@ -1,6 +1,4 @@
|
|||||||
# football-data.org API-Token (kostenlos: https://www.football-data.org/client/register)
|
# FIFA-API als Primärquelle (kein Token nötig)
|
||||||
# Ohne Token liefert die API nur eingeschränkte Daten.
|
|
||||||
FOOTBALL_DATA_TOKEN=dein_token_hier
|
|
||||||
|
|
||||||
# Polymarket-Slug des WM-Events (Standard: world-cup-2026).
|
# Polymarket-Slug des WM-Events (Standard: world-cup-2026).
|
||||||
# Den genauen Slug findest du in der Polymarket-URL nach /event/.
|
# Den genauen Slug findest du in der Polymarket-URL nach /event/.
|
||||||
|
|||||||
197
lib/feeds.ts
197
lib/feeds.ts
@@ -1,13 +1,11 @@
|
|||||||
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
|
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
|
||||||
import { venueFor } from "./venues";
|
import { TEAM_LOCALIZATION } from "./team-mappings";
|
||||||
import { localisedTeamName, TEAM_LOCALIZATION } from "./team-mappings";
|
|
||||||
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
|
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
|
||||||
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
|
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
|
||||||
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
|
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
|
// Caching: einfacher In-Memory-Cache mit TTL.
|
||||||
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
interface CacheEntry<T> { value: T; expires: number; }
|
interface CacheEntry<T> { value: T; expires: number; }
|
||||||
const cache = new Map<string, CacheEntry<unknown>>();
|
const cache = new Map<string, CacheEntry<unknown>>();
|
||||||
@@ -27,195 +25,6 @@ export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// R16–Finale: 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 ?? ""),
|
|
||||||
localisedNames: {
|
|
||||||
de: localisedTeamName(side.tla ?? "", side.name ?? "", "de"),
|
|
||||||
en: localisedTeamName(side.tla ?? "", side.name ?? "", "en"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
// Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -611,7 +420,7 @@ interface FifaScores {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// FIFA-API Calendar-Endpoint: Spiele + Teams parallel zur football-data-Quelle
|
// FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
interface FifaCalendarTeamBlock {
|
interface FifaCalendarTeamBlock {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const de: Dictionary = {
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
dashboard: "WM 2026 Dashboard",
|
dashboard: "WM 2026 Dashboard",
|
||||||
dataSources: "Daten: football-data.org · Polymarket Gamma API · FIFA Annex C",
|
dataSources: "Daten: FIFA · Polymarket · Annex C",
|
||||||
},
|
},
|
||||||
sim: {
|
sim: {
|
||||||
notice:
|
notice:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const en: Dictionary = {
|
|||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
dashboard: "WC 2026 Dashboard",
|
dashboard: "WC 2026 Dashboard",
|
||||||
dataSources: "Data: football-data.org · Polymarket Gamma API · FIFA Annex C",
|
dataSources: "Data: FIFA · Polymarket · Annex C",
|
||||||
},
|
},
|
||||||
sim: {
|
sim: {
|
||||||
notice:
|
notice:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Zentrale Lokalisierungstabelle für Teamnamen (TLA → Deutsch / Englisch).
|
// Zentrale Lokalisierungstabelle für Teamnamen (TLA → Deutsch / Englisch).
|
||||||
// Schlüssel = FIFA-3-Buchstaben-Code (uppercase), wie er von football-data.org
|
// Schlüssel = App-3-Buchstaben-Code (uppercase), gemappt von FIFA-Abbreviation.
|
||||||
// im Feld `tla` geliefert wird.
|
|
||||||
//
|
//
|
||||||
// Erweiterung auf weitere Sprachen: einfach pro Sprache ein Feld ergänzen.
|
// Erweiterung auf weitere Sprachen: einfach pro Sprache ein Feld ergänzen.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten:
|
// Datenmodell für die WM-Seite. Bewusst entkoppelt von den Feed-Formaten:
|
||||||
// Adapter (siehe lib/feeds.ts) übersetzen football-data.org & Polymarket in diese Typen.
|
// Adapter (siehe lib/feeds.ts) übersetzen FIFA & Polymarket in diese Typen.
|
||||||
|
|
||||||
export type GroupId =
|
export type GroupId =
|
||||||
| "A" | "B" | "C" | "D" | "E" | "F"
|
| "A" | "B" | "C" | "D" | "E" | "F"
|
||||||
@@ -14,7 +14,7 @@ export interface Team {
|
|||||||
name: string; // Originalname aus dem Feed (englisch)
|
name: string; // Originalname aus dem Feed (englisch)
|
||||||
code: string; // 3-Buchstaben-Code, z.B. GER
|
code: string; // 3-Buchstaben-Code, z.B. GER
|
||||||
group: GroupId;
|
group: GroupId;
|
||||||
crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest)
|
crest?: string | null; // URL zur Flagge / zum Wappen (veraltet)
|
||||||
localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland")
|
localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland")
|
||||||
localisedNames?: { de: string; en: string };
|
localisedNames?: { de: string; en: string };
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export interface Match {
|
|||||||
winnerTeamId?: string | null; // FIFA-Winner (auch bei Elfmeterschießen)
|
winnerTeamId?: string | null; // FIFA-Winner (auch bei Elfmeterschießen)
|
||||||
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
|
// Wahrscheinlichkeiten 0..1 (Polymarket), falls vorhanden
|
||||||
prob?: { home: number; draw: number; away: number } | null;
|
prob?: { home: number; draw: number; away: number } | null;
|
||||||
venue?: string | null; // Austragungsort (alt: football-data)
|
venue?: string | null; // Austragungsort (veraltet)
|
||||||
stadiumName?: string | null; // FIFA-Stadionname
|
stadiumName?: string | null; // FIFA-Stadionname
|
||||||
stadiumCity?: string | null; // FIFA-Stadt
|
stadiumCity?: string | null; // FIFA-Stadt
|
||||||
attendance?: number | null; // Zuschauerzahl
|
attendance?: number | null; // Zuschauerzahl
|
||||||
|
|||||||
Reference in New Issue
Block a user