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,69 @@
|
||||
// Geocode the 16 World Cup 2026 venues once (Open-Meteo geocoding, keyless)
|
||||
// → public/data/venues.json with lat/lon/elevation/timezone per fixture-venue
|
||||
// string. Elevation matters here: Mexico City sits at ~2,240m.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'data');
|
||||
|
||||
/** fixture venue string → geocoding query + expected country (disambiguator) */
|
||||
const VENUES: Record<string, { q: string; cc: string }> = {
|
||||
'Atlanta': { q: 'Atlanta', cc: 'US' },
|
||||
'Boston (Foxborough)': { q: 'Foxborough', cc: 'US' },
|
||||
'Dallas (Arlington)': { q: 'Arlington', cc: 'US' },
|
||||
'Guadalajara (Zapopan)': { q: 'Zapopan', cc: 'MX' },
|
||||
'Houston': { q: 'Houston', cc: 'US' },
|
||||
'Kansas City': { q: 'Kansas City', cc: 'US' },
|
||||
'Los Angeles (Inglewood)': { q: 'Inglewood', cc: 'US' },
|
||||
'Mexico City': { q: 'Mexico City', cc: 'MX' },
|
||||
'Miami (Miami Gardens)': { q: 'Miami Gardens', cc: 'US' },
|
||||
'Monterrey (Guadalupe)': { q: 'Guadalupe', cc: 'MX' },
|
||||
'New York/New Jersey (East Rutherford)': { q: 'East Rutherford', cc: 'US' },
|
||||
'Philadelphia': { q: 'Philadelphia', cc: 'US' },
|
||||
'San Francisco Bay Area (Santa Clara)': { q: 'Santa Clara', cc: 'US' },
|
||||
'Seattle': { q: 'Seattle', cc: 'US' },
|
||||
'Toronto': { q: 'Toronto', cc: 'CA' },
|
||||
'Vancouver': { q: 'Vancouver', cc: 'CA' },
|
||||
};
|
||||
|
||||
/** Geocoder misses (e.g. "Vancouver" resolving to a Vancouver-Island point):
|
||||
* pin the known city coordinates explicitly. */
|
||||
const OVERRIDES: Record<string, { lat: number; lon: number; elevation: number; tz: string }> = {
|
||||
Vancouver: { lat: 49.2827, lon: -123.1207, elevation: 70, tz: 'America/Vancouver' },
|
||||
};
|
||||
|
||||
interface GeoResult {
|
||||
results?: { latitude: number; longitude: number; elevation?: number; timezone?: string; name: string; country_code?: string }[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const out: Record<string, { lat: number; lon: number; elevation: number; tz: string }> = {};
|
||||
for (const [venue, { q, cc }] of Object.entries(VENUES)) {
|
||||
const override = OVERRIDES[venue];
|
||||
if (override) {
|
||||
out[venue] = override;
|
||||
console.log(venue, '→ (override)', override);
|
||||
continue;
|
||||
}
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=10&language=en&format=json`;
|
||||
const res = await fetch(url);
|
||||
const d = (await res.json()) as GeoResult & { results?: { population?: number }[] };
|
||||
const candidates = (d.results ?? []).filter((x) => x.country_code === cc);
|
||||
const r = candidates.sort((a, b) => ((b as { population?: number }).population ?? 0) - ((a as { population?: number }).population ?? 0))[0];
|
||||
if (!r) throw new Error(`no geocoding result for ${venue} (${q}, ${cc})`);
|
||||
out[venue] = {
|
||||
lat: +r.latitude.toFixed(4),
|
||||
lon: +r.longitude.toFixed(4),
|
||||
elevation: Math.round(r.elevation ?? 0),
|
||||
tz: r.timezone ?? 'UTC',
|
||||
};
|
||||
console.log(venue, '→', out[venue]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(join(OUT_DIR, 'venues.json'), JSON.stringify({ generatedAt: new Date().toISOString(), venues: out }, null, 1));
|
||||
console.log(`venues → public/data/venues.json (${Object.keys(out).length})`);
|
||||
}
|
||||
|
||||
void main();
|
||||
Reference in New Issue
Block a user