Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* FootApi adapter — BACKUP soccer data source (Session 9).
|
||||
*
|
||||
* Wraps the RapidAPI-hosted FootApi service (Sofascore mirror). Used
|
||||
* as the fallback when api-football.com is rate-limited or returns
|
||||
* thin data. Free tier: 50 requests/day.
|
||||
*
|
||||
* Auth: RapidAPI headers (x-rapidapi-key + x-rapidapi-host). DO NOT
|
||||
* use the x-apisports-key header here — that's the primary adapter.
|
||||
*
|
||||
* Env:
|
||||
* RAPID_API_KEY — RapidAPI marketplace key (shared across Tank01 + FootApi)
|
||||
* FOOTAPI_HOST — host header (defaults to footapi7.p.rapidapi.com)
|
||||
*
|
||||
* Rate limit:
|
||||
* Hard 50 req/day. We track in `footapi:daily_count` (24h TTL) and
|
||||
* stop at 45 — same 5-req safety margin as the primary adapter
|
||||
* (smaller absolute margin because the daily budget is smaller).
|
||||
*
|
||||
* Tournament IDs used in the URL paths:
|
||||
* 16 — FIFA World Cup
|
||||
* (others discovered via the schedule endpoint as needed)
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 8_000;
|
||||
|
||||
const TTL = Object.freeze({
|
||||
lineups: 24 * 3600,
|
||||
incidents: 12 * 3600,
|
||||
referee: 7 * 24 * 3600, // 7d — referee stats move slowly
|
||||
schedule: 6 * 3600,
|
||||
});
|
||||
|
||||
const DAILY_LIMIT = 50;
|
||||
const SAFETY_MARGIN = 5;
|
||||
const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 45
|
||||
const DAILY_COUNTER_KEY = 'footapi:daily_count';
|
||||
const DAILY_TTL_SEC = 24 * 3600;
|
||||
|
||||
const WC_TOURNAMENT_ID = 16;
|
||||
|
||||
function getHost() {
|
||||
return process.env.FOOTAPI_HOST || 'footapi7.p.rapidapi.com';
|
||||
}
|
||||
|
||||
function hasApiKey() {
|
||||
return !!process.env.RAPID_API_KEY;
|
||||
}
|
||||
|
||||
async function readDailyCount() {
|
||||
const v = await cacheGet(DAILY_COUNTER_KEY);
|
||||
if (v == null) return 0;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
async function bumpDailyCount() {
|
||||
const next = (await readDailyCount()) + 1;
|
||||
await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function fetchWithCache(path, cacheKey, ttl) {
|
||||
const fresh = await cacheGet(cacheKey);
|
||||
if (fresh !== null) return fresh;
|
||||
if (!hasApiKey()) return null;
|
||||
|
||||
const used = await readDailyCount();
|
||||
if (used >= SOFT_LIMIT) {
|
||||
const stale = await cacheGet(`${cacheKey}:stale`);
|
||||
if (stale !== null) return stale;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = getHost();
|
||||
const res = await axios.get(`https://${host}${path}`, {
|
||||
headers: {
|
||||
'x-rapidapi-key': process.env.RAPID_API_KEY,
|
||||
'x-rapidapi-host': host,
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
await bumpDailyCount();
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object') {
|
||||
await cacheSet(cacheKey, body, ttl);
|
||||
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
|
||||
}
|
||||
return body;
|
||||
} catch (err) {
|
||||
console.warn('[footApi] fetch failed:', path, err.message);
|
||||
const stale = await cacheGet(`${cacheKey}:stale`);
|
||||
if (stale !== null) return stale;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public surface ----
|
||||
|
||||
/**
|
||||
* getMatchLineups — players with minutesPlayed and the 28-key stats
|
||||
* block FootApi exposes per player (rating, shots, passes, tackles,
|
||||
* goals, assists, cards, etc.).
|
||||
*/
|
||||
async function getMatchLineups(matchId) {
|
||||
if (!matchId) return null;
|
||||
const data = await fetchWithCache(
|
||||
`/api/match/${matchId}/lineups`,
|
||||
`footapi:match:${matchId}:lineups`,
|
||||
TTL.lineups,
|
||||
);
|
||||
if (data === null) return null;
|
||||
// The FootApi response carries `home` and `away` sides, each with
|
||||
// `players` arrays. Flatten so callers don't need to reach into
|
||||
// upstream-specific structure.
|
||||
const out = [];
|
||||
for (const side of ['home', 'away']) {
|
||||
const team = data?.[side];
|
||||
if (!team || !Array.isArray(team.players)) continue;
|
||||
for (const entry of team.players) {
|
||||
const p = entry?.player || {};
|
||||
const stats = entry?.statistics || {};
|
||||
out.push({
|
||||
team: team.formation ? `${side}(${team.formation})` : side,
|
||||
side,
|
||||
playerId: p.id ?? null,
|
||||
name: p.name ?? null,
|
||||
position: entry.position ?? null,
|
||||
shirtNumber: entry.shirtNumber ?? null,
|
||||
substitute: !!entry.substitute,
|
||||
captain: !!entry.captain,
|
||||
minutesPlayed: stats.minutesPlayed ?? null,
|
||||
rating: stats.rating ?? null,
|
||||
goals: stats.goals ?? 0,
|
||||
assists: stats.goalAssist ?? 0,
|
||||
shots: stats.totalShots ?? 0,
|
||||
shotsOnTarget: stats.shotOnTarget ?? 0,
|
||||
passes: stats.totalPass ?? 0,
|
||||
accuratePasses: stats.accuratePass ?? 0,
|
||||
tackles: stats.totalTackle ?? 0,
|
||||
yellow: stats.yellowCards ?? 0,
|
||||
red: stats.redCards ?? 0,
|
||||
saves: stats.saves ?? null,
|
||||
keyPasses: stats.keyPass ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* getMatchIncidents — minute-by-minute goals, cards, subs. The
|
||||
* minute + addedTime carry detail the events feed needs for trap
|
||||
* detection (e.g. late-game cards inflate referee_card_bias signal).
|
||||
*/
|
||||
async function getMatchIncidents(matchId) {
|
||||
if (!matchId) return null;
|
||||
const data = await fetchWithCache(
|
||||
`/api/match/${matchId}/incidents`,
|
||||
`footapi:match:${matchId}:incidents`,
|
||||
TTL.incidents,
|
||||
);
|
||||
if (data === null) return null;
|
||||
const list = data?.incidents;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map((i) => ({
|
||||
type: i.incidentType ?? null,
|
||||
classType: i.incidentClass ?? null,
|
||||
minute: i.time ?? null,
|
||||
addedTime: i.addedTime ?? null,
|
||||
isHome: i.isHome ?? null,
|
||||
player: i.player?.name ?? null,
|
||||
assist: i.assist1?.name ?? null,
|
||||
text: i.text ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* getRefereeStatistics — referee card + appearance history per
|
||||
* tournament. The shape `{ yellowCards, redCards, appearances }`
|
||||
* feeds the soccer-trap `referee_card_bias` signal.
|
||||
*/
|
||||
async function getRefereeStatistics(refereeId) {
|
||||
if (!refereeId) return null;
|
||||
const data = await fetchWithCache(
|
||||
`/api/referee/${refereeId}/statistics`,
|
||||
`footapi:referee:${refereeId}:stats`,
|
||||
TTL.referee,
|
||||
);
|
||||
if (data === null) return null;
|
||||
const stats = data?.statistics;
|
||||
if (!Array.isArray(stats)) return [];
|
||||
return stats.map((s) => ({
|
||||
tournamentId: s.tournament?.id ?? null,
|
||||
tournamentName: s.tournament?.name ?? null,
|
||||
season: s.season?.year ?? null,
|
||||
appearances: s.appearances ?? 0,
|
||||
yellowCards: s.yellowCards ?? 0,
|
||||
redCards: s.redCards ?? 0,
|
||||
yellowCardsPerGame: s.appearances > 0 ? Math.round((s.yellowCards / s.appearances) * 100) / 100 : null,
|
||||
redCardsPerGame: s.appearances > 0 ? Math.round((s.redCards / s.appearances) * 1000) / 1000 : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* getWorldCupSchedule — fixtures for a date. Tournament ID 16 is
|
||||
* the FIFA World Cup; the path is `/api/tournament/16/schedules/dd/mm/yyyy`.
|
||||
*/
|
||||
async function getWorldCupSchedule(day, month, year) {
|
||||
if (!day || !month || !year) return null;
|
||||
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const data = await fetchWithCache(
|
||||
`/api/tournament/${WC_TOURNAMENT_ID}/schedules/${day}/${month}/${year}`,
|
||||
`footapi:wc:schedule:${dateKey}`,
|
||||
TTL.schedule,
|
||||
);
|
||||
if (data === null) return null;
|
||||
const list = data?.events;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map((e) => ({
|
||||
id: e.id ?? null,
|
||||
startTimestamp: e.startTimestamp ?? null,
|
||||
status: e.status?.type ?? null,
|
||||
homeTeam: e.homeTeam?.name ?? null,
|
||||
awayTeam: e.awayTeam?.name ?? null,
|
||||
homeTeamId: e.homeTeam?.id ?? null,
|
||||
awayTeamId: e.awayTeam?.id ?? null,
|
||||
homeScore: e.homeScore?.current ?? null,
|
||||
awayScore: e.awayScore?.current ?? null,
|
||||
venue: e.venue?.name ?? null,
|
||||
referee: e.referee?.name ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMatchLineups,
|
||||
getMatchIncidents,
|
||||
getRefereeStatistics,
|
||||
getWorldCupSchedule,
|
||||
hasApiKey,
|
||||
__internals: {
|
||||
TTL,
|
||||
DAILY_LIMIT,
|
||||
SOFT_LIMIT,
|
||||
DAILY_COUNTER_KEY,
|
||||
WC_TOURNAMENT_ID,
|
||||
readDailyCount,
|
||||
bumpDailyCount,
|
||||
getHost,
|
||||
resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user