Merge Wave 5B (wiring/data): pitcher arsenal via Baseball Savant (D4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 16:04:06 -04:00
9 changed files with 681 additions and 1 deletions
+22 -1
View File
@@ -2,7 +2,8 @@ const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase');
const { getStatFilters } = require('../config/statFilters');
const { createRateLimit } = require('../middleware/rateLimit');
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService');
const { getPitcherArsenal } = require('../services/adapters/savantAdapter');
const depthChart = require('../services/depthChartService');
const router = express.Router();
@@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => {
}
});
// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B).
// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast
// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other
// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates.
router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'mlb').toLowerCase();
if (sport !== 'mlb') {
return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' });
}
const name = sanitizePlayerName(req.params.name);
const arsenal = await getPitcherArsenal(name);
res.set(MISSION_HEADER).json(arsenal || { found: false });
} catch (err) {
console.error('[stats/pitcher/arsenal]', err.message);
// Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides.
res.set(MISSION_HEADER).json({ found: false });
}
});
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
// graded props by confidence) for the Terminal / Stats Explorer.
router.get('/leaders', intelLimit, async (req, res) => {
+277
View File
@@ -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,
},
};