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:
+54
-1
@@ -10,7 +10,7 @@ import { startScheduler } from './ingest/scheduler';
|
|||||||
import { ModelEngine } from './model';
|
import { ModelEngine } from './model';
|
||||||
import { buildPreview, buildTeamProfile } from './preview';
|
import { buildPreview, buildTeamProfile } from './preview';
|
||||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
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 { inPlayProbs } from '../../src/lib/model/inplay';
|
||||||
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
|
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
|
||||||
import type { EspnSummary } from './ingest/espnNormalize';
|
import type { EspnSummary } from './ingest/espnNormalize';
|
||||||
@@ -118,6 +118,59 @@ export function buildServer() {
|
|||||||
return { ok: true };
|
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
|
// Golden Boot: per-player goals aggregated from structured ESPN scoring
|
||||||
// plays across every started match (own goals + shootout kicks excluded).
|
// plays across every started match (own goals + shootout kicks excluded).
|
||||||
app.get('/api/goldenboot', async () => {
|
app.get('/api/goldenboot', async () => {
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ export async function fetchFotmobMatches(dateYYYYMMDD: string): Promise<FotmobMa
|
|||||||
/** Trimmed matchDetails: everything analytic, minus the bulky redundant tabs
|
/** Trimmed matchDetails: everything analytic, minus the bulky redundant tabs
|
||||||
* (liveticker/buzz/h2h/table/seo) we already cover via other sources. */
|
* (liveticker/buzz/h2h/table/seo) we already cover via other sources. */
|
||||||
export interface FotmobDetails {
|
export interface FotmobDetails {
|
||||||
v: 1;
|
v: 2;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
matchId: string;
|
matchId: string;
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
|
homeTeamId: number | null;
|
||||||
|
awayTeamId: number | null;
|
||||||
shots: unknown[];
|
shots: unknown[];
|
||||||
stats: unknown;
|
stats: unknown;
|
||||||
matchFacts: unknown;
|
matchFacts: unknown;
|
||||||
@@ -68,7 +70,12 @@ export interface FotmobDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RawDetails {
|
interface RawDetails {
|
||||||
general?: { matchId?: string; finished?: boolean };
|
general?: {
|
||||||
|
matchId?: string;
|
||||||
|
finished?: boolean;
|
||||||
|
homeTeam?: { id?: number };
|
||||||
|
awayTeam?: { id?: number };
|
||||||
|
};
|
||||||
content?: {
|
content?: {
|
||||||
shotmap?: { shots?: unknown[] };
|
shotmap?: { shots?: unknown[] };
|
||||||
stats?: unknown;
|
stats?: unknown;
|
||||||
@@ -92,10 +99,12 @@ export async function fetchFotmobDetails(matchId: string, live = false): Promise
|
|||||||
delete mf.preReview;
|
delete mf.preReview;
|
||||||
delete mf.poll;
|
delete mf.poll;
|
||||||
return {
|
return {
|
||||||
v: 1,
|
v: 2,
|
||||||
fetchedAt: Date.now(),
|
fetchedAt: Date.now(),
|
||||||
matchId: raw.general?.matchId ?? matchId,
|
matchId: raw.general?.matchId ?? matchId,
|
||||||
finished: raw.general?.finished ?? false,
|
finished: raw.general?.finished ?? false,
|
||||||
|
homeTeamId: raw.general?.homeTeam?.id ?? null,
|
||||||
|
awayTeamId: raw.general?.awayTeam?.id ?? null,
|
||||||
shots: c.shotmap?.shots ?? [],
|
shots: c.shotmap?.shots ?? [],
|
||||||
stats: c.stats ?? null,
|
stats: c.stats ?? null,
|
||||||
matchFacts: mf,
|
matchFacts: mf,
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ export function startScheduler(
|
|||||||
const live = f.status === 'live';
|
const live = f.status === 'live';
|
||||||
if (!live && f.status !== 'finished') continue;
|
if (!live && f.status !== 'finished') continue;
|
||||||
if (f.status === 'finished') {
|
if (f.status === 'finished') {
|
||||||
const row = getMatchProvider(f.num, 'fotmob');
|
const row = getMatchProvider<{ v?: number }>(f.num, 'fotmob');
|
||||||
const settled = row && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS;
|
const settled = row && row.data.v === 2 && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS;
|
||||||
if (settled) continue;
|
if (settled) continue;
|
||||||
}
|
}
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user