167996d99a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.7 KiB
JavaScript
104 lines
3.7 KiB
JavaScript
/**
|
|
* Weather service (Session 15).
|
|
*
|
|
* Open-Meteo proxy. No API key required (the service is free for
|
|
* non-commercial use and sportsbook analytics is firmly non-
|
|
* commercial intelligence). 5s hard timeout, 1h Redis cache,
|
|
* graceful degrade (returns null on any failure — never throws,
|
|
* never blocks the grade).
|
|
*
|
|
* Outputs are normalized to the units North-American bettors think
|
|
* in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults
|
|
* are Celsius + km/h, so we request the imperial units directly via
|
|
* query params.
|
|
*
|
|
* Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current
|
|
* UTC hour so two requests within the same hour hit cache. Slightly
|
|
* less precise than a sliding TTL but matches Open-Meteo's hourly
|
|
* forecast cadence and keeps cache churn bounded.
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const { cacheGet, cacheSet } = require('../utils/redis');
|
|
|
|
const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
|
|
const HTTP_TIMEOUT_MS = 5_000;
|
|
const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly
|
|
|
|
function currentHourBucket() {
|
|
const d = new Date();
|
|
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function buildKey(lat, lon) {
|
|
// 2-decimal precision is enough for a city-scale lookup and
|
|
// collapses neighboring venues onto the same cache key (no real-
|
|
// world impact — they share the same weather).
|
|
const latKey = Number(lat).toFixed(2);
|
|
const lonKey = Number(lon).toFixed(2);
|
|
return `weather:${latKey}:${lonKey}:${currentHourBucket()}`;
|
|
}
|
|
|
|
/**
|
|
* getWeather — fetch current weather conditions for a lat/lon.
|
|
*
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>}
|
|
*
|
|
* Returns null on:
|
|
* - invalid coordinates
|
|
* - upstream timeout / 5xx
|
|
* - missing fields in the response
|
|
*
|
|
* The grading engine and reasoning builder both treat null as "no
|
|
* signal" — features are simply omitted from the prop's overlay.
|
|
*/
|
|
async function getWeather(lat, lon) {
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
|
|
const cacheKey = buildKey(lat, lon);
|
|
try {
|
|
const cached = await cacheGet(cacheKey);
|
|
if (cached !== null) return cached;
|
|
} catch {
|
|
// Redis hiccup — proceed to network.
|
|
}
|
|
|
|
try {
|
|
const res = await axios.get(BASE_URL, {
|
|
timeout: HTTP_TIMEOUT_MS,
|
|
params: {
|
|
latitude: lat,
|
|
longitude: lon,
|
|
current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation',
|
|
temperature_unit: 'fahrenheit',
|
|
wind_speed_unit: 'mph',
|
|
precipitation_unit: 'mm', // mm is the universal precipitation unit
|
|
},
|
|
});
|
|
const current = res.data?.current;
|
|
if (!current) return null;
|
|
const out = {
|
|
temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null,
|
|
wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null,
|
|
wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null,
|
|
precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null,
|
|
_fetched_at: new Date().toISOString(),
|
|
};
|
|
try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ }
|
|
return out;
|
|
} catch (err) {
|
|
// Open-Meteo down, timeout, 5xx — silently degrade.
|
|
if (err && err.code !== 'ECONNABORTED') {
|
|
console.warn('[weatherService] fetch failed:', err.message);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getWeather,
|
|
__internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket },
|
|
};
|