#!/usr/bin/env node 'use strict'; /** * ingest-game-sequences — the raw material for the reliever chain. * * Every link in this chain needs something the ledger does not carry: when the * starter actually left, and which arm actually faced each plate appearance. * Both are free from statsapi (playByPlay + boxscore), on the same host we * already use for game logs and schedules. * * Caches to disk so Link 1, 2 and 3 all read one fetch rather than three. * * node scripts/ingest-game-sequences.js # default window * SEQ_FROM=2026-06-01 SEQ_TO=2026-08-04 node scripts/ingest-game-sequences.js */ const fs = require('fs'); const path = require('path'); const axios = require('axios'); const FROM = process.env.SEQ_FROM || '2026-06-15'; const TO = process.env.SEQ_TO || '2026-08-04'; const OUT = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json'); const CONCURRENCY = 6; const get = async (url) => (await axios.get(url, { timeout: 45_000 })).data; /** Innings pitched come as '5.2' meaning five and TWO THIRDS — parseFloat is wrong. */ function ipToOuts(ip) { if (ip == null) return null; const [whole, frac] = String(ip).split('.'); const w = Number(whole); const f = Number(frac || 0); if (!Number.isFinite(w)) return null; return w * 3 + (Number.isFinite(f) ? f : 0); } function datesBetween(from, to) { const out = []; const d = new Date(`${from}T12:00:00Z`); const end = new Date(`${to}T12:00:00Z`); while (d <= end) { out.push(d.toISOString().slice(0, 10)); d.setUTCDate(d.getUTCDate() + 1); } return out; } async function pool(items, fn, n = CONCURRENCY) { const out = []; let i = 0; await Promise.all(Array.from({ length: n }, async () => { while (i < items.length) { const idx = i; i += 1; try { out[idx] = await fn(items[idx]); } catch { out[idx] = null; } } })); return out.filter(Boolean); } async function loadGame(g) { const pk = g.gamePk; const [box, pbp] = await Promise.all([ get(`https://statsapi.mlb.com/api/v1/game/${pk}/boxscore`), get(`https://statsapi.mlb.com/api/v1/game/${pk}/playByPlay`), ]); const sides = {}; for (const side of ['home', 'away']) { const t = box.teams[side]; if (!t) return null; const arms = (t.pitchers || []).map((id) => { const pl = t.players[`ID${id}`]; const s = pl && pl.stats && pl.stats.pitching; if (!s) return null; return { id: Number(id), name: pl.person && pl.person.fullName, started: Number(s.gamesStarted || 0) === 1, bf: s.battersFaced == null ? null : Number(s.battersFaced), outs: ipToOuts(s.inningsPitched), pitches: s.pitchesThrown == null ? null : Number(s.pitchesThrown), }; }).filter(Boolean); sides[side] = { team: t.team && t.team.name, abbr: t.team && t.team.abbreviation, arms }; } // Every plate appearance in order, with who threw it. const pas = []; for (const p of pbp.allPlays || []) { const m = p.matchup || {}; const a = p.about || {}; if (!m.batter || !m.pitcher) continue; pas.push({ batter: Number(m.batter.id), batter_name: m.batter.fullName, pitcher: Number(m.pitcher.id), bats: m.batSide && m.batSide.code, throws: m.pitchHand && m.pitchHand.code, inning: a.inning, half: a.halfInning, idx: a.atBatIndex, event: p.result && p.result.eventType, }); } if (!pas.length) return null; return { gamePk: pk, date: g.officialDate || (g.gameDate || '').slice(0, 10), venue_id: g.venue && g.venue.id, home: sides.home, away: sides.away, pas, }; } async function main() { const dates = datesBetween(FROM, TO); console.error(`[seq] ${dates.length} dates ${FROM} -> ${TO}`); const allGames = []; for (const d of dates) { try { const s = await get(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue`); for (const day of s.dates || []) { for (const g of day.games || []) { if (String(g.status && g.status.detailedState) === 'Final') allGames.push(g); } } } catch { /* absent day */ } } console.error(`[seq] ${allGames.length} final games; fetching sequences`); const games = await pool(allGames, loadGame); fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, JSON.stringify({ from: FROM, to: TO, games })); const starters = games.reduce((s, g) => s + ['home', 'away'].filter((k) => g[k].arms.some((a) => a.started)).length, 0); console.log(JSON.stringify({ dates: dates.length, final_games: allGames.length, games_loaded: games.length, starter_games: starters, plate_appearances: games.reduce((s, g) => s + g.pas.length, 0), cache: OUT, }, null, 2)); process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); });