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