186 lines
5.9 KiB
JavaScript
186 lines
5.9 KiB
JavaScript
/**
|
|
* 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,
|
|
},
|
|
};
|