Match-rich API: oriented shots/xG, momentum, POTM, officials, weather

GET /api/match-rich/:num projects the captured provider data for the
match center: every shot with pitch coords and xG oriented to
home/away (FotMob payloads now carry team ids, v2 + settle-versioned
refetch), team xG sums, the momentum curve, player of the match with
rating, FIFA's official attendance/stadium/referee, and kickoff-hour
weather. Verified on real data: South Korea 2.31-0.83 Czechia on 22
oriented shots, POTM Hwang 8.9, referee Amin Mohamed Omar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:30:34 +02:00
parent 94db4274a0
commit b2f680906b
3 changed files with 68 additions and 6 deletions
+54 -1
View File
@@ -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<FmDetails>(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 () => {