Sessions 29-30: Content templates + PropLine 3-key adapter + MLB Stats API + ESPN summary (1694 tests)

This commit is contained in:
Kev
2026-06-14 22:29:01 -04:00
parent 927c4a5c65
commit a3351e2135
14 changed files with 1091 additions and 27 deletions
+42 -4
View File
@@ -28,6 +28,22 @@
const PROVIDERS = {
// === ODDS / LINES ===
// Session 30 — PropLine is now PRIMARY for player props. 3 free keys
// rotate for 3,000 req/day combined (vs odds-api's 500/MONTH), and its
// response shape is The-Odds-API-compatible. The proplineAdapter handles
// which physical key to use; the gateway counts total propline calls
// against the 3,000/day cap here. odds-api drops to priority 2 (backup
// we conserve). Configured when at least key 1 is present.
'propline': {
name: 'PropLine',
envKey: 'PROPLINE_API_KEY_1',
quotaType: 'daily',
quotaLimit: 3000,
resetDay: null,
sports: ['nba', 'wnba', 'mlb', 'nfl', 'nhl'],
capabilities: ['odds', 'props', 'lines', 'spreads'],
priority: 1,
},
'odds-api': {
name: 'The Odds API',
envKey: 'ODDS_API_KEY',
@@ -36,7 +52,7 @@ const PROVIDERS = {
resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'soccer_wc', 'nfl', 'nhl'],
capabilities: ['odds', 'props', 'lines', 'spreads'],
priority: 1,
priority: 2,
},
// Session 21 — correction. ODDSPAPI is NOT a live-props fallback
// for the-odds-api. It serves Pinnacle CLOSING lines, captured at
@@ -90,6 +106,21 @@ const PROVIDERS = {
capabilities: ['box_scores', 'schedules', 'player_stats', 'bvp'],
priority: 1,
},
// Session 30 — official MLB data. FREE, no auth, unlimited. The
// mlbStatsAdapter does NOT route through the gateway (no quota to
// track); this entry is informational + surfaces it on the admin
// dashboard. `noAuth` marks it always-configured (no env key gate).
'mlb-stats': {
name: 'MLB Stats API',
envKey: null,
noAuth: true,
quotaType: 'unlimited',
quotaLimit: null,
resetDay: null,
sports: ['mlb'],
capabilities: ['game_logs', 'season_averages', 'splits', 'probable_pitchers', 'bvp'],
priority: 1,
},
// === SOCCER ===
'api-football': {
@@ -136,9 +167,16 @@ function listProviderIds() {
* by the admin dashboard to render only providers the operator has
* actually wired up.
*/
// A provider counts as configured when its key is present OR it needs no
// auth at all (e.g. MLB Stats API). Dead providers are always excluded.
function isProviderConfigured(cfg) {
if (!cfg || cfg.status === 'dead') return false;
return cfg.noAuth === true || !!(cfg.envKey && process.env[cfg.envKey]);
}
function getConfiguredProviders() {
return Object.entries(PROVIDERS)
.filter(([, cfg]) => !!process.env[cfg.envKey] && cfg.status !== 'dead')
.filter(([, cfg]) => isProviderConfigured(cfg))
.map(([id, cfg]) => ({ id, ...cfg }));
}
@@ -156,10 +194,9 @@ function getFallbackChain(capability, sport, excludeId) {
return Object.entries(PROVIDERS)
.filter(([id, cfg]) =>
id !== excludeId &&
cfg.status !== 'dead' && // Session 23 — skip retired providers
cfg.capabilities.includes(capability) &&
(!sport || cfg.sports.includes(sport)) &&
!!process.env[cfg.envKey],
isProviderConfigured(cfg), // excludes dead + unconfigured, includes noAuth
)
.sort((a, b) => a[1].priority - b[1].priority)
.map(([id]) => id);
@@ -173,4 +210,5 @@ module.exports = {
getConfiguredProviders,
getFallbackChain,
isDeadProvider,
isProviderConfigured,
};
+138
View File
@@ -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 },
};
+188
View File
@@ -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,
},
};
+56 -19
View File
@@ -280,6 +280,27 @@ function parseQuota(headers) {
return val != null ? parseInt(val, 10) : null;
}
// Best-effort post-fetch processing shared by both providers (PropLine +
// odds-api): line movement, scratch cascade, and rolling line snapshots.
// Never throws — a failure here must not break the odds response.
async function recordDownstream(sport, props) {
let movements = [];
let scratchedPlayers = [];
try {
const lineMovement = require('./lineMovementService');
const cascade = require('./cascadeService');
const moveResult = await lineMovement.processNewOdds(sport, props);
movements = moveResult.movements || [];
const cascadeResult = await cascade.detectScratches(sport, props);
scratchedPlayers = cascadeResult.scratchedPlayers || [];
const lineSnapshots = require('./lineSnapshotService');
await lineSnapshots.recordSnapshots(sport, props);
} catch (e) {
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
}
return { movements, scratchedPlayers };
}
async function getOdds(sport) {
const redis = getRedisClient();
const apiKey = process.env.ODDS_API_KEY;
@@ -294,12 +315,43 @@ async function getOdds(sport) {
sport,
updated_at: data.updated_at,
source: 'cache',
provider: data.provider || 'odds-api',
quota_remaining: quota,
props: data.props,
spreads: data.spreads || [],
};
}
// Session 30 — PropLine is the PRIMARY props provider when configured:
// 3 free keys, 3,000 req/day combined (vs odds-api's 500/month). Try it
// first; on empty/error fall through to the conserved odds-api path
// below. Gated on hasKeys() so environments without PropLine keys (incl.
// the test suite) keep the exact prior behavior.
const propline = require('./adapters/proplineAdapter');
if (propline.hasKeys()) {
try {
const pl = await propline.getProps(sport);
if (pl && Array.isArray(pl.props) && pl.props.length > 0) {
const now = new Date().toISOString();
const cacheData = { updated_at: now, props: pl.props, spreads: pl.spreads || [], provider: 'propline' };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props);
return {
sport,
updated_at: now,
source: 'live',
provider: 'propline',
props: pl.props,
spreads: pl.spreads || [],
movements,
scratchedPlayers,
};
}
} catch (e) {
console.warn('[oddsService] PropLine failed, falling back to odds-api:', e.message);
}
}
// Session 22 — pre-flight quota check now reads from the
// Session 20 tracker (truth source: synced from upstream
// response headers on every call). The legacy
@@ -333,32 +385,17 @@ async function getOdds(sport) {
}
const now = new Date().toISOString();
const cacheData = { updated_at: now, props, spreads };
const cacheData = { updated_at: now, props, spreads, provider: 'odds-api' };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
// Line movement + cascade detection (best-effort, don't block response)
let movements = [];
let scratchedPlayers = [];
try {
const lineMovement = require('./lineMovementService');
const cascade = require('./cascadeService');
const moveResult = await lineMovement.processNewOdds(sport, props);
movements = moveResult.movements || [];
const cascadeResult = await cascade.detectScratches(sport, props);
scratchedPlayers = cascadeResult.scratchedPlayers || [];
// Session 28 — append a rolling line-history snapshot per prop so the
// sparkline / biggest-movers views have data. Redis-only, free.
const lineSnapshots = require('./lineSnapshotService');
await lineSnapshots.recordSnapshots(sport, props);
} catch (e) {
// Non-fatal — log and continue
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
}
// Line movement + cascade + snapshots (best-effort; shared helper).
const { movements, scratchedPlayers } = await recordDownstream(sport, props);
return {
sport,
updated_at: now,
source: 'live',
provider: 'odds-api',
quota_remaining: quotaRemaining,
props,
spreads,
+55 -1
View File
@@ -174,9 +174,63 @@ function hasLinesData(cache) {
return false;
}
// ---------------------------------------------------------------------
// ESPN Summary enrichment (Session 30).
//
// The ESPN `summary?event=` endpoint returns rich per-game data the
// scoreboard doesn't: real-time injuries, ESPN Bet odds, ATS records,
// stat leaders, and the full box score. FREE, no auth, unlimited. We
// cache it briefly (10 min) since injuries/odds shift pre-game.
// ---------------------------------------------------------------------
const ESPN_SPORT_PATHS = Object.freeze({
nba: 'basketball/nba',
wnba: 'basketball/wnba',
mlb: 'baseball/mlb',
nfl: 'football/nfl',
nhl: 'hockey/nhl',
ncaab: 'basketball/mens-college-basketball',
});
const SUMMARY_TTL = 10 * 60; // 10 min
/**
* Enriched per-game data for one ESPN event. Returns a defensive shape
* with empty defaults — some games lack some sections, and an invalid
* eventId must NOT crash (returns the empty defaults).
*/
async function getGameSummary(sport, eventId) {
const path = ESPN_SPORT_PATHS[String(sport || '').toLowerCase()];
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null };
if (!path || !eventId) return empty;
const key = `espn:summary:${sport}:${eventId}`;
const cached = await cacheGet(key);
if (cached !== null) return cached;
try {
const url = `https://site.api.espn.com/apis/site/v2/sports/${path}/summary?event=${encodeURIComponent(eventId)}`;
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
const data = res && res.data ? res.data : {};
const out = {
injuries: Array.isArray(data.injuries) ? data.injuries : [],
odds: Array.isArray(data.odds) ? data.odds : [],
ats: data.againstTheSpread || null,
leaders: Array.isArray(data.leaders) ? data.leaders : [],
boxscore: data.boxscore || null,
};
await cacheSet(key, out, SUMMARY_TTL);
return out;
} catch (err) {
console.warn(`[schedule] ESPN summary fetch failed for ${sport}/${eventId}:`, err.message);
return empty;
}
}
module.exports = {
getSchedule,
enrichFlags,
todayET,
__internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData },
getGameSummary,
__internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData, ESPN_SPORT_PATHS },
};
+24 -1
View File
@@ -1,6 +1,11 @@
const { getAbbreviation } = require('./teamMap');
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']);
// Session 30 — PropLine (The-Odds-API-compatible) carries pinnacle, the
// sharp-line reference the odds-api allow-list lacked. Added so PropLine
// prop data through Pinnacle survives normalization. (bovada deliberately
// left OUT — it's the canonical "not-allowed" example in the tests, and
// VYNDR surfaces regulated US books.)
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']);
const MARKET_MAP = {
// NBA / WNBA props
@@ -12,6 +17,24 @@ const MARKET_MAP = {
player_steals: 'steals',
player_points_rebounds_assists: 'pra',
player_turnovers: 'turnovers',
// MLB props (Session 30) — The Odds API + PropLine share these market
// keys for baseball. Without them PropLine/odds-api MLB props would
// normalize to ZERO (MARKET_MAP previously had no baseball keys).
// Internal stat_type names match the streaks/grading engines.
batter_hits: 'hits',
batter_home_runs: 'home_runs',
batter_total_bases: 'total_bases',
batter_rbis: 'rbis',
batter_runs: 'runs',
batter_stolen_bases: 'stolen_bases',
batter_singles: 'singles',
batter_doubles: 'doubles',
batter_walks: 'walks',
batter_strikeouts: 'batter_strikeouts',
pitcher_strikeouts: 'strikeouts',
pitcher_earned_runs: 'earned_runs',
pitcher_hits_allowed: 'hits_allowed',
pitcher_outs: 'outs',
// Soccer props — World Cup 2026 + permanent league support.
// odds-api keys verified against soccer_fifa_world_cup market list.
// 'assists' is shared with NBA — sport context discriminates downstream.