S9 (a1): slip reader — zero-API OCR

tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 19:32:17 -04:00
parent aaafc3e0f2
commit 4e49ee0990
18 changed files with 1914 additions and 1 deletions
+3
View File
@@ -187,6 +187,9 @@ app.use('/api/content', contentRoutes);
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
// (forwards to the self-hosted Listmonk; graceful no-op without env).
app.use('/api/newsletter', require('./routes/newsletter'));
// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth +
// per-tier daily quota inside the router). Values are user-slip values.
app.use('/api/slips', require('./routes/slips'));
// Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with
+147
View File
@@ -0,0 +1,147 @@
'use strict';
/**
* A1 Session 9 — POST /api/slips/parse (Slip Reader).
*
* Auth required. Accepts a base64/data-URL slip screenshot (≤4MB decoded)
* OR raw slip `text` (paste path + hermetic tests — skips OCR entirely).
* OCR is tesseract.js (self-hosted WASM, zero API spend); parsing is
* `slipReader.parseSlipText` — extracted values are USER-SLIP values,
* never written into any market cache.
*
* Rate limit: `slips:{user}:{YYYY-MM-DD}` (UTC day) — free 1/day,
* paid (analyst/desk) 10/day. Redis counter with an in-memory mirror so
* a degraded Redis still enforces the cap within this process.
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { cacheGet, cacheSet } = require('../utils/redis');
const slipReader = require('../services/slipReader');
const router = express.Router();
const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // 4MB decoded
const MAX_TEXT_CHARS = 20_000;
const COUNTER_TTL_S = 48 * 3600; // outlives the UTC day it counts
const MEM_MAX = 20_000;
const mem = new Map(); // dayKey → count (in-memory mirror / fallback)
function dailyLimit(tier) {
return tier === 'free' ? 1 : 10;
}
function dayKey(userId, now = new Date()) {
return `slips:${userId}:${now.toISOString().slice(0, 10)}`;
}
/**
* Consume one parse slot for the user's UTC day. Uses max(redis, memory)
* as the used count so neither store resetting under-counts alone.
*/
async function takeSlot(user, deps = {}) {
const get = deps.cacheGet || cacheGet;
const set = deps.cacheSet || cacheSet;
const limit = dailyLimit(user.tier || 'free');
const key = dayKey(user.id, deps.now);
let redisUsed = 0;
try {
const v = await get(key);
redisUsed = Number(v) || 0;
} catch { /* degraded redis → memory mirror carries it */ }
const used = Math.max(redisUsed, mem.get(key) || 0);
if (used >= limit) return { ok: false, used, limit };
if (mem.size > MEM_MAX) {
const oldest = mem.keys().next().value;
if (oldest !== undefined) mem.delete(oldest);
}
mem.set(key, used + 1);
try { await set(key, used + 1, COUNTER_TTL_S); } catch { /* best effort */ }
return { ok: true, used: used + 1, limit };
}
/** Decode a base64 / data-URL image field into a Buffer, or null. */
function decodeImage(image) {
if (typeof image !== 'string' || !image) return null;
const b64 = image.replace(/^data:image\/[a-z+.-]+;base64,/i, '').replace(/\s/g, '');
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) return null;
try {
const buf = Buffer.from(b64, 'base64');
return buf.length > 0 ? buf : null;
} catch {
return null;
}
}
router.post('/parse', requireAuth, async (req, res) => {
const { image, text, book } = req.body || {};
// ── Validate input BEFORE burning a daily slot ──
let buffer = null;
let rawText = null;
if (typeof text === 'string' && text.trim()) {
if (text.length > MAX_TEXT_CHARS) {
return res.status(400).json({ error: 'Slip text too long.' });
}
rawText = text;
} else if (image != null) {
buffer = decodeImage(image);
if (!buffer) {
return res.status(400).json({ error: 'Could not decode the image. Send base64 or a data URL.' });
}
if (buffer.length > MAX_IMAGE_BYTES) {
return res.status(400).json({ error: 'Image too large. 4MB max.' });
}
} else {
return res.status(400).json({ error: 'Send an image (base64) or slip text.' });
}
// ── Daily quota ──
const slot = await takeSlot(req.user, router.__deps || {});
if (!slot.ok) {
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.status(429).json({
error: slot.limit === 1
? 'One slip read per day on the free tier. Upgrade for ten.'
: 'Daily slip-read limit reached.',
used: slot.used,
limit: slot.limit,
tier: req.user.tier || 'free',
});
}
// ── OCR (image path only) ──
if (rawText == null) {
try {
const recognize = (router.__deps && router.__deps.recognizeImage) || slipReader.recognizeImage;
rawText = await recognize(buffer);
} catch (err) {
console.error('[slips] OCR failed:', err.message);
return res.status(503).json({ error: 'The reader could not process this image. Try again or paste the slip text.' });
}
}
// ── Parse. Values are user-slip values; unreadable fields stay null. ──
const result = slipReader.parseSlipText(rawText, book);
res.set('X-Slips-Used', String(slot.used));
res.set('X-Slips-Limit', String(slot.limit));
return res.json(result);
});
// Test hooks — injectable deps + counter reset (same pattern as scanLimit).
router.__internals = {
mem,
dayKey,
takeSlot,
dailyLimit,
decodeImage,
resetForTests: () => mem.clear(),
setDeps: (deps) => { router.__deps = deps; },
};
module.exports = router;
+462
View File
@@ -0,0 +1,462 @@
'use strict';
/**
* A1 Session 9 — Slip Reader (zero-API OCR).
*
* Parses sportsbook bet-slip OCR text into legs the grading engine can
* consume. Layout parsers are PURE (text in → legs out, no I/O) so they
* unit-test on raw OCR-text fixtures; the tesseract.js image path is a
* thin wrapper (`recognizeImage`) that lazy-requires the WASM engine.
*
* DATA SEMANTICS: everything extracted here is a USER-SLIP value — the
* line/odds the user's book printed on their slip. It is never written
* into any market cache and is labeled `source: 'user_slip'` upstream.
*
* NEVER GUESS: a field the parser cannot read above CONFIDENCE_THRESHOLD
* is returned null and the leg carries `needs_review: true`. Absent
* beats wrong (North Star operating rule).
*/
const { normalizeName, nameKey } = require('../utils/playerName');
const CONFIDENCE_THRESHOLD = 0.6;
const MAX_LEGS = 12; // mirrors the scan route's parlay cap
/* ── Stat vocabulary ─────────────────────────────────────────────
Slip market labels → the canonical stat_type vocabulary shared by
src/routes/scan.js VALID_STAT_TYPES + web gradeAdapter STAT_LABELS.
Keys are lowercased, space-collapsed slip labels. */
const STAT_ALIASES = {
// MLB batters
'total bases': 'total_bases', 'tb': 'total_bases',
'hits': 'hits', 'hit': 'hits',
'home runs': 'home_runs', 'home run': 'home_runs', 'hr': 'home_runs',
'any time home run': 'home_runs', 'anytime home run': 'home_runs',
'to hit a home run': 'home_runs',
'rbi': 'rbi', 'rbis': 'rbi', 'runs batted in': 'rbi',
'runs': 'runs', 'runs scored': 'runs', 'run scored': 'runs',
'stolen bases': 'stolen_bases', 'stolen base': 'stolen_bases',
'doubles': 'doubles',
'walks': 'walks', 'batter walks': 'walks',
// MLB pitchers
'strikeouts': 'strikeouts', 'strikeouts thrown': 'strikeouts',
'pitcher strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k s': 'strikeouts',
'earned runs': 'earned_runs', 'earned runs allowed': 'earned_runs',
'hits allowed': 'hits_allowed',
'innings pitched': 'innings_pitched',
'outs': 'outs', 'outs recorded': 'outs',
// NBA / WNBA
'points': 'points', 'pts': 'points',
'rebounds': 'rebounds', 'reb': 'rebounds', 'rebs': 'rebounds',
'assists': 'assists', 'ast': 'assists', 'asts': 'assists',
'threes': 'threes', '3 pointers made': 'threes', 'three pointers made': 'threes',
'3 pt made': 'threes', 'threes made': 'threes', 'made threes': 'threes', '3pm': 'threes',
'blocks': 'blocks', 'blk': 'blocks',
'steals': 'steals', 'stl': 'steals',
'turnovers': 'turnovers', 'to': 'turnovers',
'pts + reb + ast': 'pra', 'points + rebounds + assists': 'pra',
'pts rebs asts': 'pra', 'pts reb ast': 'pra', 'pra': 'pra',
// Soccer
'goals': 'goals', 'goal': 'goals', 'any time goalscorer': 'goals',
'anytime goalscorer': 'goals',
'shots': 'shots', 'shots on target': 'shots_on_target',
'shots on goal': 'shots_on_target',
'tackles': 'tackles', 'cards': 'cards', 'corners': 'corners',
'saves': 'saves', 'goalkeeper saves': 'saves',
'goals conceded': 'goals_conceded', 'passes': 'passes',
'clean sheet': 'clean_sheet',
};
// Display labels (mirror web/src/lib/gradeAdapter.js STAT_LABELS style).
function statLabel(stat) {
if (!stat) return null;
const special = { pra: 'P+R+A', rbi: 'RBI', threes: '3-Pointers' };
if (special[stat]) return special[stat];
return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Normalize a slip market label to the canonical stat_type, or null. */
function normalizeStat(rawLabel) {
if (!rawLabel) return null;
let s = String(rawLabel).toLowerCase()
.replace(/[|]/g, '')
.replace(/\balt\b/g, '') // "Alt Total Bases" → "Total Bases"
.replace(/\bo\/u\b/g, '') // "Total Bases O/U"
.replace(/[()]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (STAT_ALIASES[s]) return STAT_ALIASES[s];
const underscored = s.replace(/ /g, '_');
if (Object.values(STAT_ALIASES).includes(underscored)) return underscored;
return null;
}
/* ── Shared token grammar ──────────────────────────────────────── */
// "Aaron Judge", "Ronald Acuña Jr.", "A.J. Ewing" — 23 capitalized tokens.
const NAME_PART = "[A-Z][A-Za-z'.\\u00C0-\\u017F-]+";
const NAME_RE = new RegExp(
`(${NAME_PART}(?:\\s${NAME_PART}){1,2}(?:\\s(?:Jr|Sr|II|III|IV)\\.?)?)`,
);
// Odds token: "+320", "-115", OCR unicode minus "115" / dash "115".
const ODDS_RE = /(?:^|[\s(@])([+\-−–]\s?\d{2,4})(?:[\s).]|$)/;
function parseOdds(token) {
if (!token) return null;
const n = parseInt(String(token).replace(/[−–]/, '-').replace(/\s/g, ''), 10);
if (!Number.isFinite(n)) return null;
if (Math.abs(n) < 100 || Math.abs(n) > 9999) return null; // not american odds
return n;
}
function parseLine(token) {
const n = Number(token);
return Number.isFinite(n) && n >= 0 && n <= 500 ? n : null;
}
/** Confidence for an extracted player-name candidate. */
function nameConfidence(raw) {
if (!raw) return 0;
const parts = raw.trim().split(/\s+/);
if (parts.length < 2) return 0.3; // single token — too weak
const capitalized = parts.every((p) => /^[A-Z]/.test(p));
return capitalized ? 0.9 : 0.5;
}
/** Build a finalized leg: normalize, threshold-null, flag needs_review. */
function buildLeg(fields) {
const conf = {
player: fields.player != null ? (fields.playerConfidence ?? nameConfidence(fields.player)) : 0,
stat: 0,
line: fields.line != null ? (fields.lineConfidence ?? 0.95) : 0,
side: fields.side != null ? (fields.sideConfidence ?? 0.95) : 0,
odds: fields.odds != null ? (fields.oddsConfidence ?? 0.9) : 0,
};
const stat = normalizeStat(fields.statLabel);
if (stat) conf.stat = 0.95;
const playerOk = conf.player >= CONFIDENCE_THRESHOLD;
const norm = playerOk ? normalizeName(fields.player) : null;
const leg = {
player: playerOk ? norm.display : null,
player_key: playerOk ? nameKey(fields.player) : null,
stat: conf.stat >= CONFIDENCE_THRESHOLD ? stat : null,
stat_label: conf.stat >= CONFIDENCE_THRESHOLD ? statLabel(stat) : null,
raw_stat_label: fields.statLabel || null,
line: conf.line >= CONFIDENCE_THRESHOLD ? fields.line : null,
side: conf.side >= CONFIDENCE_THRESHOLD ? fields.side : null,
odds: conf.odds >= CONFIDENCE_THRESHOLD ? fields.odds : null,
confidence: {
player: round2(conf.player), stat: round2(conf.stat), line: round2(conf.line),
side: round2(conf.side), odds: round2(conf.odds),
},
needs_review: false,
};
leg.needs_review = leg.player == null || leg.stat == null
|| leg.line == null || leg.side == null || leg.odds == null;
return leg;
}
function round2(n) { return Math.round(n * 100) / 100; }
function cleanLines(text) {
return String(text || '')
.split(/\r?\n/)
.map((l) => l.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
// Slip chrome that is never a leg (wager rows, headers, game rows).
const NOISE_RE = /^(wager|to win|total payout|bet id|placed|cash out|share|profit boost|same game parlay|sgp|parlay|\d+ leg|odds|stake|returns|potential|today|tomorrow|final|\$)/i;
const GAME_ROW_RE = /(@| at | vs\.? | v )/i;
/* ── DraftKings ──────────────────────────────────────────────────
Layout: "<Player> Over 1.5" / market label on the NEXT line /
odds on the line after ("-115"). */
function parseDraftKings(text) {
const lines = cleanLines(text);
const legs = [];
const headRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)$`, 'i');
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const m = lines[i].match(headRe);
if (!m) continue;
const [, player, sideRaw, lineRaw] = m;
// Market label = next non-noise, non-game line.
let statLabelRaw = null;
let odds = null;
for (let j = i + 1; j <= i + 3 && j < lines.length; j += 1) {
const l = lines[j];
if (NOISE_RE.test(l)) break;
const oddsMatch = l.match(/^([+\-−–]\s?\d{2,4})$/);
if (oddsMatch) { odds = parseOdds(oddsMatch[1]); break; }
if (!statLabelRaw && !GAME_ROW_RE.test(l) && !headRe.test(l)) statLabelRaw = l;
}
legs.push(buildLeg({
player,
statLabel: statLabelRaw,
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds,
}));
}
return legs;
}
/* ── FanDuel ─────────────────────────────────────────────────────
Layouts: "<Player> To Record 2+ Total Bases" (2+ → line 1.5 over),
"<Player> Any Time Home Run", "<Player> Over 7.5 Strikeouts".
SGP legs often carry no per-leg odds → odds null + needs_review. */
function parseFanDuel(text) {
const lines = cleanLines(text);
const legs = [];
const recordRe = new RegExp(`^${NAME_RE.source}\\s+To Record\\s+(\\d+)\\+\\s+(.+)$`, 'i');
const anytimeRe = new RegExp(`^${NAME_RE.source}\\s+((?:Any\\s?Time|Anytime) (?:Home Run|Goalscorer))$`, 'i');
const ouRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+)$`, 'i');
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line) || GAME_ROW_RE.test(line)) continue;
// Trailing odds on the same line, else a bare odds token on the next.
let body = line;
let odds = null;
const trailing = body.match(/\s([+\-−–]\s?\d{2,4})$/);
if (trailing) { odds = parseOdds(trailing[1]); body = body.slice(0, trailing.index).trim(); }
if (odds == null && lines[i + 1] && /^([+\-−–]\s?\d{2,4})$/.test(lines[i + 1])) {
odds = parseOdds(lines[i + 1]);
}
let m = body.match(recordRe);
if (m) {
const threshold = parseInt(m[2], 10);
legs.push(buildLeg({
player: m[1], statLabel: m[3],
line: Number.isFinite(threshold) ? threshold - 0.5 : null,
lineConfidence: 0.9,
side: 'over', sideConfidence: 0.85,
odds,
}));
continue;
}
m = body.match(anytimeRe);
if (m) {
legs.push(buildLeg({
player: m[1], statLabel: m[2],
line: 0.5, lineConfidence: 0.9,
side: 'over', sideConfidence: 0.85,
odds,
}));
continue;
}
m = body.match(ouRe);
if (m) {
legs.push(buildLeg({
player: m[1], statLabel: m[4],
line: parseLine(m[3]),
side: m[2].toLowerCase(),
odds,
}));
}
}
return legs;
}
/* ── BetMGM ──────────────────────────────────────────────────────
Layout: "<Player> Over 1.5 Total Bases @ -115" (inline), or the
odds trailing without "@". */
function parseBetMGM(text) {
const lines = cleanLines(text);
const legs = [];
const re = new RegExp(
`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+?)(?:\\s*@\\s*([+\\-−–]\\s?\\d{2,4}))?$`, 'i',
);
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line)) continue;
// NOTE: no game-row pre-skip here — MGM leg lines carry "@ -115",
// which the game-row heuristic would eat. The grammar (Over/Under)
// is the filter; game rows never match it.
const m = line.match(re);
if (!m) continue;
let [, player, sideRaw, lineRaw, statRaw, oddsRaw] = m;
// Odds sometimes trail the stat label without "@".
if (!oddsRaw) {
const trailing = statRaw.match(/\s([+\-−–]\s?\d{2,4})$/);
if (trailing) { oddsRaw = trailing[1]; statRaw = statRaw.slice(0, trailing.index).trim(); }
}
legs.push(buildLeg({
player,
statLabel: statRaw,
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds: parseOdds(oddsRaw),
}));
}
return legs;
}
/* ── Caesars ─────────────────────────────────────────────────────
Layout: "<Player> Total Bases Over 1.5 (-115)" — stat between the
name and the side, odds parenthesized or bare at the end.
The player/stat boundary inside the head segment is ambiguous to a
regex ("Pete Alonso Home Runs" — is "Home" a name token?), so we
split on the LONGEST known stat alias the head ends with. No alias
match → stat null + needs_review; the split NEVER invents a stat. */
// Alias keys, longest (most words, then chars) first, for suffix scans.
const ALIAS_KEYS_BY_LEN = Object.keys(STAT_ALIASES)
.sort((a, b) => b.split(' ').length - a.split(' ').length || b.length - a.length);
function splitPlayerStat(head) {
const low = String(head || '').toLowerCase().replace(/\s+/g, ' ').trim();
for (const alias of ALIAS_KEYS_BY_LEN) {
if (low.length > alias.length && low.endsWith(' ' + alias)) {
const player = head.slice(0, head.length - alias.length)
.trim()
.replace(/\s+alt$/i, ''); // "Judge Alt Total Bases" → player "Judge"
return { player, statLabel: head.slice(head.length - alias.length).trim() };
}
}
// No known stat suffix — the player/stat boundary is unknown too, so
// the name candidate is low-confidence (below threshold → nulled).
const nameMatch = String(head || '').match(new RegExp(`^${NAME_RE.source}`));
return { player: nameMatch ? nameMatch[1] : null, statLabel: null, uncertain: true };
}
function parseCaesars(text) {
const lines = cleanLines(text);
const legs = [];
const re = /^(.+?)\s+(Over|Under)\s+(\d+(?:\.\d+)?)\s*(?:\(?([+\-−–]\s?\d{2,4})\)?)?$/i;
for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
const line = lines[i];
if (NOISE_RE.test(line)) continue;
const m = line.match(re);
if (!m) continue;
const [, head, sideRaw, lineRaw, oddsRaw] = m;
const { player, statLabel, uncertain } = splitPlayerStat(head);
legs.push(buildLeg({
player,
statLabel,
...(uncertain ? { playerConfidence: 0.5 } : {}),
line: parseLine(lineRaw),
side: sideRaw.toLowerCase(),
odds: parseOdds(oddsRaw),
}));
}
return legs;
}
/* ── Book detection + dispatch ─────────────────────────────────── */
const BOOK_MARKERS = [
['draftkings', /draft\s?kings|dksb/i],
['fanduel', /fan\s?duel/i],
['betmgm', /bet\s?mgm/i],
['caesars', /caesars|czr/i],
];
const PARSERS = {
draftkings: parseDraftKings,
fanduel: parseFanDuel,
betmgm: parseBetMGM,
caesars: parseCaesars,
};
function detectBook(text) {
const t = String(text || '');
for (const [book, re] of BOOK_MARKERS) {
if (re.test(t)) return book;
}
return null;
}
/**
* Parse OCR slip text. Uses the hinted/detected book's layout parser;
* unknown book → try every layout, keep the best result (most legs,
* tie-broken by fewest needs_review). Layout SELECTION is heuristic;
* field VALUES never are — unparseable fields stay null.
*/
function parseSlipText(text, bookHint) {
const hinted = bookHint && PARSERS[String(bookHint).toLowerCase()]
? String(bookHint).toLowerCase() : null;
const detected = hinted || detectBook(text);
if (detected) {
const legs = PARSERS[detected](text);
return finalize(detected, legs);
}
let best = { book: null, legs: [] };
for (const [book, parser] of Object.entries(PARSERS)) {
const legs = parser(text);
const better = legs.length > best.legs.length
|| (legs.length === best.legs.length && legs.length > 0
&& countReview(legs) < countReview(best.legs));
if (better) best = { book, legs };
}
return finalize(best.legs.length ? best.book : null, best.legs);
}
function countReview(legs) { return legs.filter((l) => l.needs_review).length; }
function finalize(book, legs) {
return {
book,
legs,
needs_review: legs.length === 0 || legs.some((l) => l.needs_review),
source: 'user_slip',
};
}
/* ── OCR (tesseract.js — self-hosted WASM, zero API) ───────────── */
/**
* Run OCR on an image buffer. Lazy-requires tesseract.js so the pure
* parsers stay loadable without the WASM engine (tests, cold paths).
* English traineddata (~11MB) downloads once and caches on disk.
*/
async function recognizeImage(buffer, opts = {}) {
const { createWorker } = opts.tesseract || require('tesseract.js');
const worker = await createWorker('eng', 1, {
cachePath: opts.cachePath || process.env.TESSERACT_CACHE_PATH || '.tesseract-cache',
// Silence per-job progress logging.
logger: () => {},
});
try {
const { data } = await worker.recognize(buffer);
return data.text || '';
} finally {
await worker.terminate();
}
}
module.exports = {
parseSlipText,
detectBook,
recognizeImage,
CONFIDENCE_THRESHOLD,
__internals: {
parseDraftKings,
parseFanDuel,
parseBetMGM,
parseCaesars,
normalizeStat,
splitPlayerStat,
statLabel,
parseOdds,
buildLeg,
STAT_ALIASES,
NOISE_RE,
},
};