From fceb3707b59d1f9c0d1ad2fdcbe268ac56f5b5ee Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 18:12:31 -0400 Subject: [PATCH] Wave 2B: reliable cross-sport headshots via ESPN athlete index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BACKEND_HANDOFF.md | 11 ++ src/services/espnAthleteIndex.js | 180 +++++++++++++++++++ src/services/scheduleService.js | 6 +- src/services/snapshotService.js | 28 +++ tests/unit/espnHeadshotIndex.test.js | 204 ++++++++++++++++++++++ tests/unit/espnSummary.test.js | 2 +- web/src/components/vyndr/GameCard.tsx | 3 + web/src/components/vyndr/PlayerAvatar.tsx | 14 +- web/src/components/vyndr/StatStrip.tsx | 5 +- web/src/lib/playerHeadshot.ts | 2 + web/src/lib/playerHeadshotUrl.js | 11 +- web/src/lib/slateAdapter.js | 4 + 12 files changed, 460 insertions(+), 10 deletions(-) create mode 100644 src/services/espnAthleteIndex.js create mode 100644 tests/unit/espnHeadshotIndex.test.js diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 8fb645d..90894aa 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -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`) diff --git a/src/services/espnAthleteIndex.js b/src/services/espnAthleteIndex.js new file mode 100644 index 0000000..c4b6d8b --- /dev/null +++ b/src/services/espnAthleteIndex.js @@ -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 }, +}; diff --git a/src/services/scheduleService.js b/src/services/scheduleService.js index 00cdaa2..f7a6c03 100644 --- a/src/services/scheduleService.js +++ b/src/services/scheduleService.js @@ -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; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 8446270..3dd1bd8 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -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, }; }); diff --git a/tests/unit/espnHeadshotIndex.test.js b/tests/unit/espnHeadshotIndex.test.js new file mode 100644 index 0000000..54363b9 --- /dev/null +++ b/tests/unit/espnHeadshotIndex.test.js @@ -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'); + }); +}); diff --git a/tests/unit/espnSummary.test.js b/tests/unit/espnSummary.test.js index f1a9b39..40605b5 100644 --- a/tests/unit/espnSummary.test.js +++ b/tests/unit/espnSummary.test.js @@ -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 () => { diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index d182524..2c8d4b8 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -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} diff --git a/web/src/components/vyndr/PlayerAvatar.tsx b/web/src/components/vyndr/PlayerAvatar.tsx index 7b56a49..f02507c 100644 --- a/web/src/components/vyndr/PlayerAvatar.tsx +++ b/web/src/components/vyndr/PlayerAvatar.tsx @@ -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; diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 0962409..483edc9 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -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({
{/* DS0 — player identity block: real headshot / team-colored monogram (never the gray silhouette). Team drives the accent color. */} - + {player} {team} {archetype && } diff --git a/web/src/lib/playerHeadshot.ts b/web/src/lib/playerHeadshot.ts index 2f25c05..7f9e60d 100644 --- a/web/src/lib/playerHeadshot.ts +++ b/web/src/lib/playerHeadshot.ts @@ -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; } diff --git a/web/src/lib/playerHeadshotUrl.js b/web/src/lib/playerHeadshotUrl.js index 138a3d7..675da81 100644 --- a/web/src/lib/playerHeadshotUrl.js +++ b/web/src/lib/playerHeadshotUrl.js @@ -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) { diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 82c1b20..effa9f1 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -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);