Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast)
D4 — build the FREE Baseball Savant adapter for pitch-level identity
(mix / velo / usage% / whiff%), the missing layer statsapi doesn't carry.
- savantAdapter.getPitcherArsenal(id|name) — normalizes two public Savant
CSV leaderboards (csv=true, NO parsing dependency): pitch-arsenal-stats
(usage% + whiff% + K%) + pitch-arsenals avg_speed (velo). League-wide,
cached 24h + in-memory mirror, indexed by MLBAM id. Defensive: null on any
unrecognized shape; a missing velo/whiff is ABSENT (null), never 0.
Injectable (fetchImpl/statsCsv/veloCsv/resolveId) → tests hit no network.
Live endpoints VERIFIED (200, exact columns) from the sandbox.
- GET /api/stats/pitcher/:name/arsenal (stats.js) + Next proxy. MLB-only;
an error/miss returns { found:false } so the card self-hides honestly.
- PitcherArsenal.tsx (+ barrel) — the mockup's PITCHER IDENTITY strip:
pitch mix % + velo + whiff%, mono/tabular, ranked by usage, sharpest-whiff
pitch highlighted green. Self-hides (heading included) when arsenal absent.
Mounted on the MLB player profile (a pitcher surface). Context, not a
graded market value.
- Tests: savantAdapter (fake CSV → ranked arsenal; unknown shape/blank cells
→ absent not 0; name→id resolve) + PitcherArsenal source locks (self-hide,
mono/tabular, em-dash-not-zero). +2 suites / +17 tests (3012 → 3029).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Baseball Savant / Statcast adapter (Wave 5B — Pitcher Arsenal).
|
||||
*
|
||||
* Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com —
|
||||
* it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public.
|
||||
* `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal
|
||||
* is the missing "one identity → many props" lens (the mockup's PITCHER IDENTITY).
|
||||
*
|
||||
* ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter):
|
||||
* - Free source → NO gateway / NO quota tracking.
|
||||
* - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is
|
||||
* needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape).
|
||||
* - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff
|
||||
* is ABSENT (null), NEVER 0 — `Number(null) === 0` is the fabrication trap.
|
||||
* - Never fabricate: no pitcher → `{ found:false }`; the card self-hides.
|
||||
* - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` /
|
||||
* `opts.resolveId` / cacheGet/cacheSet) → tests never hit the network.
|
||||
*
|
||||
* SOURCE ENDPOINTS (public, `csv=true` → no dependency):
|
||||
* 1. Pitch-arsenal-stats (usage% + whiff% + K%) — LONG format, one row per
|
||||
* (pitcher, pitch_type):
|
||||
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true
|
||||
* 2. Pitch-arsenals avg velo (best-effort enrichment) — WIDE format, one row
|
||||
* per pitcher with a `{abbr}_avg_speed` column per pitch:
|
||||
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true
|
||||
* Both are LEAGUE-WIDE → fetched once, cached 24h, indexed by MLBAM pitcher id.
|
||||
* Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) —
|
||||
* never a fabricated 0. NEEDS PROD VERIFICATION of the live column names.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet: redisGet, cacheSet: redisSet } = require('../../utils/redis');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 12_000;
|
||||
const ARSENAL_TTL = 24 * 3600; // 24h — pitch mix is a slow-moving season identity
|
||||
const LEAGUE_TTL = 24 * 3600;
|
||||
const DEFAULT_SEASON = 2026;
|
||||
const TOP_PITCHES = 6; // cap the strip — nobody throws more than ~6 real pitches
|
||||
|
||||
// In-memory mirror so a single process serves the merged per-pitcher arsenal
|
||||
// without a Redis round-trip (and so it degrades when Redis is down).
|
||||
const _mem = new Map(); // key -> { value, exp }
|
||||
function memGet(key) {
|
||||
const hit = _mem.get(key);
|
||||
if (!hit) return null;
|
||||
if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; }
|
||||
return hit.value;
|
||||
}
|
||||
function memSet(key, value, ttl) {
|
||||
_mem.set(key, { value, exp: Date.now() + ttl * 1000 });
|
||||
}
|
||||
|
||||
function statsUrl(year) {
|
||||
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`;
|
||||
}
|
||||
function veloUrl(year) {
|
||||
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`;
|
||||
}
|
||||
|
||||
// ── Strict numeric parsing — absent beats zero ──────────────────────────────
|
||||
// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a
|
||||
// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number
|
||||
// percent only when the source is clearly a fraction (0..1).
|
||||
function numOrNull(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim().replace('%', '');
|
||||
if (s === '' || s === 'NA' || s === 'null') return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function pctOrNull(raw) {
|
||||
const n = numOrNull(raw);
|
||||
if (n === null) return null;
|
||||
// Savant returns whole-number percents ("32.4"); a value in (0,1] is a
|
||||
// fraction we scale up. Never invent a value where none exists.
|
||||
return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n;
|
||||
}
|
||||
|
||||
// ── Minimal quote-aware CSV parser (NO dependency) ──────────────────────────
|
||||
// Savant's first column header is literally `"last_name, first_name"` (a quoted
|
||||
// field containing a comma), so a naive split is wrong — we must honor quotes.
|
||||
function parseCsvLine(line) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQ) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false;
|
||||
} else cur += ch;
|
||||
} else if (ch === '"') {
|
||||
inQ = true;
|
||||
} else if (ch === ',') {
|
||||
out.push(cur); cur = '';
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
function parseCsv(text) {
|
||||
if (typeof text !== 'string' || text.trim() === '') return [];
|
||||
const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0);
|
||||
if (rows.length < 2) return [];
|
||||
const header = parseCsvLine(rows[0]).map((h) => h.trim());
|
||||
const objs = [];
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const cols = parseCsvLine(rows[r]);
|
||||
if (cols.length === 0) continue;
|
||||
const obj = {};
|
||||
for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : '';
|
||||
objs.push(obj);
|
||||
}
|
||||
return objs;
|
||||
}
|
||||
|
||||
// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape → [].
|
||||
function idOf(row) {
|
||||
return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null;
|
||||
}
|
||||
|
||||
// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ───────────
|
||||
async function fetchText(url, opts) {
|
||||
if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' });
|
||||
return typeof res.data === 'string' ? res.data : null;
|
||||
}
|
||||
|
||||
// stats leaderboard → { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] }
|
||||
async function loadStatsIndex(year, opts) {
|
||||
const cacheGet = opts.cacheGet || redisGet;
|
||||
const cacheSet = opts.cacheSet || redisSet;
|
||||
const key = `savant:arsenal-stats:${year}`;
|
||||
let csv = opts.statsCsv;
|
||||
if (csv === undefined) {
|
||||
const cached = await cacheGet(key).catch(() => null);
|
||||
if (cached && typeof cached === 'object') return cached; // already indexed
|
||||
csv = await fetchText(statsUrl(year), opts).catch(() => null);
|
||||
}
|
||||
const rows = parseCsv(csv);
|
||||
if (rows.length === 0) return null; // unrecognized / empty → caller decides
|
||||
const index = {};
|
||||
let usable = 0;
|
||||
for (const row of rows) {
|
||||
const id = idOf(row);
|
||||
const type = row.pitch_type != null ? String(row.pitch_type).trim() : '';
|
||||
if (id == null || String(id).trim() === '' || type === '') continue;
|
||||
const key2 = String(id).trim();
|
||||
(index[key2] = index[key2] || []).push({
|
||||
type,
|
||||
name: row.pitch_name ? String(row.pitch_name).trim() : type,
|
||||
usagePct: pctOrNull(row.pitch_usage),
|
||||
whiffPct: pctOrNull(row.whiff_percent),
|
||||
kPct: pctOrNull(row.k_percent),
|
||||
});
|
||||
usable++;
|
||||
}
|
||||
if (usable === 0) return null; // header present but no id/pitch columns → unknown shape
|
||||
if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
|
||||
return index;
|
||||
}
|
||||
|
||||
// velo leaderboard (WIDE) → { [pitcherId]: { ff: 99.1, sl: 87.0, ... } } (lowercased abbr → velo)
|
||||
async function loadVeloIndex(year, opts) {
|
||||
const cacheGet = opts.cacheGet || redisGet;
|
||||
const key = `savant:arsenal-velo:${year}`;
|
||||
let csv = opts.veloCsv;
|
||||
if (csv === undefined) {
|
||||
const cached = await cacheGet(key).catch(() => null);
|
||||
if (cached && typeof cached === 'object') return cached;
|
||||
csv = await fetchText(veloUrl(year), opts).catch(() => null);
|
||||
}
|
||||
const rows = parseCsv(csv);
|
||||
if (rows.length === 0) return {}; // velo is OPTIONAL — absent index is fine
|
||||
const index = {};
|
||||
for (const row of rows) {
|
||||
const id = idOf(row);
|
||||
if (id == null || String(id).trim() === '') continue;
|
||||
const speeds = {};
|
||||
for (const col of Object.keys(row)) {
|
||||
const m = /^([a-z]{1,3})_avg_speed$/i.exec(col);
|
||||
if (!m) continue;
|
||||
const v = numOrNull(row[col]);
|
||||
if (v !== null) speeds[m[1].toLowerCase()] = v;
|
||||
}
|
||||
if (Object.keys(speeds).length) index[String(id).trim()] = speeds;
|
||||
}
|
||||
if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pitcher's arsenal by MLBAM id (preferred) or name.
|
||||
* @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>}
|
||||
* pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc.
|
||||
* Always graceful — any miss / unrecognized shape → { found:false }.
|
||||
*/
|
||||
async function getPitcherArsenal(idOrName, opts = {}) {
|
||||
try {
|
||||
const year = opts.year || DEFAULT_SEASON;
|
||||
|
||||
// Resolve name → MLBAM id when we weren't given a numeric id.
|
||||
let id = idOrName;
|
||||
const isNumericId = id != null && /^\d+$/.test(String(id).trim());
|
||||
if (!isNumericId) {
|
||||
const name = String(idOrName || '').trim();
|
||||
if (!name) return { found: false };
|
||||
const resolveId = opts.resolveId || (async (n) => {
|
||||
const mlb = opts.mlbAdapter || require('./mlbStatsAdapter');
|
||||
const person = await mlb.searchPlayer(n).catch(() => null);
|
||||
return person && person.id != null ? person.id : null;
|
||||
});
|
||||
id = await resolveId(name);
|
||||
if (id == null) return { found: false };
|
||||
}
|
||||
const key = String(id).trim();
|
||||
|
||||
// Merged per-pitcher cache (Redis + in-memory mirror), only for the live path
|
||||
// (injected CSV fixtures skip the cache so tests are deterministic).
|
||||
const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined;
|
||||
const memKey = `savant:arsenal:${key}:${year}`;
|
||||
if (!usingFixtures) {
|
||||
const m = memGet(memKey);
|
||||
if (m) return m;
|
||||
const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null);
|
||||
if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; }
|
||||
}
|
||||
|
||||
const statsIndex = await loadStatsIndex(year, opts);
|
||||
if (!statsIndex) return { found: false }; // unrecognized / empty stats feed
|
||||
const rows = statsIndex[key];
|
||||
if (!Array.isArray(rows) || rows.length === 0) return { found: false };
|
||||
|
||||
// Velo is best-effort enrichment; a failure leaves velo absent (null).
|
||||
// In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network —
|
||||
// tests stay hermetic and velo is honestly absent.
|
||||
let veloIndex = {};
|
||||
if (opts.veloCsv !== undefined || !usingFixtures) {
|
||||
try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; }
|
||||
}
|
||||
const speeds = (veloIndex && veloIndex[key]) || {};
|
||||
|
||||
const pitches = rows
|
||||
.map((p) => ({
|
||||
type: p.type,
|
||||
name: p.name,
|
||||
usagePct: p.usagePct,
|
||||
velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null),
|
||||
whiffPct: p.whiffPct,
|
||||
kPct: p.kPct,
|
||||
}))
|
||||
// rank by usage desc; a null usage sorts last (never fabricated to 0)
|
||||
.sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1))
|
||||
.slice(0, TOP_PITCHES);
|
||||
|
||||
const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' };
|
||||
if (!usingFixtures) {
|
||||
memSet(memKey, result, ARSENAL_TTL);
|
||||
await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPitcherArsenal,
|
||||
__internals: {
|
||||
parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf,
|
||||
loadStatsIndex, loadVeloIndex, statsUrl, veloUrl,
|
||||
DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user