Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)
This commit is contained in:
+62
-20
@@ -57,12 +57,28 @@ function teamsFromGameId(gameId) {
|
||||
return { awayTeam: m[1], homeTeam: m[2] };
|
||||
}
|
||||
|
||||
// Session 25 — Tank01's real shape (traced 2026-06-12) puts each
|
||||
// sportsbook as a TOP-LEVEL key inside the game object, NOT inside a
|
||||
// `sportsBooks` array:
|
||||
// { "20260612_ARI@CIN": {
|
||||
// awayTeam: "ARI", homeTeam: "CIN", gameID: "...",
|
||||
// bet365: { homeTeamML: "-110", totalOver: "9.5", ... },
|
||||
// betmgm: { ... }, caesars: { ... } } }
|
||||
// These non-book keys must be excluded so they don't get treated as books.
|
||||
const NON_BOOK_KEYS = new Set([
|
||||
'awayTeam', 'homeTeam', 'gameID', 'gameId', 'gameDate', 'gameTime',
|
||||
'gameStatus', 'gameStatusCode', 'teamIDAway', 'teamIDHome',
|
||||
'season', 'seasonType', 'last_updated_e_time', 'espnID', 'espnLink',
|
||||
'cbsLink', 'sportsBooks', 'books',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize one sportsbook's raw odds object into a flat, UI-ready row.
|
||||
* Tank01 field names are verbose and occasionally vary; pull defensively.
|
||||
* Tank01 field names are verbose and vary across feeds — pull defensively,
|
||||
* tolerating both the MLB run-line and NBA spread spellings.
|
||||
*/
|
||||
function normalizeBook(odds) {
|
||||
if (!odds || typeof odds !== 'object') return null;
|
||||
if (!odds || typeof odds !== 'object' || Array.isArray(odds)) return null;
|
||||
const pick = (...keys) => {
|
||||
for (const k of keys) {
|
||||
if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k];
|
||||
@@ -70,18 +86,53 @@ function normalizeBook(odds) {
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
homeML: pick('homeTeamMLOdds', 'homeML', 'moneyLineHome'),
|
||||
awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'),
|
||||
total: pick('totalOver', 'total', 'overUnder'),
|
||||
homeML: pick('homeTeamML', 'homeTeamMLOdds', 'homeML', 'moneyLineHome'),
|
||||
awayML: pick('awayTeamML', 'awayTeamMLOdds', 'awayML', 'moneyLineAway'),
|
||||
total: pick('totalOver', 'totalUnder', 'total', 'overUnder'),
|
||||
overOdds: pick('totalOverOdds', 'overOdds'),
|
||||
underOdds: pick('totalUnderOdds', 'underOdds'),
|
||||
homeSpread: pick('homeTeamSpread', 'homeSpread'),
|
||||
awaySpread: pick('awayTeamSpread', 'awaySpread'),
|
||||
homeSpreadOdds: pick('homeTeamSpreadOdds'),
|
||||
awaySpreadOdds: pick('awayTeamSpreadOdds'),
|
||||
homeSpread: pick('homeTeamRunLine', 'homeTeamSpread', 'homeSpread'),
|
||||
awaySpread: pick('awayTeamRunLine', 'awayTeamSpread', 'awaySpread'),
|
||||
homeSpreadOdds: pick('homeTeamSpreadOdds', 'homeTeamRunLineOdds'),
|
||||
awaySpreadOdds: pick('awayTeamSpreadOdds', 'awayTeamRunLineOdds'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the books map from a single game object. Handles BOTH shapes:
|
||||
* 1. (current) sportsbooks as top-level keys on the game object
|
||||
* 2. (legacy) a `sportsBooks` array of { sportsBook, odds } entries
|
||||
* A book is only counted if it yields at least one real odds field, so a
|
||||
* stray non-book object key can't pollute the result.
|
||||
*/
|
||||
function extractBooks(game) {
|
||||
const books = {};
|
||||
|
||||
// Shape 1 — top-level book keys.
|
||||
for (const [key, val] of Object.entries(game)) {
|
||||
if (NON_BOOK_KEYS.has(key)) continue;
|
||||
if (!val || typeof val !== 'object' || Array.isArray(val)) continue;
|
||||
const row = normalizeBook(val);
|
||||
if (row && Object.values(row).some((v) => v !== null)) {
|
||||
books[String(key).toLowerCase()] = row;
|
||||
}
|
||||
}
|
||||
|
||||
// Shape 2 — legacy sportsBooks array (kept for backward compatibility).
|
||||
const sbList = game.sportsBooks || game.books;
|
||||
if (Array.isArray(sbList)) {
|
||||
for (const sb of sbList) {
|
||||
const name = sb?.sportsBook || sb?.book || sb?.name;
|
||||
const row = normalizeBook(sb?.odds || sb);
|
||||
if (name && row && Object.values(row).some((v) => v !== null)) {
|
||||
books[String(name).toLowerCase()] = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the Tank01 betting-odds body (a map keyed by gameID) into the
|
||||
* route's `games` shape. Defensive against both the documented map form
|
||||
@@ -98,19 +149,10 @@ function normalizeGameLines(body) {
|
||||
for (const [gameId, game] of entries) {
|
||||
if (!gameId || !game || typeof game !== 'object') continue;
|
||||
const { awayTeam, homeTeam } = teamsFromGameId(gameId);
|
||||
const books = {};
|
||||
const sbList = game.sportsBooks || game.books || [];
|
||||
if (Array.isArray(sbList)) {
|
||||
for (const sb of sbList) {
|
||||
const name = sb?.sportsBook || sb?.book || sb?.name;
|
||||
const row = normalizeBook(sb?.odds || sb);
|
||||
if (name && row) books[String(name).toLowerCase()] = row;
|
||||
}
|
||||
}
|
||||
games[gameId] = {
|
||||
homeTeam: game.homeTeam || homeTeam,
|
||||
awayTeam: game.awayTeam || awayTeam,
|
||||
books,
|
||||
books: extractBooks(game),
|
||||
};
|
||||
}
|
||||
return games;
|
||||
@@ -141,4 +183,4 @@ router.get('/:sport', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines };
|
||||
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines, extractBooks };
|
||||
|
||||
@@ -6,11 +6,20 @@
|
||||
* as the grading flow touches them. There's no roster-wide pull, and we
|
||||
* will NOT add API calls to build one (free/cheap-only session).
|
||||
*
|
||||
* So we read what's ALREADY cached:
|
||||
* So we read what's ALREADY cached, in priority order:
|
||||
* 1. A precomputed roster blob `rosterlogs:{sport}` if a prefetch wrote
|
||||
* one (fast path — a single read).
|
||||
* 2. Otherwise SCAN the per-player `gamelogs:{sport}:*` keys and assemble
|
||||
* a roster from whatever's warm.
|
||||
* 2. The per-player `gamelogs:{sport}:*` keys (NBA/WNBA: written by
|
||||
* gameLogService during grading, with real multi-game logs).
|
||||
* 3. (Session 25) The Tank01 box-score cache `tank01:{sport}:boxscore:*`
|
||||
* that the prefetch writes. Each key is ONE game; we aggregate by
|
||||
* player across games into the same roster shape. This closes the
|
||||
* key-alignment gap Session 25 traced: the prefetch wrote box scores
|
||||
* under a key rosterLogs never read, so MLB streaks were always empty.
|
||||
*
|
||||
* Streaks need 2+ games to surface (MIN_STREAK), so a single cached game
|
||||
* day yields no streak — correct, not a bug. Coverage grows as box scores
|
||||
* accumulate across prefetch runs.
|
||||
*
|
||||
* Everything here is Redis-only (free) and defensive — any failure yields
|
||||
* an empty roster, never a throw. An empty roster is a valid state: the
|
||||
@@ -37,11 +46,10 @@ function playerFromKey(key, sport) {
|
||||
return rest.slice(0, lastColon);
|
||||
}
|
||||
|
||||
async function scanGameLogKeys(sport) {
|
||||
async function scanKeys(match) {
|
||||
if (isDegraded && isDegraded()) return [];
|
||||
const redis = getRedisClient();
|
||||
if (!redis || typeof redis.scan !== 'function') return [];
|
||||
const match = `gamelogs:${sport}:*`;
|
||||
const keys = [];
|
||||
let cursor = '0';
|
||||
try {
|
||||
@@ -60,6 +68,63 @@ async function scanGameLogKeys(sport) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function scanGameLogKeys(sport) {
|
||||
return scanKeys(`gamelogs:${sport}:*`);
|
||||
}
|
||||
|
||||
// Session 25 — box-score-cache aggregation (the prefetch-alignment bridge).
|
||||
|
||||
// Extract the YYYYMMDD date from a `tank01:{sport}:boxscore:{gameId}` key,
|
||||
// where gameId is `YYYYMMDD_AWAY@HOME`. Used to order games most-recent-first
|
||||
// (streaksService counts the streak from games[0] backward).
|
||||
function boxScoreKeyDate(key) {
|
||||
const m = String(key || '').match(/boxscore:(\d{8})/);
|
||||
return m ? m[1] : '0';
|
||||
}
|
||||
|
||||
// Project one cached box-score row into a stat row the streaks/hot-list
|
||||
// engines can read. NBA rows already carry pts/reb/ast/etc. at the top
|
||||
// level; MLB rows keep stats under `_raw`, so we flatten that up.
|
||||
function projectBoxRow(sport, row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
if (sport === 'mlb') {
|
||||
const raw = row._raw && typeof row._raw === 'object' ? row._raw : {};
|
||||
return { ...raw, team: row.team, playerId: row.playerId, _final: row._final };
|
||||
}
|
||||
// nba / wnba — already flat.
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate cached Tank01 box scores into [{ name, playerId, team, games }].
|
||||
* One box-score key = one game; a player appearing across N cached games
|
||||
* accumulates an N-length log. Games are ordered most-recent-first by the
|
||||
* date embedded in the key.
|
||||
*/
|
||||
async function aggregateBoxScores(sport) {
|
||||
const keys = await scanKeys(`tank01:${sport}:boxscore:*`);
|
||||
if (keys.length === 0) return [];
|
||||
// Most-recent game first.
|
||||
keys.sort((a, b) => boxScoreKeyDate(b).localeCompare(boxScoreKeyDate(a)));
|
||||
|
||||
const byPlayer = new Map();
|
||||
for (const k of keys) {
|
||||
const box = await cacheGet(k);
|
||||
if (!Array.isArray(box)) continue;
|
||||
for (const row of box) {
|
||||
const name = row?.name;
|
||||
if (!name) continue;
|
||||
const stat = projectBoxRow(sport, row);
|
||||
if (!stat) continue;
|
||||
if (!byPlayer.has(name)) {
|
||||
byPlayer.set(name, { name, playerId: row.playerId ?? null, team: row.team ?? null, games: [] });
|
||||
}
|
||||
byPlayer.get(name).games.push(stat);
|
||||
}
|
||||
}
|
||||
return Array.from(byPlayer.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [{ name, playerId, team, games }] for a sport. Dedupes players
|
||||
* (the highest game-count key wins) so one player isn't double-counted
|
||||
@@ -73,9 +138,8 @@ async function loadRosterLogs(sport) {
|
||||
const blob = await cacheGet(`rosterlogs:${key}`);
|
||||
if (Array.isArray(blob) && blob.length > 0) return blob;
|
||||
|
||||
// Per-player game-log keys (NBA/WNBA grading flow writes these).
|
||||
const keys = await scanGameLogKeys(key);
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const byPlayer = new Map();
|
||||
for (const k of keys) {
|
||||
const name = playerFromKey(k, key);
|
||||
@@ -89,7 +153,17 @@ async function loadRosterLogs(sport) {
|
||||
byPlayer.set(name, { name, playerId, team, games });
|
||||
}
|
||||
}
|
||||
return Array.from(byPlayer.values());
|
||||
if (byPlayer.size > 0) return Array.from(byPlayer.values());
|
||||
|
||||
// Session 25 — fall back to the Tank01 box-score cache the prefetch
|
||||
// writes. Closes the key-alignment gap that left MLB streaks empty.
|
||||
return aggregateBoxScores(key);
|
||||
}
|
||||
|
||||
module.exports = { loadRosterLogs, __internals: { playerFromKey, scanGameLogKeys } };
|
||||
module.exports = {
|
||||
loadRosterLogs,
|
||||
__internals: {
|
||||
playerFromKey, scanGameLogKeys, scanKeys,
|
||||
boxScoreKeyDate, projectBoxRow, aggregateBoxScores,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user