Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)

This commit is contained in:
Kev
2026-06-12 11:16:58 -04:00
parent 6ab49d4c37
commit 0538205fab
32 changed files with 2276 additions and 2 deletions
+144
View File
@@ -0,0 +1,144 @@
/**
* /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 router = express.Router();
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] };
}
/**
* Normalize one sportsbook's raw odds object into a flat, UI-ready row.
* Tank01 field names are verbose and occasionally vary; pull defensively.
*/
function normalizeBook(odds) {
if (!odds || typeof odds !== 'object') 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('homeTeamMLOdds', 'homeML', 'moneyLineHome'),
awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'),
total: pick('totalOver', 'total', 'overUnder'),
overOdds: pick('totalOverOdds', 'overOdds'),
underOdds: pick('totalUnderOdds', 'underOdds'),
homeSpread: pick('homeTeamSpread', 'homeSpread'),
awaySpread: pick('awayTeamSpread', 'awaySpread'),
homeSpreadOdds: pick('homeTeamSpreadOdds'),
awaySpreadOdds: pick('awayTeamSpreadOdds'),
};
}
/**
* 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);
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,
};
}
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 };