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 },
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PropLine adapter (Session 30).
|
||||
*
|
||||
* PropLine returns The-Odds-API-COMPATIBLE responses (an array of game
|
||||
* objects, each with `bookmakers[].markets[].outcomes[]` carrying
|
||||
* name/description/price/point). So this adapter is THIN: fetch + hand the
|
||||
* raw array to the shared `oddsNormalizer` — no bespoke parsing.
|
||||
*
|
||||
* Differences from The Odds API:
|
||||
* - Auth: `?apiKey=` query param (not x-api-key header)
|
||||
* - Base: https://api.prop-line.com/v1/sports
|
||||
* - THREE free keys rotate for 3,000 req/day combined (1,000 each)
|
||||
* - Sport keys match odds-api (baseball_mlb, basketball_nba, …)
|
||||
*
|
||||
* Two layers of quota:
|
||||
* - Gateway/quotaTracker counts TOTAL propline calls (3,000/day cap).
|
||||
* - This adapter rotates which PHYSICAL key serves each call so no
|
||||
* single key exceeds its 1,000/day. Per-key usage is tracked in Redis
|
||||
* (`propline:usage:{i}:{utcDate}`), with an in-memory fallback.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const gateway = require('../providerGateway');
|
||||
const { normalizeProps, extractSpreads } = require('../../utils/oddsNormalizer');
|
||||
const { getRedisClient, isDegraded } = require('../../utils/redis');
|
||||
|
||||
const BASE = 'https://api.prop-line.com/v1/sports';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const PER_KEY_DAILY_LIMIT = 1000;
|
||||
const ROTATE_THRESHOLD = 900; // rotate off a key once it hits 90%
|
||||
|
||||
// Internal sport → PropLine sport key (mirrors oddsService.SPORT_KEYS).
|
||||
const SPORT_KEYS = {
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'football_nfl',
|
||||
nhl: 'hockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
};
|
||||
|
||||
// Markets to request per sport (comma-joined). Spreads requested too so
|
||||
// extractSpreads has data.
|
||||
const MARKETS = {
|
||||
nba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes', 'player_blocks', 'player_steals'],
|
||||
wnba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes'],
|
||||
mlb: ['batter_hits', 'batter_home_runs', 'batter_total_bases', 'batter_rbis', 'batter_stolen_bases', 'pitcher_strikeouts'],
|
||||
nfl: [],
|
||||
nhl: [],
|
||||
ncaab: ['player_points', 'player_rebounds', 'player_assists'],
|
||||
};
|
||||
|
||||
// In-memory fallback when Redis is unavailable (resets on process restart;
|
||||
// acceptable — Redis is the real counter in production).
|
||||
const memUsage = {};
|
||||
|
||||
function utcDate() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getKeys() {
|
||||
return [
|
||||
process.env.PROPLINE_API_KEY_1,
|
||||
process.env.PROPLINE_API_KEY_2,
|
||||
process.env.PROPLINE_API_KEY_3,
|
||||
].map((k) => (k && k.trim() ? k.trim() : null));
|
||||
}
|
||||
|
||||
function hasKeys() {
|
||||
return getKeys().some(Boolean);
|
||||
}
|
||||
|
||||
function usageKey(i) {
|
||||
return `propline:usage:${i}:${utcDate()}`;
|
||||
}
|
||||
|
||||
async function getUsage(i) {
|
||||
if (!(isDegraded && isDegraded())) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (redis && typeof redis.get === 'function') {
|
||||
const v = await redis.get(usageKey(i));
|
||||
if (v != null) return parseInt(v, 10) || 0;
|
||||
}
|
||||
} catch { /* fall through to memory */ }
|
||||
}
|
||||
return memUsage[usageKey(i)] || 0;
|
||||
}
|
||||
|
||||
async function incrUsage(i) {
|
||||
const key = usageKey(i);
|
||||
memUsage[key] = (memUsage[key] || 0) + 1;
|
||||
if (isDegraded && isDegraded()) return;
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (redis && typeof redis.incr === 'function') {
|
||||
const n = await redis.incr(key);
|
||||
if (n === 1 && typeof redis.expire === 'function') await redis.expire(key, 36 * 3600);
|
||||
}
|
||||
} catch { /* memory already incremented */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the key index with the MOST remaining capacity (least used) that is
|
||||
* present and under the rotate threshold. Returns null when every present
|
||||
* key is at/over the per-key limit (gateway then falls through to backup).
|
||||
*/
|
||||
async function pickKey(keys) {
|
||||
let best = null;
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
if (!keys[i]) continue;
|
||||
const used = await getUsage(i);
|
||||
if (used >= PER_KEY_DAILY_LIMIT) continue;
|
||||
const remaining = PER_KEY_DAILY_LIMIT - used;
|
||||
// Prefer keys under the rotate threshold; among those, most remaining.
|
||||
const score = used < ROTATE_THRESHOLD ? remaining + PER_KEY_DAILY_LIMIT : remaining;
|
||||
if (best === null || score > best.score) best = { index: i, key: keys[i], score };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function buildUrl(sportKey) {
|
||||
return `${BASE}/${sportKey}/odds`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw PropLine game array for a sport. Returns null when the
|
||||
* sport is unsupported, no keys exist, or every key is exhausted —
|
||||
* letting the caller fall through to the backup provider.
|
||||
*/
|
||||
async function fetchRaw(sport) {
|
||||
const sportKey = SPORT_KEYS[sport];
|
||||
if (!sportKey) return null;
|
||||
const keys = getKeys();
|
||||
if (!keys.some(Boolean)) return null;
|
||||
|
||||
const picked = await pickKey(keys);
|
||||
if (!picked) return null; // all keys exhausted today
|
||||
|
||||
const markets = (MARKETS[sport] || []).join(',');
|
||||
const url = buildUrl(sportKey);
|
||||
|
||||
const res = await gateway.fetch(
|
||||
'propline',
|
||||
() => axios.get(url, {
|
||||
params: { apiKey: picked.key, ...(markets ? { markets } : {}) },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
{ capability: 'props', sport },
|
||||
);
|
||||
await incrUsage(picked.index);
|
||||
|
||||
const body = res && res.data;
|
||||
if (Array.isArray(body)) return body;
|
||||
// PropLine occasionally wraps in { data: [...] } — tolerate it.
|
||||
if (Array.isArray(body && body.data)) return body.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + normalize props for a sport. Returns { props, spreads, source }
|
||||
* on success, or null on failure / no data (caller falls back).
|
||||
*/
|
||||
async function getProps(sport) {
|
||||
try {
|
||||
const raw = await fetchRaw(sport);
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const props = normalizeProps(raw);
|
||||
const spreads = extractSpreads(raw);
|
||||
return { props, spreads, source: 'propline' };
|
||||
} catch (err) {
|
||||
console.warn('[propline] getProps failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProps,
|
||||
fetchRaw,
|
||||
hasKeys,
|
||||
pickKey,
|
||||
__internals: {
|
||||
SPORT_KEYS, MARKETS, PER_KEY_DAILY_LIMIT, ROTATE_THRESHOLD,
|
||||
getKeys, getUsage, incrUsage, utcDate, buildUrl, usageKey, memUsage,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user