Files
vyndr/src/services/intelligence/injuryParser.js
T

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