Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)

This commit is contained in:
Kev
2026-06-10 14:50:13 -04:00
parent b9084408bf
commit ad5ea8d5a8
28 changed files with 3175 additions and 49 deletions
@@ -0,0 +1,232 @@
/**
* football-data.org adapter.
*
* Free tier:
* - 10 requests per minute (HARD rate limit on the API side — 429 on overflow)
* - Fixtures, standings, squads, scorers only (NO per-player game stats)
* - Requires `FOOTBALL_DATA_API_KEY` env var
*
* Design:
* - All responses cached in Redis with tier-appropriate TTLs.
* - Built-in token bucket holds calls at 8 req/min (2-req safety margin).
* - When the bucket is empty, stale-while-revalidate returns whatever
* is in Redis even if the TTL has lapsed — better to serve old data
* than to crash the request path.
* - When the API key is missing, every method returns null without
* touching the network. Callers (feature extractor, poller) treat
* null as "no data available — degrade gracefully".
* - All errors are caught and logged, never thrown. Same contract as
* the existing intelligence services.
*
* Endpoints exposed:
* getWorldCupFixtures(),
* getWorldCupStandings(),
* getWorldCupScorers(),
* getTeamSquad(teamId),
* getLeagueFixtures(competitionCode), // generic — EPL/PD/BL1/...
* getLeagueStandings(competitionCode),
* getLeagueScorers(competitionCode).
*
* Competition codes: WC (World Cup), PL (Premier League),
* PD (La Liga), BL1 (Bundesliga), SA (Serie A), FL1 (Ligue 1),
* CL (Champions League), MLS (MLS), LIGA (Liga MX).
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const BASE_URL = 'https://api.football-data.org/v4';
const HTTP_TIMEOUT_MS = 8_000;
// Cache TTLs (seconds) — tier matched to data volatility.
const TTL = Object.freeze({
fixtures: 6 * 3600, // 6h — drifts as match status changes
standings: 12 * 3600, // 12h — moves once per matchday at most
squad: 24 * 3600, // 24h — only changes between matchdays
scorers: 6 * 3600, // 6h — moves only on goal events
});
// Token bucket — refills 8 tokens per 60-second window. We hold 2 tokens
// below the 10 req/min ceiling so a burst from the poller can't 429 the
// adapter on the user request path.
const BUCKET_MAX = 8;
const BUCKET_REFILL_MS = 60_000;
let _tokens = BUCKET_MAX;
let _lastRefill = 0;
function nowMs() {
// jest.fakeTimers compatible — process.uptime is monotonic.
return Math.floor(process.uptime() * 1000);
}
function refillBucket() {
const now = nowMs();
if (_lastRefill === 0) _lastRefill = now;
const elapsed = now - _lastRefill;
if (elapsed >= BUCKET_REFILL_MS) {
// Full refill on window boundary — simpler than fractional refills,
// and matches how the API's own per-minute window resets.
_tokens = BUCKET_MAX;
_lastRefill = now;
}
}
function tryConsumeToken() {
refillBucket();
if (_tokens <= 0) return false;
_tokens -= 1;
return true;
}
function hasApiKey() {
return !!process.env.FOOTBALL_DATA_API_KEY;
}
// One central HTTP wrapper — applies key, timeout, rate-limit check, and
// stale-while-revalidate fallback. Returns parsed JSON or null. Never throws.
async function fetchWithCache(path, cacheKey, ttl) {
// 1. Try fresh cache (within TTL).
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
// 2. No key → can't fetch. Return null (callers degrade).
if (!hasApiKey()) return null;
// 3. Token bucket — if we're rate-limited, try the stale-while-revalidate
// key. If THAT misses too, give up rather than 429'ing the upstream API.
if (!tryConsumeToken()) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
// 4. Hit the network.
try {
const res = await axios.get(`${BASE_URL}${path}`, {
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
// Write to BOTH the live and stale keys. Stale key has a much
// longer TTL so stale-while-revalidate always finds something.
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[footballData] fetch failed:', path, err.message);
// Network failure — fall back to stale if we have it.
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
async function getLeagueFixtures(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/matches`,
`soccer:${code.toLowerCase()}:fixtures`,
TTL.fixtures,
);
// null → API unavailable (no key, fetch failure, drained bucket+no stale)
if (data === null) return null;
// Object present but no matches array → API returned nothing meaningful.
if (!Array.isArray(data.matches)) return [];
// Project to a stable shape so callers don't depend on API field names.
return data.matches.map((m) => ({
id: m.id,
homeTeam: m.homeTeam?.name || m.homeTeam?.shortName || null,
awayTeam: m.awayTeam?.name || m.awayTeam?.shortName || null,
utcDate: m.utcDate || null,
status: m.status || null,
score: m.score || null,
matchday: m.matchday ?? null,
venue: m.venue || null,
competition: code,
}));
}
async function getLeagueStandings(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/standings`,
`soccer:${code.toLowerCase()}:standings`,
TTL.standings,
);
if (data === null) return null;
if (!Array.isArray(data.standings)) return [];
return data.standings;
}
async function getLeagueScorers(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/scorers`,
`soccer:${code.toLowerCase()}:scorers`,
TTL.scorers,
);
if (data === null) return null;
if (!Array.isArray(data.scorers)) return [];
// Project: { player: {name, position, nationality}, team, goals, assists, playedMatches, ... }
return data.scorers.map((s) => ({
name: s.player?.name || null,
position: s.player?.position || null,
nationality: s.player?.nationality || null,
team: s.team?.name || null,
goals: s.goals ?? 0,
assists: s.assists ?? 0,
playedMatches: s.playedMatches ?? 0,
minutesPlayed: s.minutesPlayed ?? null,
}));
}
async function getTeamSquad(teamId) {
if (!teamId) return null;
const data = await fetchWithCache(
`/teams/${teamId}`,
`soccer:team:${teamId}:squad`,
TTL.squad,
);
if (data === null) return null;
if (!Array.isArray(data.squad)) return [];
return data.squad.map((p) => ({
id: p.id,
name: p.name,
position: p.position || null,
nationality: p.nationality || null,
shirtNumber: p.shirtNumber ?? null,
dateOfBirth: p.dateOfBirth || null,
}));
}
// Convenience wrappers for the World Cup — most-used competition code.
async function getWorldCupFixtures() { return getLeagueFixtures('WC'); }
async function getWorldCupStandings() { return getLeagueStandings('WC'); }
async function getWorldCupScorers() { return getLeagueScorers('WC'); }
module.exports = {
getLeagueFixtures,
getLeagueStandings,
getLeagueScorers,
getTeamSquad,
getWorldCupFixtures,
getWorldCupStandings,
getWorldCupScorers,
hasApiKey,
__internals: {
BASE_URL,
TTL,
BUCKET_MAX,
tryConsumeToken,
refillBucket,
resetBucketForTests: () => { _tokens = BUCKET_MAX; _lastRefill = 0; },
},
};