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:
Kev
2026-07-14 00:21:59 -04:00
parent c2cfb7c508
commit bbfce05b94
2 changed files with 311 additions and 7 deletions
+136 -7
View File
@@ -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 ~1530 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,
},
};
+175
View File
@@ -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);
});
});