"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 }, };