diff --git a/src/services/adapters/espnStatsAdapter.js b/src/services/adapters/espnStatsAdapter.js index 7b651a0..0d5e3cc 100644 --- a/src/services/adapters/espnStatsAdapter.js +++ b/src/services/adapters/espnStatsAdapter.js @@ -23,8 +23,10 @@ const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search'; // carries the numeric athlete id inside its `uid` ("s:40~l:59~a:3149391"). const SEARCH_V2 = 'https://site.api.espn.com/apis/search/v2'; const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' }; +const SITE_V2 = 'https://site.api.espn.com/apis/site/v2/sports'; const TTL = 6 * 3600; const GAMELOG_TTL = 4 * 3600; // per-game logs refresh once per night +const ROSTER_TTL = 24 * 3600; // team rosters change rarely const TIMEOUT = 10_000; // ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped. @@ -235,13 +237,115 @@ async function fetchJsonG(url, opts = {}) { return res && res.data; } +// Seasons to try when a gamelog comes back league-ambiguous (filters-only). +// ESPN keys WNBA/NBA seasons on a calendar year; the current + previous year +// cover the in-season window and the year boundary. Newest first. +function candidateSeasons() { + const y = new Date().getFullYear(); + return [y, y - 1]; +} + +// ───────────────────────────────────────────────────────────────────────── +// Resolver hardening — ESPN TEAM ROSTERS are the PRIMARY id source. +// +// The v2 /search resolver is unreliable at the edges (near-miss namesakes, +// dual-league athletes) and depends on a live search call per name. ESPN's +// team-roster feed is a COMPLETE, cacheable id source: every athlete on every +// team of the league, keyed by the canonical accent-folded `nameKey`. We build +// it once (bounded concurrency over the ~15–30 teams), cache it (Redis +// `espnroster:{sport}` 24h + in-memory mirror), and look names up there FIRST; +// the v2 search stays as a backstop for a roster miss. A unique roster hit wins +// (S59 doctrine) — a missing name beats guessing another player's id. +// ───────────────────────────────────────────────────────────────────────── + +// A roster's `athletes` may be a flat array of athlete objects OR grouped by +// position ([{ position, items:[…] }]). Flatten both; skip anything unusable. +function flattenAthletes(athletes) { + if (!Array.isArray(athletes)) return []; + const out = []; + for (const a of athletes) { + if (!a || typeof a !== 'object') continue; + if (Array.isArray(a.items)) out.push(...a.items); // grouped shape + else out.push(a); + } + return out; +} + +const rosterMem = new Map(); // sport → { nameKey → { id, displayName, teamId } } + /** - * Resolve name → ESPN numeric athlete id for a basketball league via the v2 - * search. Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is - * NOT returned for a WNBA query), then prefers an exact canonical-name match. + * Aggregate every team's roster for an NBA/WNBA league into a + * { nameKey → { id, displayName, teamId } } map. Cached (Redis + * `espnroster:{sport}` 24h + in-memory mirror). Bounded concurrency over the + * team fetches; a team that fails is skipped (partial index, never a throw). + * Returns {} on total failure. opts.fetchImpl/opts.http injectable for tests. + */ +async function buildAthleteRosterIndex(sport, opts = {}) { + const sp = String(sport || '').toLowerCase(); + const path = SPORT_PATH[sp]; + if (!path) return {}; + if (rosterMem.has(sp)) return rosterMem.get(sp); + const ck = `espnroster:${sp}`; + try { + const cached = await cacheGet(ck); + if (cached && cached.index && Object.keys(cached.index).length) { + rosterMem.set(sp, cached.index); + return cached.index; + } + } catch { /* ignore cache read */ } + + try { + const teamsData = await fetchJsonG(`${SITE_V2}/${path}/teams`, opts); + const teams = teamsData?.sports?.[0]?.leagues?.[0]?.teams || []; + const teamIds = teams.map((t) => t && t.team && t.team.id).filter((x) => x != null).map(String); + if (teamIds.length === 0) return {}; + + const index = {}; + let cursor = 0; + const CONCURRENCY = 6; + async function worker() { + while (cursor < teamIds.length) { + const tid = teamIds[cursor++]; + try { + const r = await fetchJsonG(`${SITE_V2}/${path}/teams/${tid}/roster`, opts); + for (const a of flattenAthletes(r && r.athletes)) { + const id = a && a.id; + const dn = a && (a.displayName || a.fullName); + if (id == null || !/^\d+$/.test(String(id)) || !dn) continue; + const k = nameKey(dn); + if (!k) continue; + // First writer wins — a team is a full roster; don't overwrite. + if (!index[k]) index[k] = { id: String(id), displayName: String(dn), teamId: String(tid) }; + } + } catch { /* skip a team that fails — partial index beats none */ } + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, teamIds.length) }, worker)); + + if (Object.keys(index).length === 0) return {}; // never cache/mirror an empty index + rosterMem.set(sp, index); + try { await cacheSet(ck, { index }, ROSTER_TTL); } catch { /* ignore cache write */ } + return index; + } catch (err) { + console.warn('[espnStats] roster index failed:', sp, err.message); + return {}; + } +} + +/** PRIMARY: name → numeric id via the complete team-roster index. null on miss. */ +async function resolveAthleteIdViaRoster(name, sport, opts = {}) { + const index = await buildAthleteRosterIndex(sport, opts); + const hit = index[nameKey(name)]; + return hit ? String(hit.id) : null; +} + +/** + * BACKSTOP: resolve name → ESPN numeric athlete id via the v2 search. + * Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is NOT + * returned for a WNBA query), then prefers an exact canonical-name match. * Returns the numeric id string or null (a missing id beats the wrong player). */ -async function resolveAthleteId(name, sport, opts = {}) { +async function resolveAthleteIdViaSearch(name, sport, opts = {}) { const data = await fetchJsonG(`${SEARCH_V2}?query=${encodeURIComponent(name)}&limit=10`, opts); const section = (data && Array.isArray(data.results) ? data.results : []).find((r) => r && r.type === 'player'); const players = (section && Array.isArray(section.contents)) ? section.contents : []; @@ -257,6 +361,16 @@ async function resolveAthleteId(name, sport, opts = {}) { return /^\d+$/.test(String(chosen.id)) ? String(chosen.id) : null; } +/** + * Resolve name → ESPN numeric athlete id. Tries the complete team-roster index + * FIRST (reliable, cached), falls back to the v2 search on a roster miss. + */ +async function resolveAthleteId(name, sport, opts = {}) { + const viaRoster = await resolveAthleteIdViaRoster(name, sport, opts); + if (viaRoster) return viaRoster; + return resolveAthleteIdViaSearch(name, sport, opts); +} + const gameLogMem = new Map(); /** @@ -278,8 +392,18 @@ async function getPlayerGameLog(name, sport, opts = {}) { if (cached) { gameLogMem.set(ck, cached); return cached; } } catch { /* ignore cache read */ } - const payload = await fetchJsonG(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`, opts); - const last10 = parseGameLog(payload); + const gamelogUrl = `https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`; + let last10 = parseGameLog(await fetchJsonG(gamelogUrl, opts)); + if (!last10) { + // Dual-league athletes (WNBA + NCAA — e.g. Napheesa Collier, Brionna + // Jones) get a filters-only gamelog until a season is specified (verified + // live 2026-07). Retry with candidate seasons before giving up. Only fires + // on a parse miss, so the common path is untouched. + for (const yr of candidateSeasons()) { + last10 = parseGameLog(await fetchJsonG(`${gamelogUrl}?season=${yr}`, opts)); + if (last10) break; + } + } if (!last10) return { found: false, id }; const result = { found: true, id, last10 }; gameLogMem.set(ck, result); @@ -297,5 +421,10 @@ module.exports = { getPlayerGameLog, parseGameLog, resolveAthleteId, - __internals: { STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum, gameLogMem }, + buildAthleteRosterIndex, + __internals: { + STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum, + gameLogMem, rosterMem, flattenAthletes, + resolveAthleteIdViaRoster, resolveAthleteIdViaSearch, candidateSeasons, + }, }; diff --git a/tests/unit/espnRosterIndex.test.js b/tests/unit/espnRosterIndex.test.js new file mode 100644 index 0000000..b57e36b --- /dev/null +++ b/tests/unit/espnRosterIndex.test.js @@ -0,0 +1,175 @@ +// Resolver hardening — ESPN team-roster index is the PRIMARY name→id source +// for NBA/WNBA (the v2 search is a backstop). Hermetic: fetch is injected, no +// network. Verifies the index is COMPLETE (accented + suffix names fold), that +// a roster-only player resolves (search never consulted needlessly), that the +// roster hit WINS over a conflicting search result, that an unknown name never +// guesses, and that a failing team fetch is skipped (partial index, no throw). + +// Redis no-op so cache read/write paths don't touch a live server. +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async () => null, + cacheSet: async () => {}, + cacheDel: async () => {}, +})); + +const espn = require('../../src/services/adapters/espnStatsAdapter'); + +// ── Fixtures ──────────────────────────────────────────────────────────────── +const TEAMS = { + sports: [{ leagues: [{ teams: [ + { team: { id: '1', displayName: 'Minnesota Lynx' } }, + { team: { id: '2', displayName: 'Atlanta Dream' } }, + { team: { id: 'FAIL', displayName: 'Broken Team' } }, + ] }] }], +}; + +// Team 1 roster: the dual-league athlete the search missed + an accented name + +// a suffix name (the two forms nameKey must fold to canonical keys). +const ROSTER_1 = { + athletes: [ + { id: '3917450', displayName: 'Napheesa Collier', fullName: 'Napheesa Collier' }, + { id: '999', displayName: 'José Álvarez', fullName: 'José Álvarez' }, + { id: '555', displayName: 'Ronald Acuña Jr.', fullName: 'Ronald Acuña Jr.' }, + { id: null, displayName: 'No Id Player' }, // skipped — no id + { id: 'guid-xyz', displayName: 'Guid Only' }, // skipped — non-numeric id + ], +}; +// Team 2 roster uses the GROUPED shape ([{ position, items:[…] }]). +const ROSTER_2 = { + athletes: [ + { position: 'Guard', items: [{ id: '4066533', displayName: 'Sabrina Ionescu' }] }, + { position: 'Forward', items: [{ id: '2998928', displayName: 'Breanna Stewart' }] }, + ], +}; + +const GAMELOG = { + names: ['points', 'totalRebounds', 'assists'], + seasonTypes: [{ categories: [{ events: [{ eventId: 'G1', stats: ['20', '9', '5'] }] }] }], + events: { G1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } }, +}; + +// A search payload that would resolve "Napheesa Collier" to a DIFFERENT +// (wrong) id — proves the roster index is consulted first and wins. +const SEARCH_CONFLICT = { + results: [{ type: 'player', contents: [ + { uid: 's:40~l:59~a:9999', displayName: 'Napheesa Collier', defaultLeagueSlug: 'wnba' }, + ] }], +}; +const SEARCH_EMPTY = { results: [] }; + +// Route an injected fetch by URL. `/roster` matched BEFORE `/teams` (the roster +// URL contains "/teams/"). Records whether search was ever hit. +function makeFetch({ search = SEARCH_EMPTY, failTeam = 'FAIL' } = {}) { + const calls = { search: 0, teams: 0, rosters: [] }; + const impl = async (url) => { + if (url.includes('/search/v2')) { calls.search++; return search; } + const rosterMatch = /\/teams\/([^/]+)\/roster/.exec(url); + if (rosterMatch) { + const tid = rosterMatch[1]; + calls.rosters.push(tid); + if (tid === failTeam) throw new Error('roster fetch boom'); + if (tid === '1') return ROSTER_1; + if (tid === '2') return ROSTER_2; + return { athletes: [] }; + } + if (url.endsWith('/teams')) { calls.teams++; return TEAMS; } + if (url.includes('/gamelog')) return GAMELOG; + return null; + }; + impl.calls = calls; + return impl; +} + +beforeEach(() => { + espn.__internals.gameLogMem.clear(); + espn.__internals.rosterMem.clear(); +}); + +describe('buildAthleteRosterIndex', () => { + it('aggregates all team rosters into a complete nameKey→{id,displayName,teamId} map', async () => { + const fetchImpl = makeFetch(); + const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl }); + + // Dual-league athlete present. + expect(index['napheesa collier']).toMatchObject({ id: '3917450', teamId: '1' }); + // Accented name folds to an accent-stripped key. + expect(index['jose alvarez']).toMatchObject({ id: '999', teamId: '1' }); + // Suffix name folds (Jr. stripped) — display keeps the accent/original. + expect(index['ronald acuna']).toMatchObject({ id: '555', displayName: 'Ronald Acuña Jr.' }); + // Grouped-shape roster (team 2) flattened. + expect(index['sabrina ionescu']).toMatchObject({ id: '4066533', teamId: '2' }); + expect(index['breanna stewart']).toMatchObject({ id: '2998928', teamId: '2' }); + }); + + it('skips athletes with no id or a non-numeric id (absent beats wrong)', async () => { + const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl: makeFetch() }); + expect(index['no id player']).toBeUndefined(); + expect(index['guid only']).toBeUndefined(); + }); + + it('skips a team whose roster fetch fails — partial index, never throws', async () => { + const fetchImpl = makeFetch(); // team 'FAIL' throws + let index; + await expect((async () => { index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl }); })()).resolves.toBeUndefined(); + // The two healthy teams still populated the index. + expect(index['napheesa collier']).toBeDefined(); + expect(index['sabrina ionescu']).toBeDefined(); + expect(fetchImpl.calls.rosters).toContain('FAIL'); + }); + + it('returns {} for an unsupported sport without fetching', async () => { + const fetchImpl = makeFetch(); + expect(await espn.buildAthleteRosterIndex('nfl', { fetchImpl })).toEqual({}); + expect(fetchImpl.calls.teams).toBe(0); + }); +}); + +describe('getPlayerGameLog — roster index is the primary resolver', () => { + it('resolves a player found ONLY in the roster index (search returns nothing) → returns its gamelog', async () => { + const fetchImpl = makeFetch({ search: SEARCH_EMPTY }); + const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl }); + expect(res.found).toBe(true); + expect(res.id).toBe('3917450'); + expect(res.last10[0].stat.points).toBe(20); + }); + + it('tries the roster index BEFORE search — a roster hit wins over a conflicting search id', async () => { + const fetchImpl = makeFetch({ search: SEARCH_CONFLICT }); + const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl }); + // Roster id (3917450), NOT the search's wrong 9999. + expect(res.id).toBe('3917450'); + // Search was never consulted — the roster resolved it. + expect(fetchImpl.calls.search).toBe(0); + }); + + it('a name in NEITHER roster nor search → { found:false } (never guesses another player)', async () => { + const fetchImpl = makeFetch({ search: SEARCH_EMPTY }); + const res = await espn.getPlayerGameLog('Ghost Player', 'wnba', { fetchImpl }); + expect(res).toEqual({ found: false }); + // Search WAS consulted as the backstop after the roster miss. + expect(fetchImpl.calls.search).toBe(1); + }); + + it('falls back to search when the roster index has no hit', async () => { + // Sabrina is on team 2's roster, so instead query a name only search knows. + const searchOnly = { + results: [{ type: 'player', contents: [ + { uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' }, + ] }], + }; + const fetchImpl = makeFetch({ search: searchOnly }); + const res = await espn.getPlayerGameLog("A'ja Wilson", 'wnba', { fetchImpl }); + expect(res.found).toBe(true); + expect(res.id).toBe('3149391'); + expect(fetchImpl.calls.search).toBe(1); + }); +}); + +describe('resolveAthleteId ordering', () => { + it('returns the roster id without ever calling search when the roster has the name', async () => { + const fetchImpl = makeFetch({ search: SEARCH_CONFLICT }); + const id = await espn.resolveAthleteId('Napheesa Collier', 'wnba', { fetchImpl }); + expect(id).toBe('3917450'); + expect(fetchImpl.calls.search).toBe(0); + }); +});