Files
vyndr/src/services/adapters/espnStatsAdapter.js
T
builtbykev 47ada9013c Wave 2A: real player headshots — sport-agnostic id threaded from ingestion
Threads a REAL athlete id from the snapshot's per-player stats resolve (zero
new I/O) → enriched grade → grades:{sport} → slate strip → PlayerAvatar. Real
photo where an id resolves; team-colored monogram (never a gray silhouette,
never a broken image) where it can't. Ids are never fabricated.

Ingestion (Addition 1):
- espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id
  (was discarded) as espnId; non-numeric uid degrades to null.
- playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA).
- snapshotService captures both per player and stores them on the enriched
  grade beside archetype/team (null when unresolved → monogram path).

Thread → component:
- slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each
  strip; StatStrip → PlayerAvatar (accepts both ids; getHeadshotUrl routes by
  sport: MLB→mlbstatic, NBA/WNBA→a.espncdn).
- Silhouette surfaces rewired to PlayerAvatar (branded monogram on null):
  scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal,
  HotListPanel, GradeResultCard header. Scan grade card feeds the picked
  MLBAM id through gradeAdapter.
- playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js
  (unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH.

Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram
null path) + extended snapshotService/espnStatsAdapter suites. Full suite
241 suites / 2915 green; next build exit 0.

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

122 lines
5.3 KiB
JavaScript

'use strict';
/**
* espnStatsAdapter — best-effort NBA/WNBA season averages from ESPN (Session 45).
*
* The primary NBA/WNBA stats source (`nbaStatsClient`) depends on a Python
* nba_api service that is frequently offline in prod. This adapter is a FREE,
* no-auth fallback off ESPN's public site API. It is intentionally DEFENSIVE:
* any shape it doesn't recognize → null (the caller degrades to found:false),
* never a throw and never a wrong-but-confident number.
*
* Parsing is tolerant by design (ESPN's athlete-stats JSON varies by sport and
* season), so `parseAthleteStats` is a pure, unit-tested function.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
const TTL = 6 * 3600;
const TIMEOUT = 10_000;
// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
const STAT_MAP = {
pointspergame: 'ppg', avgpoints: 'ppg', points: 'ppg', ppg: 'ppg',
reboundspergame: 'rpg', avgrebounds: 'rpg', rebounds: 'rpg', rpg: 'rpg', totalrebounds: 'rpg',
assistspergame: 'apg', avgassists: 'apg', assists: 'apg', apg: 'apg',
blockspergame: 'bpg', avgblocks: 'bpg', blocks: 'bpg', bpg: 'bpg',
stealspergame: 'spg', avgsteals: 'spg', steals: 'spg', spg: 'spg',
threepointfieldgoalsmade: 'threes', threepointfieldgoalspergame: 'threes', avg3pointfieldgoalsmade: 'threes',
};
const keyify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
/**
* Walk an ESPN athlete-stats payload and pull out per-game averages we can
* classify. Returns a classifier-input object (possibly partial) or null when
* nothing usable is found.
*/
function parseAthleteStats(payload) {
if (!payload || typeof payload !== 'object') return null;
const out = {};
// ESPN nests stats under categories[].stats[] with { name|abbreviation, value|displayValue }.
const categories = payload?.statistics?.splits?.categories
|| payload?.splits?.categories
|| payload?.categories
|| [];
const visit = (statArr) => {
for (const st of statArr || []) {
const label = keyify(st.name || st.abbreviation || st.label);
const mapped = STAT_MAP[label];
if (!mapped) continue;
const val = Number(st.value != null ? st.value : st.displayValue);
if (Number.isFinite(val) && out[mapped] == null) out[mapped] = val;
}
};
for (const cat of categories) visit(cat.stats);
if (Array.isArray(payload.stats)) visit(payload.stats); // flat fallback
return Object.keys(out).length > 0 ? out : null;
}
async function fetchJson(url, http) {
const client = http || axios;
const res = await client.get(url, { timeout: TIMEOUT });
return res && res.data;
}
/**
* Resolve a player's NBA/WNBA season averages from ESPN. Returns
* { found, team, position, classifierInput } or { found:false }. Never throws.
* opts.http injectable for tests.
*/
async function getSeasonAverages(name, sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const path = SPORT_PATH[sp];
if (!path || !name) return { found: false };
const cacheKey = `espnstats:${sp}:${keyify(name)}`;
try {
const cached = await cacheGet(cacheKey);
if (cached) return cached;
} catch { /* ignore */ }
try {
// 1. Resolve the athlete id via ESPN search.
const search = await fetchJson(`${SEARCH}?query=${encodeURIComponent(name)}&limit=5&sport=${encodeURIComponent(path)}`, opts.http);
const items = (search && (search.items || search.results)) || [];
const athlete = items.find((it) => keyify(it.displayName || it.name) === keyify(name)) || items[0];
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
if (!id) return { found: false };
// Wave 2A — the REAL ESPN athlete id for the headshot CDN
// (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
// pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
// id, so it degrades to null → monogram. Never fabricate.
const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;
// 2. Fetch that athlete's stats overview.
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
const classifierInput = parseAthleteStats(stats);
if (!classifierInput) return { found: false };
const result = {
found: true,
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
position: (athlete.position && athlete.position.abbreviation) || '',
classifierInput,
// Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
// slate strip → headshot. Absent → monogram (doctrine).
espnId,
};
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
return result;
} catch (err) {
console.warn('[espnStats] season averages failed:', name, sp, err.message);
return { found: false };
}
}
module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } };