Files
vyndr/scripts/pull-parlayapi-history.js
T

155 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/**
* ParlayAPI historical pull.
*
* BULK operation — runs for hours, not seconds. Free tier is 1,000 credits
* per month, so we walk one (sport, date) at a time with a checkpoint in
* Redis so an interrupted run can resume right where it stopped.
*
* Usage:
* node scripts/pull-parlayapi-history.js nba 2025 # one sport / season
* node scripts/pull-parlayapi-history.js all # all active sports
* node scripts/pull-parlayapi-history.js --resume # continue from checkpoint
* node scripts/pull-parlayapi-history.js --dry-run # don't insert
* node scripts/pull-parlayapi-history.js --yes # skip confirmation
*
* Pace: 2 requests / minute. Set PARLAYAPI_PULL_RATE in env to override.
*/
if (require.main !== module) {
throw new Error('Run directly: node scripts/pull-parlayapi-history.js');
}
const path = require('path');
const readline = require('readline');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const { getSupabaseServiceClient } = require('../src/utils/supabase');
const { getRedisClient } = require('../src/utils/redis');
const parlayApi = require('../src/services/adapters/parlayApiAdapter');
const { getActiveSports } = require('../src/config/sports');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const resume = args.includes('--resume');
const skipConfirm = args.includes('--yes');
const positional = args.filter((a) => !a.startsWith('--'));
const sportArg = positional[0];
const seasonArg = positional[1];
const RATE_MS = Number(process.env.PARLAYAPI_PULL_RATE_MS) || 30_000; // 2/min by default
const SEASON_DEFAULT = new Date().getFullYear();
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function confirm(question) {
if (skipConfirm) return true;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((r) => rl.question(question, r));
rl.close();
return /^y(es)?$/i.test(answer.trim());
}
function* dateRange(season) {
// Walk a season's worth of days. ParlayAPI's date arg is YYYY-MM-DD.
// For each season we cover Aug 1 → Jul 31 of the following calendar
// year — that umbrella catches every sport's annual span.
const start = new Date(Date.UTC(season, 7, 1));
const end = new Date(Date.UTC(season + 1, 6, 31));
const cursor = new Date(start);
while (cursor <= end) {
yield cursor.toISOString().slice(0, 10);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
async function checkpointKey(sport, season) {
return `parlayapi:checkpoint:${sport}:${season}`;
}
async function readCheckpoint(sport, season) {
const redis = getRedisClient();
return redis.get(await checkpointKey(sport, season));
}
async function writeCheckpoint(sport, season, date) {
const redis = getRedisClient();
await redis.set(await checkpointKey(sport, season), date, 'EX', 30 * 24 * 60 * 60);
}
async function processSportSeason(sport, season) {
console.log(`[parlayapi-pull] ${sport} ${season} starting (rate=${RATE_MS}ms)`);
let totalInserted = 0;
let lastSeen = null;
if (resume) {
lastSeen = await readCheckpoint(sport, season);
if (lastSeen) console.log(`[parlayapi-pull] resuming after ${lastSeen}`);
}
for (const date of dateRange(season)) {
if (lastSeen && date <= lastSeen) continue;
let lines;
try {
lines = await parlayApi.getClosingLines(sport, date);
} catch (err) {
console.warn(`[parlayapi-pull] ${date} failed: ${err.message}`);
await sleep(RATE_MS);
continue;
}
if (lines.length && !dryRun) {
const supabase = getSupabaseServiceClient();
const rows = lines.map((l) => ({
sport,
game_date: date,
player_name: l.player ?? l.player_name ?? 'unknown',
stat_type: l.stat_type ?? l.market ?? 'unknown',
line: Number(l.line ?? l.point ?? 0),
closing_line: Number(l.closing_line ?? l.close ?? null) || null,
result: l.result ?? l.outcome ?? null,
source: 'parlayapi',
}));
const { error } = await supabase.from('historical_props').insert(rows);
if (error) {
console.warn(`[parlayapi-pull] insert failed for ${date}: ${error.message}`);
} else {
totalInserted += rows.length;
}
}
await writeCheckpoint(sport, season, date);
if (dryRun) {
console.log(`[parlayapi-pull] dry-run ${date}${lines.length} lines`);
}
await sleep(RATE_MS);
}
console.log(`[parlayapi-pull] ${sport} ${season} done — inserted ${totalInserted} rows`);
return totalInserted;
}
async function main() {
if (!parlayApi.configured()) {
console.error('PARLAYAPI_KEY is not set');
process.exit(2);
}
const sportList = sportArg && sportArg !== 'all'
? [sportArg]
: getActiveSports().map((s) => s.key);
const season = Number(seasonArg) || SEASON_DEFAULT;
if (!dryRun) {
const ok = await confirm(
`This will insert ParlayAPI history for ${sportList.join(', ')} ${season} into ${process.env.SUPABASE_URL || '(unknown)'}. Continue? (y/n) `
);
if (!ok) { console.log('aborted'); process.exit(0); }
}
for (const sport of sportList) {
try { await processSportSeason(sport, season); }
catch (err) { console.error(`[parlayapi-pull] ${sport} fatal: ${err.message}`); }
}
}
main().catch((err) => {
console.error('[parlayapi-pull] fatal:', err.message);
process.exit(1);
});