文件
hyb-oa/scripts/qa/check-ocr-provider-samples.mjs
wangsli 5f2788ccac
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
增加铁路OCR金额字段安全诊断(07-28-travel-reimbursement-guide)
2026-07-29 15:07:58 +08:00

243 行
7.1 KiB
JavaScript

#!/usr/bin/env node
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
process.noDeprecation = true;
const require = createRequire(import.meta.url);
const {
OCR_PDF_MAX_BYTES,
buildMissingRequiredOcrError,
runOcrForInvoice,
sanitizeOcrResultForPersistence,
} = require("../../server/src/services/ocr");
const PDF_MIME_TYPE = "application/pdf";
const PDF_EOF_MARKER = Buffer.from("%%EOF", "ascii");
const SAFE_ALIAS_PATTERN = /^SAMPLE-PDF-[A-Z0-9_-]{1,48}$/u;
const SAFE_TOKEN_PATTERN = /^[A-Za-z0-9_.-]{1,96}$/u;
const SAFE_FIELD_NAMES = [
"invoiceCode",
"invoiceNumber",
"invoiceDate",
"amount",
"taxAmount",
"totalAmount",
"sellerName",
"buyerName",
];
class LiveOcrSampleError extends Error {
constructor(code) {
super(code);
this.name = "LiveOcrSampleError";
this.code = code;
}
}
function parseArgs(argv) {
let alias = "";
for (const arg of argv) {
if (arg.startsWith("--alias=")) {
alias = arg.slice("--alias=".length).trim().toUpperCase();
continue;
}
throw new LiveOcrSampleError("unsupported_argument");
}
if (!SAFE_ALIAS_PATTERN.test(alias)) {
throw new LiveOcrSampleError("sample_alias_invalid");
}
return { alias };
}
function getTencentConfig() {
return {
ocrProvider: "tencent",
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 validateTencentConfig(config) {
if (!config?.tencentSecretId || !config?.tencentSecretKey) {
throw new LiveOcrSampleError("ocr_credentials_missing");
}
if (config.ocrProvider !== "tencent") {
throw new LiveOcrSampleError("ocr_provider_invalid");
}
}
async function readBoundedStdin(input, maxBytes = OCR_PDF_MAX_BYTES) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of input) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > maxBytes) {
throw new LiveOcrSampleError("sample_too_large");
}
chunks.push(buffer);
}
if (totalBytes === 0) {
throw new LiveOcrSampleError("sample_empty");
}
return Buffer.concat(chunks, totalBytes);
}
function validatePdfBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new LiveOcrSampleError("sample_empty");
}
if (buffer.length > OCR_PDF_MAX_BYTES) {
throw new LiveOcrSampleError("sample_too_large");
}
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 16));
if (!/^%PDF-(?:1\.[0-7]|2\.0)(?:\r\n|\r|\n)/u.test(header)) {
throw new LiveOcrSampleError("sample_pdf_invalid");
}
let lastContentIndex = buffer.length - 1;
while (
lastContentIndex >= 0 &&
[0x09, 0x0a, 0x0c, 0x0d, 0x20].includes(buffer[lastContentIndex])
) {
lastContentIndex -= 1;
}
const markerStart = lastContentIndex - PDF_EOF_MARKER.length + 1;
if (
markerStart < 0 ||
!buffer.subarray(markerStart, lastContentIndex + 1).equals(PDF_EOF_MARKER)
) {
throw new LiveOcrSampleError("sample_pdf_invalid");
}
}
function safeToken(value) {
return typeof value === "string" && SAFE_TOKEN_PATTERN.test(value) ? value : null;
}
function safeErrorCode(error) {
const code = typeof error?.code === "string" ? error.code : "provider_failed";
return SAFE_TOKEN_PATTERN.test(code) ? code : "provider_failed";
}
function buildFieldHits(normalized) {
return Object.fromEntries(
SAFE_FIELD_NAMES.map((field) => [field, normalized?.[field] != null && normalized[field] !== ""]),
);
}
function buildTrainAmountDiagnostics(providerResult) {
if (providerResult?.documentCategory !== "electronic_train_ticket") {
return null;
}
const source = providerResult?.diagnostics?.trainAmountFields;
const booleanValue = (field, key) => source?.[field]?.[key] === true;
return {
totalPresent: booleanValue("total", "present"),
totalIsString: booleanValue("total", "isString"),
totalIsNumber: booleanValue("total", "isNumber"),
totalNonEmpty: booleanValue("total", "nonEmpty"),
totalParseable: booleanValue("total", "parseable"),
farePresent: booleanValue("fare", "present"),
fareIsString: booleanValue("fare", "isString"),
fareIsNumber: booleanValue("fare", "isNumber"),
fareNonEmpty: booleanValue("fare", "nonEmpty"),
fareParseable: booleanValue("fare", "parseable"),
};
}
function buildErrorSummary(alias, error) {
return {
alias: SAFE_ALIAS_PATTERN.test(alias) ? alias : "SAMPLE-PDF-UNKNOWN",
provider: "tencent",
status: "HOLD",
transportMode: "pdf_native",
errorCode: safeErrorCode(error),
};
}
async function runLiveSampleCheck({
alias,
buffer,
config,
runOcr = runOcrForInvoice,
now = () => Date.now(),
}) {
if (!SAFE_ALIAS_PATTERN.test(alias)) {
throw new LiveOcrSampleError("sample_alias_invalid");
}
validatePdfBuffer(buffer);
validateTencentConfig(config);
const startedAt = now();
const providerResult = await runOcr({
config,
ocrType: "vat",
imageBase64: buffer.toString("base64"),
sourceMimeType: PDF_MIME_TYPE,
});
const normalized = sanitizeOcrResultForPersistence("tencent", providerResult);
const qualityError = buildMissingRequiredOcrError("vat", normalized);
return {
alias,
provider: "tencent",
status: qualityError ? "HOLD" : "PASS",
transportMode: normalized.transportMode === "pdf_native" ? "pdf_native" : null,
elapsedMs: Math.max(0, now() - startedAt),
documentCategory: safeToken(normalized.documentCategory),
documentSubtype: safeToken(normalized.documentSubtype),
documentCount: Number.isInteger(normalized.documentCount) ? normalized.documentCount : null,
fieldHits: buildFieldHits(normalized),
trainAmountDiagnostics: buildTrainAmountDiagnostics(providerResult),
missingRequiredFields: qualityError?.startsWith("missing_required_fields:")
? qualityError.slice("missing_required_fields:".length).split(",").filter(Boolean)
: [],
errorCode: qualityError && !qualityError.startsWith("missing_required_fields:")
? safeToken(qualityError) || "invoice_quality_failed"
: null,
};
}
async function main() {
let alias = "SAMPLE-PDF-UNKNOWN";
try {
({ alias } = parseArgs(process.argv.slice(2)));
const buffer = await readBoundedStdin(process.stdin);
const summary = await runLiveSampleCheck({
alias,
buffer,
config: getTencentConfig(),
});
process.stdout.write(`${JSON.stringify(summary)}\n`);
if (summary.status !== "PASS") {
process.exitCode = 1;
}
} catch (error) {
process.stdout.write(`${JSON.stringify(buildErrorSummary(alias, error))}\n`);
process.exitCode = 1;
}
}
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
if (import.meta.url === invokedPath) {
await main();
}
export {
LiveOcrSampleError,
buildErrorSummary,
buildTrainAmountDiagnostics,
parseArgs,
readBoundedStdin,
runLiveSampleCheck,
validatePdfBuffer,
};