The why behind events: narrative context from the match report

'X is shown the red card' explains nothing. ESPN's summary carries the
full post-match report (Reuters-style narrative) that we fetched and
discarded — it has exactly the missing why ('red-carded for bringing
down Gutierrez just outside the box').

- normalizer keeps the report (headline + plain-text story, schema v4;
  boot backfill re-stores played matches automatically)
- expanding an event surfaces the report sentences about that player,
  scored by event vocabulary so a player's sending-off sentence beats
  his goal mention; attribution line marks the source
- the raw no-reason fallback line is suppressed once the report
  explains a booking; xG/momentum/VAR layers stay underneath

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:16:59 +02:00
parent 089d789e49
commit 2d52995967
9 changed files with 97 additions and 12 deletions
+13 -2
View File
@@ -75,7 +75,7 @@ export interface EspnTimelineEvent {
shootout?: boolean;
}
/** Current normalizer schema — bump to make the boot backfill re-store. */
export const SUMMARY_V = 3;
export const SUMMARY_V = 4;
export interface EspnCommentaryLine { minute: number | null; text: string }
@@ -95,11 +95,15 @@ export interface EspnSummary {
events: EspnTimelineEvent[];
/** Full play-by-play feed (attempts, fouls, corners, VAR…), kickoff first. */
commentary?: EspnCommentaryLine[];
/** Post-match report article (the narrative — why cards were shown, how
* goals came about). Plain text, appears around full time. */
report?: { headline: string; story: string } | null;
}
export interface RawSummary {
gameInfo?: { venue?: { fullName?: string } };
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
article?: { headline?: string; story?: string };
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
boxscore?: {
@@ -182,5 +186,12 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
})
.filter((c) => c.text);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary };
const story = (raw.article?.story ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8000);
const report = story ? { headline: raw.article?.headline ?? '', story } : null;
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report };
}