Session 25: Fix all data rendering — proxy routes, Tank01 normalizer, box-score bridge, inline streaks (1579 tests)

This commit is contained in:
Kev
2026-06-12 17:58:55 -04:00
parent 433e827103
commit 956cdb863a
15 changed files with 602 additions and 39 deletions
+62 -20
View File
@@ -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 };