f0c8b4f29b
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
5MB pipeline test given 20s timeout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.7 KiB
JavaScript
190 lines
6.7 KiB
JavaScript
/**
|
|
* /api/gamelines/:sport (Session 23)
|
|
*
|
|
* Today's game-level betting odds from Tank01 — book-by-book moneylines,
|
|
* run/point spreads, and totals. Separate budget from odds-api player
|
|
* props: this uses the RAPID_API_KEY quota via the Tank01 adapters.
|
|
*
|
|
* Chrome Claude confirmed (2026-06-12) Tank01 MLB serves LIVE lines from
|
|
* bet365 / betmgm / caesars. NBA carries the same in-season — an empty
|
|
* result off-season is correct, not an error.
|
|
*
|
|
* Response shape:
|
|
* {
|
|
* sport: 'mlb',
|
|
* date: '2026-06-12',
|
|
* games: {
|
|
* '20260612_ARI@CIN': {
|
|
* homeTeam: 'CIN', awayTeam: 'ARI',
|
|
* books: {
|
|
* bet365: { homeML, awayML, total, overOdds, underOdds,
|
|
* homeSpread, awaySpread },
|
|
* betmgm: { ... },
|
|
* },
|
|
* },
|
|
* },
|
|
* source: 'tank01',
|
|
* }
|
|
*/
|
|
|
|
const express = require('express');
|
|
const nbaAdapter = require('../services/adapters/tank01NbaAdapter');
|
|
const mlbAdapter = require('../services/adapters/tank01MlbAdapter');
|
|
const scheduleService = require('../services/scheduleService');
|
|
const { createRateLimit } = require('../middleware/rateLimit');
|
|
|
|
const router = express.Router();
|
|
// Session 32 — public throttle (60/min; Tank01, cached).
|
|
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
|
|
|
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Lines keep the slate alive' };
|
|
|
|
// Sports that have a Tank01 game-lines feed wired up.
|
|
const FETCHERS = {
|
|
nba: (date) => nbaAdapter.getNBABettingOdds(date),
|
|
mlb: (date) => mlbAdapter.getMLBBettingOdds(date),
|
|
};
|
|
|
|
const HAS_KEY = {
|
|
nba: () => nbaAdapter.hasApiKey(),
|
|
mlb: () => mlbAdapter.hasApiKey(),
|
|
};
|
|
|
|
/**
|
|
* Parse `YYYYMMDD_AWAY@HOME` → { awayTeam, homeTeam }. Tank01 keys every
|
|
* game this way. Falls back to nulls on an unexpected key.
|
|
*/
|
|
function teamsFromGameId(gameId) {
|
|
const m = String(gameId || '').match(/_([A-Za-z0-9]+)@([A-Za-z0-9]+)/);
|
|
if (!m) return { awayTeam: null, homeTeam: null };
|
|
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 vary across feeds — pull defensively,
|
|
* tolerating both the MLB run-line and NBA spread spellings.
|
|
*/
|
|
function normalizeBook(odds) {
|
|
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];
|
|
}
|
|
return null;
|
|
};
|
|
return {
|
|
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('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
|
|
* and a bare array of game objects.
|
|
*/
|
|
function normalizeGameLines(body) {
|
|
const games = {};
|
|
if (!body || typeof body !== 'object') return games;
|
|
|
|
const entries = Array.isArray(body)
|
|
? body.map((g) => [g.gameID || g.gameId, g])
|
|
: Object.entries(body);
|
|
|
|
for (const [gameId, game] of entries) {
|
|
if (!gameId || !game || typeof game !== 'object') continue;
|
|
const { awayTeam, homeTeam } = teamsFromGameId(gameId);
|
|
games[gameId] = {
|
|
homeTeam: game.homeTeam || homeTeam,
|
|
awayTeam: game.awayTeam || awayTeam,
|
|
books: extractBooks(game),
|
|
};
|
|
}
|
|
return games;
|
|
}
|
|
|
|
router.get('/:sport', async (req, res) => {
|
|
const sport = String(req.params.sport || '').toLowerCase();
|
|
const date = req.query.date || scheduleService.todayET();
|
|
|
|
const fetcher = FETCHERS[sport];
|
|
if (!fetcher) {
|
|
return res.status(404).set(MISSION_HEADER).json({ error: `No game lines for sport: ${sport}` });
|
|
}
|
|
|
|
// Missing RAPID_API_KEY → graceful empty, never a crash.
|
|
if (HAS_KEY[sport] && !HAS_KEY[sport]()) {
|
|
return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01', configured: false });
|
|
}
|
|
|
|
try {
|
|
const body = await fetcher(date);
|
|
const games = normalizeGameLines(body);
|
|
return res.set(MISSION_HEADER).json({ sport, date, games, source: 'tank01' });
|
|
} catch (err) {
|
|
console.error(`[gamelines/${sport}]`, err.message);
|
|
return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines, extractBooks };
|