167996d99a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
6.5 KiB
JavaScript
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 },
|
|
};
|