/** * ESPN injury parser. * * Two data paths: * 1. ESPN team-injuries endpoint: * https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{teamId}/injuries * 2. Injury info embedded in scoreboard / summary responses under * events[i].competitions[0].competitors[t].injuries * * We expose three callers: * getTeamInjuries(sport, teamId) — primary fetch + cache * getGameInjuries(sport, gameId, espnSummary?) — convenience reading * the summary JSON the resolution path already loads, so we don't * refetch * isPlayerOut / getMissingStarters — derived helpers * * Cache: Redis, 2-hour TTL — injuries can change at shootaround on * game day so we deliberately don't go longer. */ const axios = require('axios'); const { cacheGet, cacheSet } = require('../../utils/redis'); const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter'); const HTTP_TIMEOUT_MS = 10_000; const CACHE_TTL_SECONDS = 2 * 60 * 60; // ESPN's team-injuries endpoint takes a sport/league path. We resolve the // league portion off the same SPORT_CONFIG used by the resolution poller // rather than maintaining a parallel map. const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports'; const SPORT_PATH = Object.freeze({ nba: 'basketball/nba', wnba: 'basketball/wnba', mlb: 'baseball/mlb', nfl: 'football/nfl', nhl: 'hockey/nhl', ncaab: 'basketball/mens-college-basketball', ncaafb: 'football/college-football', }); const limiter = createLimiter({ tokensPerInterval: 6, interval: 60_000 }); const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 }); const STATUS_CANON = (status) => { if (!status) return 'UNKNOWN'; const upper = String(status).toUpperCase(); if (upper.includes('OUT')) return 'OUT'; if (upper.includes('DOUBTFUL')) return 'DOUBTFUL'; if (upper.includes('QUESTIONABLE')) return 'QUESTIONABLE'; if (upper.includes('PROBABLE')) return 'PROBABLE'; if (upper.includes('DAY-TO-DAY') || upper.includes('DAY_TO_DAY') || upper.includes('DTD')) return 'DAY_TO_DAY'; return upper; }; function normalizeInjuryEntry(entry) { // ESPN payloads vary — entries may carry the player at `.athlete` or be // flat with `.name` / `.id`. Try both shapes. const player = entry?.athlete ?? entry; return { playerId: String(player?.id ?? entry?.id ?? ''), playerName: player?.displayName ?? player?.fullName ?? entry?.name ?? null, status: STATUS_CANON(entry?.status ?? entry?.type?.description ?? entry?.details?.type), detail: entry?.details?.detail ?? entry?.shortComment ?? entry?.longComment ?? null, }; } async function getTeamInjuries(sport, teamId) { const path = SPORT_PATH[sport]; if (!path) return []; const cacheKey = `injuries:${sport}:${teamId}`; const cached = await cacheGet(cacheKey); if (cached) return cached; await limiter.waitForToken(); try { const data = await breaker.call(async () => { const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/injuries`, { timeout: HTTP_TIMEOUT_MS, validateStatus: (s) => (s >= 200 && s < 300) || s === 404, }); // ESPN returns 404 for teams with no current injuries on some sports // — that's a clean "no injuries", not an error. if (res.status === 404) return { injuries: [] }; return res.data; }); const raw = data?.injuries || data?.athletes || []; const normalized = (Array.isArray(raw) ? raw : []).map(normalizeInjuryEntry).filter((e) => e.playerName); await cacheSet(cacheKey, normalized, CACHE_TTL_SECONDS); return normalized; } catch (err) { if (err?.code !== 'CIRCUIT_OPEN') { console.warn(`[injuries] fetch failed for ${sport}/${teamId}:`, err?.message); } return []; } } function extractGameInjuries(espnSummary) { // espnSummary is the JSON from /summary?event={id}. Some sports nest // injuries under competitions[0].competitors[t].injuries; others under // a top-level injuries[] array. We try both. const out = { home: [], away: [] }; const comp = espnSummary?.header?.competitions?.[0] ?? espnSummary?.competitions?.[0]; if (comp?.competitors) { for (const team of comp.competitors) { const bucket = team?.homeAway === 'home' ? 'home' : 'away'; const list = team?.injuries || []; for (const e of list) { const normalized = normalizeInjuryEntry(e); if (normalized.playerName) out[bucket].push(normalized); } } } if (Array.isArray(espnSummary?.injuries)) { for (const e of espnSummary.injuries) { const normalized = normalizeInjuryEntry(e); if (!normalized.playerName) continue; const bucket = e?.team === 'home' ? 'home' : 'away'; out[bucket].push(normalized); } } return out; } async function getGameInjuries(sport, gameId, espnSummary) { if (espnSummary) return extractGameInjuries(espnSummary); // Without a summary in hand, we'd need both team IDs from the scoreboard // — defer to the caller to pass espnSummary so we don't multiply ESPN // requests. return { home: [], away: [] }; } async function isPlayerOut(sport, teamId, playerId) { const list = await getTeamInjuries(sport, teamId); const match = list.find((i) => i.playerId === String(playerId)); if (!match) return false; return match.status === 'OUT' || match.status === 'DOUBTFUL'; } // starterIds is an iterable of ESPN player IDs known to start for this team // (resolved upstream from player_id_map or yesterday's box score). async function getMissingStarters(sport, teamId, starterIds) { const injuries = await getTeamInjuries(sport, teamId); const starterSet = new Set([...starterIds].map(String)); return injuries.filter( (i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL') ); } module.exports = { getTeamInjuries, getGameInjuries, isPlayerOut, getMissingStarters, __internals: { limiter, breaker, normalizeInjuryEntry, STATUS_CANON }, };