257 lines
7.8 KiB
JavaScript
257 lines
7.8 KiB
JavaScript
/**
|
|
* 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),
|
|
},
|
|
};
|