'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: " 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: " To Record 2+ Total Bases" (2+ → line 1.5 over), " Any Time Home Run", " 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: " 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: " 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, }, };