5d19660f8e
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>
298 lines
11 KiB
JavaScript
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 },
|
|
};
|