#!/usr/bin/env node import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; process.noDeprecation = true; const require = createRequire(import.meta.url); const { runTencentVatOcr } = require("../../server/src/services/ocr/tencent"); const SUPPORTED_PROVIDERS = new Set(["tencent"]); const MIME_BY_EXT = new Map([ [".pdf", "application/pdf"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".png", "image/png"], ]); const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]); const PDF_EXTENSIONS = new Set([".pdf"]); const REQUIRED_FIELDS = ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"]; const OPTIONAL_FIELDS = ["buyerName", "amount", "taxAmount"]; function parseArgs(argv) { const options = { provider: process.env.OCR_PROVIDER_SAMPLE_PROVIDER || "tencent", json: false, strict: false, manifest: process.env.OCR_PROVIDER_SAMPLE_MANIFEST || "", imageEvaluation: process.env.OCR_IMAGE_OCR_EVALUATION === "1", }; for (const arg of argv) { if (arg === "--json") { options.json = true; } else if (arg === "--strict") { options.strict = true; } else if (arg === "--image-evaluation") { options.imageEvaluation = true; } else if (arg.startsWith("--provider=")) { options.provider = arg.slice("--provider=".length); } else if (arg.startsWith("--manifest=")) { options.manifest = arg.slice("--manifest=".length); } } return options; } function splitEnvList(value) { return String(value || "") .split(/[\n,]/) .map((item) => item.trim()) .filter(Boolean); } function resolveSamplePath(rawPath, baseDir) { if (!rawPath || typeof rawPath !== "string") return ""; return path.isAbsolute(rawPath) ? rawPath : path.resolve(baseDir, rawPath); } function inferKind(filePath, declaredKind) { const normalizedKind = String(declaredKind || "").trim().toLowerCase(); if (["pdf", "pdf_vat", "vat_pdf"].includes(normalizedKind)) return "pdf"; if (["image", "image_vat", "vat_image"].includes(normalizedKind)) return "image"; const ext = path.extname(filePath).toLowerCase(); if (PDF_EXTENSIONS.has(ext)) return "pdf"; if (IMAGE_EXTENSIONS.has(ext)) return "image"; return "unknown"; } function safeAlias(value, fallback) { const alias = String(value || "").trim(); if (!alias) return fallback; if (/^SAMPLE-(PDF|IMG)-[A-Z0-9_-]+$/i.test(alias)) return alias.toUpperCase(); return fallback; } function pushSample(samples, source) { const kind = inferKind(source.path, source.kind); const fallbackPrefix = kind === "pdf" ? "SAMPLE-PDF" : kind === "image" ? "SAMPLE-IMG" : "SAMPLE-UNKNOWN"; samples.push({ alias: safeAlias(source.alias, `${fallbackPrefix}-${String(samples.length + 1).padStart(3, "0")}`), filePath: source.path, kind, source: source.source, }); } function loadManifestSamples(manifestPath) { if (!manifestPath) return { samples: [], error: null }; if (!fs.existsSync(manifestPath)) { return { samples: [], error: "manifest_not_found" }; } try { const baseDir = path.dirname(manifestPath); const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const items = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.samples) ? parsed.samples : []; const samples = []; for (const item of items) { const samplePath = resolveSamplePath(item?.path, baseDir); if (!samplePath) continue; pushSample(samples, { alias: item?.alias, path: samplePath, kind: item?.kind, source: "manifest", }); } return { samples, error: null }; } catch { return { samples: [], error: "manifest_parse_failed" }; } } function loadEnvSamples() { const samples = []; const pdfFiles = [ ...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILE), ...splitEnvList(process.env.LIVE_OCR_INVOICE_FILE), ...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILES), ]; for (const item of pdfFiles) { pushSample(samples, { path: item, kind: "pdf", source: "env_pdf" }); } const pdfDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim(); if (pdfDir && fs.existsSync(pdfDir)) { for (const entry of fs.readdirSync(pdfDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) { const filePath = path.join(pdfDir, entry); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && PDF_EXTENSIONS.has(path.extname(entry).toLowerCase())) { pushSample(samples, { path: filePath, kind: "pdf", source: "env_pdf_dir" }); } } } const imageFiles = [ ...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILE), ...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILES), ...splitEnvList(process.env.LIVE_OCR_IMAGE_FILE), ]; for (const item of imageFiles) { pushSample(samples, { path: item, kind: "image", source: "env_image" }); } const imageDirs = [ process.env.PLAYWRIGHT_OCR_IMAGE_DIR?.trim(), process.env.LIVE_OCR_IMAGE_DIR?.trim(), ].filter(Boolean); for (const dir of imageDirs) { if (!fs.existsSync(dir)) continue; for (const entry of fs.readdirSync(dir).sort((left, right) => left.localeCompare(right, "zh-CN"))) { const filePath = path.join(dir, entry); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && IMAGE_EXTENSIONS.has(path.extname(entry).toLowerCase())) { pushSample(samples, { path: filePath, kind: "image", source: "env_image_dir" }); } } } return samples; } function hasExplicitImageEnv() { return [ "PLAYWRIGHT_OCR_IMAGE_FILE", "PLAYWRIGHT_OCR_IMAGE_FILES", "PLAYWRIGHT_OCR_IMAGE_DIR", "LIVE_OCR_IMAGE_FILE", "LIVE_OCR_IMAGE_DIR", ].some((key) => Boolean(process.env[key]?.trim())); } function dedupeSamples(samples) { const seen = new Set(); const result = []; for (const sample of samples) { const key = `${sample.alias}:${sample.filePath}`; if (seen.has(key)) continue; seen.add(key); result.push(sample); } return result; } function getTencentConfig() { return { tencentSecretId: process.env.TENCENT_SECRET_ID || "", tencentSecretKey: process.env.TENCENT_SECRET_KEY || "", tencentOcrRegion: process.env.TENCENT_OCR_REGION || "ap-beijing", ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS || 5000), }; } function hasTencentCredentials(config) { return Boolean(config.tencentSecretId && config.tencentSecretKey); } function summarizeFile(sample) { try { if (!fs.existsSync(sample.filePath)) { return { ok: false, errorCode: "sample_not_found" }; } const stat = fs.statSync(sample.filePath); if (!stat.isFile()) { return { ok: false, errorCode: "sample_not_file" }; } const ext = path.extname(sample.filePath).toLowerCase(); const mimeType = MIME_BY_EXT.get(ext); if (!mimeType) { return { ok: false, errorCode: "unsupported_sample_type" }; } return { ok: true, mimeType, fileSizeBytes: stat.size, transportMode: mimeType === "application/pdf" ? "pdf_native" : "image_native", }; } catch { return { ok: false, errorCode: "sample_stat_failed" }; } } function summarizeNormalizedResult(result) { const fieldHits = {}; for (const field of [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]) { const value = result?.[field]; fieldHits[field] = value != null && value !== ""; } const missingRequiredFields = REQUIRED_FIELDS.filter((field) => !fieldHits[field]); return { fieldHits, missingRequiredFields, transportMode: result?.raw?.transportMode || null, }; } async function checkSample(provider, config, sample) { const fileSummary = summarizeFile(sample); const base = { alias: sample.alias, kind: sample.kind, source: sample.source, status: "SKIP", mimeType: fileSummary.mimeType || null, fileSizeBytes: fileSummary.fileSizeBytes || null, transportMode: fileSummary.transportMode || null, }; if (!fileSummary.ok) { return { ...base, status: "SKIP", errorCode: fileSummary.errorCode }; } if (provider !== "tencent") { return { ...base, status: "SKIP", errorCode: "unsupported_provider" }; } if (!hasTencentCredentials(config)) { return { ...base, status: "SKIP", errorCode: "missing_tencent_credentials" }; } try { const imageBase64 = fs.readFileSync(sample.filePath).toString("base64"); const result = await runTencentVatOcr(config, { imageBase64, sourceMimeType: fileSummary.mimeType }); const normalizedSummary = summarizeNormalizedResult(result); const missingRequired = normalizedSummary.missingRequiredFields; return { ...base, status: missingRequired.length ? "HOLD" : "PASS", fieldHits: normalizedSummary.fieldHits, missingRequiredFields: missingRequired, transportMode: normalizedSummary.transportMode || base.transportMode, }; } catch (error) { return { ...base, status: "HOLD", errorCode: typeof error?.code === "string" ? error.code : "provider_failed", transportMode: error?.transportMode || base.transportMode, }; } } function buildDecision(results, manifestError, imageEvaluation) { if (!imageEvaluation) return "FREEZE_BY_DEFAULT"; if (manifestError) return "HOLD_FOR_SAMPLE"; if (!results.length) return "HOLD_FOR_SAMPLE"; const imageResults = results.filter((item) => item.kind === "image"); if (!imageResults.length) return "HOLD_FOR_SAMPLE"; if (imageResults.every((item) => item.status === "PASS")) return "GO_TO_IMPLEMENT"; return "HOLD_FOR_SAMPLE"; } function printHuman(summary) { console.log(`[ocr-provider-samples] status=${summary.status} decision=${summary.decision}`); console.log(`[ocr-provider-samples] provider=${summary.provider} imageEvaluation=${summary.imageEvaluation} samples=${summary.counts.total} pdf=${summary.counts.pdf} image=${summary.counts.image}`); if (summary.manifestError) { console.log(`[ocr-provider-samples] manifest=${summary.manifestError}`); } for (const result of summary.results) { const error = result.errorCode ? ` error=${result.errorCode}` : ""; console.log( `[ocr-provider-samples] ${result.alias} kind=${result.kind} status=${result.status} transport=${result.transportMode || "-"} size=${result.fileSizeBytes ?? "-"}${error}`, ); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (!SUPPORTED_PROVIDERS.has(options.provider)) { throw new Error(`unsupported provider: ${options.provider}`); } const manifest = loadManifestSamples(options.manifest); const samples = dedupeSamples([...manifest.samples, ...loadEnvSamples()]); const config = getTencentConfig(); const results = []; const imageEvaluation = options.imageEvaluation || Boolean(options.manifest) || hasExplicitImageEnv(); for (const sample of samples) { results.push(await checkSample(options.provider, config, sample)); } const decision = buildDecision(results, manifest.error, imageEvaluation); const failed = results.some((item) => item.status === "HOLD"); const skipped = !results.length || results.some((item) => item.status === "SKIP"); const summary = { checkedAt: new Date().toISOString(), provider: options.provider, status: failed ? "HOLD" : skipped ? "SKIP" : "PASS", decision, imageEvaluation, manifestProvided: Boolean(options.manifest), manifestError: manifest.error, credentialReady: hasTencentCredentials(config), counts: { total: results.length, pdf: results.filter((item) => item.kind === "pdf").length, image: results.filter((item) => item.kind === "image").length, pass: results.filter((item) => item.status === "PASS").length, hold: results.filter((item) => item.status === "HOLD").length, skip: results.filter((item) => item.status === "SKIP").length, }, results, }; if (options.json) { console.log(JSON.stringify(summary, null, 2)); } else { printHuman(summary); } if (options.strict && summary.status !== "PASS") { process.exit(1); } } main().catch((error) => { console.error(`[ocr-provider-samples][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });