4e49ee0990
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>
463 lines
17 KiB
JavaScript
463 lines
17 KiB
JavaScript
'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" — 2–3 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,
|
||
},
|
||
};
|