Wave 2A: real player headshots — sport-agnostic id threaded from ingestion

Threads a REAL athlete id from the snapshot's per-player stats resolve (zero
new I/O) → enriched grade → grades:{sport} → slate strip → PlayerAvatar. Real
photo where an id resolves; team-colored monogram (never a gray silhouette,
never a broken image) where it can't. Ids are never fabricated.

Ingestion (Addition 1):
- espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id
  (was discarded) as espnId; non-numeric uid degrades to null.
- playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA).
- snapshotService captures both per player and stores them on the enriched
  grade beside archetype/team (null when unresolved → monogram path).

Thread → component:
- slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each
  strip; StatStrip → PlayerAvatar (accepts both ids; getHeadshotUrl routes by
  sport: MLB→mlbstatic, NBA/WNBA→a.espncdn).
- Silhouette surfaces rewired to PlayerAvatar (branded monogram on null):
  scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal,
  HotListPanel, GradeResultCard header. Scan grade card feeds the picked
  MLBAM id through gradeAdapter.
- playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js
  (unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH.

Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram
null path) + extended snapshotService/espnStatsAdapter suites. Full suite
241 suites / 2915 green; 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 13:18:59 -04:00
parent b48dc2ed15
commit 47ada9013c
17 changed files with 381 additions and 121 deletions
+10
View File
@@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) {
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
if (!id) return { found: false };
// Wave 2A — the REAL ESPN athlete id for the headshot CDN
// (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
// pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
// id, so it degrades to null → monogram. Never fabricate.
const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;
// 2. Fetch that athlete's stats overview.
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
const classifierInput = parseAthleteStats(stats);
@@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) {
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
position: (athlete.position && athlete.position.abbreviation) || '',
classifierInput,
// Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
// slate strip → headshot. Absent → monogram (doctrine).
espnId,
};
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
return result;
+3 -1
View File
@@ -165,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) {
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] };
// Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
// adapter. Absent → no id → monogram. Never guessed.
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
}
return { found: false };
}
+23 -6
View File
@@ -298,6 +298,13 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {};
// Wave 2A — the REAL athlete id from the SAME stats resolve, keyed by player.
// MLB → MLBAM id (mlbstatic headshot CDN); NBA/WNBA → ESPN athlete id
// (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to
// grades:{sport} → slate strips → PlayerAvatar. Zero new I/O. Absent → the
// component falls to a team-colored monogram (never a fabricated face).
const playerIdByPlayer = {};
const espnIdByPlayer = {};
// Wave 1 (trust bug) — the prop's game participants become the resolve's
// teamHint: it disambiguates namesake collisions (two "James Wood") and, when
// the resolved player's real team isn't in the prop's game, the resolver drops
@@ -326,6 +333,9 @@ async function runSnapshot(sport, opts = {}) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team;
// Wave 2A — capture the resolved athlete id (headshot thread).
if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId;
if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId;
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
logEntries.push({
name: normalizeName(player).display || player,
@@ -341,12 +351,19 @@ async function runSnapshot(sport, opts = {}) {
});
await mergeRosterLogs(sp, logEntries, deps);
const enriched = graded.map((g) => ({
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || null,
}));
const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[pn] || null,
team: teamByPlayer[pn] || g.team || null,
// Wave 2A — real headshot id (MLBAM for MLB, ESPN for NBA/WNBA), threaded
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
};
});
// Line deltas vs the previous snapshot's locked lines.
const prev = await deps.cacheGet(`snapshot:${sp}:latest`);