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