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