fceb3707b5
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>
181 lines
7.3 KiB
JavaScript
181 lines
7.3 KiB
JavaScript
"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 },
|
|
};
|