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:
@@ -33,6 +33,8 @@ export interface HeadshotInput {
|
||||
playerId?: string | number | null;
|
||||
/** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||
espnId?: string | number | null;
|
||||
/** Wave 2B — a RESOLVED absolute headshot URL (ESPN's exact href); wins over a constructed one. */
|
||||
headshotUrl?: string | null;
|
||||
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
|
||||
cachedPhotoUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* genuinely unit-testable (jest can't transform the `.ts`).
|
||||
*
|
||||
* Each league hosts its own CDN. Fallback chain inside the resolver:
|
||||
* 1. `cachedPhotoUrl` — a stored URL (soccer, where API-Football returns the
|
||||
* photo). We DO NOT construct soccer URLs (no central CDN).
|
||||
* 1. `headshotUrl` / `cachedPhotoUrl` — a RESOLVED absolute URL (Wave 2B: the
|
||||
* exact href ESPN served; or a soccer photo from API-Football). Preferred —
|
||||
* it never 404s on a constructed path. We DO NOT construct soccer URLs.
|
||||
* 2. League CDN with `playerId` — official source.
|
||||
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
|
||||
* 4. `/images/player-silhouette.svg` — the sentinel the component swaps for a
|
||||
@@ -34,8 +35,14 @@ function getHeadshotUrl(input) {
|
||||
const sport = String(input.sport || '').toLowerCase();
|
||||
const playerId = input.playerId != null ? String(input.playerId) : '';
|
||||
const espnId = input.espnId != null ? String(input.espnId) : '';
|
||||
// Wave 2B — a resolved absolute URL (ESPN's exact href) wins over any
|
||||
// constructed one. Guarded to an http(s) URL so a stray relative/garbage
|
||||
// value degrades to the id/monogram path rather than a broken image.
|
||||
const headshotUrl = input.headshotUrl && /^https?:\/\//i.test(String(input.headshotUrl))
|
||||
? String(input.headshotUrl) : '';
|
||||
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
|
||||
|
||||
if (headshotUrl) return headshotUrl;
|
||||
if (cached) return cached;
|
||||
|
||||
if (playerId) {
|
||||
|
||||
@@ -352,6 +352,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
|
||||
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
|
||||
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
|
||||
// Wave 2B — a RESOLVED absolute ESPN headshot URL (preferred over the
|
||||
// constructed (sport,id) URL). Absent → the id/monogram path stands.
|
||||
headshotUrl: (rec && rec.headshotUrl ? rec.headshotUrl : undefined),
|
||||
lineup: lineupStatusFor(pk, knownTeam),
|
||||
injury: injuryFor(pk),
|
||||
stats: [],
|
||||
@@ -366,6 +369,7 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
// Fill an id from a later graded row if the first row lacked one.
|
||||
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
|
||||
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
|
||||
if (byPlayer[pk].headshotUrl == null && rec && rec.headshotUrl) byPlayer[pk].headshotUrl = rec.headshotUrl;
|
||||
}
|
||||
if (rec) {
|
||||
const side = sideCh(rec.direction);
|
||||
|
||||
Reference in New Issue
Block a user