v2 Phase D: capture-first ingestion — FotMob, FIFA, weather, scorer props

Four new feeds, all verified against real tournament matches:
- FotMob (unofficial, works from the VPS again): per-shot xG/xGOT with
  pitch coords, team xG, player ratings, POTM, momentum, attacking
  zones — trimmed payloads (~87KB/match) on a gentle 90s-live/30m-idle
  cadence. Capture-first: this source could lock again any day.
- FIFA official v3: confirmed lineups with player IDs, typed XY event
  timelines (80 events for the opener), attendance + named referees,
  tactics — id-mapped via the calendar, hot while live.
- Open-Meteo: kickoff-hour weather per venue from a build-time geocoded
  venues.json (with elevation — Azteca 2,240m); fetched in the enrich
  loop inside the 48h horizon.
- DraftKings scorer props (first/last/anytime goalscorer per athlete)
  via ESPN's core propBets, insert-if-changed line history, athlete
  names resolved through a 30-day cache.

Also fixes a real resilience gap found along the way: fixture results
now persist in SQLite and restore at boot — previously a restart after
ESPN's scoreboard window moved on would silently forget a match day
(standings, model and scoreboard would all regress).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:19:10 +02:00
parent ae984bba7b
commit 517a86233a
9 changed files with 717 additions and 3 deletions
+10 -1
View File
@@ -10,7 +10,7 @@ import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, allFixtureResults, saveFixtureResult } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
import type { EspnSummary } from './ingest/espnNormalize';
@@ -37,6 +37,14 @@ export function buildServer() {
db(); // open + migrate SQLite before anything reads it
const state = new TournamentState(STATIC_DIR);
// Restore persisted results over the seed — the in-memory snapshot forgets
// them on restart once ESPN's scoreboard window has moved past a match day.
let restored = 0;
for (const r of allFixtureResults()) {
if (r.status === 'scheduled') continue;
if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++;
}
if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`);
const model = new ModelEngine(STATIC_DIR);
model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
model.recompute(state.allFixtures());
@@ -184,6 +192,7 @@ export function buildServer() {
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
snapshotCheck(state, model);
recordInplayTick();
broadcast();