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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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