Golden Boot race from structured ESPN scoring plays

ESPN's keyEvents carry participants[].athlete for goals — no text
parsing needed. The normalizer now extracts scorer/scoring/shootout
(payload versioned, with a boot backfill that re-stores summaries
saved by the old normalizer), /api/goldenboot aggregates per-player
goals (own goals and shootout kicks excluded), and the Teams page
leads with the top-10 race. Verified against tonight's real goals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:18:19 +02:00
parent 06e4835ad4
commit ce83d4dbc4
6 changed files with 126 additions and 6 deletions
+26 -1
View File
@@ -10,8 +10,9 @@ 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 } from './db/db';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import type { EspnSummary } from './ingest/espnNormalize';
import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
@@ -86,6 +87,30 @@ export function buildServer() {
const num = Number((req.params as { num: string }).num);
return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
});
// 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 () => {
const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
for (const f of state.allFixtures()) {
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (!ext) continue;
const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
for (const e of ext.data.events) {
if (!e.scoring || e.shootout || !e.scorer) continue;
if (/own goal/i.test(e.type)) continue;
const team = (e.teamId && teamName.get(e.teamId)) ?? '';
const key = `${e.scorer}|${team}`;
const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff };
cur.goals += 1;
if (f.kickoff > cur.latest) cur.latest = f.kickoff;
tally.set(key, cur);
}
}
const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25);
return { players };
});
app.get('/api/scoreboard', async () => buildScoreboard(state));
// The data lake, quantified — counters for the /data page.
app.get('/api/data/stats', async () => {
+26 -3
View File
@@ -64,8 +64,19 @@ export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[]
export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number }
export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' }
export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null }
export interface EspnTimelineEvent {
minute: number | null;
type: string;
text: string;
teamId: string | null;
/** Scorer name for scoring plays (structured, from participants). */
scorer?: string;
scoring?: boolean;
shootout?: boolean;
}
export interface EspnSummary {
/** Normalizer schema version — bump to trigger the boot backfill. */
v?: number;
fetchedAt: number;
venue: string | null;
teams: EspnTeamRef[];
@@ -88,7 +99,15 @@ export interface RawSummary {
teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[];
};
rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[];
keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[];
keyEvents?: {
clock?: { displayValue?: string };
type?: { text?: string };
text?: string;
team?: { id?: string };
scoringPlay?: boolean;
shootout?: boolean;
participants?: { athlete?: { displayName?: string } }[];
}[];
}
export function normalizeSummary(raw: RawSummary): EspnSummary {
@@ -134,13 +153,17 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => {
const m = e.clock?.displayValue?.match(/(\d+)/);
const scorer = e.scoringPlay ? e.participants?.[0]?.athlete?.displayName : undefined;
return {
minute: m ? Number(m[1]) : null,
type: e.type?.text ?? '',
text: e.text ?? '',
teamId: e.team?.id ?? null,
...(scorer ? { scorer } : {}),
...(e.scoringPlay ? { scoring: true } : {}),
...(e.shootout ? { shootout: true } : {}),
};
});
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
return { v: 2, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
}
+20 -1
View File
@@ -4,7 +4,8 @@ import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
import { fetchEspnOdds } from './espnOdds';
import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, setMatchExt, recordOdds, logIngest, prune, backupDb } from '../db/db';
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, recordOdds, logIngest, prune, backupDb } from '../db/db';
import type { EspnSummary } from './espnNormalize';
import type { Fixture } from '../../../src/lib/types';
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
@@ -104,6 +105,23 @@ export function startScheduler(
}
};
// One-time backfill: re-store summaries saved by an older normalizer (e.g.
// before structured scorers existed), so started matches stay fully usable.
const backfillExt = async (): Promise<void> => {
for (const f of state.allFixtures()) {
if (stopped) return;
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (ext && ext.data.v === 2) continue;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try {
setMatchExt(f.num, await fetchEspnSummary(eid, true)); // live TTL → skips the stale cache
console.log(`[ingest] backfilled summary for M${f.num}`);
} catch { /* health-tracked */ }
}
};
// ---- enrichment: ESPN summary for imminent/live fixtures ----
const enrichTick = async (): Promise<void> => {
const now = Date.now();
@@ -189,6 +207,7 @@ export function startScheduler(
await mapAllFixtures().catch(() => {});
await oddsTick().catch(() => {});
await liveTick().catch(() => {});
await backfillExt().catch(() => {});
await enrichTick().catch(() => {});
scheduleLive();
scheduleEnrich();