219 行
6.2 KiB
JavaScript
219 行
6.2 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 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),
|
|
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,
|
|
parseArgs,
|
|
readBoundedStdin,
|
|
runLiveSampleCheck,
|
|
validatePdfBuffer,
|
|
};
|