Files
vyndr/src/services/slipReader.js
T
builtbykev 4e49ee0990 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>
2026-07-11 19:32:17 -04:00

463 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
},
};