Session 45: Snapshot pipeline + GameCard swap + live ticker (2100 tests)
The on-demand "Read" grade model is RETIRED. A scheduled pipeline pre-grades the
slate, locks grades to the line, tracks movement; the dashboard shows them already
there. Orchestrates existing services — nothing rebuilt.
- snapshotService.runSnapshot(sport): getOdds → gradeAndCacheSlate → classify
archetype per player → lock gradedAt → line deltas vs previous snapshot → write
snapshot:{sport}:latest/previous + grades:{sport} → ticker events. Fully
injectable, zero-network unit tests. runAllSnapshots = cron entrypoint.
- Internal trigger POST /api/internal/snapshot/:sport + /all (requireInternalAuth).
In-process cron (SNAPSHOT_CRON=1, UTC 14,19,22,1,3) in server.js, no new dep.
- Public reads: GET /api/snapshot/:sport (cache-only) + GET /api/ticker (merges
TICKER_MANUAL pins) + Next proxies.
- GameCard swap: live Slate renders vyndr/GameCard (legacy kept for types only),
overlays locked grades onto game props → player name once + archetype badge +
"Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0". Ungraded → "Awaiting next
scan", NO Read button. On-demand onGrade flow deleted.
- Ticker polls /api/ticker every 30s, graceful fallback to hardcoded items.
- NBA/WNBA: espnStatsAdapter free fallback (defensive parse → found:false on shape
mismatch) wired into resolvePlayerStats after the offline Python service.
Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, TICKER_MANUAL.
Backend 2061 -> 2100 tests (+39), 173 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* espnStatsAdapter — best-effort NBA/WNBA season averages from ESPN (Session 45).
|
||||
*
|
||||
* The primary NBA/WNBA stats source (`nbaStatsClient`) depends on a Python
|
||||
* nba_api service that is frequently offline in prod. This adapter is a FREE,
|
||||
* no-auth fallback off ESPN's public site API. It is intentionally DEFENSIVE:
|
||||
* any shape it doesn't recognize → null (the caller degrades to found:false),
|
||||
* never a throw and never a wrong-but-confident number.
|
||||
*
|
||||
* Parsing is tolerant by design (ESPN's athlete-stats JSON varies by sport and
|
||||
* season), so `parseAthleteStats` is a pure, unit-tested function.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
|
||||
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
|
||||
const TTL = 6 * 3600;
|
||||
const TIMEOUT = 10_000;
|
||||
|
||||
// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
|
||||
const STAT_MAP = {
|
||||
pointspergame: 'ppg', avgpoints: 'ppg', points: 'ppg', ppg: 'ppg',
|
||||
reboundspergame: 'rpg', avgrebounds: 'rpg', rebounds: 'rpg', rpg: 'rpg', totalrebounds: 'rpg',
|
||||
assistspergame: 'apg', avgassists: 'apg', assists: 'apg', apg: 'apg',
|
||||
blockspergame: 'bpg', avgblocks: 'bpg', blocks: 'bpg', bpg: 'bpg',
|
||||
stealspergame: 'spg', avgsteals: 'spg', steals: 'spg', spg: 'spg',
|
||||
threepointfieldgoalsmade: 'threes', threepointfieldgoalspergame: 'threes', avg3pointfieldgoalsmade: 'threes',
|
||||
};
|
||||
|
||||
const keyify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/**
|
||||
* Walk an ESPN athlete-stats payload and pull out per-game averages we can
|
||||
* classify. Returns a classifier-input object (possibly partial) or null when
|
||||
* nothing usable is found.
|
||||
*/
|
||||
function parseAthleteStats(payload) {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const out = {};
|
||||
// ESPN nests stats under categories[].stats[] with { name|abbreviation, value|displayValue }.
|
||||
const categories = payload?.statistics?.splits?.categories
|
||||
|| payload?.splits?.categories
|
||||
|| payload?.categories
|
||||
|| [];
|
||||
const visit = (statArr) => {
|
||||
for (const st of statArr || []) {
|
||||
const label = keyify(st.name || st.abbreviation || st.label);
|
||||
const mapped = STAT_MAP[label];
|
||||
if (!mapped) continue;
|
||||
const val = Number(st.value != null ? st.value : st.displayValue);
|
||||
if (Number.isFinite(val) && out[mapped] == null) out[mapped] = val;
|
||||
}
|
||||
};
|
||||
for (const cat of categories) visit(cat.stats);
|
||||
if (Array.isArray(payload.stats)) visit(payload.stats); // flat fallback
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, http) {
|
||||
const client = http || axios;
|
||||
const res = await client.get(url, { timeout: TIMEOUT });
|
||||
return res && res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a player's NBA/WNBA season averages from ESPN. Returns
|
||||
* { found, team, position, classifierInput } or { found:false }. Never throws.
|
||||
* opts.http injectable for tests.
|
||||
*/
|
||||
async function getSeasonAverages(name, sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const path = SPORT_PATH[sp];
|
||||
if (!path || !name) return { found: false };
|
||||
const cacheKey = `espnstats:${sp}:${keyify(name)}`;
|
||||
try {
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 1. Resolve the athlete id via ESPN search.
|
||||
const search = await fetchJson(`${SEARCH}?query=${encodeURIComponent(name)}&limit=5&sport=${encodeURIComponent(path)}`, opts.http);
|
||||
const items = (search && (search.items || search.results)) || [];
|
||||
const athlete = items.find((it) => keyify(it.displayName || it.name) === keyify(name)) || items[0];
|
||||
const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
|
||||
if (!id) return { found: false };
|
||||
|
||||
// 2. Fetch that athlete's stats overview.
|
||||
const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
|
||||
const classifierInput = parseAthleteStats(stats);
|
||||
if (!classifierInput) return { found: false };
|
||||
|
||||
const result = {
|
||||
found: true,
|
||||
team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
|
||||
position: (athlete.position && athlete.position.abbreviation) || '',
|
||||
classifierInput,
|
||||
};
|
||||
try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('[espnStats] season averages failed:', name, sp, err.message);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } };
|
||||
Reference in New Issue
Block a user