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