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