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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user