Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.
Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
numeric athlete id via the v2 search (the v3 /search now returns
count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
league), fetches the per-athlete gamelog, and parses per-game rows
keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
blocks/turnovers + computed pra). Columns are indexed by the
response's own names[] array (NBA and WNBA orders DIFFER), never
positionally. Most-recent first, defensive (null on unrecognized
shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
nba/wnba when the Python source returns null/empty, producing
l5/l10/l20 + rest_days + minutes_per_game via a new local
NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
separate from MLB_LOG_FIELD).
Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.
Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,10 +15,16 @@
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { nameKey } = require('../../utils/playerName');
|
||||
|
||||
const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
|
||||
// Wave 0 — the v3 /search endpoint above now returns count:0 for every query
|
||||
// (verified live 2026-07-13); the v2 search below is the reliable resolver and
|
||||
// carries the numeric athlete id inside its `uid` ("s:40~l:59~a:3149391").
|
||||
const SEARCH_V2 = 'https://site.api.espn.com/apis/search/v2';
|
||||
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
|
||||
const TTL = 6 * 3600;
|
||||
const GAMELOG_TTL = 4 * 3600; // per-game logs refresh once per night
|
||||
const TIMEOUT = 10_000;
|
||||
|
||||
// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
|
||||
@@ -118,4 +124,178 @@ async function getSeasonAverages(name, sport, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } };
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Wave 0 — FREE per-game logs for NBA/WNBA (the grade unlock).
|
||||
//
|
||||
// The Python nba_api service (gameLogService) is offline in prod, so
|
||||
// featureCache's non-MLB branch produced no l5/l20 → projectionFor returned
|
||||
// null → the ENTIRE NBA/WNBA slate refused. ESPN's per-athlete gamelog is free,
|
||||
// no-auth, and returns per-game rows (VERIFIED LIVE: NBA id 1966 → 73 rows,
|
||||
// WNBA id 3149391 → 23 rows). This mirrors mlbStatsAdapter.getPlayerGameLog's
|
||||
// output contract ({ found, id, last10:[{ date, opponent, isHome, stat:{…} }] })
|
||||
// so it drops straight into the existing feature pipeline.
|
||||
//
|
||||
// The per-game `stats[]` array is aligned to the response's OWN `names[]`
|
||||
// array — and that order DIFFERS between NBA and WNBA — so columns are indexed
|
||||
// by name token, never by a hardcoded position.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ESPN `names[]` token → our per-game stat field. Compound cells ("9-23" =
|
||||
// made-attempted) take the MADE portion. pra is COMPUTED (pts+reb+ast), never
|
||||
// a raw column.
|
||||
function toNum(v) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function madeOf(v) {
|
||||
if (v == null) return null;
|
||||
const s = String(v);
|
||||
const dash = s.indexOf('-');
|
||||
const n = parseInt(dash >= 0 ? s.slice(0, dash) : s, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function buildGameStat(statArr, idxOf) {
|
||||
if (!Array.isArray(statArr)) return null;
|
||||
const at = (token, fn = toNum) => {
|
||||
const i = idxOf[token];
|
||||
return i == null ? null : fn(statArr[i]);
|
||||
};
|
||||
const out = {};
|
||||
const points = at('points');
|
||||
const rebounds = at('totalRebounds');
|
||||
const assists = at('assists');
|
||||
const threes = at('threePointFieldGoalsMade-threePointFieldGoalsAttempted', madeOf);
|
||||
const steals = at('steals');
|
||||
const blocks = at('blocks');
|
||||
const turnovers = at('turnovers');
|
||||
const minutes = at('minutes');
|
||||
// Absent beats zero — only attach a field ESPN actually reported.
|
||||
if (points != null) out.points = points;
|
||||
if (rebounds != null) out.rebounds = rebounds;
|
||||
if (assists != null) out.assists = assists;
|
||||
if (threes != null) out.threes = threes;
|
||||
if (steals != null) out.steals = steals;
|
||||
if (blocks != null) out.blocks = blocks;
|
||||
if (turnovers != null) out.turnovers = turnovers;
|
||||
if (minutes != null) out.minutes = minutes;
|
||||
if (points != null && rebounds != null && assists != null) out.pra = points + rebounds + assists;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: parse an ESPN athlete-gamelog payload into per-game rows, most-recent
|
||||
* first. Returns an array or null (unrecognized shape). NEVER throws.
|
||||
*/
|
||||
function parseGameLog(payload) {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const names = Array.isArray(payload.names) ? payload.names : null;
|
||||
if (!names || names.length === 0) return null;
|
||||
const idxOf = {};
|
||||
names.forEach((n, i) => { if (idxOf[String(n)] == null) idxOf[String(n)] = i; });
|
||||
const evMap = (payload.events && typeof payload.events === 'object') ? payload.events : {};
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const st of Array.isArray(payload.seasonTypes) ? payload.seasonTypes : []) {
|
||||
for (const cat of (st && Array.isArray(st.categories)) ? st.categories : []) {
|
||||
for (const ev of (cat && Array.isArray(cat.events)) ? cat.events : []) {
|
||||
const eid = ev && ev.eventId;
|
||||
if (!eid || seen.has(eid)) continue;
|
||||
const stat = buildGameStat(ev.stats, idxOf);
|
||||
if (!stat) continue;
|
||||
seen.add(eid);
|
||||
const meta = evMap[eid] || {};
|
||||
const isHome = meta.atVs === 'vs' ? true : (meta.atVs === '@' ? false : null);
|
||||
rows.push({
|
||||
date: meta.gameDate || null,
|
||||
opponent: (meta.opponent && (meta.opponent.abbreviation || meta.opponent.displayName)) || null,
|
||||
isHome,
|
||||
stat,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
// Most-recent first. Undated rows sink to the end without reordering.
|
||||
rows.sort((a, b) => {
|
||||
const ta = a.date ? Date.parse(a.date) : NaN;
|
||||
const tb = b.date ? Date.parse(b.date) : NaN;
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return tb - ta;
|
||||
});
|
||||
return rows.slice(0, 20);
|
||||
}
|
||||
|
||||
async function fetchJsonG(url, opts = {}) {
|
||||
if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
|
||||
const client = opts.http || axios;
|
||||
const res = await client.get(url, { timeout: TIMEOUT });
|
||||
return res && res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve name → ESPN numeric athlete id for a basketball league via the v2
|
||||
* search. Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is
|
||||
* NOT returned for a WNBA query), then prefers an exact canonical-name match.
|
||||
* Returns the numeric id string or null (a missing id beats the wrong player).
|
||||
*/
|
||||
async function resolveAthleteId(name, sport, opts = {}) {
|
||||
const data = await fetchJsonG(`${SEARCH_V2}?query=${encodeURIComponent(name)}&limit=10`, opts);
|
||||
const section = (data && Array.isArray(data.results) ? data.results : []).find((r) => r && r.type === 'player');
|
||||
const players = (section && Array.isArray(section.contents)) ? section.contents : [];
|
||||
if (players.length === 0) return null;
|
||||
const inLeague = players.filter((p) => String(p.defaultLeagueSlug || '').toLowerCase() === sport);
|
||||
const pool = inLeague.length ? inLeague : players;
|
||||
const target = nameKey(name);
|
||||
const exact = pool.find((p) => nameKey(p.displayName || p.name || '') === target);
|
||||
const chosen = exact || pool[0];
|
||||
if (!chosen) return null;
|
||||
const m = /a:(\d+)/.exec(String(chosen.uid || ''));
|
||||
if (m) return m[1];
|
||||
return /^\d+$/.test(String(chosen.id)) ? String(chosen.id) : null;
|
||||
}
|
||||
|
||||
const gameLogMem = new Map();
|
||||
|
||||
/**
|
||||
* NBA/WNBA per-game logs from ESPN. Returns { found, id, last10 } (most-recent
|
||||
* first) or { found:false }. Never throws. Cached (Redis `espngamelog:{sport}:
|
||||
* {id}` 4h + in-memory mirror). opts.fetchImpl/opts.http injectable for tests.
|
||||
*/
|
||||
async function getPlayerGameLog(name, sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const path = SPORT_PATH[sp];
|
||||
if (!path || !name) return { found: false };
|
||||
try {
|
||||
const id = await resolveAthleteId(name, sp, opts);
|
||||
if (!id) return { found: false };
|
||||
const ck = `espngamelog:${sp}:${id}`;
|
||||
if (gameLogMem.has(ck)) return gameLogMem.get(ck);
|
||||
try {
|
||||
const cached = await cacheGet(ck);
|
||||
if (cached) { gameLogMem.set(ck, cached); return cached; }
|
||||
} catch { /* ignore cache read */ }
|
||||
|
||||
const payload = await fetchJsonG(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`, opts);
|
||||
const last10 = parseGameLog(payload);
|
||||
if (!last10) return { found: false, id };
|
||||
const result = { found: true, id, last10 };
|
||||
gameLogMem.set(ck, result);
|
||||
try { await cacheSet(ck, result, GAMELOG_TTL); } catch { /* ignore cache write */ }
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('[espnStats] game log failed:', name, sp, err.message);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSeasonAverages,
|
||||
parseAthleteStats,
|
||||
getPlayerGameLog,
|
||||
parseGameLog,
|
||||
resolveAthleteId,
|
||||
__internals: { STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum, gameLogMem },
|
||||
};
|
||||
|
||||
@@ -93,6 +93,22 @@ function mlbStatValue(statObj, statType) {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Wave 0 — NBA/WNBA stat_type → the per-game field on an espnStatsAdapter
|
||||
// gamelog row's `stat` object. A SEPARATE local map from MLB_LOG_FIELD (the
|
||||
// S11 three-map-split rule — never merge). Combo stats route to statFromGameLog
|
||||
// (which sums points/rebounds/assists at read time); `pra`/`threes` are already
|
||||
// normalized fields on the row. A stat_type absent here does NOT grade via this
|
||||
// path (absent beats a fabricated projection). Add a new NBA/WNBA stat here to
|
||||
// unlock it.
|
||||
const NBA_LOG_FIELD = {
|
||||
points: 'points', rebounds: 'rebounds', assists: 'assists',
|
||||
threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers',
|
||||
pra: 'pra',
|
||||
// combos (statFromGameLog sums the raw components)
|
||||
pts_reb_ast: 'pts_reb_ast', pts_reb: 'pts_reb', pts_ast: 'pts_ast',
|
||||
reb_ast: 'reb_ast', stl_blk: 'stl_blk',
|
||||
};
|
||||
|
||||
/**
|
||||
* Session 46 — derive recent/season averages from a real MLB game log
|
||||
* (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
|
||||
@@ -140,6 +156,49 @@ function mlbGameLogFeatures(res, statType) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave 0 — derive recent/season averages from an ESPN NBA/WNBA gamelog
|
||||
* (espnStatsAdapter.getPlayerGameLog result). PURE + unit-testable. The result's
|
||||
* last10 is MOST-RECENT-FIRST, so l5 = first 5, l20 = all available (the season
|
||||
* per-game reference projectionFor needs). Emits the SAME fields the Python
|
||||
* path did, plus rest_days + minutes_per_game (usage), so the grade card lights
|
||||
* up. Returns {} when the log is missing or the stat_type isn't mapped.
|
||||
*/
|
||||
function nbaGameLogFeatures(res, statType) {
|
||||
if (!res || !res.found) return {};
|
||||
const field = NBA_LOG_FIELD[statType];
|
||||
if (!field) return {};
|
||||
const logs = Array.isArray(res.last10) ? res.last10 : [];
|
||||
const vals = logs.map((g) => statFromGameLog(g && g.stat, field)).filter((v) => v != null);
|
||||
const out = {};
|
||||
if (vals.length) {
|
||||
const m5 = avg(vals.slice(0, 5)); // most-recent first
|
||||
const m10 = avg(vals.slice(0, 10));
|
||||
const m20 = avg(vals.slice(0, 20));
|
||||
const s10 = stddev(vals.slice(0, 10));
|
||||
if (m5 != null) out.l5_avg = m5;
|
||||
if (m10 != null) out.l10_avg = m10;
|
||||
if (m20 != null) out.l20_avg = m20;
|
||||
if (s10 != null) out.l10_stddev = s10;
|
||||
}
|
||||
|
||||
// rest_days from the two most-recent dated games (0 = back-to-back), mirroring
|
||||
// the MLB branch + the NBA convention buildIntelFields reads.
|
||||
const dated = logs.filter((g) => g && g.date);
|
||||
if (dated.length >= 2) {
|
||||
const last = new Date(dated[0].date).getTime(); // most recent
|
||||
const prev = new Date(dated[1].date).getTime();
|
||||
const gap = Math.round((last - prev) / 86_400_000);
|
||||
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
|
||||
}
|
||||
|
||||
// minutes_per_game — the NBA "usage" equivalent buildIntelFields surfaces.
|
||||
const mins = logs.map((g) => g && g.stat && Number(g.stat.minutes)).filter((v) => Number.isFinite(v));
|
||||
const mpg = avg(mins);
|
||||
if (mpg != null) out.minutes_per_game = mpg;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function gameLogFeatures(playerName, sport, statType) {
|
||||
// MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
|
||||
// gameLogService only covers NBA/WNBA, so MLB props had no recent/season
|
||||
@@ -156,6 +215,22 @@ async function gameLogFeatures(playerName, sport, statType) {
|
||||
}
|
||||
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||
|
||||
// Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService)
|
||||
// is offline in prod, so `logs` is null and this branch used to return {} →
|
||||
// no l5/l20 → projectionFor null → the ENTIRE slate refused. Fall back to the
|
||||
// FREE ESPN per-athlete gamelog so NBA (off-season) + WNBA (in-season) grade.
|
||||
if ((!logs || logs.length === 0) && (sport === 'nba' || sport === 'wnba')) {
|
||||
try {
|
||||
const espnStats = require('../adapters/espnStatsAdapter');
|
||||
const res = await espnStats.getPlayerGameLog(playerName, sport);
|
||||
return nbaGameLogFeatures(res, statType);
|
||||
} catch (e) {
|
||||
console.warn('[featureCache] ESPN game-log fallback failed:', e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) return {};
|
||||
|
||||
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
|
||||
@@ -341,6 +416,8 @@ module.exports = {
|
||||
statFromGameLog,
|
||||
mlbGameLogFeatures,
|
||||
mlbStatValue,
|
||||
nbaGameLogFeatures,
|
||||
NBA_LOG_FIELD,
|
||||
avg,
|
||||
stddev,
|
||||
daysBetween,
|
||||
|
||||
Reference in New Issue
Block a user