Merge S9 (a1): slip reader — zero-API OCR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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" — 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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user