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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user