/** * /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 };