Players become first-class: profile pages, unified names, deeper match data
- /player/:name pages assembled entirely from stored data: tournament totals, per-match log (rating, minutes, G/A, cards), aggregated shot map, suspension status, next-match scorer line. Either source's spelling resolves to the same page (token-set + romanization fold). - Player names are now links everywhere: stats boards, golden boot, discipline, injuries, all-time scorers, lineups, the pitch dots, timeline events and assists, and the search palette (diacritic-folded). - Golden Boot displays the FotMob form of each name — no more "Hwang In-Beom" and "In-Beom Hwang" on the same page. - Match pages: detailed full-time stats card (passes, duels, xGOT… from the stored FotMob report), O/U + spread closing line, "Who scores?" pre-match props board, and a FIFA-confirmed-lineups badge. - Stats hub: new boards — xA, chances created, save percentage, saves per 90. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -9,8 +9,11 @@ import { TournamentState } from './tournament';
|
||||
import { startScheduler } from './ingest/scheduler';
|
||||
import { ModelEngine } from './model';
|
||||
import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { buildPlayerProfile, playersIndex } from './player';
|
||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
|
||||
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
|
||||
import { confirmedXI } from './ingest/fifaLineupNormalize';
|
||||
import { buildIcs } from './ics';
|
||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
|
||||
import { inPlayProbs } from '../../src/lib/model/inplay';
|
||||
@@ -139,11 +142,13 @@ export function buildServer() {
|
||||
interface FmDetails {
|
||||
homeTeamId?: number | null;
|
||||
shots?: FmShot[];
|
||||
stats?: unknown;
|
||||
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
|
||||
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
|
||||
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
}
|
||||
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
|
||||
const fifa = getMatchProvider<{ live?: unknown }>(num, 'fifa');
|
||||
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
|
||||
const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather');
|
||||
|
||||
@@ -196,6 +201,7 @@ export function buildServer() {
|
||||
};
|
||||
const lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
|
||||
const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);
|
||||
const fullStats = normalizeMatchStats(fm?.data.stats);
|
||||
|
||||
return {
|
||||
shots,
|
||||
@@ -211,6 +217,10 @@ export function buildServer() {
|
||||
}
|
||||
: null,
|
||||
weather: weather ? weather.data : null,
|
||||
fullStats: fullStats.length ? fullStats : null,
|
||||
// Official XI published by FIFA, or FotMob no longer calling its
|
||||
// lineup "predicted" — either way, stop labeling them as projections.
|
||||
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -252,6 +262,15 @@ export function buildServer() {
|
||||
const profile = buildTeamProfile(name, state, model);
|
||||
return profile ?? reply.code(404).send({ error: 'no such team' });
|
||||
});
|
||||
// Player profiles, assembled from stored boards, lineups, shots and events.
|
||||
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
|
||||
app.get('/api/player/:name', async (req, reply) => {
|
||||
const name = decodeURIComponent((req.params as { name: string }).name);
|
||||
const profile = buildPlayerProfile(name, state);
|
||||
return profile ?? reply.code(404).send({ error: 'unknown player' });
|
||||
});
|
||||
// The search palette's player list.
|
||||
app.get('/api/players', async () => ({ players: playersIndex(state) }));
|
||||
// Calendar export: one team's schedule (?team=X) or the whole tournament.
|
||||
app.get('/api/calendar.ics', async (req, reply) => {
|
||||
const raw = (req.query as { team?: string }).team;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { confirmedXI } from './fifaLineupNormalize';
|
||||
|
||||
const squad = (starters: number, bench = 15, prefix = 'P') => ({
|
||||
Players: [
|
||||
...Array.from({ length: starters }, (_, i) => ({ Status: 1, ShortName: [{ Locale: 'en-GB', Description: `${prefix}${i + 1}` }] })),
|
||||
...Array.from({ length: bench }, (_, i) => ({ Status: 2, ShortName: [{ Locale: 'en-GB', Description: `${prefix}B${i + 1}` }] })),
|
||||
],
|
||||
});
|
||||
|
||||
describe('confirmedXI', () => {
|
||||
it('returns both XIs when each side has exactly 11 Status===1 starters', () => {
|
||||
const xi = confirmedXI({ HomeTeam: squad(11, 15, 'H'), AwayTeam: squad(11, 15, 'A') });
|
||||
expect(xi?.home).toHaveLength(11);
|
||||
expect(xi?.away).toHaveLength(11);
|
||||
expect(xi?.home[0]).toBe('H1');
|
||||
expect(xi?.away[10]).toBe('A11');
|
||||
});
|
||||
|
||||
it('rejects sides without exactly 11 starters', () => {
|
||||
expect(confirmedXI({ HomeTeam: squad(10), AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: squad(12), AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: squad(0, 26), AwayTeam: squad(11) })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects unnamed starters and shape surprises', () => {
|
||||
const broken = squad(11);
|
||||
broken.Players[3] = { Status: 1, ShortName: [] };
|
||||
expect(confirmedXI({ HomeTeam: broken, AwayTeam: squad(11) })).toBeNull();
|
||||
expect(confirmedXI(null)).toBeNull();
|
||||
expect(confirmedXI({})).toBeNull();
|
||||
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Pure normalizer for the FIFA live payload's lineups — no network or DB.
|
||||
// FIFA publishes the official XI ~75–90 min before kickoff; Status === 1
|
||||
// marks starters within the 26-man squad list.
|
||||
|
||||
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] }
|
||||
interface RawFifaTeam { Players?: RawFifaPlayer[] }
|
||||
/** The `live` value stored under match_provider provider='fifa'. */
|
||||
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
|
||||
|
||||
/** The officially confirmed XIs, or null unless BOTH sides have exactly 11
|
||||
* named starters — validation doubles as the feature flag, so a payload
|
||||
* drift silently keeps lineups marked "predicted". */
|
||||
export function confirmedXI(raw: unknown): { home: string[]; away: string[] } | null {
|
||||
const live = raw as RawFifaLive | null | undefined;
|
||||
const side = (t?: RawFifaTeam): string[] | null => {
|
||||
if (!t || !Array.isArray(t.Players)) return null;
|
||||
const names: string[] = [];
|
||||
for (const p of t.Players) {
|
||||
if (!p || p.Status !== 1) continue;
|
||||
const n = p.ShortName?.[0]?.Description;
|
||||
if (typeof n !== 'string' || !n.trim()) return null;
|
||||
names.push(n.trim());
|
||||
}
|
||||
return names.length === 11 ? names : null;
|
||||
};
|
||||
const home = side(live?.HomeTeam);
|
||||
const away = side(live?.AwayTeam);
|
||||
return home && away ? { home, away } : null;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeMatchStats } from './fotmobStatsNormalize';
|
||||
|
||||
// Trimmed from the real match-1 capture (Mexico 2-0 South Africa).
|
||||
const sample = {
|
||||
Periods: {
|
||||
All: {
|
||||
stats: [
|
||||
{
|
||||
title: 'Top stats',
|
||||
key: 'top_stats',
|
||||
stats: [
|
||||
{ title: 'Ball possession', key: 'BallPossesion', stats: [60, 40], format: 'integer', type: 'graph' },
|
||||
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: ['1.46', '0.07'], format: 'double', type: 'text' },
|
||||
{ title: 'Big chances', key: 'big_chance', stats: [2, 0], format: 'integer', type: 'text' },
|
||||
{ title: 'Touches in opposition box', key: 'touches_opp_box', stats: [20, 2], format: null, type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Expected goals (xG)',
|
||||
key: 'expected_goals',
|
||||
stats: [
|
||||
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: [null, null], format: null, type: 'title' },
|
||||
{ title: 'Expected goals (xG)', key: 'expected_goals', stats: ['9.99', '9.99'], format: 'double', type: 'text' },
|
||||
{ title: 'xG on target (xGOT)', key: 'expected_goals_on_target', stats: ['1.28', '0.13'], format: 'double', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Passes',
|
||||
key: 'passes',
|
||||
stats: [
|
||||
{ title: 'Accurate passes', key: 'accurate_passes', stats: ['467 (90%)', '272 (81%)'], format: 'integerWithPercentage', type: 'text' },
|
||||
{ title: 'Accurate long balls', key: 'long_balls_accurate', stats: ['31 (65%)', '17 (37%)'], format: 'integerWithPercentage', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Duels',
|
||||
key: 'duels',
|
||||
stats: [
|
||||
{ title: 'Duels won', key: 'duel_won', stats: [47, 33], format: 'integer', type: 'text' },
|
||||
{ title: 'Aerial duels won', key: 'aerials_won', stats: ['15 (68%)', '7 (32%)'], format: 'integerWithPercentage', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('normalizeMatchStats', () => {
|
||||
it('extracts whitelisted rows in display order, first occurrence wins', () => {
|
||||
const rows = normalizeMatchStats(sample);
|
||||
expect(rows.map((r) => r.key)).toEqual([
|
||||
'xg', 'xgot', 'bigChances', 'accuratePasses', 'longBalls', 'touchesInBox', 'duelsWon', 'aerialDuels',
|
||||
]);
|
||||
// 1.46 from "Top stats" beats the 9.99 repeat in the xG group.
|
||||
expect(rows[0]).toEqual({ key: 'xg', home: '1.46', away: '0.07' });
|
||||
// provider formatting passes through verbatim
|
||||
expect(rows[3]).toEqual({ key: 'accuratePasses', home: '467 (90%)', away: '272 (81%)' });
|
||||
});
|
||||
|
||||
it('drops non-whitelisted keys and title separators', () => {
|
||||
const rows = normalizeMatchStats(sample);
|
||||
expect(rows.find((r) => r.key === 'BallPossesion')).toBeUndefined();
|
||||
expect(rows.every((r) => r.home !== '' && r.away !== '')).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades to [] on null, garbage and pre-match payloads', () => {
|
||||
expect(normalizeMatchStats(null)).toEqual([]);
|
||||
expect(normalizeMatchStats(undefined)).toEqual([]);
|
||||
expect(normalizeMatchStats('nope')).toEqual([]);
|
||||
expect(normalizeMatchStats({ Periods: { All: { stats: 'nope' } } })).toEqual([]);
|
||||
expect(normalizeMatchStats({ Periods: {} })).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips rows with missing or empty values', () => {
|
||||
const rows = normalizeMatchStats({
|
||||
Periods: { All: { stats: [{ stats: [
|
||||
{ key: 'fouls', stats: [12, null], type: 'text' },
|
||||
{ key: 'corners', stats: ['', 3], type: 'text' },
|
||||
{ key: 'interceptions', stats: [8, 7], type: 'text' },
|
||||
] }] } },
|
||||
});
|
||||
expect(rows).toEqual([{ key: 'interceptions', home: '8', away: '7' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MatchFullStat } from '../../../src/lib/types';
|
||||
|
||||
// Pure normalizer for the FotMob matchDetails `stats` payload — no network or
|
||||
// DB, unit-testable in isolation (same pattern as statsNormalize).
|
||||
|
||||
/** Curated full-time stats, in display order: FotMob key → our i18n key.
|
||||
* Unknown keys are dropped so the client's label dict stays exhaustive. */
|
||||
const FULL_STAT_KEYS: [string, string][] = [
|
||||
['expected_goals', 'xg'],
|
||||
['expected_goals_on_target', 'xgot'],
|
||||
['big_chance', 'bigChances'],
|
||||
['accurate_passes', 'accuratePasses'],
|
||||
['long_balls_accurate', 'longBalls'],
|
||||
['accurate_crosses', 'crosses'],
|
||||
['touches_opp_box', 'touchesInBox'],
|
||||
['corners', 'corners'],
|
||||
['Offsides', 'offsides'],
|
||||
['fouls', 'fouls'],
|
||||
['matchstats.headers.tackles', 'tackles'],
|
||||
['interceptions', 'interceptions'],
|
||||
['shot_blocks', 'blocks'],
|
||||
['clearances', 'clearances'],
|
||||
['keeper_saves', 'saves'],
|
||||
['duel_won', 'duelsWon'],
|
||||
['ground_duels_won', 'groundDuels'],
|
||||
['aerials_won', 'aerialDuels'],
|
||||
['dribbles_succeeded', 'dribbles'],
|
||||
];
|
||||
|
||||
interface RawStatEntry { key?: string; stats?: unknown[]; type?: string }
|
||||
interface RawStatGroup { stats?: RawStatEntry[] }
|
||||
/** matchDetails `stats`: Periods.{All,FirstHalf,SecondHalf}.stats = groups. */
|
||||
export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } } }
|
||||
|
||||
const usable = (v: unknown): v is string | number =>
|
||||
(typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '');
|
||||
|
||||
/** Full-match stat rows from a raw FotMob `stats` payload. Any shape
|
||||
* surprise degrades to fewer rows (or none) — never a throw. */
|
||||
export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
|
||||
const groups = (raw as RawMatchStats | null | undefined)?.Periods?.All?.stats;
|
||||
if (!Array.isArray(groups)) return [];
|
||||
// Categories repeat across groups ("Top stats" previews the rest) — first
|
||||
// occurrence wins, output follows the whitelist order.
|
||||
const seen = new Map<string, { home: string; away: string }>();
|
||||
for (const g of groups) {
|
||||
if (!g || !Array.isArray(g.stats)) continue;
|
||||
for (const s of g.stats) {
|
||||
if (!s || typeof s.key !== 'string' || s.type === 'title' || !Array.isArray(s.stats)) continue;
|
||||
const [h, a] = s.stats;
|
||||
if (!usable(h) || !usable(a) || seen.has(s.key)) continue;
|
||||
seen.set(s.key, { home: String(h), away: String(a) });
|
||||
}
|
||||
}
|
||||
const out: MatchFullStat[] = [];
|
||||
for (const [fmKey, key] of FULL_STAT_KEYS) {
|
||||
const v = seen.get(fmKey);
|
||||
if (v) out.push({ key, ...v });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { whoScores } from './propsNormalize';
|
||||
|
||||
const row = (athlete: string, odds: number | null, market = 'Anytime Goalscorer', captured_at = 1) =>
|
||||
({ athlete, market, odds, captured_at });
|
||||
|
||||
describe('whoScores', () => {
|
||||
it('keeps only anytime lines with odds, favorites first', () => {
|
||||
const out = whoScores([
|
||||
row('Longshot', 400),
|
||||
row('Favorite', -150),
|
||||
row('Mid', 120),
|
||||
row('No Line', null),
|
||||
row('First Only', -300, 'First Goalscorer'),
|
||||
row('Two Plus', 200, 'To Score 2+ Goals'),
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ player: 'Favorite', odds: -150 },
|
||||
{ player: 'Mid', odds: 120 },
|
||||
{ player: 'Longshot', odds: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the latest capture per athlete wins', () => {
|
||||
const out = whoScores([
|
||||
row('Mover', 150, 'Anytime Goalscorer', 1),
|
||||
row('Mover', 110, 'Anytime Goalscorer', 2),
|
||||
]);
|
||||
expect(out).toEqual([{ player: 'Mover', odds: 110 }]);
|
||||
});
|
||||
|
||||
it('caps the board', () => {
|
||||
const rows = Array.from({ length: 20 }, (_, i) => row(`P${i}`, 100 + i));
|
||||
expect(whoScores(rows)).toHaveLength(8);
|
||||
expect(whoScores(rows)[0]?.player).toBe('P0');
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(whoScores([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Pure scorer-props folds — no network or DB (same pattern as the other
|
||||
// *Normalize modules). Odds are a display-only benchmark, never a model input.
|
||||
|
||||
export interface PropRow {
|
||||
athlete: string;
|
||||
market: string;
|
||||
odds: number | null;
|
||||
captured_at: number;
|
||||
}
|
||||
|
||||
const implied = (american: number): number =>
|
||||
american < 0 ? -american / (-american + 100) : 100 / (american + 100);
|
||||
|
||||
/** The bookmaker's anytime-scorer board for one fixture: latest capture per
|
||||
* athlete, favorites first. */
|
||||
export function whoScores(props: PropRow[], max = 8): { player: string; odds: number }[] {
|
||||
const latest = new Map<string, { odds: number; at: number }>();
|
||||
for (const p of props) {
|
||||
if (p.odds == null || !/anytime/i.test(p.market)) continue;
|
||||
const cur = latest.get(p.athlete);
|
||||
if (!cur || p.captured_at >= cur.at) latest.set(p.athlete, { odds: p.odds, at: p.captured_at });
|
||||
}
|
||||
return [...latest.entries()]
|
||||
.map(([player, v]) => ({ player, odds: v.odds }))
|
||||
.sort((a, b) => implied(b.odds) - implied(a.odds) || a.player.localeCompare(b.player))
|
||||
.slice(0, max);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard } from './statsNormalize';
|
||||
import { PLAYER_BOARDS, TEAM_BOARDS, boardName, boardSlug, normalizeStatBoard } from './statsNormalize';
|
||||
import { nameMatch } from '../../../src/lib/matchEvents';
|
||||
|
||||
// Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
|
||||
const realBoard = {
|
||||
@@ -47,7 +48,36 @@ describe('board slugs', () => {
|
||||
it('maps the CDN url to our category keys', () => {
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/goals.json')]).toBe('goals');
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/expected_goals.json')]).toBe('xg');
|
||||
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_save_percentage.json')]).toBe('savePct');
|
||||
expect(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
|
||||
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boardName', () => {
|
||||
const boards = {
|
||||
goals: [
|
||||
{ name: 'In-Beom Hwang', team: 'South Korea', value: 1, matches: 1, minutes: 90 },
|
||||
{ name: 'Cyle Larin', team: 'Canada', value: 1, matches: 1, minutes: 80 },
|
||||
],
|
||||
rating: [{ name: 'Thiago Silva', team: 'Brazil', value: 8.1, matches: 1, minutes: 90 }],
|
||||
};
|
||||
|
||||
it('prefers the board form of the name when a same-team row matches', () => {
|
||||
expect(boardName('Hwang In-Beom', 'South Korea', boards, nameMatch)).toBe('In-Beom Hwang');
|
||||
});
|
||||
|
||||
it('keeps the ESPN form when nothing matches', () => {
|
||||
expect(boardName('Oh Hyeon-Gyu', 'South Korea', boards, nameMatch)).toBe('Oh Hyeon-Gyu');
|
||||
expect(boardName('Cyle Larin', 'Canada', undefined, nameMatch)).toBe('Cyle Larin');
|
||||
});
|
||||
|
||||
it('never merges across teams', () => {
|
||||
// a hypothetical second Thiago Silva playing elsewhere stays untouched
|
||||
expect(boardName('Thiago Silva', 'Portugal', boards, nameMatch)).toBe('Thiago Silva');
|
||||
});
|
||||
|
||||
it('falls back through rating/minutes boards for non-scorers', () => {
|
||||
expect(boardName('Silva Thiago', 'Brazil', boards, nameMatch)).toBe('Thiago Silva');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export const PLAYER_BOARDS: Record<string, string> = {
|
||||
'rating.json': 'rating',
|
||||
'expected_goals.json': 'xg',
|
||||
'expected_goals_per_90.json': 'xgPer90',
|
||||
'expected_assists.json': 'xa',
|
||||
'total_att_assist.json': 'chancesCreated',
|
||||
'_save_percentage.json': 'savePct',
|
||||
'saves.json': 'savesPer90',
|
||||
'mins_played.json': 'minutes',
|
||||
};
|
||||
|
||||
@@ -67,3 +71,20 @@ export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 3
|
||||
export function boardSlug(url: string | undefined): string {
|
||||
return url?.split('/').pop() ?? '';
|
||||
}
|
||||
|
||||
/** The FotMob display form of a player name, when a board row for the same
|
||||
* team matches. ESPN spells the same player differently ("Hwang In-Beom" vs
|
||||
* "In-Beom Hwang"), and the boards are what the rest of the page shows. */
|
||||
export function boardName(
|
||||
player: string,
|
||||
team: string,
|
||||
boards: Record<string, StatRow[]> | undefined,
|
||||
match: (a: string, b: string) => boolean,
|
||||
): string {
|
||||
if (!boards) return player;
|
||||
for (const cat of ['goals', 'rating', 'minutes']) {
|
||||
const hit = boards[cat]?.find((r) => r.team === team && match(r.name, player));
|
||||
if (hit) return hit.name;
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getMatchProvider } from './db/db';
|
||||
import { getTournamentStats } from './ingest/tournamentStats';
|
||||
import { disciplineFor, fixtureEvents, goldenBootRows, nextAnytimeOdds } from './stats';
|
||||
import {
|
||||
aliasKey, buildPlayerIndex, findBoardRow, playerMatchLine, resolvePlayer,
|
||||
type LineupPerfPlayer, type PlayerFixtureData, type PlayerRef,
|
||||
} from '../../src/lib/playerProfile';
|
||||
import { nameMatch } from '../../src/lib/matchEvents';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { Fixture, MatchLiveEvent, PlayerMatchLine, PlayerProfile, RichShot } from '../../src/lib/types';
|
||||
|
||||
// Assembles /api/player + /api/players entirely from stored data: tournament
|
||||
// boards, the per-fixture FotMob lineups/shots and our ESPN event feeds.
|
||||
// Pattern: thin orchestration here, the fold logic lives in src/lib/playerProfile.
|
||||
|
||||
interface FmPerfPlayer {
|
||||
name?: string;
|
||||
performance?: { rating?: number; substitutionEvents?: { time?: number; type?: string }[] };
|
||||
}
|
||||
interface FmLineupTeam { starters?: FmPerfPlayer[]; subs?: FmPerfPlayer[] }
|
||||
interface FmDetails {
|
||||
homeTeamId?: number | null;
|
||||
awayTeamId?: number | null;
|
||||
shots?: {
|
||||
teamId?: number; playerName?: string; x?: number; y?: number; min?: number;
|
||||
expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string;
|
||||
}[];
|
||||
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
}
|
||||
|
||||
const subTime = (p: FmPerfPlayer, type: string): number | null => {
|
||||
const e = p.performance?.substitutionEvents?.find((s) => s.type === type);
|
||||
return typeof e?.time === 'number' ? e.time : null;
|
||||
};
|
||||
|
||||
const perf = (p: FmPerfPlayer): LineupPerfPlayer => ({
|
||||
name: p.name ?? '',
|
||||
rating: typeof p.performance?.rating === 'number' ? p.performance.rating : null,
|
||||
subIn: subTime(p, 'subIn'),
|
||||
subOut: subTime(p, 'subOut'),
|
||||
});
|
||||
|
||||
/** Cross-source same-player check: alias-key equality (bridges romanization)
|
||||
* or token-set match (bridges name order). */
|
||||
const samePlayer = (a: string, b: string): boolean => aliasKey(a) === aliasKey(b) || nameMatch(a, b);
|
||||
|
||||
const opponentOf = (f: Fixture, side: 'home' | 'away'): string =>
|
||||
side === 'home' ? f.away.team ?? f.away.label : f.home.team ?? f.home.label;
|
||||
|
||||
/** Everyone the stored data knows: boards (FotMob display forms first), every
|
||||
* lineup that was captured, then the ESPN-derived scorers dedupe onto them. */
|
||||
function playerIndex(state: TournamentState): Map<string, PlayerRef> {
|
||||
const sources: PlayerRef[] = [];
|
||||
const boards = getTournamentStats()?.players;
|
||||
if (boards) {
|
||||
for (const cat of ['rating', 'minutes', 'goals', 'assists', 'xg']) {
|
||||
for (const r of boards[cat] ?? []) sources.push({ name: r.name, team: r.team });
|
||||
}
|
||||
}
|
||||
for (const f of state.allFixtures()) {
|
||||
if (f.status === 'scheduled' || !f.home.team || !f.away.team) continue;
|
||||
const fm = getMatchProvider<FmDetails>(f.num, 'fotmob');
|
||||
if (!fm) continue;
|
||||
for (const [key, team] of [['homeTeam', f.home.team], ['awayTeam', f.away.team]] as const) {
|
||||
const tm = fm.data.lineup?.[key];
|
||||
for (const p of [...(tm?.starters ?? []), ...(tm?.subs ?? [])]) {
|
||||
if (p.name) sources.push({ name: p.name, team });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const b of goldenBootRows(state)) sources.push({ name: b.player, team: b.team });
|
||||
return buildPlayerIndex(sources);
|
||||
}
|
||||
|
||||
function assemble(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const ref = resolvePlayer(nameParam, playerIndex(state));
|
||||
if (!ref) return null;
|
||||
const { name, team } = ref;
|
||||
|
||||
const cache = new Map<number, MatchLiveEvent[]>();
|
||||
const fixtures = state
|
||||
.allFixtures()
|
||||
.filter((f) => f.home.team === team || f.away.team === team)
|
||||
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
||||
|
||||
const matchLog: PlayerMatchLine[] = [];
|
||||
const shots: RichShot[] = [];
|
||||
for (const f of fixtures) {
|
||||
if (f.status === 'scheduled') continue;
|
||||
const side: 'home' | 'away' = f.home.team === team ? 'home' : 'away';
|
||||
const fm = getMatchProvider<FmDetails>(f.num, 'fotmob');
|
||||
const tm = fm?.data.lineup?.[side === 'home' ? 'homeTeam' : 'awayTeam'];
|
||||
const line = playerMatchLine(name, {
|
||||
num: f.num,
|
||||
opponent: opponentOf(f, side),
|
||||
side,
|
||||
finished: f.status === 'finished',
|
||||
kickoff: f.kickoff,
|
||||
homeScore: f.homeScore,
|
||||
awayScore: f.awayScore,
|
||||
starters: (tm?.starters ?? []).map(perf),
|
||||
bench: (tm?.subs ?? []).map(perf),
|
||||
events: fixtureEvents(f.num, f.home.team, f.away.team, cache),
|
||||
} satisfies PlayerFixtureData);
|
||||
if (line) matchLog.push(line);
|
||||
|
||||
// His shots, forced onto the home orientation so one aggregate ShotMap
|
||||
// can draw every match attacking right.
|
||||
const teamId = side === 'home' ? fm?.data.homeTeamId : fm?.data.awayTeamId;
|
||||
for (const s of fm?.data.shots ?? []) {
|
||||
if (!s.playerName || !samePlayer(s.playerName, name)) continue;
|
||||
if (teamId != null && s.teamId !== teamId) continue;
|
||||
shots.push({
|
||||
isHome: true, player: name,
|
||||
x: s.x ?? null, y: s.y ?? null, min: s.min ?? null,
|
||||
xg: s.expectedGoals ?? null, xgot: s.expectedGoalsOnTarget ?? null,
|
||||
type: s.eventType ?? '', situation: s.situation ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totals: PlayerProfile['totals'] = {};
|
||||
const boards = getTournamentStats()?.players;
|
||||
if (boards) {
|
||||
for (const [cat, rows] of Object.entries(boards)) {
|
||||
const row = findBoardRow(rows, name);
|
||||
if (row) totals[cat] = { value: row.value, matches: row.matches, minutes: row.minutes };
|
||||
}
|
||||
}
|
||||
const bootGoals = goldenBootRows(state).find((b) => samePlayer(b.player, name))?.goals ?? 0;
|
||||
const discipline = disciplineFor(team, state, cache).find((p) => samePlayer(p.player, name)) ?? null;
|
||||
|
||||
const next = state
|
||||
.allFixtures()
|
||||
.filter((f) => f.status !== 'finished' && (f.home.team === team || f.away.team === team))
|
||||
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0];
|
||||
const odds = nextAnytimeOdds(name, team, state);
|
||||
const nextProp = next && odds != null
|
||||
? { num: next.num, opponent: opponentOf(next, next.home.team === team ? 'home' : 'away'), odds }
|
||||
: null;
|
||||
|
||||
return { name, team, totals, bootGoals, matchLog, shots, discipline, nextProp, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
// A 60s memo guards the 10s polling bursts of an open player page; negative
|
||||
// results are cached too (retired all-time scorers get linked).
|
||||
const TTL = 60_000;
|
||||
const memo = new Map<string, { at: number; profile: PlayerProfile | null }>();
|
||||
let listMemo: { at: number; players: PlayerRef[] } | null = null;
|
||||
|
||||
export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const k = aliasKey(nameParam);
|
||||
const hit = memo.get(k);
|
||||
if (hit && Date.now() - hit.at < TTL) return hit.profile;
|
||||
const profile = assemble(nameParam, state);
|
||||
if (memo.size > 200) memo.clear();
|
||||
memo.set(k, { at: Date.now(), profile });
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** The search palette's lightweight player list. */
|
||||
export function playersIndex(state: TournamentState): PlayerRef[] {
|
||||
if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players;
|
||||
const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
listMemo = { at: Date.now(), players };
|
||||
return players;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { getMatchExt, injuriesFor, snapshotFor } from './db/db';
|
||||
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
|
||||
import { whoScores } from './ingest/propsNormalize';
|
||||
import { disciplineFor } from './stats';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { ModelEngine } from './model';
|
||||
@@ -149,6 +150,8 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
|
||||
away: away ? injuriesFor(away) : [],
|
||||
},
|
||||
videos: summary?.videos ?? [],
|
||||
// The board disappears at kickoff — in-play lines drift too fast to mirror.
|
||||
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
|
||||
dataNote: !home || !away
|
||||
? 'Knockout participants are decided once the bracket resolves.'
|
||||
: ext
|
||||
|
||||
+13
-2
@@ -1,7 +1,9 @@
|
||||
import { getMatchExt, scorerPropsFor } from './db/db';
|
||||
import { getTournamentStats } from './ingest/tournamentStats';
|
||||
import { boardName } from './ingest/statsNormalize';
|
||||
import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline';
|
||||
import { nameMatch } from '../../src/lib/matchEvents';
|
||||
import { aliasKey } from '../../src/lib/playerProfile';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { EspnSummary } from './ingest/espnNormalize';
|
||||
import type { GoldenBootRow, MatchLiveEvent, PlayerDiscipline, StatsHub } from '../../src/lib/types';
|
||||
@@ -73,14 +75,23 @@ export function goldenBootRows(state: TournamentState, max = 25): GoldenBootRow[
|
||||
tally.set(key, cur);
|
||||
}
|
||||
}
|
||||
const boards = getTournamentStats()?.players;
|
||||
// Token-set match plus the romanization fold ("Oh Hyeon-Gyu" = "Hyun-Gyu Oh").
|
||||
const same = (a: string, b: string) => aliasKey(a) === aliasKey(b) || nameMatch(a, b);
|
||||
return [...tally.values()]
|
||||
.sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player))
|
||||
.slice(0, max)
|
||||
.map((p) => ({ ...p, nextOdds: nextAnytimeOdds(p.player, p.team, state) }));
|
||||
// Display the FotMob form of the name (what the boards beside this list
|
||||
// show); the ESPN form still drives the props lookup.
|
||||
.map((p) => ({
|
||||
...p,
|
||||
player: boardName(p.player, p.team, boards, same),
|
||||
nextOdds: nextAnytimeOdds(p.player, p.team, state),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Latest captured anytime-scorer line for the player's next fixture. */
|
||||
function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
|
||||
export function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
|
||||
if (!team) return null;
|
||||
const next = state
|
||||
.allFixtures()
|
||||
|
||||
Reference in New Issue
Block a user