/** * 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), }, };