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
+28
View File
@@ -221,6 +221,10 @@ async function runSnapshot(sport, opts = {}) {
// Session 58 — Phase 1 truth infrastructure. ledger no-ops without
// SUPABASE env, so tests / local dev never touch a database.
ledger: opts.ledger || require('./ledgerService'),
// Wave 2B — reliable ESPN athlete id + DIRECT headshot href from feeds the
// pipeline already calls (schedule + summary). Fills the NBA/WNBA espnId gap
// when the stats-resolve fallback misses. Returns {} for MLB / errors.
buildEspnIndex: opts.buildEspnIndex || require('./espnAthleteIndex').buildEspnAthleteIndex,
};
const start = deps.nowMs();
const ts = deps.now();
@@ -351,6 +355,27 @@ async function runSnapshot(sport, opts = {}) {
});
await mergeRosterLogs(sp, logEntries, deps);
// Wave 2B — the RELIABLE espnId/headshot source. The stats-resolve espnId
// above comes only from espnStatsAdapter (the offline-Python fallback), which
// is flaky in prod. ESPN's own schedule→summary feeds (already free, already
// called elsewhere) carry each athlete's id AND often a DIRECT headshot href.
// Build the index once per snapshot (MLB → {} so its MLBAM path is untouched)
// and fill any player the primary resolve left without an id. A direct href is
// preferred — it's the exact URL, so it never 404s on a constructed path.
let espnIndex = {};
try {
espnIndex = (await deps.buildEspnIndex(sp, { cacheGet: deps.cacheGet, cacheSet: deps.cacheSet })) || {};
} catch { espnIndex = {}; /* graceful — every player falls to a monogram */ }
const headshotUrlByPlayer = {};
for (const player of players) {
const entry = espnIndex[nameKey(player)];
if (!entry) continue;
if (espnIdByPlayer[player] == null && entry.espnId != null) espnIdByPlayer[player] = entry.espnId;
// A direct ESPN href wins over any constructed URL (most reliable; the only
// honest route for soccer, where we never construct an id-based URL).
if (entry.headshotHref) headshotUrlByPlayer[player] = entry.headshotHref;
}
const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
@@ -362,6 +387,9 @@ async function runSnapshot(sport, opts = {}) {
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
// Wave 2B — a RESOLVED absolute headshot URL from ESPN (preferred over the
// constructed (sport,id) URL). Absent → the id/monogram path stands.
headshotUrl: headshotUrlByPlayer[pn] ?? g.headshotUrl ?? null,
};
});