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 () => {