Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)

This commit is contained in:
Kev
2026-06-10 19:41:37 -04:00
parent 4db1c1c539
commit b55dcbd614
25 changed files with 2463 additions and 22 deletions
+256
View File
@@ -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),
},
};