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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user