Sessions 29-30: Content templates + PropLine 3-key adapter + MLB Stats API + ESPN summary (1694 tests)
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* MLB Stats API adapter (Session 30).
|
||||
*
|
||||
* Official MLB data from statsapi.mlb.com — FREE, no auth, unlimited. The
|
||||
* ground truth for MLB prop grading. Does NOT route through the provider
|
||||
* gateway (there's no quota to track); it caches in Redis with
|
||||
* stat-appropriate TTLs and degrades to null on any failure.
|
||||
*
|
||||
* getScheduleWithPitchers(date) — schedule + probable pitchers
|
||||
* getPlayerGameLog(playerId, season, group)— per-game splits (recent form)
|
||||
* getSeasonAverages(playerId, season, group)— season totals (AVG/OBP/SLG/…)
|
||||
* getBatterVsPitcher(batterId, pitcherId) — career/season matchup splits
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const BASE = 'https://statsapi.mlb.com/api/v1';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SEASON = 2026;
|
||||
|
||||
const TTL = Object.freeze({
|
||||
schedule: 30 * 60, // 30 min — lineups/probables update
|
||||
gameLog: 6 * 3600, // 6 h — changes after games complete
|
||||
season: 6 * 3600, // 6 h
|
||||
bvp: 24 * 3600, // 24 h — rarely changes intraday
|
||||
});
|
||||
|
||||
// No auth headers — this is a free, open API.
|
||||
async function fetchWithCache(url, cacheKey, ttl) {
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const data = res && res.data;
|
||||
if (data && typeof data === 'object') {
|
||||
await cacheSet(cacheKey, data, ttl);
|
||||
await cacheSet(`${cacheKey}:stale`, data, ttl * 4);
|
||||
}
|
||||
return data ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[mlbStats] fetch failed:', url, err.message);
|
||||
const stale = await cacheGet(`${cacheKey}:stale`);
|
||||
return stale !== null ? stale : null;
|
||||
}
|
||||
}
|
||||
|
||||
function ymd(date) {
|
||||
return String(date || '').slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule + probable pitchers for a date. Returns a normalized array of
|
||||
* games, or [] when none / on failure.
|
||||
*/
|
||||
async function getScheduleWithPitchers(date) {
|
||||
if (!date) return [];
|
||||
const d = ymd(date);
|
||||
const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=probablePitcher(note)`;
|
||||
const data = await fetchWithCache(url, `mlbstats:schedule:${d}`, TTL.schedule);
|
||||
if (!data) return [];
|
||||
const games = (data.dates || []).flatMap((day) => day.games || []);
|
||||
return games.map((g) => ({
|
||||
gamePk: g.gamePk ?? null,
|
||||
gameDate: g.gameDate ?? null,
|
||||
status: g.status?.abstractGameState ?? null,
|
||||
venue: g.venue?.name ?? null,
|
||||
home: {
|
||||
team: g.teams?.home?.team?.name ?? null,
|
||||
teamId: g.teams?.home?.team?.id ?? null,
|
||||
probablePitcher: g.teams?.home?.probablePitcher
|
||||
? { id: g.teams.home.probablePitcher.id, name: g.teams.home.probablePitcher.fullName ?? null }
|
||||
: null,
|
||||
},
|
||||
away: {
|
||||
team: g.teams?.away?.team?.name ?? null,
|
||||
teamId: g.teams?.away?.team?.id ?? null,
|
||||
probablePitcher: g.teams?.away?.probablePitcher
|
||||
? { id: g.teams.away.probablePitcher.id, name: g.teams.away.probablePitcher.fullName ?? null }
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Pull the splits array out of the standard people/stats response shape.
|
||||
function extractSplits(data) {
|
||||
if (!data || !Array.isArray(data.stats)) return [];
|
||||
return data.stats.flatMap((s) => s.splits || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game splits for a player. Returns an array of { date, opponent, stat }
|
||||
* (most recent last, as MLB returns chronologically). [] on failure.
|
||||
*/
|
||||
async function getPlayerGameLog(playerId, season = DEFAULT_SEASON, group = 'hitting') {
|
||||
if (!playerId) return [];
|
||||
const url = `${BASE}/people/${playerId}/stats?stats=gameLog&season=${season}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:gamelog:${playerId}:${season}:${group}`, TTL.gameLog);
|
||||
return extractSplits(data).map((sp) => ({
|
||||
date: sp.date ?? null,
|
||||
opponent: sp.opponent?.name ?? null,
|
||||
isHome: sp.isHome ?? null,
|
||||
stat: sp.stat || {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Season averages for a player. Returns the season stat object (AVG, OBP,
|
||||
* SLG, OPS, homeRuns, rbi, …) or null.
|
||||
*/
|
||||
async function getSeasonAverages(playerId, season = DEFAULT_SEASON, group = 'hitting') {
|
||||
if (!playerId) return null;
|
||||
const url = `${BASE}/people/${playerId}/stats?stats=season&season=${season}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:season:${playerId}:${season}:${group}`, TTL.season);
|
||||
const splits = extractSplits(data);
|
||||
return splits.length > 0 ? (splits[0].stat || null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batter-vs-pitcher matchup splits. Returns the matchup stat object or null.
|
||||
*/
|
||||
async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
|
||||
if (!batterId || !pitcherId) return null;
|
||||
const url = `${BASE}/people/${batterId}/stats?stats=vsPlayer&opposingPlayerId=${pitcherId}&group=${group}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:bvp:${batterId}:${pitcherId}:${group}`, TTL.bvp);
|
||||
const splits = extractSplits(data);
|
||||
return splits.length > 0 ? (splits[0].stat || null) : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getScheduleWithPitchers,
|
||||
getPlayerGameLog,
|
||||
getSeasonAverages,
|
||||
getBatterVsPitcher,
|
||||
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
|
||||
};
|
||||
Reference in New Issue
Block a user