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
+51
View File
@@ -0,0 +1,51 @@
// Open-Meteo (keyless, works from the VPS): kickoff-hour weather per venue.
// Venues are pre-geocoded at build time into public/data/venues.json —
// includes elevation (Mexico City 2,240m), which is its own story.
import { cachedFetch } from './fetcher';
import { loadStatic } from '../preview';
const SOURCE = 'open-meteo';
interface VenuesFile {
venues: Record<string, { lat: number; lon: number; elevation: number; tz: string }>;
}
let _venues: VenuesFile | null = null;
const venues = (): VenuesFile => (_venues ??= loadStatic<VenuesFile>('venues.json'));
export interface MatchWeather {
v: 1;
fetchedAt: number;
tempC: number | null;
precipProbPct: number | null;
windKmh: number | null;
elevationM: number;
tz: string;
}
interface Forecast {
hourly?: { time?: string[]; temperature_2m?: number[]; precipitation_probability?: number[]; wind_speed_10m?: number[] };
}
/** Kickoff-hour forecast for a fixture's venue (forecast horizon ≈16 days). */
export async function fetchMatchWeather(venue: string, kickoffIso: string): Promise<MatchWeather | null> {
const v = venues().venues[venue];
if (!v) return null;
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${v.lat}&longitude=${v.lon}` +
`&hourly=temperature_2m,precipitation_probability,wind_speed_10m&forecast_days=16&timezone=UTC`;
const f = await cachedFetch<Forecast>(SOURCE, `weather:${venue}`, url, { ttl: 3 * 60 * 60_000, minInterval: 1_000 });
const hours = f.hourly?.time ?? [];
const wantHour = kickoffIso.slice(0, 13); // YYYY-MM-DDTHH
const idx = hours.findIndex((t) => t.startsWith(wantHour));
if (idx < 0) return null;
return {
v: 1,
fetchedAt: Date.now(),
tempC: f.hourly?.temperature_2m?.[idx] ?? null,
precipProbPct: f.hourly?.precipitation_probability?.[idx] ?? null,
windKmh: f.hourly?.wind_speed_10m?.[idx] ?? null,
elevationM: v.elevation,
tz: v.tz,
};
}