Tournament stats hub, suspension tracker, card-aware live probabilities
- /stats: FotMob tournament boards (goals/assists/rating/xG player+team categories from the data.fotmob.com CDN, capture-first via scheduler), Golden Boot race with DraftKings anytime-line benchmark from the already-captured scorer props, and an attack-vs-defence xG scatter. - Discipline engine (src/lib/discipline.ts): FIFA suspension rules folded from stored event feeds — red/second-yellow bans, two-yellow accumulation, post-QF wipe. Surfaces on /stats, team profiles and match lineups tabs. - In-play win probability now accounts for red cards (10 men: own rate x0.67, opponent x1.15 per card) on the server tick and the match page. - Fix ModelDiary infinite render loop (unstable zustand snapshot while the model loads crashed the Methodology route under React 19). - Drop dead tables squad + goalscorers; scorer_props is now read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { fetchFootballData } from '../footballData';
|
||||
import { fetchSofascore } from '../sofascore';
|
||||
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
|
||||
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
|
||||
import { refreshTournamentStats } from './tournamentStats';
|
||||
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
|
||||
import { fetchMatchWeather } from './weather';
|
||||
import { fetchScorerProps } from './espnProps';
|
||||
@@ -248,6 +249,24 @@ export function startScheduler(
|
||||
fotmobTimer = setTimeout(async () => { await fotmobTick().catch(() => {}); scheduleFotmob(); }, state.hasLive() ? 90_000 : 30 * 60_000);
|
||||
};
|
||||
|
||||
// ---- FotMob tournament stat boards (top scorers, assists, xG, team xG…).
|
||||
// Static CDN files; the assembled payload is stored and served as-is, so a
|
||||
// blocked source only ever means staler boards.
|
||||
const statsTick = async (): Promise<void> => {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const { boards, rows } = await refreshTournamentStats();
|
||||
logIngest('fotmob-stats', 'boards', true, Date.now() - t0, `${boards} boards, ${rows} rows`);
|
||||
} catch (e) {
|
||||
logIngest('fotmob-stats', 'boards', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
let statsTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const scheduleStats = (): void => {
|
||||
if (stopped) return;
|
||||
statsTimer = setTimeout(async () => { await statsTick().catch(() => {}); scheduleStats(); }, state.hasLive() ? 20 * 60_000 : 60 * 60_000);
|
||||
};
|
||||
|
||||
// ---- FIFA official: confirmed lineups, XY timelines, attendance/officials.
|
||||
const fifaTick = async (): Promise<void> => {
|
||||
const now = Date.now();
|
||||
@@ -382,6 +401,8 @@ export function startScheduler(
|
||||
}
|
||||
await fotmobTick().catch(() => {}); // capture-first: unofficial source, harvest while it lasts
|
||||
scheduleFotmob();
|
||||
await statsTick().catch(() => {});
|
||||
scheduleStats();
|
||||
await fifaTick().catch(() => {});
|
||||
scheduleFifa();
|
||||
await oddsTick().catch(() => {});
|
||||
@@ -398,6 +419,7 @@ export function startScheduler(
|
||||
if (enrichTimer) clearTimeout(enrichTimer);
|
||||
if (oddsTimer) clearTimeout(oddsTimer);
|
||||
if (fotmobTimer) clearTimeout(fotmobTimer);
|
||||
if (statsTimer) clearTimeout(statsTimer);
|
||||
if (fifaTimer) clearTimeout(fifaTimer);
|
||||
clearInterval(pruneTimer);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard } from './statsNormalize';
|
||||
|
||||
// Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
|
||||
const realBoard = {
|
||||
LeagueName: 'World Cup',
|
||||
TopLists: [{
|
||||
StatList: [
|
||||
{
|
||||
ParticipantName: 'Hyun-Gyu Oh', ParticiantId: 1044299, TeamId: 7804, TeamColor: '#F7383F',
|
||||
StatValue: 1.0, SubStatValue: 0.0, MinutesPlayed: 21, MatchesPlayed: 1, Rank: 1,
|
||||
ParticipantCountryCode: 'KOR', TeamName: 'Korea Republic', Positions: [115],
|
||||
},
|
||||
{ ParticipantName: 'No Value Yet', TeamName: 'Mexico' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
describe('normalizeStatBoard', () => {
|
||||
it('keeps the analytic fields, canonicalizes the team and drops rows without a value', () => {
|
||||
expect(normalizeStatBoard(realBoard, false)).toEqual([
|
||||
// canonicalTeam: FotMob's "Korea Republic" is our "South Korea"
|
||||
{ name: 'Hyun-Gyu Oh', team: 'South Korea', value: 1, matches: 1, minutes: 21 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the canonicalized participant as the team on team boards', () => {
|
||||
const rows = normalizeStatBoard(
|
||||
{ TopLists: [{ StatList: [{ ParticipantName: 'Korea Republic', StatValue: 2.41, MatchesPlayed: 1 }] }] },
|
||||
true,
|
||||
);
|
||||
expect(rows[0]).toMatchObject({ name: 'South Korea', team: 'South Korea', value: 2.41, matches: 1 });
|
||||
});
|
||||
|
||||
it('caps the board length', () => {
|
||||
const long = { TopLists: [{ StatList: Array.from({ length: 80 }, (_, i) => ({ ParticipantName: `P${i}`, TeamName: 'Mexico', StatValue: i })) }] };
|
||||
expect(normalizeStatBoard(long, false)).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('survives empty or malformed boards', () => {
|
||||
expect(normalizeStatBoard({}, false)).toEqual([]);
|
||||
expect(normalizeStatBoard({ TopLists: [] }, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
|
||||
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { canonicalTeam } from '../../../src/lib/teams';
|
||||
import type { StatRow } from '../../../src/lib/types';
|
||||
|
||||
// Pure FotMob stat-board normalizers — no network or DB, so they're
|
||||
// unit-testable in isolation (same pattern as espnNormalize).
|
||||
|
||||
/** fetchAllUrl filename → our category key. Slugs are locale-stable where
|
||||
* the human headers ("Top scorer") are not. */
|
||||
export const PLAYER_BOARDS: Record<string, string> = {
|
||||
'goals.json': 'goals',
|
||||
'goal_assist.json': 'assists',
|
||||
'_goals_and_goal_assist.json': 'goalsAssists',
|
||||
'rating.json': 'rating',
|
||||
'expected_goals.json': 'xg',
|
||||
'expected_goals_per_90.json': 'xgPer90',
|
||||
'mins_played.json': 'minutes',
|
||||
};
|
||||
|
||||
export const TEAM_BOARDS: Record<string, string> = {
|
||||
'expected_goals_team.json': 'xg',
|
||||
'_xg_diff_team.json': 'xgDiff',
|
||||
'possession_percentage_team.json': 'possession',
|
||||
'clean_sheet_team.json': 'cleanSheets',
|
||||
'big_chance_team.json': 'bigChances',
|
||||
'goals_team_match.json': 'goalsPerMatch',
|
||||
'goals_conceded_team_match.json': 'concededPerMatch',
|
||||
'rating_team.json': 'rating',
|
||||
};
|
||||
|
||||
/** The leagues `stats` tab — each entry points at a static CDN board. */
|
||||
export interface RawStatsTab {
|
||||
stats?: {
|
||||
players?: { header?: string; fetchAllUrl?: string }[];
|
||||
teams?: { header?: string; fetchAllUrl?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawStatRow {
|
||||
ParticipantName?: string;
|
||||
TeamName?: string;
|
||||
StatValue?: number;
|
||||
MatchesPlayed?: number;
|
||||
MinutesPlayed?: number;
|
||||
}
|
||||
|
||||
export interface RawStatBoard { TopLists?: { StatList?: RawStatRow[] }[] }
|
||||
|
||||
export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 30): StatRow[] {
|
||||
const rows = board.TopLists?.[0]?.StatList ?? [];
|
||||
const out: StatRow[] = [];
|
||||
for (const r of rows.slice(0, max)) {
|
||||
const team = canonicalTeam(r.TeamName ?? (isTeam ? r.ParticipantName ?? '' : ''));
|
||||
const name = isTeam ? team : r.ParticipantName ?? '';
|
||||
if (!name || typeof r.StatValue !== 'number' || !Number.isFinite(r.StatValue)) continue;
|
||||
out.push({
|
||||
name,
|
||||
team,
|
||||
value: r.StatValue,
|
||||
matches: typeof r.MatchesPlayed === 'number' ? r.MatchesPlayed : null,
|
||||
minutes: typeof r.MinutesPlayed === 'number' ? r.MinutesPlayed : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The board slug of a fetchAllUrl ("…/season/24254/goals.json" → "goals.json"). */
|
||||
export function boardSlug(url: string | undefined): string {
|
||||
return url?.split('/').pop() ?? '';
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cachedFetch } from './fetcher';
|
||||
import { cacheGet, cacheSet } from '../db/db';
|
||||
import {
|
||||
PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard,
|
||||
type RawStatBoard, type RawStatsTab,
|
||||
} from './statsNormalize';
|
||||
import type { StatRow } from '../../../src/lib/types';
|
||||
|
||||
// FotMob's tournament stat boards. The leagues "stats" tab lists per-category
|
||||
// static JSONs on data.fotmob.com (a plain CDN) — top scorers, assists,
|
||||
// ratings, xG, team xG difference… Same unofficial caveat as matchDetails:
|
||||
// capture-first, store the assembled payload, and let the API route serve
|
||||
// the stored copy so a blocked source means stale boards, never a slow or
|
||||
// broken request.
|
||||
|
||||
const SOURCE = 'fotmob'; // the discovery call shares FotMob's politeness key
|
||||
const CDN_SOURCE = 'fotmob-stats'; // data.fotmob.com gets its own breaker
|
||||
const TAB_URL = 'https://www.fotmob.com/api/data/leagues?id=77&tab=stats&type=league&timeZone=UTC';
|
||||
const PAYLOAD_KEY = 'tournament:stats:v1';
|
||||
const PAYLOAD_TTL = 365 * 24 * 60 * 60_000; // never expires mid-tournament; refreshes overwrite
|
||||
|
||||
export interface StatsBoards {
|
||||
updatedAt: number;
|
||||
players: Record<string, StatRow[]>;
|
||||
teams: Record<string, StatRow[]>;
|
||||
}
|
||||
|
||||
/** Fetch every wanted board and store the assembled payload. Boards that
|
||||
* fail keep their previous rows — partial refreshes never lose categories. */
|
||||
export async function refreshTournamentStats(): Promise<{ boards: number; rows: number }> {
|
||||
const tab = await cachedFetch<RawStatsTab>(SOURCE, 'fotmob:statstab', TAB_URL, {
|
||||
ttl: 6 * 60 * 60_000,
|
||||
minInterval: 2_500,
|
||||
});
|
||||
const prev = getTournamentStats();
|
||||
const players: Record<string, StatRow[]> = { ...prev?.players };
|
||||
const teams: Record<string, StatRow[]> = { ...prev?.teams };
|
||||
let boards = 0;
|
||||
let rows = 0;
|
||||
|
||||
const harvest = async (
|
||||
items: { fetchAllUrl?: string }[] | undefined,
|
||||
wanted: Record<string, string>,
|
||||
into: Record<string, StatRow[]>,
|
||||
isTeam: boolean,
|
||||
): Promise<void> => {
|
||||
for (const item of items ?? []) {
|
||||
const slug = boardSlug(item.fetchAllUrl);
|
||||
const key = wanted[slug];
|
||||
if (!item.fetchAllUrl || !key) continue;
|
||||
try {
|
||||
const board = await cachedFetch<RawStatBoard>(CDN_SOURCE, `fotmob:board:${slug}`, item.fetchAllUrl, {
|
||||
ttl: 25 * 60_000,
|
||||
minInterval: 500,
|
||||
});
|
||||
const normed = normalizeStatBoard(board, isTeam);
|
||||
if (normed.length) {
|
||||
into[key] = normed;
|
||||
boards++;
|
||||
rows += normed.length;
|
||||
}
|
||||
} catch { /* breaker/health track it; the stored payload keeps serving */ }
|
||||
}
|
||||
};
|
||||
|
||||
await harvest(tab.stats?.players, PLAYER_BOARDS, players, false);
|
||||
await harvest(tab.stats?.teams, TEAM_BOARDS, teams, true);
|
||||
|
||||
if (boards) {
|
||||
const payload: StatsBoards = { updatedAt: Date.now(), players, teams };
|
||||
cacheSet(PAYLOAD_KEY, JSON.stringify(payload), PAYLOAD_TTL);
|
||||
}
|
||||
return { boards, rows };
|
||||
}
|
||||
|
||||
/** The last assembled payload — stale beats absent for a stats hub. */
|
||||
export function getTournamentStats(): StatsBoards | null {
|
||||
const row = cacheGet(PAYLOAD_KEY);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value) as StatsBoards;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user