'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 }, };