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:
Kev
2026-07-13 18:12:31 -04:00
parent 3b1aa9f265
commit fceb3707b5
12 changed files with 460 additions and 10 deletions
+180
View File
@@ -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 },
};
+5 -1
View File
@@ -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;
+28
View File
@@ -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,
};
});