Files
vyndr/src/services/adapters/mlbStatsAdapter.js
T
builtbykev 5d19660f8e Session E (night2): Phase 4 — scan + parlay polish
4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend
    /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline
    Python service). New Express route + mlbStatsAdapter.matchPlayers —
    canonical nameKey fuzzy match (exact > last-name prefix > folded
    substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani /
    Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented
    and unaccented return identical results. Non-MLB matches the
    platform's cached names (rosterlogs + grades), cache-only.
4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp →
    90ms-staggered context panels (entrance floors visible per the
    Phase-0 rule); prefers-reduced-motion skips straight to the card.
4.3 PRIOR READS chips on scan results — the model's public ledger
    history for the player (deferred-render, outcomes + pending, never
    invented). /api/ledger/model gains ?player= on entries.
4.4 Parlay Lab: humanized stat labels via the ONE shared formatter
    (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade:
    B — add a leg for the combined read'); discoverable entry — Nav
    'Parlay Lab' item opens the drawer via window.__openParlay, and the
    open drawer now renders an honest empty state at 0 legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:59:17 -04:00

298 lines
11 KiB
JavaScript

'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;
}
const { nameKey } = require('../../utils/playerName');
/**
* Resolve a player name → MLB person record (Session 43; rebuilt Session 59,
* work-order 1.6). Pulls the season player list (cached 24h) and matches on
* the CANONICAL nameKey — accent-folded, suffix/nickname-resolved — so
* "Sánchez" ≡ "Sanchez" and "Matt" ≡ "Matthew" resolve to ONE player.
*
* The old matcher lower-cased and stripped non-[a-z0-9], which DELETED
* accented letters ("Sánchez" → "snchez" ≠ "sanchez") and then fell back to
* a raw substring match that could silently return the WRONG player — the
* audit's "last-10 opponents don't match his team" bug. The fallback now
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* ambiguous returns null (a missing profile beats another player's log).
*/
/**
* Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the
* canonical list. Pure: rank = exact nameKey > last-name prefix > folded
* substring. Case/diacritic-insensitive via nameKey's folding, so
* "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve.
*/
function matchPlayers(people, query, limit = 12) {
const qKey = nameKey(query);
if (!qKey) return [];
const qLast = qKey.split(' ').pop();
const scored = [];
for (const p of people || []) {
const k = nameKey(p.fullName);
if (!k) continue;
let score = null;
if (k === qKey) score = 0;
else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1;
else if (k.includes(qKey)) score = 2;
if (score == null) continue;
scored.push({ score, p });
}
scored.sort((a, b) => a.score - b.score);
return scored.slice(0, limit).map(({ p }) => ({
id: p.id,
fullName: p.fullName,
team: p.currentTeam?.name ?? null,
position: p.primaryPosition?.abbreviation ?? null,
}));
}
/** Multi-result fuzzy search (scan search box). Cached list, free API. */
async function searchPlayers(query, opts = {}) {
const season = opts.season || DEFAULT_SEASON;
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : [];
return matchPlayers(people, query, opts.limit || 12);
}
async function searchPlayer(name, season = DEFAULT_SEASON) {
const targetKey = nameKey(name);
if (!targetKey) return null;
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : [];
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
if (!hit) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (first && last && first !== last) {
const cands = people.filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess
}
}
if (!hit) return null;
return {
id: hit.id,
fullName: hit.fullName ?? name,
team: hit.currentTeam?.name ?? null,
teamId: hit.currentTeam?.id ?? null,
position: hit.primaryPosition?.abbreviation ?? null,
};
}
/**
* Name-keyed convenience: resolve the player, then fetch the season stat
* object for the right group (pitching for pitchers, hitting otherwise) plus a
* recent game log. Returns { found, id, name, team, position, group, season,
* last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
* { found: false } on any miss/failure (never throws).
*/
async function getPlayerStats(name, season = DEFAULT_SEASON) {
try {
const person = await searchPlayer(name, season);
if (!person) return { found: false };
const group = person.position === 'P' ? 'pitching' : 'hitting';
const [seasonStat, log] = await Promise.all([
getSeasonAverages(person.id, season, group),
getPlayerGameLog(person.id, season, group),
]);
if (!seasonStat) return { found: false, id: person.id, name: person.fullName, team: person.team, position: person.position, group };
return {
found: true,
id: person.id,
name: person.fullName,
team: person.team,
position: person.position,
group,
season: seasonStat,
last10: (log || []).slice(-10),
};
} catch (err) {
console.warn('[mlbStats] getPlayerStats failed:', name, err.message);
return { found: false };
}
}
/**
* All MLB teams (Session 51) → [{ id, abbr, name }]. Cached 24h. Used to map a
* UI abbreviation ("NYY") to the statsapi team id.
*/
async function getTeams(season = DEFAULT_SEASON) {
const url = `${BASE}/teams?sportId=1&season=${season}`;
const data = await fetchWithCache(url, `mlbstats:teams:${season}`, 24 * 3600);
const teams = (data && Array.isArray(data.teams)) ? data.teams : [];
return teams.map((t) => ({ id: t.id, abbr: t.abbreviation || null, name: t.name || null }));
}
/** Resolve a team abbreviation → { id, abbr, name } or null. */
async function resolveTeam(abbr, season = DEFAULT_SEASON) {
const a = String(abbr || '').toUpperCase();
if (!a) return null;
const teams = await getTeams(season);
return teams.find((t) => String(t.abbr).toUpperCase() === a) || null;
}
/**
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
* Cached 6h. [] on failure.
*/
async function getTeamRoster(teamId, season = DEFAULT_SEASON) {
if (!teamId) return [];
const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`;
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600);
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
return roster.map((r) => ({
id: r.person?.id ?? null,
name: r.person?.fullName ?? null,
position: r.position?.abbreviation ?? null,
jersey: r.jerseyNumber ?? null,
})).filter((p) => p.id && p.name);
}
module.exports = {
getScheduleWithPitchers,
getPlayerGameLog,
getSeasonAverages,
getBatterVsPitcher,
searchPlayer,
searchPlayers,
matchPlayers,
getPlayerStats,
getTeams,
resolveTeam,
getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
};