9e1f6e5cee
- the ESPN feed description and attribution line are gone from goal
panels whenever the report narrative or shot data tells the story
(attribution lives in a tooltip); bookings keep their reason line
- one report sentence per event, ranked by event vocabulary with a
bonus when the player is the actor ('Quinones, who drove a low
shot…' beats 'goals from Quinones and Jimenez gave…')
- momentum verdicts only ever applied to goals (a clear-pressure
booking no longer claims it was 'scored against the run of play')
Routine events the report never mentions (e.g. an early booking) show
what the sources have: the reason. There is no richer feed for those.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import type { MatchLiveEvent, RichShot } from '@/lib/types';
|
||
|
||
/**
|
||
* Turns ESPN's flat commentary events into structured timeline items.
|
||
* ESPN text is English prose with a stable shape per event type, e.g.
|
||
* "Goal! Korea Republic 1, Czechia 1. Hwang In-Beom (Korea Republic) right
|
||
* footed shot … Assisted by Lee Jae-Sung."
|
||
* "Substitution, Czechia. Adam Hlozek replaces Pavel Sulc."
|
||
* "Lee Gi-Hyuk (Korea Republic) is shown the yellow card for a bad foul."
|
||
* Anything we can't parse keeps its raw text — never an empty card.
|
||
*/
|
||
|
||
export type EventKind = 'goal' | 'yellow' | 'red' | 'sub' | 'other';
|
||
|
||
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
|
||
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
|
||
export const norm = (s: string): string =>
|
||
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
|
||
export function nameMatch(a: string, b: string): boolean {
|
||
const na = norm(a);
|
||
const nb = norm(b);
|
||
if (!na || !nb) return false;
|
||
if (na === nb) return true;
|
||
const ta = na.split(' ');
|
||
const tb = nb.split(' ');
|
||
const sa = new Set(ta);
|
||
const sb = new Set(tb);
|
||
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
|
||
}
|
||
|
||
|
||
export type EventDetail =
|
||
| { type: 'assist'; name: string }
|
||
| { type: 'penalty' }
|
||
| { type: 'ownGoal' }
|
||
| { type: 'raw'; text: string };
|
||
|
||
export interface ParsedEvent {
|
||
kind: EventKind;
|
||
side: 'home' | 'away' | null;
|
||
minute: number | null;
|
||
/** Scorer / booked player / incoming sub — or the raw text when unparseable. */
|
||
title: string;
|
||
detail: EventDetail | null;
|
||
/** Running score after this event; goals only. */
|
||
score: [number, number] | null;
|
||
/** The full source commentary line — feeds the tap-to-expand context. */
|
||
raw: string;
|
||
/** Outgoing player (substitutions). */
|
||
subOut?: string;
|
||
}
|
||
|
||
/** Whole-match markers the timeline replaces with its own styled dividers. */
|
||
const MARKER_TYPES = new Set([
|
||
'kickoff', 'halftime', 'start 2nd half', 'end regular time', 'end of game',
|
||
'full time', 'match end', 'start 1st half',
|
||
]);
|
||
|
||
export function eventKind(type: string): EventKind {
|
||
const t = type.toLowerCase();
|
||
if (t.includes('goal') || t.includes('penalty - scored')) return 'goal';
|
||
if (t.includes('yellow')) return 'yellow';
|
||
if (t.includes('red')) return 'red';
|
||
if (t.includes('substitution')) return 'sub';
|
||
return 'other';
|
||
}
|
||
|
||
/** "… Korea Republic 1, Czechia 1." → [1, 1] (the score embedded in goal text). */
|
||
function parseScore(text: string): [number, number] | null {
|
||
const m = /(\d+)\s*,[^.,\d]*?(\d+)\s*\./.exec(text);
|
||
if (!m) return null;
|
||
return [Number(m[1]), Number(m[2])];
|
||
}
|
||
|
||
function parseGoal(e: MatchLiveEvent, fallbackScore: [number, number] | null): Pick<ParsedEvent, 'title' | 'detail' | 'score'> {
|
||
const text = e.text;
|
||
const score = parseScore(text) ?? fallbackScore;
|
||
const own = /Own Goal by ([^,.]+)/.exec(text);
|
||
if (own?.[1]) return { title: own[1].trim(), detail: { type: 'ownGoal' }, score };
|
||
// Scorer = first "Name (Team)" after the score sentence.
|
||
const scorer = /\.\s*([^.()]+?)\s*\(/.exec(text);
|
||
const title = scorer?.[1]?.trim() || text || e.type;
|
||
let detail: EventDetail | null = null;
|
||
const assist = /Assisted by ([^.]+?)(?:\s+(?:with|following|after)\b[^.]*)?\./.exec(text);
|
||
if (assist?.[1]) detail = { type: 'assist', name: assist[1].trim() };
|
||
else if (/penalty/i.test(text) || /penalty/i.test(e.type)) detail = { type: 'penalty' };
|
||
return { title, detail, score };
|
||
}
|
||
|
||
function parseBooking(e: MatchLiveEvent): Pick<ParsedEvent, 'title' | 'detail'> {
|
||
const m = /^([^(]+?)\s*\(/.exec(e.text);
|
||
return { title: m?.[1]?.trim() || e.text || e.type, detail: null };
|
||
}
|
||
|
||
function parseSub(e: MatchLiveEvent): Pick<ParsedEvent, 'title' | 'detail' | 'subOut'> {
|
||
const m = /\.\s*(.+?)\s+replaces\s+(.+?)\.?\s*$/.exec(e.text);
|
||
if (m?.[1] && m[2]) return { title: m[1].trim(), detail: null, subOut: m[2].trim() };
|
||
return { title: e.text || e.type, detail: null };
|
||
}
|
||
|
||
/**
|
||
* Parse, de-noise and order events for display.
|
||
* Drops whole-match markers (the component draws its own dividers) and the
|
||
* empty-text duplicate rows ESPN emits for both teams on neutral events.
|
||
*/
|
||
export function parseEvents(events: MatchLiveEvent[]): ParsedEvent[] {
|
||
const out: ParsedEvent[] = [];
|
||
let running: [number, number] = [0, 0];
|
||
for (const e of events) {
|
||
const kind = eventKind(e.type);
|
||
const type = e.type.toLowerCase();
|
||
if (MARKER_TYPES.has(type)) continue;
|
||
if (kind === 'other' && !e.text) continue; // duplicate/no-info rows
|
||
let parsed: ParsedEvent;
|
||
if (kind === 'goal') {
|
||
// Fallback when the text carries no score: count by attributed side.
|
||
const counted: [number, number] = e.team === 'away' ? [running[0], running[1] + 1] : [running[0] + 1, running[1]];
|
||
const g = parseGoal(e, e.team ? counted : null);
|
||
if (g.score) running = g.score;
|
||
parsed = { kind, side: e.team, minute: e.minute, raw: e.text, ...g };
|
||
} else if (kind === 'yellow' || kind === 'red') {
|
||
parsed = { kind, side: e.team, minute: e.minute, score: null, raw: e.text, ...parseBooking(e) };
|
||
} else if (kind === 'sub') {
|
||
parsed = { kind, side: e.team, minute: e.minute, score: null, raw: e.text, ...parseSub(e) };
|
||
} else {
|
||
// Neutral happenings (delays, VAR…) render centered regardless of team.
|
||
parsed = { kind, side: null, minute: e.minute, title: e.text || e.type, detail: null, score: null, raw: e.text };
|
||
}
|
||
out.push(parsed);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Goals only — feeds the Summary tab's key-moments card. */
|
||
export function goalEvents(events: MatchLiveEvent[]): ParsedEvent[] {
|
||
return parseEvents(events).filter((e) => e.kind === 'goal');
|
||
}
|
||
|
||
/**
|
||
* The "what actually happened" prose behind a timeline item, for tap-to-expand:
|
||
* for goals the shot description with the redundant score sentence stripped
|
||
* ("right footed shot from the centre of the box…"), for bookings the reason
|
||
* ("For a bad foul."). Null when the source adds nothing beyond the title.
|
||
*/
|
||
export function eventContext(e: ParsedEvent): string | null {
|
||
const raw = e.raw.trim();
|
||
if (!raw) return null;
|
||
let ctx: string | null = null;
|
||
if (e.kind === 'goal') {
|
||
// Drop the leading "Goal! Home 1, Away 0." — the card shows the score chip.
|
||
const m = /^(?:Goal!|Own Goal by[^.]*\.)[^.]*?\d+\s*[,.][^.]*?\.\s*(.+)$/.exec(raw);
|
||
ctx = m?.[1]?.trim() ?? raw;
|
||
// The card already names the scorer — drop the "Name (Team) " prefix.
|
||
const p = /^[^.()]{2,45}\(([^)]+)\)\s+(.{10,})$/.exec(ctx);
|
||
if (p?.[2]) ctx = p[2].charAt(0).toUpperCase() + p[2].slice(1);
|
||
} else if (e.kind === 'yellow' || e.kind === 'red') {
|
||
const m = /shown the (?:yellow|red|second yellow) card\s+(.+?)\.?\s*$/.exec(raw);
|
||
const reason = m?.[1]?.trim();
|
||
ctx = reason ? reason.charAt(0).toUpperCase() + reason.slice(1) + '.' : raw;
|
||
} else {
|
||
return null; // subs and neutral rows already show everything they have
|
||
}
|
||
// No point expanding into the exact text the row already shows.
|
||
if (!ctx || ctx === e.title || ctx.length < 4) return null;
|
||
return ctx;
|
||
}
|
||
|
||
export interface CommentaryLine { minute: number | null; text: string }
|
||
|
||
/** Data behind a goal, matched from the captured shot list. */
|
||
export interface GoalShotInsight {
|
||
xg: number | null;
|
||
xgot: number | null;
|
||
situation: string;
|
||
/** Distance to the goal center in meters (FotMob pitch coords). */
|
||
distM: number | null;
|
||
}
|
||
|
||
/**
|
||
* Find the shot that IS this goal: scorer name match within ±2 minutes,
|
||
* else the only goal-shot for that side within ±1 minute (the sources
|
||
* romanize names differently, so the name can miss).
|
||
*/
|
||
export function goalShot(e: ParsedEvent, shots: RichShot[]): GoalShotInsight | null {
|
||
if (e.kind !== 'goal' || e.minute == null) return null;
|
||
const goals = shots.filter((s) => s.type === 'Goal' && s.min != null);
|
||
let m = goals.find((s) => Math.abs(s.min! - e.minute!) <= 2 && nameMatch(s.player, e.title));
|
||
if (!m) {
|
||
const near = goals.filter((s) => Math.abs(s.min! - e.minute!) <= 1 && (e.side === null || s.isHome === (e.side === 'home')));
|
||
if (near.length === 1) m = near[0];
|
||
}
|
||
if (!m) return null;
|
||
const distM = m.x != null && m.y != null ? Math.round(Math.hypot(105 - m.x, 34 - m.y)) : null;
|
||
return { xg: m.xg, xgot: m.xgot, situation: m.situation, distM };
|
||
}
|
||
|
||
/** Was the goal scored with or against the momentum of the prior 5 minutes?
|
||
* Null when the pressure was mixed or there isn't enough curve. */
|
||
export function momentumVerdict(
|
||
e: ParsedEvent,
|
||
momentum: { minute: number; value: number }[],
|
||
): 'with' | 'against' | null {
|
||
if (e.minute == null || e.side == null) return null;
|
||
const window = momentum.filter((p) => p.minute >= e.minute! - 5 && p.minute < e.minute!);
|
||
if (window.length < 3) return null;
|
||
const avg = window.reduce((s, p) => s + p.value, 0) / window.length;
|
||
if (Math.abs(avg) < 25) return null;
|
||
return (e.side === 'home') === avg > 0 ? 'with' : 'against';
|
||
}
|
||
|
||
|
||
const KIND_WORDS: Record<string, RegExp> = {
|
||
goal: /\b(goal|scor|header|finish|net|drove|struck|slott|tapp|convert|fired|curl|poked|volley|made it|doubled|equali[sz]|lead|winner)\w*/i,
|
||
red: /\b(red[- ]card|sent[- ]off|dismiss|marching orders|red card)\w*/i,
|
||
yellow: /\b(book|yellow|caution)\w*/i,
|
||
};
|
||
|
||
/**
|
||
* The match report's sentences about THIS event — the narrative "why" the
|
||
* feed lacks ("red-carded for bringing down Gutierrez just outside the
|
||
* box"). Sentences must mention the player; those matching the event kind's
|
||
* vocabulary win, so a player's card sentence beats his goal sentence.
|
||
*/
|
||
export function reportSentences(e: ParsedEvent, story: string, max = 1): string[] {
|
||
if (!story || e.kind === 'sub' || e.kind === 'other') return [];
|
||
const last = norm(e.title).split(' ').pop() ?? '';
|
||
if (last.length < 3) return [];
|
||
const kindRe = KIND_WORDS[e.kind === 'goal' ? 'goal' : e.kind === 'red' ? 'red' : 'yellow'];
|
||
const scored = story
|
||
.split(/(?<=[.!?])\s+/)
|
||
.filter((s) => s.length > 20 && s.length < 400 && new RegExp(`\\b${last}\\b`, 'i').test(norm(s)))
|
||
.map((s) => {
|
||
let hits = kindRe ? (s.match(new RegExp(kindRe.source, 'gi')) ?? []).length : 0;
|
||
// The player ACTING ("Quinones, who drove a low shot") outranks the
|
||
// player merely listed ("goals from Quinones and Jimenez gave…").
|
||
if (kindRe && new RegExp(`\\b${last}\\b[^.]{0,60}?${kindRe.source}`, 'i').test(norm(s))) hits += 2;
|
||
return { s: s.trim(), hits };
|
||
});
|
||
if (!scored.length) return [];
|
||
scored.sort((a, b) => b.hits - a.hits);
|
||
return scored.slice(0, max).map((x) => x.s);
|
||
}
|
||
|
||
/** VAR / video-review feed lines belonging to this event (±1 minute) —
|
||
* the one slice of the play-by-play that genuinely explains a decision. */
|
||
export function varLines(e: ParsedEvent, commentary: CommentaryLine[]): string[] {
|
||
if (e.minute == null) return [];
|
||
return commentary
|
||
.filter((c) => c.minute != null && Math.abs(c.minute - e.minute!) <= 1 && /\b(VAR|video review|video assistant)\b/i.test(c.text))
|
||
.map((c) => c.text);
|
||
}
|
||
|
||
/** Score at half-time: last goal score at minute ≤ 45 (0–0 when none). */
|
||
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
|
||
let ht: [number, number] = [0, 0];
|
||
for (const e of parsed) {
|
||
if (e.kind === 'goal' && e.score && e.minute != null && e.minute <= 45) ht = e.score;
|
||
}
|
||
return ht;
|
||
}
|