Files
vyndr/src/services/adapters/mlbStatsAdapter.js
T
builtbykev b6787af191 Wave 1: kill three trust bugs (billing renewal + namesake collision + Desk copy)
FIX 1 — Honest billing renewal render. VYNDR tiers are monthly, so a
`subscription_end` far in the future (the manually-seeded "RENEWS 6/9/2036"
founder row) is a comped/lifetime/seed value, not a renewal. New
web/src/lib/billingDisplay.js `classifyRenewal()` → date | none | lapsed |
unknown (strict Date.parse guard, MONTHLY_RENEWAL_MAX_DAYS=60). Profile page
renders the classified label for both the "Renews" stat and the
cancel-scheduled "Access ends" line — no raw far-future date. No DB row mutated.

FIX 2 — MLB namesake collision (James Wood → "Chicago Cubs"). searchPlayer now
collects ALL exact-nameKey matches instead of first-`.find`; a ≥2 collision
resolves ONLY via a confident teamHint (the prop's game participants, matched
against the cached /teams list with ESPN↔statsapi abbr reconciliation), else
refuses (null) — never guesses. The hint threads getPlayerStats →
resolvePlayerStats → snapshotService (built from each prop's home/away team).
Join invariant: a single-exact player whose team isn't in the hinted game has
its team DROPPED (null), so streaks/rosterlogs never tag a foreign team. Full
teamHint recovery shipped (not just the refuse fallback).

FIX 3 — DeskShowcase headline "A $1M terminal." → deadpan value-showing copy
"Every grade, every alt line, live." Prices ($44.99 / $34.99) unchanged.

Tests: billingDisplay.test.js (7), mlbNamesakeResolve.test.js (12,
disambiguation + join invariant + pure helpers), ds5PricingStates updated to
assert the new headline and no "$1M". Full suite green (237 suites / 2863
tests); web `next build` exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 03:48:58 -04:00

403 lines
16 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);
}
// ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
// Two different players can share an EXACT nameKey ("James Wood" — the Nationals
// star + a Cubs-affiliate namesake). Taking the first `.find` match silently
// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in
// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a
// confident team hint (the prop's game participants); otherwise refuse (null).
// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs.
// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal.
const ABBR_ALIAS = Object.freeze({
AZ: 'ARI', ARI: 'ARI',
CHW: 'CWS', CWS: 'CWS',
WSN: 'WSH', WSH: 'WSH',
SDP: 'SD', SD: 'SD',
SFG: 'SF', SF: 'SF',
TBR: 'TB', TB: 'TB',
KCR: 'KC', KC: 'KC',
});
function canonAbbr(a) {
const u = String(a || '').toUpperCase().trim();
return ABBR_ALIAS[u] || u;
}
function teamNorm(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
/**
* Does a resolved team record (`{ id, name }`, from a candidate's currentTeam)
* match ANY identifier in the prop's team hint? A hint entry may be an abbr
* ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals").
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
* an abbr hint into a team id.
*/
function teamRecordMatchesHint(team, teamHint, teams) {
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
const tId = team.id;
const tName = teamNorm(team.name);
for (const h of teamHint) {
if (!h) continue;
// 1) hint as a name (equal / either-contains — handles "Nationals" vs full)
const hn = teamNorm(h);
if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true;
// 2) hint as an abbr → resolve to a team id via the teams list, compare ids
const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h));
if (rec && tId != null && rec.id === tId) return true;
}
return false;
}
/** Among namesake candidates, return the SINGLE one whose currentTeam matches
* the hint, else null (2+ or 0 matches → refuse; never guess). */
function disambiguateByHint(candidates, teamHint, teams) {
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
const matches = (candidates || []).filter((p) =>
p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams));
return matches.length === 1 ? matches[0] : null;
}
/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial
* hit, else null. A missing profile beats another player's log. */
function lastNameInitialFallback(people, targetKey) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (!(first && last && first !== last)) return null;
const cands = (people || []).filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
return cands.length === 1 ? cands[0] : null;
}
/**
* Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game
* team identifiers) disambiguates namesakes AND enforces the join invariant:
* when a hint is present but the resolved player's team is NOT a participant of
* the prop's game, the team is DROPPED (returned null) rather than tagging a
* foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests.
*/
async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
const targetKey = nameKey(name);
if (!targetKey) return null;
let people = opts.people;
if (!Array.isArray(people)) {
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
people = (data && Array.isArray(data.people)) ? data.people : [];
}
const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null;
let teams = Array.isArray(opts.teams) ? opts.teams : null;
const ensureTeams = async () => {
if (teams) return teams;
try { teams = await getTeams(season); } catch { teams = []; }
return teams;
};
const exact = people.filter((p) => nameKey(p.fullName) === targetKey);
let hit = null;
let teamConfirmed = true; // stays true when there's no hint to check against
if (exact.length === 1) {
hit = exact[0];
if (teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
}
} else if (exact.length >= 2) {
// Namesake collision — resolve ONLY with a confident hint, else refuse.
hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null;
// a hit here is team-confirmed by construction.
} else {
hit = lastNameInitialFallback(people, targetKey);
if (hit && teamHint && hit.currentTeam) {
teamConfirmed = teamRecordMatchesHint(
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
}
}
if (!hit) return null;
return {
id: hit.id,
fullName: hit.fullName ?? name,
team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : 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, opts = {}) {
try {
const person = await searchPlayer(name, season, opts);
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,
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
},
};