diff --git a/server/src/index.ts b/server/src/index.ts index 8db86b1..fc20970 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,7 +10,7 @@ import { startScheduler } from './ingest/scheduler'; import { ModelEngine } from './model'; import { buildPreview, buildTeamProfile } from './preview'; import { buildScoreboard, snapshotCheck } from './scoreboard'; -import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, allFixtureResults, saveFixtureResult } from './db/db'; +import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult } from './db/db'; import { inPlayProbs } from '../../src/lib/model/inplay'; import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push'; import type { EspnSummary } from './ingest/espnNormalize'; @@ -118,6 +118,59 @@ export function buildServer() { return { ok: true }; }); + // Rich match data captured from FotMob/FIFA/Open-Meteo, projected for the + // match center: oriented shots with xG, momentum, POTM, official info, weather. + app.get('/api/match-rich/:num', async (req, reply) => { + const num = Number((req.params as { num: string }).num); + if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' }); + interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string } + interface FmDetails { + homeTeamId?: number | null; + shots?: FmShot[]; + momentum?: { main?: { data?: { minute: number; value: number }[] } } | null; + matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null; + } + const fm = getMatchProvider(num, 'fotmob'); + const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info'); + const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather'); + + const homeId = fm?.data.homeTeamId ?? null; + const shots = (fm?.data.shots ?? []).map((s) => ({ + isHome: homeId != null ? s.teamId === homeId : null, + player: s.playerName ?? '', + x: s.x ?? null, + y: s.y ?? null, + min: s.min ?? null, + xg: s.expectedGoals ?? null, + xgot: s.expectedGoalsOnTarget ?? null, + type: s.eventType ?? '', + situation: s.situation ?? '', + })); + const xg = shots.reduce( + (acc, s) => { + if (s.isHome === true) acc.home += s.xg ?? 0; + else if (s.isHome === false) acc.away += s.xg ?? 0; + return acc; + }, + { home: 0, away: 0 }, + ); + const potmRaw = fm?.data.matchFacts?.playerOfTheMatch; + return { + shots, + xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null, + momentum: fm?.data.momentum?.main?.data ?? [], + potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null, + info: fifaInfo + ? { + attendance: fifaInfo.data.attendance, + stadium: fifaInfo.data.stadium, + referee: fifaInfo.data.officials?.find((o) => o.TypeLocalized?.[0]?.Description === 'Referee')?.Name?.[0]?.Description ?? null, + } + : null, + weather: weather ? weather.data : null, + }; + }); + // Golden Boot: per-player goals aggregated from structured ESPN scoring // plays across every started match (own goals + shootout kicks excluded). app.get('/api/goldenboot', async () => { diff --git a/server/src/ingest/fotmob.ts b/server/src/ingest/fotmob.ts index fad0bb4..aaf7d43 100644 --- a/server/src/ingest/fotmob.ts +++ b/server/src/ingest/fotmob.ts @@ -54,10 +54,12 @@ export async function fetchFotmobMatches(dateYYYYMMDD: string): Promise new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS; + const row = getMatchProvider<{ v?: number }>(f.num, 'fotmob'); + const settled = row && row.data.v === 2 && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS; if (settled) continue; } const t0 = Date.now();