Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -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; },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user