158 lines
5.9 KiB
JavaScript
158 lines
5.9 KiB
JavaScript
/**
|
|
* 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 },
|
|
};
|