155 lines
5.4 KiB
JavaScript
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);
|
|
});
|