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