Resolver hardening: ESPN team-roster index as primary NBA/WNBA name→id source
Wave 0 shipped NBA/WNBA grading off ESPN per-athlete gamelogs, but name→id
resolution went only through the v2 /search endpoint, which is unreliable at
the edges. Live probing surfaced the real coverage gap: search actually
resolves the right id for most names, but dual-league athletes (WNBA + NCAA —
e.g. Napheesa Collier, Brionna Jones) get a filters-only gamelog until a
`?season=` is supplied, so they silently returned insufficient_data despite a
full season of games.
Two fixes:
1. buildAthleteRosterIndex(sport) — aggregates every team roster for nba/wnba
into a complete { nameKey → {id, displayName, teamId} } map (canonical
accent-folded keys via playerName.nameKey). Bounded concurrency (6) over the
~15-30 team fetches, Redis `espnroster:{sport}` (24h) + in-memory mirror,
fully defensive (a failing team is skipped → partial index, never throws;
grouped OR flat athletes[] shapes handled; non-numeric ids dropped). This is
now the PRIMARY resolver in resolveAthleteId/getPlayerGameLog; the v2 search
stays as a backstop on a roster miss. A unique roster hit wins (S59 doctrine)
— a missing name beats guessing another player's id.
2. getPlayerGameLog retries the gamelog with candidate seasons (current +
previous calendar year) ONLY when the first parse comes back empty
(filters-only), unlocking the dual-league athletes. The common path is
untouched.
MLB path (statsapi) unchanged; settlement/snapshot/frontend untouched.
Live probe: WNBA roster index = 206 players (Collier id 3917450 / Lynx team 8,
Brionna Jones id 3058895 present); NBA index = 544. Collier now resolves
end-to-end with 20 gamelog rows (was NOT FOUND); Brionna Jones likewise; all
previously-working players (A'ja Wilson, Ionescu, Clark, Stewart, Plum) still
resolve. NBA hyphen names (Gilgeous-Alexander) resolve via nameKey folding.
Tests: tests/unit/espnRosterIndex.test.js (9, fail-then-pass on base adapter).
Full backend suite 3183 green; web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user