Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -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 };
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* /api/hotlist/:sport (Session 23)
|
||||
*
|
||||
* Rolling recent-window leaders, ranked by how far ABOVE baseline each
|
||||
* player is trending. NO API calls — reads warm cached game logs and runs
|
||||
* the pure hot-list engine. Supports `?stat=points` and `?limit=N`.
|
||||
*
|
||||
* Response: { sport, stat, players: [...], source: 'computed' }
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const hotListService = require('../services/hotListService');
|
||||
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Hot right now' };
|
||||
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer']);
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
if (!SUPPORTED.has(sport)) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: `No hot list for sport: ${sport}` });
|
||||
}
|
||||
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
||||
|
||||
try {
|
||||
const roster = await loadRosterLogs(sport);
|
||||
const players = hotListService.computeHotList(roster, sport, { stat, limit });
|
||||
return res.set(MISSION_HEADER).json({ sport, stat, players, source: 'computed' });
|
||||
} catch (err) {
|
||||
console.error(`[hotlist/${sport}]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, stat, players: [], source: 'computed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* /api/schedule/:sport (Session 23)
|
||||
*
|
||||
* Returns today's game schedule from cached/free ESPN data. NO odds-api
|
||||
* credits burned. The PM2 pollers warm this cache every 60s; on a miss
|
||||
* the schedule service self-heals by fetching the free ESPN scoreboard.
|
||||
*
|
||||
* Each game carries two boolean flags read from OTHER caches (no fetch):
|
||||
* hasOdds — odds-api player props exist for this slate
|
||||
* hasGameLines — Tank01 game-level lines exist for this slate
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* sport: 'nba',
|
||||
* date: '2026-06-12',
|
||||
* games: [ { id, homeTeam, awayTeam, gameTime, status, score,
|
||||
* venue, broadcast, hasOdds, hasGameLines } ],
|
||||
* source: 'espn',
|
||||
* }
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const scheduleService = require('../services/scheduleService');
|
||||
const { SPORT_CONFIG } = require('../config/sports');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
const date = req.query.date || scheduleService.todayET();
|
||||
|
||||
if (!SPORT_CONFIG[sport]) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: `Unknown sport: ${sport}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await scheduleService.getSchedule(sport, date);
|
||||
// null → unsupported sport (already guarded above); treat defensively.
|
||||
const games = await scheduleService.enrichFlags(sport, date, raw || []);
|
||||
return res.set(MISSION_HEADER).json({
|
||||
sport,
|
||||
date,
|
||||
games,
|
||||
source: 'espn',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[schedule/${sport}]`, err.message);
|
||||
// Even on error we return an empty slate, not a 5xx — the platform
|
||||
// is NEVER down. Other layers (game lines, props) keep it alive.
|
||||
return res.set(MISSION_HEADER).json({ sport, date, games: [], source: 'espn' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +1,19 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getStatFilters } = require('../config/statFilters');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
||||
|
||||
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
|
||||
// (Session 23). Lets the frontend stay data-driven without re-declaring
|
||||
// the category list. NO auth / NO DB — pure config.
|
||||
router.get('/filters/:sport', (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
res.set(MISSION_HEADER).json({ sport, filters: getStatFilters(sport) });
|
||||
});
|
||||
|
||||
// GET /parlays-graded — total scan count
|
||||
router.get('/parlays-graded', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* /api/streaks/:sport (Session 23)
|
||||
*
|
||||
* Computed player streaks from cached game logs. NO API calls — reads
|
||||
* warm Redis logs and runs the pure streaks engine over them. Supports
|
||||
* `?stat=points` to narrow to one category, and `?limit=N`.
|
||||
*
|
||||
* Response: { sport, stat, streaks: [...], source: 'computed' }
|
||||
*
|
||||
* An empty `streaks` array is a valid, non-error state — the platform
|
||||
* leans on the other layers (schedule, game lines, props) when no logs
|
||||
* are warm yet.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const streaksService = require('../services/streaksService');
|
||||
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' };
|
||||
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']);
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
if (!SUPPORTED.has(sport)) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: `No streaks for sport: ${sport}` });
|
||||
}
|
||||
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
||||
|
||||
try {
|
||||
const roster = await loadRosterLogs(sport);
|
||||
const streaks = streaksService.computeStreaks(roster, sport, { stat, limit });
|
||||
return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' });
|
||||
} catch (err) {
|
||||
console.error(`[streaks/${sport}]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user