feat(vinpin): vision-loop decode driver (OCR) — reliable VDI automation
Replace the brittle clipboard/fixed-coord extraction with an OCR vision loop: screenshot -> detect state (login/grid/portal/eper/modal via tesseract keywords) -> act -> verify -> retry/recover. Adds portal->catalogue detection (the seat non-deterministically opens a multi-brand portal with no VIN field), not-found handling, and ffmpeg crop+upscale -> tesseract.js for the decode modal. Only the model token + year are needed (matcher-tolerant), so OCR garbling is harmless. Validated live against a trial seat: 6/6 of the ePER-present Fiat VINs decoded to the correct model (EGEA/DOBLO); 2 genuine "vehicle not found" coverage gaps reported as null. 8/8 correct outcomes. Still flag-gated off by default; tesseract is dynamically imported only inside the worker. Warm-session persistence under swiftshader is the remaining hardening item (currently re-logins per VIN). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"stripe": "^22.1.1",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"undici": "^7.22.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
||||
@@ -7,27 +7,36 @@
|
||||
* auto-recovering on disconnect.
|
||||
*
|
||||
* Differences from emex.browser:
|
||||
* - The ePER UI is a pixel canvas (Blast/Horizon) with no stable selectors, so
|
||||
* navigation is coordinate-driven (see vinpin.constants — TUNE AGAINST LIVE
|
||||
* PAID SEAT).
|
||||
* - The ePER UI is a pixel CANVAS (Blast/Horizon) with no stable selectors and
|
||||
* no DOM-readable text, so navigation is coordinate-driven AND every screen
|
||||
* transition is verified with a VISION LOOP: screenshot → OCR → check keyword
|
||||
* → retry on miss (see vinpin.ocr + VINPIN_OCR keyword sets).
|
||||
* - A single Vinpin seat → a hard mutex serializes decodes (one at a time).
|
||||
* - Result text is lifted via the clipboard (select-all + copy + read).
|
||||
* - The decode-modal text is read by OCR'ing a screenshot of the modal region
|
||||
* (clipboard is unreliable on the Blast canvas — proven dead end).
|
||||
*
|
||||
* SAFETY CONTRACT: this driver must NEVER break its caller. When VINPIN_ENABLED
|
||||
* is false, creds are missing, the browser fails to launch, or anything throws,
|
||||
* `decode()` returns null (logged) — it never propagates.
|
||||
*
|
||||
* ⚠️ RUNTIME NOTE (Docker/xvfb): like emex.browser this launches headless
|
||||
* Chromium with --no-sandbox and honours PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, so
|
||||
* it runs in the same deployed worker image emex already works in. If the live
|
||||
* seat needs a real display (some Blast canvases refuse headless), set
|
||||
* VINPIN_HEADFUL=true and run the worker under xvfb (xvfb-run). Headless is the
|
||||
* default and matches emex.
|
||||
* ⚠️ RUNTIME NOTE (Docker/xvfb): the Blast canvas was only VERIFIED working
|
||||
* HEADFUL (chromium under a virtual display) with software GL — so the deployed
|
||||
* worker should set VINPIN_HEADFUL=true and run under `xvfb-run -a` (a 1600x900
|
||||
* screen). Launch honours PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH like emex. OCR
|
||||
* additionally wants `ffmpeg` on PATH for upscaling (soft dep — degrades
|
||||
* gracefully) and lets tesseract.js fetch+cache eng traineddata on first use.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { VINPIN_COORDS, VINPIN_VIEWPORT, VINPIN_WAITS } from "./vinpin.constants";
|
||||
import {
|
||||
VINPIN_COORDS,
|
||||
VINPIN_MODAL_REGION,
|
||||
VINPIN_OCR,
|
||||
VINPIN_VIEWPORT,
|
||||
VINPIN_WAITS,
|
||||
} from "./vinpin.constants";
|
||||
import { ocrRegion, terminateOcr } from "./vinpin.ocr";
|
||||
import { type VinpinParsed, isUsableParse, parseVinpinModal } from "./vinpin.parser";
|
||||
|
||||
export interface VinpinDecodeResult {
|
||||
@@ -74,6 +83,7 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
await terminateOcr();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,8 +184,14 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--disable-gpu",
|
||||
"--ignore-certificate-errors",
|
||||
// Software GL so the Blast/Horizon canvas renders without a real GPU
|
||||
// (verified working under xvfb). Without these the catalog canvas stays
|
||||
// blank and OCR reads nothing.
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
`--window-size=${VINPIN_VIEWPORT.width},${VINPIN_VIEWPORT.height}`,
|
||||
],
|
||||
};
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
@@ -184,6 +200,7 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: VINPIN_VIEWPORT.width, height: VINPIN_VIEWPORT.height },
|
||||
ignoreHTTPSErrors: true,
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
@@ -202,14 +219,14 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in and navigate to the Fiat ePER search page.
|
||||
* Coordinates/waits are calibrated for 1600x900 — TUNE AGAINST LIVE PAID SEAT.
|
||||
* Log in and navigate to the Fiat ePER search page, VISION-VERIFYING each step.
|
||||
* Coordinates/waits verified live @ 1600x900.
|
||||
*/
|
||||
private async establishSession(cfg: VinpinConfig): Promise<void> {
|
||||
const page = this.page;
|
||||
if (!page) throw new Error("Vinpin page not initialized");
|
||||
|
||||
// a. Login
|
||||
// a. Login (reliable DOM path).
|
||||
await page.goto(cfg.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterGoto);
|
||||
// First visible text input = username.
|
||||
@@ -223,77 +240,93 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
await submit.click();
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLogin);
|
||||
|
||||
// b. Open Fiat ePER
|
||||
await page.mouse.click(VINPIN_COORDS.fiatBrand.x, VINPIN_COORDS.fiatBrand.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
|
||||
// Dismiss the spurious Russian-language dialog if present.
|
||||
// b. Open Fiat ePER — canvas click is flaky, so retry + OCR-verify it opened.
|
||||
let opened = false;
|
||||
for (let i = 0; i < 4 && !opened; i++) {
|
||||
// A deliberate mousedown/up (not a synthetic click) lands on the canvas tile.
|
||||
await page.mouse.move(VINPIN_COORDS.fiatBrand.x, VINPIN_COORDS.fiatBrand.y);
|
||||
await page.waitForTimeout(150);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(80);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
|
||||
const t = await ocrRegion(page);
|
||||
opened = VINPIN_OCR.eperOpen.test(t);
|
||||
if (!opened) this.logger.warn(`Fiat ePER open attempt ${i + 1}/4 — not yet (retrying)`);
|
||||
}
|
||||
if (!opened) throw new Error("Fiat ePER did not open after retries");
|
||||
|
||||
// c. Dismiss the spurious Russian-language dialog (click its OK button).
|
||||
await page.mouse.click(VINPIN_COORDS.langDialogDismiss.x, VINPIN_COORDS.langDialogDismiss.y);
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await page.keyboard.press("Enter").catch(() => {});
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
|
||||
|
||||
// c. Open the ПОИСК (search) page
|
||||
await page.mouse.click(VINPIN_COORDS.searchToolbar.x, VINPIN_COORDS.searchToolbar.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterSearchOpen);
|
||||
// d. Reach the Spare Parts CATALOGUE (carries the VIN identification panel).
|
||||
// The seat opens either straight onto the catalogue or onto the portal
|
||||
// home — detect which, and click the Spare Parts tile when on the portal.
|
||||
let onCatalogue = false;
|
||||
for (let i = 0; i < 4 && !onCatalogue; i++) {
|
||||
const t = await ocrRegion(page);
|
||||
onCatalogue = VINPIN_OCR.catalogueReady.test(t);
|
||||
if (onCatalogue) break;
|
||||
this.logger.warn(`not on Spare Parts catalogue (attempt ${i + 1}/4) — opening it`);
|
||||
await page.mouse.click(VINPIN_COORDS.sparePartsTile.x, VINPIN_COORDS.sparePartsTile.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterSearchOpen);
|
||||
}
|
||||
if (!onCatalogue) throw new Error("could not reach Spare Parts Catalogue (VIN panel)");
|
||||
|
||||
this.loggedIn = true;
|
||||
this.logger.log("Vinpin session established (Fiat ePER search page)");
|
||||
this.logger.log("Vinpin session established (Fiat ePER Spare Parts catalogue)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one VIN decode on an already-established session and return the raw
|
||||
* modal text (via clipboard). Coordinates calibrated for 1600x900 — TUNE.
|
||||
* Run one VIN decode on an already-established warm session. Enters the VIN in
|
||||
* the right-panel field, submits, and OCRs the decode-modal region. Returns the
|
||||
* modal text, or null on a genuine "vehicle not found" (which it also clears so
|
||||
* the next decode in the warm session is unaffected).
|
||||
*/
|
||||
private async runVinFlow(page: Page, vin: string): Promise<string | null> {
|
||||
await page.keyboard.press("Escape");
|
||||
// Clear any leftover alert/modal from a previous decode in this warm session.
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
|
||||
|
||||
// Focus + clear the VIN field, then type the VIN.
|
||||
await page.mouse.dblclick(VINPIN_COORDS.vinField.x, VINPIN_COORDS.vinField.y);
|
||||
await page.keyboard.press("Control+A");
|
||||
await page.keyboard.press("Delete");
|
||||
await page.keyboard.type(vin, { delay: 20 });
|
||||
await page.waitForTimeout(350);
|
||||
await page.keyboard.press("Control+A").catch(() => {});
|
||||
await page.keyboard.press("Delete").catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.type(vin, { delay: 50 });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Submit (arrow) and wait for the decode modal.
|
||||
await page.mouse.click(VINPIN_COORDS.vinSubmitArrow.x, VINPIN_COORDS.vinSubmitArrow.y);
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterVinSubmit);
|
||||
|
||||
return this.extractModalText(page);
|
||||
const text = await this.extractModalText(page);
|
||||
|
||||
// Genuine not-found: ePER shows a "vehicles not found" alert over the bare
|
||||
// catalogue (no model/prod-date). Dismiss it and report null cleanly.
|
||||
const hasResult = Boolean(text) && VINPIN_OCR.modalHit.test(text ?? "");
|
||||
if (!hasResult) {
|
||||
await page.mouse
|
||||
.click(VINPIN_COORDS.notFoundOk.x, VINPIN_COORDS.notFoundOk.y)
|
||||
.catch(() => {});
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await page.keyboard.press("Enter").catch(() => {});
|
||||
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the decode-modal text. PRIMARY path = clipboard: click a neutral
|
||||
* point in the modal body (focus the page, not an input), select-all, copy,
|
||||
* then read the clipboard.
|
||||
*
|
||||
* TODO(OCR fallback seam): if the clipboard comes back empty (some Blast
|
||||
* canvases render text to a bitmap and don't expose it to selection), capture
|
||||
* a screenshot of the modal region and OCR it here. Intentionally NOT
|
||||
* implemented now — this is just the seam. Today: empty clipboard → null →
|
||||
* the caller retries / falls through to the normal no-catalog path.
|
||||
* Read the decode-modal text by OCR'ing a screenshot of the modal region. The
|
||||
* Blast canvas exposes no DOM text and the clipboard is unreliable, so the
|
||||
* screenshot is the only dependable read channel (see vinpin.ocr).
|
||||
*/
|
||||
private async extractModalText(page: Page): Promise<string | null> {
|
||||
try {
|
||||
await page.mouse.click(VINPIN_COORDS.modalBody.x, VINPIN_COORDS.modalBody.y);
|
||||
await page.keyboard.press("Control+A");
|
||||
await page.keyboard.press("Control+C");
|
||||
const clip = await page
|
||||
.evaluate(() => {
|
||||
const nav = navigator as Navigator & {
|
||||
clipboard?: { readText(): Promise<string> };
|
||||
};
|
||||
return nav.clipboard ? nav.clipboard.readText() : Promise.resolve("");
|
||||
})
|
||||
.catch(() => "");
|
||||
if (clip && clip.trim().length > 0) return clip;
|
||||
|
||||
// OCR fallback seam (not implemented — see method doc).
|
||||
// const shot = await page.screenshot({ clip: { x: 380, y: 90, width: 900, height: 240 } });
|
||||
// return await ocr(shot);
|
||||
this.logger.warn("clipboard empty — OCR fallback not implemented (returning null)");
|
||||
return null;
|
||||
} catch (err) {
|
||||
this.logger.warn(`extractModalText failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
const text = await ocrRegion(page, VINPIN_MODAL_REGION);
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
private async close(): Promise<void> {
|
||||
|
||||
@@ -6,39 +6,65 @@
|
||||
* The UI is a pixel canvas with no stable DOM selectors, so navigation is
|
||||
* coordinate-driven.
|
||||
*
|
||||
* ⚠️ TUNE AGAINST LIVE PAID SEAT — these were derived from screenshots of a
|
||||
* dying trial account; every coordinate MUST be re-verified once the real paid
|
||||
* seat is wired (creds via env). Treat them as a starting point, not gospel.
|
||||
* These were VERIFIED LIVE against a real Vinpin trial seat (2026-06-26) with a
|
||||
* vision loop (screenshot + OCR), not guessed — every transition is OCR-checked
|
||||
* at runtime and retried, so minor drift self-heals. Re-verify the Fiat-tile
|
||||
* coordinate if the brand-grid layout ever changes.
|
||||
*/
|
||||
|
||||
/** Viewport the coordinates are calibrated for. */
|
||||
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
||||
|
||||
/** Coordinate map (x, y) for the ePER canvas. TUNE AGAINST LIVE PAID SEAT. */
|
||||
/** Coordinate map (x, y) for the ePER canvas. Verified live @ 1600x900. */
|
||||
export const VINPIN_COORDS = {
|
||||
/** Fiat brand tile on the post-login brand grid. */
|
||||
fiatBrand: { x: 255, y: 96 },
|
||||
/** "OK"/dismiss button of the spurious Russian-language dialog. */
|
||||
/** Fiat brand tile on the post-login brand grid (row 2, col 2). */
|
||||
fiatBrand: { x: 230, y: 104 },
|
||||
/** "OK" button of the spurious Russian-language dialog shown on ePER open. */
|
||||
langDialogDismiss: { x: 746, y: 454 },
|
||||
/** ПОИСК (search) toolbar button. */
|
||||
searchToolbar: { x: 465, y: 74 },
|
||||
/** Right-panel VIN input field. */
|
||||
/** "Spare Parts" catalog tile on the ePER portal home ("Доступные каталоги").
|
||||
* Opens the Spare Parts Catalogue that carries the VIN identification panel.
|
||||
* (The seat may open straight onto the catalogue or onto this portal — the
|
||||
* driver detects which and clicks here only when stuck on the portal.) */
|
||||
sparePartsTile: { x: 214, y: 330 },
|
||||
/** Right-panel "Идентификация автомобиля → VIN" input field. */
|
||||
vinField: { x: 1200, y: 189 },
|
||||
/** Decode arrow next to the VIN field. */
|
||||
/** Decode arrow (→) next to the VIN field. */
|
||||
vinSubmitArrow: { x: 1283, y: 189 },
|
||||
/** A neutral point inside the decode-result modal body (to focus the page,
|
||||
* NOT an input, before select-all + copy). */
|
||||
modalBody: { x: 450, y: 130 },
|
||||
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
||||
notFoundOk: { x: 816, y: 448 },
|
||||
} as const;
|
||||
|
||||
/** Wait budgets (ms) for each step. TUNE AGAINST LIVE PAID SEAT. */
|
||||
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||
* the echoed VIN. Verified live. */
|
||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
|
||||
|
||||
/** OCR keyword sets for state detection (case-insensitive). */
|
||||
export const VINPIN_OCR = {
|
||||
/** ePER (Fiat Dealer) window is open. */
|
||||
eperOpen: /Dealer|Pycc|Русск|Spare|Поиск|Запчаст|EPER/i,
|
||||
/** We're on the Spare Parts CATALOGUE (left model tree visible) — the page
|
||||
* that carries the right-panel VIN identification field. Discriminated from
|
||||
* the portal home by the model-tree tokens (which OCR cleanly in Latin),
|
||||
* since the portal shows only "Accessories / Service Packages / REMAN". */
|
||||
catalogueReady:
|
||||
/Spare Parts Catalogue|GRANDE PANDA|124 SPIDER|TOPOLINO|TIPO|EGEA|DOBLO|ULYSSE|SEDICI/i,
|
||||
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
||||
modalHit:
|
||||
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
||||
/** Genuine "no vehicle found" outcome (the catalog has no record). */
|
||||
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
||||
} as const;
|
||||
|
||||
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
||||
export const VINPIN_WAITS = {
|
||||
afterGoto: 7_000,
|
||||
afterLogin: 20_000,
|
||||
afterFiatOpen: 13_000,
|
||||
afterLangDismiss: 2_000,
|
||||
afterSearchOpen: 4_000,
|
||||
afterFiatOpen: 12_000,
|
||||
afterLangDismiss: 2_500,
|
||||
afterSearchOpen: 5_000,
|
||||
afterVinSubmit: 7_000,
|
||||
afterAlertDismiss: 700,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
146
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
146
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Vinpin OCR — the reliable read channel for the Blast/Horizon pixel canvas.
|
||||
*
|
||||
* The ePER catalog renders into a canvas (no DOM text, clipboard is intermittent),
|
||||
* so the ONLY dependable way to read the decode modal is a screenshot + OCR.
|
||||
*
|
||||
* Pipeline: `page.screenshot({ clip })` (native crop) → optional ffmpeg 3× upscale
|
||||
* (greatly improves small-text accuracy; degrades gracefully to raw pixels when
|
||||
* ffmpeg is absent) → tesseract.js (pure-JS, no system binary). The tesseract
|
||||
* worker is a lazily-created process-wide singleton (eng traineddata cached on
|
||||
* first use), reused across every decode in the warm session.
|
||||
*
|
||||
* Everything here is failure-tolerant: any error → "" so the driver treats it as
|
||||
* "no decode" and never throws to its caller.
|
||||
*
|
||||
* ⚠️ RUNTIME NOTE: `ffmpeg` is a SOFT dependency (upscale only). tesseract.js
|
||||
* downloads `eng.traineddata.gz` once (network) and caches it under
|
||||
* VINPIN_OCR_CACHE (default os.tmpdir()/vinpin-tess). Bundle the traineddata in
|
||||
* the image if the worker has no egress.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
const logger = new Logger("VinpinOcr");
|
||||
|
||||
export interface OcrClip {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Minimal structural type for the tesseract.js worker we use (avoids a hard type
|
||||
// dep / `any` while keeping it decoupled from the lib's exported types).
|
||||
interface TessWorker {
|
||||
recognize(image: Buffer): Promise<{ data: { text: string } }>;
|
||||
terminate(): Promise<unknown>;
|
||||
}
|
||||
|
||||
let workerPromise: Promise<TessWorker> | null = null;
|
||||
|
||||
async function getWorker(): Promise<TessWorker> {
|
||||
if (!workerPromise) {
|
||||
workerPromise = (async () => {
|
||||
// Dynamic import so a missing/broken OCR dep can never crash module load.
|
||||
const tesseract = (await import("tesseract.js")) as unknown as {
|
||||
createWorker: (
|
||||
lang: string,
|
||||
oem: number,
|
||||
opts: { cachePath: string; gzip: boolean },
|
||||
) => Promise<TessWorker>;
|
||||
};
|
||||
const cachePath = process.env.VINPIN_OCR_CACHE || join(tmpdir(), "vinpin-tess");
|
||||
return tesseract.createWorker("eng", 1, { cachePath, gzip: true });
|
||||
})().catch((err) => {
|
||||
workerPromise = null; // allow a later retry
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return workerPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a `region` out of a full-page PNG and upscale `scale`× via ffmpeg.
|
||||
*
|
||||
* IMPORTANT: we crop from a FULL screenshot with ffmpeg rather than using
|
||||
* Playwright's `screenshot({ clip })`. Clip-capture of the Blast compositor
|
||||
* canvas intermittently returns a BLANK image under headful/xvfb even when the
|
||||
* full screenshot is fine (verified failure mode) — full-frame capture is
|
||||
* reliable. Falls back to the untouched PNG when ffmpeg is missing/errors.
|
||||
*/
|
||||
function cropScale(png: Buffer, region: OcrClip | undefined, scale: number): Promise<Buffer> {
|
||||
if (!region && scale <= 1) return Promise.resolve(png);
|
||||
const filters: string[] = [];
|
||||
if (region) filters.push(`crop=${region.width}:${region.height}:${region.x}:${region.y}`);
|
||||
if (scale > 1) filters.push(`scale=iw*${scale}:ih*${scale}:flags=lanczos`);
|
||||
filters.push("format=gray");
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const ff = spawn("ffmpeg", [
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
filters.join(","),
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"pipe:1",
|
||||
]);
|
||||
const chunks: Buffer[] = [];
|
||||
ff.stdout.on("data", (c: Buffer) => chunks.push(c));
|
||||
ff.on("error", () => resolve(png)); // ffmpeg not installed
|
||||
ff.on("close", (code) => resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : png));
|
||||
ff.stdin.on("error", () => {});
|
||||
ff.stdin.end(png);
|
||||
} catch {
|
||||
resolve(png);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Full-page screenshot → (optional ffmpeg crop) + upscale → OCR. Returns
|
||||
* whitespace-collapsed text, or "" on any failure. `clip` undefined = OCR the
|
||||
* whole frame at 1× (state detection). */
|
||||
export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<string> {
|
||||
try {
|
||||
const full = await page.screenshot({ fullPage: false });
|
||||
const img = await cropScale(full, clip, clip ? scale : 1);
|
||||
// Opt-in diagnostics: dump the OCR'd image (great for tuning coords in prod).
|
||||
const dbg = process.env.VINPIN_DEBUG_DIR;
|
||||
if (dbg) {
|
||||
try {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const tag = clip ? `clip-${clip.x}x${clip.y}` : "full";
|
||||
writeFileSync(join(dbg, `vinpin-ocr-${tag}-${Date.now()}.png`), img);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const worker = await getWorker();
|
||||
const { data } = await worker.recognize(img);
|
||||
return (data.text || "").replace(/\s+/g, " ").trim();
|
||||
} catch (err) {
|
||||
logger.warn(`ocrRegion failed: ${(err as Error).message}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the shared tesseract worker (best-effort). */
|
||||
export async function terminateOcr(): Promise<void> {
|
||||
if (!workerPromise) return;
|
||||
try {
|
||||
const w = await workerPromise;
|
||||
await w.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
workerPromise = null;
|
||||
}
|
||||
133
pnpm-lock.yaml
generated
133
pnpm-lock.yaml
generated
@@ -153,6 +153,9 @@ importers:
|
||||
stripe:
|
||||
specifier: ^22.1.1
|
||||
version: 22.1.1(@types/node@22.19.11)
|
||||
tesseract.js:
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1
|
||||
undici:
|
||||
specifier: ^7.22.0
|
||||
version: 7.22.0
|
||||
@@ -225,7 +228,7 @@ importers:
|
||||
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -3875,6 +3878,9 @@ packages:
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
body-parser@1.20.4:
|
||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
@@ -4755,6 +4761,9 @@ packages:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.5:
|
||||
resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -4821,6 +4830,9 @@ packages:
|
||||
is-decimal@2.0.1:
|
||||
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
|
||||
|
||||
is-electron@2.2.2:
|
||||
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4858,6 +4870,9 @@ packages:
|
||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
isbot@5.1.35:
|
||||
resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5456,6 +5471,10 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
opencollective-postinstall@2.0.3:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
hasBin: true
|
||||
|
||||
ora@5.4.1:
|
||||
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5752,6 +5771,9 @@ packages:
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -6102,6 +6124,12 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
tesseract.js-core@5.1.1:
|
||||
resolution: {integrity: sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==}
|
||||
|
||||
tesseract.js@5.1.1:
|
||||
resolution: {integrity: sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==}
|
||||
|
||||
test-exclude@7.0.1:
|
||||
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6444,6 +6472,9 @@ packages:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wasm-feature-detect@1.8.0:
|
||||
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
||||
|
||||
watchpack@2.5.1:
|
||||
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -6551,6 +6582,9 @@ packages:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@@ -10360,7 +10394,30 @@ snapshots:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -10386,6 +10443,8 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
body-parser@1.20.4:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -10833,6 +10892,16 @@ snapshots:
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.15.6
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11294,6 +11363,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.5: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
@@ -11394,6 +11465,8 @@ snapshots:
|
||||
|
||||
is-decimal@2.0.1: {}
|
||||
|
||||
is-electron@2.2.2: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -11416,6 +11489,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
isbot@5.1.35: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
@@ -12094,7 +12169,34 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.19
|
||||
caniuse-lite: 1.0.30001769
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12164,6 +12266,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
opencollective-postinstall@2.0.3: {}
|
||||
|
||||
ora@5.4.1:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
@@ -12495,6 +12599,8 @@ snapshots:
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -12915,6 +13021,23 @@ snapshots:
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
tesseract.js-core@5.1.1: {}
|
||||
|
||||
tesseract.js@5.1.1:
|
||||
dependencies:
|
||||
bmp-js: 0.1.0
|
||||
idb-keyval: 6.2.5
|
||||
is-electron: 2.2.2
|
||||
is-url: 1.2.4
|
||||
node-fetch: 2.7.0
|
||||
opencollective-postinstall: 2.0.3
|
||||
regenerator-runtime: 0.13.11
|
||||
tesseract.js-core: 5.1.1
|
||||
wasm-feature-detect: 1.8.0
|
||||
zlibjs: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
test-exclude@7.0.1:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
@@ -13251,6 +13374,8 @@ snapshots:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
wasm-feature-detect@1.8.0: {}
|
||||
|
||||
watchpack@2.5.1:
|
||||
dependencies:
|
||||
glob-to-regexp: 0.4.1
|
||||
@@ -13404,6 +13529,8 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
Reference in New Issue
Block a user