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:
@@ -121,6 +121,17 @@ Built by `slateAdapter.mapScheduleToGameCards` (`groupPropsByPlayer` +
|
||||
`mapPitchers`). The card prefers `playerStrips` (name once, horizontal) over
|
||||
legacy per-prop rows. Book chips use `web/src/lib/books.js` brand colors.
|
||||
|
||||
**Headshots (Wave 2A/2B):** each enriched grade + player strip may carry
|
||||
`playerId` (MLBAM), `espnId` (ESPN athlete id), and `headshotUrl` (a RESOLVED
|
||||
absolute ESPN href). `PlayerAvatar` prefers `headshotUrl` (exact URL, never
|
||||
404s) → else constructs from `(sport, playerId|espnId)` → else a team-colored
|
||||
monogram. `espnId`/`headshotUrl` come from `snapshotService`: primarily the
|
||||
per-player stats resolve, and — when that misses (the flaky NBA/WNBA fallback)
|
||||
— from `espnAthleteIndex.buildEspnAthleteIndex(sport)`, which harvests ESPN
|
||||
schedule→summary/boxscore/leaders/injuries/roster feeds (free, cached, MLB→{}).
|
||||
Soccer resolves ONLY via a direct `headshotUrl` (no constructed URL); absent →
|
||||
honest monogram (a free `API_FOOTBALL_KEY` is the reliable soccer path, unwired).
|
||||
|
||||
---
|
||||
|
||||
## 5. Stat Strip (`web/src/components/vyndr/StatStrip.tsx`)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* espnAthleteIndex — Wave 2B: reliable ESPN athlete-id + DIRECT headshot capture
|
||||
* from feeds VYNDR already calls for a slate.
|
||||
*
|
||||
* THE GAP it closes: NBA/WNBA `espnId` was captured ONLY from
|
||||
* `espnStatsAdapter.getSeasonAverages` (the offline-Python fallback), which is
|
||||
* unreliable in prod. ESPN's own summary / boxscore / leaders / injuries / roster
|
||||
* payloads carry, per athlete, `athlete.id` AND often a direct
|
||||
* `athlete.headshot.href` (the exact image URL). This harvests both into a
|
||||
* { nameKey -> { espnId, headshotHref } }
|
||||
* index — reusing `scheduleService.getSchedule` + `getGameSummary` (no new
|
||||
* endpoint), bounded (mapLimit), cached per sport+date, and NEVER throwing.
|
||||
*
|
||||
* Doctrine (unchanged): a REAL photo where ESPN gives an id/href; a
|
||||
* team-colored monogram where it can't. Nothing here fabricates a face. MLB's
|
||||
* MLBAM path is untouched (this returns {} for MLB — MLB owns its own id).
|
||||
*
|
||||
* A direct `headshotHref` is PREFERRED over a constructed `(sport,id)` URL: it
|
||||
* is the exact URL ESPN serves, so it never 404s on a league whose CDN path we
|
||||
* would otherwise guess. This is the ONLY honest route for soccer (ESPN soccer
|
||||
* headshots are inconsistent → we trust a direct href only, never a constructed
|
||||
* soccer URL). All parsing is pure + defensive so a malformed shape yields {}.
|
||||
*/
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
|
||||
const INDEX_TTL = 3600; // 1h — an athlete's id/href is stable within a day
|
||||
const GAME_CONCURRENCY = 4; // bounded per-game summary fan-out
|
||||
const MAX_DEPTH = 8; // recursion guard on the ESPN payload walk
|
||||
|
||||
// Sports whose headshots ESPN hosts by athlete id (a.espncdn CDN). MLB is
|
||||
// deliberately excluded (its MLBAM path owns headshots). Soccer is included
|
||||
// best-effort: ESPN soccer headshots are inconsistent, so only a DIRECT href is
|
||||
// trusted downstream — snapshotService never constructs a soccer URL from an id.
|
||||
const ESPN_INDEX_SPORTS = new Set(['nba', 'wnba', 'nfl', 'nhl', 'soccer']);
|
||||
|
||||
// Athlete-specific markers that distinguish an athlete object from a TEAM object
|
||||
// (teams also carry displayName + numeric id, but never these). Guards the
|
||||
// recursive harvest from tagging a team name as a player.
|
||||
const ATHLETE_MARKERS = ['headshot', 'position', 'jersey', 'guid'];
|
||||
|
||||
function isNumericId(v) {
|
||||
return v != null && /^\d+$/.test(String(v));
|
||||
}
|
||||
|
||||
/** Pull a direct absolute headshot URL from an athlete node, else null. */
|
||||
function extractHeadshotHref(node) {
|
||||
const h = node && node.headshot;
|
||||
if (!h) return null;
|
||||
if (typeof h === 'string') return /^https?:\/\//i.test(h) ? h : null;
|
||||
if (typeof h === 'object' && typeof h.href === 'string' && /^https?:\/\//i.test(h.href)) return h.href;
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeAthlete(o) {
|
||||
if (!o || typeof o !== 'object') return false;
|
||||
if (!(o.displayName || o.fullName)) return false;
|
||||
return ATHLETE_MARKERS.some((m) => o[m] != null);
|
||||
}
|
||||
|
||||
/** Record one athlete into the index, preferring the richest data on collision. */
|
||||
function recordAthlete(out, athlete) {
|
||||
if (!athlete || typeof athlete !== 'object') return;
|
||||
const name = athlete.displayName || athlete.fullName || athlete.name;
|
||||
if (!name || typeof name !== 'string') return;
|
||||
const espnId = isNumericId(athlete.id) ? String(athlete.id) : null;
|
||||
const headshotHref = extractHeadshotHref(athlete);
|
||||
if (!espnId && !headshotHref) return; // nothing useful — absent beats noise
|
||||
const key = nameKey(name);
|
||||
if (!key) return;
|
||||
const existing = out[key];
|
||||
if (!existing) {
|
||||
out[key] = { espnId: espnId || null, headshotHref: headshotHref || null };
|
||||
return;
|
||||
}
|
||||
// A direct href is the reliable asset — fill it if a later source has one.
|
||||
if (headshotHref && !existing.headshotHref) existing.headshotHref = headshotHref;
|
||||
if (espnId && !existing.espnId) existing.espnId = espnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: walk any ESPN payload (summary, boxscore, leaders, injuries, roster)
|
||||
* and harvest every athlete-like object into { nameKey -> { espnId, headshotHref } }.
|
||||
* Records ONLY explicit `.athlete` wrappers or objects carrying an athlete
|
||||
* marker (never bare team objects). Defensive: a malformed shape yields {} and
|
||||
* NEVER throws.
|
||||
*/
|
||||
function harvestAthletes(payload, out, depth, seen) {
|
||||
out = out || {};
|
||||
depth = depth || 0;
|
||||
seen = seen || new Set();
|
||||
if (payload == null || depth > MAX_DEPTH) return out;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) harvestAthletes(item, out, depth + 1, seen);
|
||||
return out;
|
||||
}
|
||||
if (typeof payload !== 'object') return out;
|
||||
if (seen.has(payload)) return out;
|
||||
seen.add(payload);
|
||||
|
||||
// Canonical ESPN wrapper: { athlete: {...}, stats|value|... }.
|
||||
if (payload.athlete && typeof payload.athlete === 'object') recordAthlete(out, payload.athlete);
|
||||
// A bare athlete object surfaced directly (roster/injury shapes vary).
|
||||
else if (looksLikeAthlete(payload)) recordAthlete(out, payload);
|
||||
|
||||
for (const k of Object.keys(payload)) {
|
||||
const v = payload[k];
|
||||
if (v && typeof v === 'object') harvestAthletes(v, out, depth + 1, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function mapLimit(items, concurrency, fn) {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fn(items[idx], idx);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
|
||||
}
|
||||
|
||||
/** Look up an athlete's { espnId, headshotHref } by (possibly raw) name. */
|
||||
function lookup(index, name) {
|
||||
if (!index || typeof index !== 'object') return null;
|
||||
const k = nameKey(name || '');
|
||||
return (k && index[k]) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-sport { nameKey -> { espnId, headshotHref } } index from the
|
||||
* ESPN feeds the pipeline already fetches. Cached (`espnindex:{sport}:{date}`,
|
||||
* 1h). Returns {} for MLB and for any error (never throws). All deps injectable
|
||||
* → unit tests hit no network.
|
||||
*/
|
||||
async function buildEspnAthleteIndex(sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
if (!ESPN_INDEX_SPORTS.has(sp)) return {};
|
||||
|
||||
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
|
||||
const sched = require('./scheduleService');
|
||||
const getSchedule = opts.getSchedule || sched.getSchedule;
|
||||
const getGameSummary = opts.getGameSummary || sched.getGameSummary;
|
||||
const date = opts.date || sched.todayET();
|
||||
const key = `espnindex:${sp}:${date}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheGet(key);
|
||||
if (cached && typeof cached === 'object') return cached;
|
||||
} catch { /* ignore cache read */ }
|
||||
|
||||
const out = {};
|
||||
try {
|
||||
const games = await getSchedule(sp, date).catch(() => null);
|
||||
const ids = Array.isArray(games) ? games.map((g) => g && g.id).filter(Boolean) : [];
|
||||
await mapLimit(ids, GAME_CONCURRENCY, async (id) => {
|
||||
try {
|
||||
const summary = await getGameSummary(sp, id);
|
||||
harvestAthletes(summary, out);
|
||||
} catch { /* one game failing must not sink the index */ }
|
||||
});
|
||||
try { await cacheSet(key, out, INDEX_TTL); } catch { /* ignore cache write */ }
|
||||
} catch {
|
||||
return {}; // total failure → empty → every player falls to a monogram
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEspnAthleteIndex,
|
||||
harvestAthletes,
|
||||
lookup,
|
||||
extractHeadshotHref,
|
||||
__internals: { recordAthlete, looksLikeAthlete, isNumericId, ESPN_INDEX_SPORTS, INDEX_TTL },
|
||||
};
|
||||
@@ -227,7 +227,7 @@ const SUMMARY_TTL = 10 * 60; // 10 min
|
||||
*/
|
||||
async function getGameSummary(sport, eventId) {
|
||||
const path = ESPN_SPORT_PATHS[String(sport || '').toLowerCase()];
|
||||
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null };
|
||||
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] };
|
||||
if (!path || !eventId) return empty;
|
||||
|
||||
const key = `espn:summary:${sport}:${eventId}`;
|
||||
@@ -244,6 +244,10 @@ async function getGameSummary(sport, eventId) {
|
||||
ats: data.againstTheSpread || null,
|
||||
leaders: Array.isArray(data.leaders) ? data.leaders : [],
|
||||
boxscore: data.boxscore || null,
|
||||
// Wave 2B — pre-game lineups carry athlete id + direct headshot href, the
|
||||
// reliable NBA/WNBA headshot source (espnAthleteIndex harvests it). Passed
|
||||
// through defensively; absent → [] (no fabricated roster).
|
||||
rosters: Array.isArray(data.rosters) ? data.rosters : [],
|
||||
};
|
||||
await cacheSet(key, out, SUMMARY_TTL);
|
||||
return out;
|
||||
|
||||
@@ -221,6 +221,10 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// Session 58 — Phase 1 truth infrastructure. ledger no-ops without
|
||||
// SUPABASE env, so tests / local dev never touch a database.
|
||||
ledger: opts.ledger || require('./ledgerService'),
|
||||
// Wave 2B — reliable ESPN athlete id + DIRECT headshot href from feeds the
|
||||
// pipeline already calls (schedule + summary). Fills the NBA/WNBA espnId gap
|
||||
// when the stats-resolve fallback misses. Returns {} for MLB / errors.
|
||||
buildEspnIndex: opts.buildEspnIndex || require('./espnAthleteIndex').buildEspnAthleteIndex,
|
||||
};
|
||||
const start = deps.nowMs();
|
||||
const ts = deps.now();
|
||||
@@ -351,6 +355,27 @@ async function runSnapshot(sport, opts = {}) {
|
||||
});
|
||||
await mergeRosterLogs(sp, logEntries, deps);
|
||||
|
||||
// Wave 2B — the RELIABLE espnId/headshot source. The stats-resolve espnId
|
||||
// above comes only from espnStatsAdapter (the offline-Python fallback), which
|
||||
// is flaky in prod. ESPN's own schedule→summary feeds (already free, already
|
||||
// called elsewhere) carry each athlete's id AND often a DIRECT headshot href.
|
||||
// Build the index once per snapshot (MLB → {} so its MLBAM path is untouched)
|
||||
// and fill any player the primary resolve left without an id. A direct href is
|
||||
// preferred — it's the exact URL, so it never 404s on a constructed path.
|
||||
let espnIndex = {};
|
||||
try {
|
||||
espnIndex = (await deps.buildEspnIndex(sp, { cacheGet: deps.cacheGet, cacheSet: deps.cacheSet })) || {};
|
||||
} catch { espnIndex = {}; /* graceful — every player falls to a monogram */ }
|
||||
const headshotUrlByPlayer = {};
|
||||
for (const player of players) {
|
||||
const entry = espnIndex[nameKey(player)];
|
||||
if (!entry) continue;
|
||||
if (espnIdByPlayer[player] == null && entry.espnId != null) espnIdByPlayer[player] = entry.espnId;
|
||||
// A direct ESPN href wins over any constructed URL (most reliable; the only
|
||||
// honest route for soccer, where we never construct an id-based URL).
|
||||
if (entry.headshotHref) headshotUrlByPlayer[player] = entry.headshotHref;
|
||||
}
|
||||
|
||||
const enriched = graded.map((g) => {
|
||||
const pn = g.player || g.player_name;
|
||||
return {
|
||||
@@ -362,6 +387,9 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
|
||||
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
|
||||
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
|
||||
// Wave 2B — a RESOLVED absolute headshot URL from ESPN (preferred over the
|
||||
// constructed (sport,id) URL). Absent → the id/monogram path stands.
|
||||
headshotUrl: headshotUrlByPlayer[pn] ?? g.headshotUrl ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Wave 2B (WIRING & DATA TRAIN, Step 2 follow-up) — the ESPN athlete index.
|
||||
//
|
||||
// THE GAP: NBA/WNBA `espnId` was captured ONLY from espnStatsAdapter (the
|
||||
// offline-Python fallback), unreliable in prod. ESPN's own summary/boxscore/
|
||||
// leaders/injuries/roster payloads — already free, already called for a slate —
|
||||
// carry each athlete's id AND often a DIRECT headshot href. This suite locks:
|
||||
// (a) an ESPN summary fixture → { nameKey → { espnId, headshotHref } } index
|
||||
// (b) a grade with NO stats-espnId gets the id/href from the index by nameKey
|
||||
// (c) a direct headshotHref WINS over the constructed (sport,id) URL
|
||||
// (d) soccer with no reliable href → absent → monogram (honest)
|
||||
// (e) a malformed ESPN shape → empty index, never a throw
|
||||
// NO network — every dep is injected.
|
||||
|
||||
const { nameKey } = require('../../src/utils/playerName');
|
||||
const idx = require('../../src/services/espnAthleteIndex');
|
||||
const svc = require('../../src/services/snapshotService');
|
||||
const adapter = require('../../web/src/lib/slateAdapter');
|
||||
const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl');
|
||||
|
||||
function memCache() {
|
||||
const store = {};
|
||||
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
|
||||
}
|
||||
|
||||
// A realistic ESPN summary: athletes surface via injuries/leaders/boxscore/
|
||||
// roster wrappers; a bare TEAM object must NOT be harvested as a player.
|
||||
const HREF = (id) => `https://a.espncdn.com/i/headshots/wnba/players/full/${id}.png`;
|
||||
const summaryFixture = {
|
||||
injuries: [
|
||||
{ team: { displayName: 'Indiana Fever' }, injuries: [
|
||||
{ status: 'OUT', athlete: { id: '4433403', displayName: 'Caitlin Clark', headshot: { href: HREF('4433403') } } },
|
||||
] },
|
||||
],
|
||||
leaders: [
|
||||
{ leaders: [ { leaders: [
|
||||
{ athlete: { id: '2529140', displayName: 'Kelsey Mitchell', headshot: { href: HREF('2529140') } }, value: 21 },
|
||||
] } ] },
|
||||
],
|
||||
boxscore: { players: [
|
||||
{ statistics: [ { athletes: [
|
||||
{ athlete: { id: '3906972', displayName: 'Aliyah Boston', headshot: { href: HREF('3906972') } }, stats: ['12'] },
|
||||
] } ] },
|
||||
] },
|
||||
rosters: [
|
||||
// headshot as a bare string (ESPN varies the shape) + no numeric id-only team.
|
||||
{ roster: [ { athlete: { id: '4281190', displayName: 'Sophie Cunningham', headshot: HREF('4281190') } } ] },
|
||||
],
|
||||
// A team object (numeric id + displayName, NO athlete marker) — must be ignored.
|
||||
teams: [ { team: { id: '5', displayName: 'Indiana Fever' } } ],
|
||||
};
|
||||
|
||||
describe('(a) harvestAthletes — ESPN summary → { nameKey → {espnId, headshotHref} }', () => {
|
||||
it('pulls athletes from injuries, leaders, boxscore, and roster wrappers', () => {
|
||||
const index = idx.harvestAthletes(summaryFixture);
|
||||
expect(index[nameKey('Caitlin Clark')]).toEqual({ espnId: '4433403', headshotHref: HREF('4433403') });
|
||||
expect(index[nameKey('Kelsey Mitchell')].espnId).toBe('2529140');
|
||||
expect(index[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
|
||||
// headshot given as a bare string still resolves.
|
||||
expect(index[nameKey('Sophie Cunningham')].headshotHref).toBe(HREF('4281190'));
|
||||
});
|
||||
|
||||
it('does NOT harvest a bare team object as an athlete', () => {
|
||||
const index = idx.harvestAthletes(summaryFixture);
|
||||
expect(index[nameKey('Indiana Fever')]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('buildEspnAthleteIndex fans out per game (injected schedule + summary, no network)', async () => {
|
||||
const cache = memCache();
|
||||
const built = await idx.buildEspnAthleteIndex('wnba', {
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
getSchedule: async () => [{ id: '401' }, { id: '402' }],
|
||||
getGameSummary: async () => summaryFixture,
|
||||
date: '2026-07-10',
|
||||
});
|
||||
expect(built[nameKey('Caitlin Clark')].espnId).toBe('4433403');
|
||||
expect(built[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
|
||||
// Cached under the sport+date key.
|
||||
expect(cache.store['espnindex:wnba:2026-07-10']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('MLB → {} (MLBAM path is untouched); returns {} on any error, never throws', async () => {
|
||||
const cache = memCache();
|
||||
expect(await idx.buildEspnAthleteIndex('mlb', { cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toEqual({});
|
||||
// A schedule that throws degrades to {} (graceful).
|
||||
const built = await idx.buildEspnAthleteIndex('nba', {
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
getSchedule: async () => { throw new Error('espn down'); },
|
||||
getGameSummary: async () => ({}),
|
||||
date: '2026-07-10',
|
||||
});
|
||||
expect(built).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// A generic grade-capture stub (mirrors gradeAndCacheSlate's envelope contract).
|
||||
function fakeGrade(grades) {
|
||||
return async (_sport, _props, opts) => { await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' }); };
|
||||
}
|
||||
|
||||
describe('(b) snapshot merge — no stats-espnId → index fills espnId + headshotUrl', () => {
|
||||
it('a WNBA grade with no resolved id inherits the ESPN index id + direct href', async () => {
|
||||
const cache = memCache();
|
||||
await svc.runSnapshot('wnba', {
|
||||
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, over_odds: -115, under_odds: -105, book: 'dk' }], provider: 'test' }),
|
||||
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
|
||||
resolveStats: async () => ({ found: true, classifierInput: {} }), // NO espnId from the flaky fallback
|
||||
classify: () => ({ primary: null }),
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
|
||||
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
|
||||
});
|
||||
const snap = cache.store['snapshot:wnba:latest'];
|
||||
const cc = snap.grades.find((g) => nameKey(g.player) === nameKey('Caitlin Clark'));
|
||||
expect(cc.espnId).toBe('4433403');
|
||||
expect(cc.headshotUrl).toBe(HREF('4433403'));
|
||||
// grades:{sport} inherits it too (GameCard/Explore read from here).
|
||||
const g = cache.store['grades:wnba'].grades.find((x) => nameKey(x.player) === nameKey('Caitlin Clark'));
|
||||
expect(g.headshotUrl).toBe(HREF('4433403'));
|
||||
});
|
||||
|
||||
it('the stats-resolve espnId still wins when present (index is only a fallback)', async () => {
|
||||
const cache = memCache();
|
||||
await svc.runSnapshot('wnba', {
|
||||
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, book: 'dk' }], provider: 'test' }),
|
||||
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
|
||||
resolveStats: async () => ({ found: true, classifierInput: {}, espnId: '999999' }),
|
||||
classify: () => ({ primary: null }),
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
|
||||
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
|
||||
});
|
||||
const cc = cache.store['snapshot:wnba:latest'].grades[0];
|
||||
expect(cc.espnId).toBe('999999'); // primary resolve wins for the id
|
||||
expect(cc.headshotUrl).toBe(HREF('4433403')); // but the direct href still applies
|
||||
});
|
||||
});
|
||||
|
||||
describe('(c) a direct headshotHref WINS over the constructed (sport,id) URL — in the strip', () => {
|
||||
it('buildPlayerStripsFromProps threads headshotUrl; getHeadshotUrl prefers it', () => {
|
||||
const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }];
|
||||
const gradeIndex = adapter.indexGrades([
|
||||
{
|
||||
player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A',
|
||||
espnId: '4433403', headshotUrl: HREF('4433403'), team: 'IND',
|
||||
gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' },
|
||||
},
|
||||
]);
|
||||
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
|
||||
expect(strips).toHaveLength(1);
|
||||
expect(strips[0].headshotUrl).toBe(HREF('4433403'));
|
||||
// The direct href resolves verbatim, never the constructed a.espncdn combiner URL.
|
||||
const resolved = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId, headshotUrl: strips[0].headshotUrl });
|
||||
expect(resolved).toBe(HREF('4433403'));
|
||||
expect(resolved).not.toContain('combiner');
|
||||
// Without the href, it falls back to the constructed URL (still honest).
|
||||
const constructed = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId });
|
||||
expect(constructed).toContain('/headshots/wnba/players/full/4433403.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('(d) soccer with no reliable href → absent → monogram (honest)', () => {
|
||||
it('a soccer index that yields no href leaves the resolver at the silhouette sentinel', async () => {
|
||||
const cache = memCache();
|
||||
// ESPN soccer summaries carry no athlete id/headshot via getGameSummary (no
|
||||
// soccer path) → the index is empty → nothing to thread.
|
||||
const built = await idx.buildEspnAthleteIndex('soccer', {
|
||||
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
||||
getSchedule: async () => [{ id: '9' }],
|
||||
getGameSummary: async () => ({ injuries: [], leaders: [], boxscore: null, rosters: [] }),
|
||||
date: '2026-07-10',
|
||||
});
|
||||
expect(idx.lookup(built, 'Lionel Messi')).toBeNull();
|
||||
// No id + no href → the silhouette sentinel (PlayerAvatar swaps to a monogram).
|
||||
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: null })).toBe('/images/player-silhouette.svg');
|
||||
// We NEVER construct a soccer URL from an id (would 404).
|
||||
expect(getHeadshotUrl({ sport: 'soccer', espnId: '12345' })).toBe('/images/player-silhouette.svg');
|
||||
});
|
||||
|
||||
it('BUT a soccer athlete WITH a direct href is honored (best-effort)', () => {
|
||||
const href = 'https://a.espncdn.com/i/headshots/soccer/players/full/45843.png';
|
||||
const index = idx.harvestAthletes({ rosters: [{ roster: [{ athlete: { id: '45843', displayName: 'Lionel Messi', headshot: { href } } }] }] });
|
||||
expect(index[nameKey('Lionel Messi')].headshotHref).toBe(href);
|
||||
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: href })).toBe(href);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(e) defensive parse — malformed ESPN shapes never throw', () => {
|
||||
it('null / non-object / garbage → empty index', () => {
|
||||
expect(idx.harvestAthletes(null)).toEqual({});
|
||||
expect(idx.harvestAthletes(undefined)).toEqual({});
|
||||
expect(idx.harvestAthletes(42)).toEqual({});
|
||||
expect(idx.harvestAthletes('nope')).toEqual({});
|
||||
expect(idx.harvestAthletes({ nonsense: true, boxscore: { players: 'not-an-array' } })).toEqual({});
|
||||
// An athlete with no id AND no headshot contributes nothing (absent beats noise).
|
||||
expect(idx.harvestAthletes({ leaders: [{ athlete: { displayName: 'No Id Here' } }] })).toEqual({});
|
||||
});
|
||||
|
||||
it('a cyclic object does not hang the walker', () => {
|
||||
const a = { athlete: { id: '1', displayName: 'Loop Player', headshot: { href: 'https://x/1.png' } } };
|
||||
a.self = a; // cycle
|
||||
const index = idx.harvestAthletes(a);
|
||||
expect(index[nameKey('Loop Player')].espnId).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe('getGameSummary', () => {
|
||||
test('missing sections → empty defaults (no crash)', async () => {
|
||||
mockAxiosGet.mockResolvedValue({ data: {} });
|
||||
const out = await getGameSummary('nba', '999');
|
||||
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null });
|
||||
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] });
|
||||
});
|
||||
|
||||
test('invalid sport → empty defaults without axios', async () => {
|
||||
|
||||
@@ -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