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
+185
View File
@@ -0,0 +1,185 @@
/**
* Tank01 MLB adapter (Session 9) — live in-game stats + batter-vs-pitcher.
*
* The BvP endpoint is the headline new signal: pre-game, we can show
* a batter's historical line against the starting pitcher (hits, K's,
* total bases). This was a documented Day-1 gap.
*
* Same RAPID_API_KEY as the NBA adapter; different host. Free tier
* is 1,000 req/month — we rely on cache TTLs to bound consumption.
*
* Env:
* RAPID_API_KEY — shared RapidAPI marketplace key
* TANK01_MLB_HOST — host (default `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com`)
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
const TTL = Object.freeze({
boxScoreLive: 5 * 60,
boxScoreFinal: 24 * 3600,
scoreboard: 1 * 3600,
bvp: 24 * 3600, // BvP doesn't change mid-day — 24h cache is fine
});
function getHost() {
return process.env.TANK01_MLB_HOST || DEFAULT_HOST;
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) 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,
});
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('[tank01MLB] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
/**
* getMLBBoxScore — per-player batting + pitching lines.
* Status-aware TTL: same pattern as the NBA adapter (5 min live,
* 24 h once Final).
*/
async function getMLBBoxScore(gameId) {
if (!gameId) return null;
const cacheKey = `tank01:mlb:boxscore:${gameId}`;
const data = await fetchWithCache(
`/getMLBBoxScore?gameID=${encodeURIComponent(gameId)}`,
cacheKey,
TTL.boxScoreLive,
);
if (data === null) return null;
const body = data?.body || data;
const isFinal = (() => {
const s = body?.gameStatus || body?.status || '';
return typeof s === 'string' && /final/i.test(s);
})();
if (isFinal) await cacheSet(cacheKey, data, TTL.boxScoreFinal);
// Project batters + pitchers into a flat list. Tank01 splits these
// into `playerStats.{batting,pitching}` — we tag the role so the
// consumer can filter.
const stats = body?.playerStats || {};
const out = [];
for (const [id, entry] of Object.entries(stats.batting || stats.batters || {})) {
out.push({ role: 'batter', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
for (const [id, entry] of Object.entries(stats.pitching || stats.pitchers || {})) {
out.push({ role: 'pitcher', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
return out;
}
/**
* getMLBBatterVsPitcher — historical BvP matchup. The headline new
* MLB signal: a batter's plate appearances, hits, K's, HRs, total
* bases against a specific pitcher's career. Use ID, not name.
*/
async function getMLBBatterVsPitcher(batterId, pitcherId) {
if (!batterId || !pitcherId) return null;
const data = await fetchWithCache(
`/getMLBBatterVsPitcher?batterID=${encodeURIComponent(batterId)}&pitcherID=${encodeURIComponent(pitcherId)}`,
`tank01:mlb:bvp:${batterId}:${pitcherId}`,
TTL.bvp,
);
if (data === null) return null;
const body = data?.body || data;
// The response shape can be either a single object or a `matchups`
// array depending on schema version — normalize.
if (Array.isArray(body)) {
return body.map(projectBvP);
}
return projectBvP(body);
}
function projectBvP(row) {
if (!row || typeof row !== 'object') return null;
return {
batterId: row.batterID ?? null,
pitcherId: row.pitcherID ?? null,
plateAppearances: num(row.PA ?? row.pa, 0),
atBats: num(row.AB ?? row.ab, 0),
hits: num(row.H ?? row.hits, 0),
doubles: num(row['2B'] ?? row.doubles, 0),
triples: num(row['3B'] ?? row.triples, 0),
homeRuns: num(row.HR ?? row.homeRuns, 0),
rbi: num(row.RBI ?? row.rbi, 0),
walks: num(row.BB ?? row.walks, 0),
strikeouts: num(row.SO ?? row.K ?? row.strikeouts, 0),
avg: row.AVG ?? row.avg ?? null,
ops: row.OPS ?? row.ops ?? null,
};
}
function num(v, fallback = 0) {
if (v == null || v === '') return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/**
* getMLBDailyScoreboard — schedule + scores for a date.
*/
async function getMLBDailyScoreboard(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getMLBScoresOnly?gameDate=${ymd}`,
`tank01:mlb:scoreboard:${ymd}`,
TTL.scoreboard,
);
if (data === null) return null;
const body = data?.body || data;
// Body shape varies: array of games OR map keyed by gameID. Normalize to array.
const entries = Array.isArray(body) ? body : Object.values(body || {});
return entries.map((g) => ({
gameId: g.gameID ?? null,
homeTeam: g.home ?? null,
awayTeam: g.away ?? null,
gameTime: g.gameTime ?? null,
gameStatus: g.gameStatus ?? null,
homeScore: num(g.homePts, null),
awayScore: num(g.awayPts, null),
}));
}
module.exports = {
getMLBBoxScore,
getMLBBatterVsPitcher,
getMLBDailyScoreboard,
hasApiKey,
__internals: {
TTL,
DEFAULT_HOST,
getHost,
projectBvP,
num,
},
};