Files
vyndr/scripts/tank01-prefetch.js
T

185 lines
6.5 KiB
JavaScript

#!/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 },
};