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,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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user