#!/usr/bin/env node /** * Tank01 daily prefetch (Session 15). * * trigger: n8n "Morning Ops" workflow OR cron `0 7 * * *` (7am UTC, * ~3am ET — before US slates publish but after overnight * box scores finalize). * call: node scripts/tank01-prefetch.js [--max=80] [--dry-run] * * Why this exists: Session 14 wired `src/services/intelligence/tank01Augment.js` * to read `tank01:nba:*` and `tank01:mlb:*` cache keys, but nothing * populated those keys. Session 15 closes that loop with a daily * pull. The augmentor returns empty objects when the keys are * missing — grades still work today, they just don't include the * Tank01 signals. Once this script runs, grades pick up the new * data automatically. * * Budget posture: free tier is 1,000 req/month per RapidAPI account. * Even with NBA + MLB combined, one daily run targets ≤80 requests. * 80 * 30 days = 2,400 → would burn the budget. So default cap is 80 * total across both sports, with a hard stop. Override via `--max`. * * What it writes: * tank01:nba:games:{ymd} — daily schedule * tank01:nba:boxscore:{gameId} — per-game final box scores * tank01:nba:odds:{ymd} — book-by-book lines * tank01:mlb:scoreboard:{ymd} — daily schedule * tank01:mlb:boxscore:{gameId} — per-game lines for finals * tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchups * * The adapters (Session 9) own the `cacheSet` calls — this script * is the orchestrator that decides what to fetch. Adapter calls are * idempotent: re-running the script won't double-spend the budget * because each adapter checks its cache before hitting RapidAPI. */ const tankNba = require('../src/services/adapters/tank01NbaAdapter'); const tankMlb = require('../src/services/adapters/tank01MlbAdapter'); const DEFAULT_BUDGET = 80; function parseArgs(argv) { const args = { maxRequests: DEFAULT_BUDGET, dryRun: false, sports: ['nba', 'mlb'] }; for (const a of argv.slice(2)) { if (a.startsWith('--max=')) { const n = Number(a.slice('--max='.length)); if (Number.isFinite(n) && n > 0) args.maxRequests = Math.floor(n); } else if (a === '--dry-run') { args.dryRun = true; } else if (a.startsWith('--sports=')) { args.sports = a.slice('--sports='.length).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); } } return args; } function todayYMD() { const d = new Date(); return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`; } // Simple counter — the adapters cache internally, so a "request" here // is "I asked the adapter for X; whether it hit cache or network is // out of our control." For budgeting we treat every adapter call as // 1 potential request and stop when the cap is hit. function makeBudget(maxRequests) { let spent = 0; return { canSpend: () => spent < maxRequests, spend: () => { spent += 1; return spent; }, spent: () => spent, }; } async function processNba(args, budget, summary) { if (!tankNba.hasApiKey()) { summary.nba.skipped = 'no_key'; return; } const ymd = todayYMD(); if (!budget.canSpend()) return; budget.spend(); const games = await tankNba.getNBAGamesForDate(ymd); if (!Array.isArray(games)) { summary.nba.games = 0; return; } summary.nba.games = games.length; // Box scores for every final game — captures the post-game stat // lines the augmentor surfaces to the analyze path. for (const g of games) { if (!budget.canSpend()) break; if (!g.gameId) continue; const status = String(g.gameStatus || '').toLowerCase(); if (!status.includes('final')) continue; budget.spend(); await tankNba.getNBABoxScore(g.gameId); summary.nba.boxscores += 1; } // One odds pull for the day — surfaces the t01_market_present // marker the trap detector reads alongside odds-api. if (budget.canSpend()) { budget.spend(); await tankNba.getNBABettingOdds(ymd); summary.nba.odds = true; } } async function processMlb(args, budget, summary) { if (!tankMlb.hasApiKey()) { summary.mlb.skipped = 'no_key'; return; } const ymd = todayYMD(); if (!budget.canSpend()) return; budget.spend(); const slate = await tankMlb.getMLBDailyScoreboard(ymd); if (!Array.isArray(slate)) { summary.mlb.games = 0; return; } summary.mlb.games = slate.length; // Box scores for finished games. for (const g of slate) { if (!budget.canSpend()) break; if (!g.gameId) continue; const status = String(g.gameStatus || '').toLowerCase(); if (!status.includes('final') && !status.includes('completed')) continue; budget.spend(); await tankMlb.getMLBBoxScore(g.gameId); summary.mlb.boxscores += 1; } // BvP matchups — only when probable pitchers are exposed on the // scoreboard payload (Tank01 includes them for upcoming games). We // can't safely run BvP without batter/pitcher IDs, so the pass is // a no-op until the scoreboard projection grows those fields. The // augmentor handles the empty-cache case gracefully. summary.mlb.bvp_skipped_reason = 'awaiting_starter_ids_on_scoreboard'; } async function main(argv = process.argv) { const args = parseArgs(argv); const budget = makeBudget(args.maxRequests); const summary = { nba: { games: 0, boxscores: 0, odds: false, skipped: null }, mlb: { games: 0, boxscores: 0, bvp: 0, skipped: null, bvp_skipped_reason: null }, requestsSpent: 0, dryRun: args.dryRun, }; console.log(`[tank01-prefetch] starting — max_requests=${args.maxRequests} sports=${args.sports.join(',')} dry_run=${args.dryRun}`); if (args.dryRun) { summary.nba.skipped = 'dry_run'; summary.mlb.skipped = 'dry_run'; console.log('[tank01-prefetch] dry-run — adapter calls suppressed'); } else { if (args.sports.includes('nba')) await processNba(args, budget, summary); if (args.sports.includes('mlb')) await processMlb(args, budget, summary); } summary.requestsSpent = budget.spent(); console.log(`[tank01-prefetch] done — nba.games=${summary.nba.games} nba.box=${summary.nba.boxscores} mlb.games=${summary.mlb.games} mlb.box=${summary.mlb.boxscores} spent=${summary.requestsSpent}/${args.maxRequests}`); return summary; } if (require.main === module) { main().then(() => process.exit(0)).catch((err) => { console.error('[tank01-prefetch] fatal:', err); process.exit(1); }); } module.exports = { main, __internals: { parseArgs, makeBudget, todayYMD, processNba, processMlb, DEFAULT_BUDGET }, };