把护照照片读成结构化数据,这类工作看起来就是“调一下 OCR API”,可一旦放到生产环境,马上就会冒出三个后续问题。照片模糊怎么办?请求超时后重试,是不是就付了两次钱?您自己的记录又如何知道哪些调用花了钱?
本教程用纯 Node.js 18+ 和内置的 fetch 来回答这三个问题,不用 SDK。示例使用的是 doc.cheap,而这正是 doc.cheap 自己的博客,所以对其中的产品选择请保持适当的怀疑。这些模式(幂等键、根据稳定的错误码分支处理、为每次调用保存费用标志)适用于任何付费 API。
第一次调用,无需账户
这个 API 在文档中公开了一个沙箱(sandbox)密钥 sk_sandbox_public。它运行的识别与付费密钥相同,而且无需注册:每个 IP 地址总共可免费识别 10 份证件,每小时最多 10 次请求,无论结果如何。这足以试完下面的所有内容。之后注册还会再送 20 个免费点数,无需绑卡。
import { readFileSync } from "node:fs";
const image = readFileSync("specimen.jpg").toString("base64");
const response = await fetch("https://api.doc.cheap/v1/scans", {
method: "POST",
headers: {
Authorization: "Bearer sk_sandbox_public",
"Content-Type": "application/json",
},
body: JSON.stringify({ image }),
});
const scan = await response.json();
console.log(scan.meta.status, scan.holder?.full_name, scan.mrz.status);
保存为 first.mjs,然后运行 node first.mjs。这个调用是同步的:没有任务 ID,不用轮询,也没有 webhook。识别在请求内完成,字段随响应一起返回。
测试时请使用合成样本,不要用您自己的护照。许多签发机构都公开了样本页,ICAO 虚构的“乌托邦”证件正是为此而存在。
图片大小的影响比您想象的更大。手机相机直出的照片可能有好几 MB,base64 编码还会让它再膨胀约三分之一。文档建议长边约 1600 px、JPEG 质量 85。如果第一次调用感觉很慢,先看看 meta.timing.upload_ms,再去怪别人的服务器。
返回的内容
只有一种 JSON 结构,分八个组,每个键始终存在。未知的值是 null,绝不会缺少键。一个经过删减的识别成功响应如下:
{
"meta": {
"schema_version": "1.0",
"id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
"status": "recognized",
"billed": true,
"confidence": "high",
"timing": { "upload_ms": 214, "processing_ms": 843, "total_ms": 1074 },
"created_at": "2026-09-17T09:41:12Z",
"reference": null
},
"document": {
"kind": "passport", "country": "GRC", "country_name": "Greece",
"number": "AM7304518", "issue_date": "2022-03-10", "expiry_date": "2032-03-10",
"is_expired": false, "days_remaining": 2001
},
"holder": {
"given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA",
"birth_date": "1994-03-08", "sex": "F", "nationality": "GRC"
},
"fields": [],
"mrz": { "status": "passed", "reason": null, "lines": ["P<GRC…", "AM7304518…"], "text": "P<GRC…" },
"images": { "document_crop": "data:image/jpeg;base64,…", "main_photo": "data:image/jpeg;base64,…" },
"quality": { "overall": "pass" },
"authenticity": { "overall": "not_checked", "checks": [] }
}
(持有人是文档中虚构的样本;fields 和 images 已删减。)值得了解的部分:
document和holder是整理后的值。日期采用 ISO 8601,国家采用 ISO 3166-1 alpha-3。如果扫描没有为某个组产生任何内容,整个组就是null,所以上面的代码片段用了?.。fields[]是每一条单独的读取结果,各自带有confidence等级(high、medium、low,而不是看似精确的百分比)。用希腊文印刷的姓名会返回两次,一次是希腊文,一次是转写结果,希腊文那条会标注其语言。mrz.status是passed、failed或absent,mrz.text是原始机读区,方便您自己重新计算校验位。authenticity.overall是not_checked。这是识别,不是伪造检测。不要把它当作身份核验推销给您的合规团队。
照片模糊不是异常
最值得牢记的一点:无法读取的照片返回的是 200,而不是错误。meta.status 只会是以下五个字符串之一:
status |
含义 | 该怎么做 |
|---|---|---|
recognized |
读取成功 | 使用数据 |
no_document_found |
画面中没有像证件的东西 | 请用户重新取景 |
unreadable |
有证件,但没有可用的文字 | 改善光线、对焦、角度 |
unsupported_document |
找到了,但不是 API 支持的类型 | 停止,重试结果相同 |
rejected |
服务端处理失败 | 重试一次 |
所以您的代码要分支两次:错误看 HTTP 状态码,结果看 meta.status。把 no_document_found 当作异常,您就会反复重试一张永远读不出来的照片;把它当作成功,您就会存下一份没有任何字段的证件。
模糊照片由谁买单
每个响应都带有 meta.billed。使用正式(live)密钥时,只有在确定了证件类型并真正提取出数据时它才是 true:MRZ 校验位通过、印刷区至少读出五个字段,或者条形码被正确解码。其他情况(未找到证件、图片无法读取、类型不支持、内部错误、超时)都不收费。计费证件的价格是 $0.01,固定不变,不论用量多少。
使用任一沙箱密钥都完全不收费。使用公共密钥 sk_sandbox_public 时,billed 仍然会告诉您同样的扫描在正式密钥上是否会计费,这正是它适合用来测试这个标志的原因。账户自己的沙箱密钥不会读取您的图片:它对每次调用都用同一份内置样本作答,所以那里的 billed 描述的是这份样本。
这个标志按调用给出,所以请把它和结果存在一起。这样,对于正式密钥,您自己统计的某个自然月(UTC)内 billed: true 的行数,就与 GET /v1/usage 为该月报告的 scans.billed 是同一个数字,无需任何对账步骤。
不会重复扣费的重试
危险的重试是超时之后的那一次:您不知道第一次请求是否已经送达。API 在 POST /v1/scans 上接受 Idempotency-Key 请求头(1 到 255 个字符)。使用正式密钥时,以相同的键和相同的请求体重试,会返回已保存的第一次结果(不含图片裁剪),而不会再次运行识别、再次扣费。
参考文档中有三个细节,会改变您编写客户端的方式:
- 键会连同整个请求体的指纹一起匹配。相同的键、不同的图片会得到
409 idempotency_conflict,而不是悄无声息地重放。 - 键在结果被保存期间有效。如果您发送
retain_hours: 0(什么都不保存),键仍会保留 24 小时:在此期间用同一个键重试会得到409 idempotency_replay_unavailable,扫描不会运行两次,但也没有可返回的内容。24 小时之后,用同一个键重试就是一次新的扫描。零保存与可重放的重试不可兼得,请按使用场景二选一。 - 使用沙箱密钥时,请求头会被接受但不起作用,因为沙箱本来就不计费。您仍然可以编写并测试这段代码路径。
完整的客户端
这是我们会放进服务里的版本。它为每张图片生成一个键,并在每次重试时复用;只重试文档标明可重试的错误码;遵循 Retry-After 最多等待一分钟,更长就放弃;并返回原始扫描结果,让您保留所有字段。
// scan.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const API = "https://api.doc.cheap/v1/scans";
const KEY = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const RETRY = new Set([
"rate_limited", "document_repeated", "internal_error", "engine_unavailable",
"service_unavailable", "maintenance", "idempotency_in_progress",
]);
export class ScanError extends Error {
constructor(status, error) {
super(`${error.code} (${status}): ${error.message}`);
this.code = error.code;
this.docsUrl = error.docs_url;
this.requestId = error.request_id;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function scan(path, { reference = null, attempts = 4 } = {}) {
const image = (await readFile(path)).toString("base64");
const body = JSON.stringify({ image, reference, options: { return_portrait: false } });
const idempotencyKey = randomUUID(); // 这张图片只用一个键,每次重试都复用它
for (let attempt = 1; ; attempt++) {
let response;
try {
response = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
signal: AbortSignal.timeout(60_000),
});
} catch (networkError) {
if (attempt >= attempts) throw networkError;
await sleep(1000 * 2 ** attempt);
continue;
}
// 中间的代理可能返回 HTML;按网络错误处理。
const payload = await response.json().catch(() => null);
if (payload === null) {
if (attempt >= attempts) throw new Error(`HTTP ${response.status} without a JSON body`);
await sleep(1000 * 2 ** attempt);
continue;
}
if (response.ok) return payload;
const { error } = payload;
if (!RETRY.has(error.code) || attempt >= attempts) throw new ScanError(response.status, error);
// 沙箱的每小时限额可能要求等待长达一小时;在一次调用里等这么久
// 对谁都没有好处,所以超过一分钟就当作错误。
const retryAfter = Number(response.headers.get("Retry-After"));
const wait = retryAfter > 0 ? retryAfter : 2 ** attempt;
if (wait > 60) throw new ScanError(response.status, error);
await sleep(1000 * wait);
}
}
export function summarise({ meta, document, holder, mrz }) {
if (meta.status !== "recognized") {
return { ok: false, status: meta.status, billed: meta.billed };
}
return {
ok: true,
billed: meta.billed,
confidence: meta.confidence,
kind: document?.kind ?? null,
country: document?.country ?? null,
number: document?.number ?? null,
expiryDate: document?.expiry_date ?? null,
isExpired: document?.is_expired ?? null,
surname: holder?.surname ?? null,
givenNames: holder?.given_names ?? null,
birthDate: holder?.birth_date ?? null,
mrz: mrz.status,
mrzReason: mrz.reason,
};
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const result = await scan(process.argv[2], { reference: "demo-1" });
console.log(summarise(result), result.meta.timing);
} catch (err) {
if (err instanceof ScanError) console.error(err.message, err.docsUrl, err.requestId);
else throw err;
process.exitCode = 1;
}
}
用 node scan.mjs specimen.jpg 运行。下面是 2026 年 9 月 24 日用公共沙箱密钥扫描一本生成的测试护照时它的输出。从证件上读出的值已替换为 …;其余内容与原始输出完全一致:
{
ok: true,
billed: true,
confidence: 'medium',
kind: 'passport',
country: '…',
number: '…',
expiryDate: '…',
isExpired: false,
surname: '…',
givenNames: '…',
birthDate: '…',
mrz: 'passed',
mrzReason: null
} { upload_ms: 271, processing_ms: 410, total_ms: 691 }
沙箱密钥上的 billed: true 并不是扣费:它表示这次扫描在正式密钥上会消耗一个点数。
有几个选择值得解释一下:
fetch遇到 4xx 或 5xx 不会抛出异常。它只在网络故障时抛出,所以客户端会检查response.ok,并且无论哪种情况都会读取 JSON 错误体。- 根据
error.code分支,绝不要根据 HTTP 状态码。在POST /v1/scans上,三个幂等相关的错误码共用409,三种不同的服务中断共用503,它们需要不同的处理。每个错误体的结构都相同:code、message、docs_url、request_id、event_id。请记录request_id,技术支持需要它。完整的表格见 handle errors 指南(英文)。 - 并非所有错误都可以重试。
validation_failed、payload_too_large、unauthorized和insufficient_credits需要修正,而不是循环重试。在沙箱上您还会遇到registration_required(免费额度已用完,等待也不会恢复)和document_repeated(同一张图片在一小时内发送次数过多)。 - 不要把请求体写进日志。它是一份身份证件。
reference会原样作为meta.reference返回(最多 128 个字符),这是把扫描与您自己的订单或用户记录关联起来的简便方法。身份证的正反两面是两次调用,请给它们相同的reference。
少保存数据
上传的图片在请求期间保存在内存中,从不写入持久存储。结果则是另一回事:它会在您选择的时长内被保存,以便您用 GET /v1/scans/{id} 再次读取。账户设置提供 24 小时、7 天、30 天或一年,新账户的默认值是一年。按请求设置时,options.retain_hours 可取 0 到 8760;0 表示完全不写入任何记录。如果您只需要用一次 JSON,就发送 retain_hours: 0,并接受上面所说的幂等性取舍。处理在欧盟境内进行。
上面的客户端使用了 return_portrait: false,它会省略 images.main_photo,即持有人照片的裁剪图,这样您在自己的日志和存储中就少了一样需要小心处理的东西。整页的裁剪图仍会返回。
正式上线
通过 DOC_CHEAP_API_KEY 把 sk_sandbox_public 换成您自己的密钥,其他什么都不用改:同一个端点,同样的结构。注册后的密钥每分钟允许 60 次请求。点数用加密货币购买(BTC、ETH、TRX,或 Ethereum、Tron 上的 USDT),最低 $1;目前不支持银行卡付款,如果您打算给财务团队做演示,最好事先知道这一点。价格页面列出了唯一的价格以及只为识别成功付费的规则,免费护照 OCR API 页面则给出了无需注册的第一次调用,只需一条 curl 命令。
如果您试用后发现响应结构中有什么地方在 Node 里用起来别扭,请写信至 admin@doc.cheap。这正是我们想要的反馈。
上面的客户端已在真实的沙箱上运行,输出按原样粘贴;关于 doc.cheap 的每一项说法都已对照其代码核实。