Wave 2B: reliable cross-sport headshots via ESPN athlete index

The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 18:12:31 -04:00
parent 3b1aa9f265
commit fceb3707b5
12 changed files with 460 additions and 10 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ describe('getGameSummary', () => {
test('missing sections → empty defaults (no crash)', async () => {
mockAxiosGet.mockResolvedValue({ data: {} });
const out = await getGameSummary('nba', '999');
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null });
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] });
});
test('invalid sport → empty defaults without axios', async () => {