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