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
+3
View File
@@ -40,6 +40,8 @@ export interface PlayerStrip {
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -371,6 +373,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
headshotUrl={ps.headshotUrl}
archetype={ps.archetype}
lineup={ps.lineup}
injury={ps.injury}
+9 -5
View File
@@ -22,6 +22,7 @@ export default function PlayerAvatar({
sport = 'mlb',
playerId,
espnId,
headshotUrl,
team,
size = 36,
}: {
@@ -31,15 +32,18 @@ export default function PlayerAvatar({
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 ESPN headshot URL; wins over a constructed one. */
headshotUrl?: string | null;
team?: string | null;
size?: number;
}) {
const [broken, setBroken] = useState(false);
// Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
// all → null → team-colored monogram (never a gray silhouette).
const url = (playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId })
// Wave 2A/2B — resolve from whichever real asset we have. A direct
// headshotUrl (ESPN's exact href) wins; else getHeadshotUrl routes by sport:
// MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. Nothing → null →
// team-colored monogram (never a gray silhouette).
const url = (headshotUrl || playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId, headshotUrl })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+4 -1
View File
@@ -199,6 +199,8 @@ interface StatStripProps {
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -231,6 +233,7 @@ export default function StatStrip({
sport,
playerId,
espnId,
headshotUrl,
archetype,
lineup,
injury,
@@ -384,7 +387,7 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} headshotUrl={headshotUrl} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+2
View File
@@ -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;
}
+9 -2
View File
@@ -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) {
+4
View File
@@ -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);