قراءة صورة جواز سفر وتحويلها إلى بيانات منظمة من تلك المهام التي تبدو كأنها مجرد «استدعاء لواجهة OCR»، ثم تتحول إلى ثلاثة أسئلة جديدة بمجرد تشغيلها في بيئة الإنتاج. ماذا يحدث حين تكون الصورة ضبابية؟ ماذا يحدث حين تنتهي مهلة الطلب وتعيد المحاولة: هل دفعت للتو مرتين؟ وكيف تعرف سجلاتك أيّ الاستدعاءات كلّفت مالًا؟
يجيب هذا الدرس عن الأسئلة الثلاثة باستخدام Node.js 18+ ودالة fetch المدمجة فقط، دون SDK. يستخدم الدرس doc.cheap، وهذه مدونة doc.cheap نفسها، فتعامل مع اختيارات المنتج بالقدر المناسب من الشك. أما الأنماط (مفاتيح idempotency التي تمنع تنفيذ الطلب نفسه مرتين، والتفرع بحسب رمز خطأ ثابت، والاحتفاظ بمؤشر تكلفة لكل استدعاء) فتنطبق على أي واجهة API مدفوعة.
الاستدعاء الأول، دون حساب
لواجهة API مفتاح sandbox عام منشور في توثيقها، هو sk_sandbox_public. يجري التعرّف نفسه الذي يجريه المفتاح المدفوع ولا يحتاج إلى تسجيل: 10 وثائق مُتعرَّف عليها مجانًا لكل عنوان IP إجمالًا، و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. الاستدعاء متزامن: لا معرّف مهمة، ولا استطلاع دوري (polling)، ولا webhook. يجري التعرّف داخل الطلب وتعود الحقول في استجابته.
استخدم عيّنة اصطناعية للاختبار، لا جواز سفرك. كثير من جهات الإصدار تنشر صفحات عيّنات، ووثائق «يوتوبيا» الوهمية من ICAO موجودة لهذا الغرض تحديدًا.
حجم الصورة أهم مما تظن. قد يبلغ حجم صورة الهاتف القادمة مباشرة من الكاميرا عدة ميغابايتات، وترميز 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 |
عُثر عليها، لكن نوعها غير معروف للواجهة | توقّف، فإعادة المحاولة تقرأ الشيء نفسه |
rejected |
فشل من جهة الخدمة | أعد المحاولة مرة واحدة |
فتتفرع شيفرتك مرتين: بحسب حالة HTTP للأخطاء، وبحسب meta.status للنتائج. إذا عاملت no_document_found كاستثناء فستعيد محاولة صورة لن تُقرأ أبدًا. وإذا عاملتها كنجاح فستخزّن وثيقة بلا حقول.
من يدفع ثمن الصورة الضبابية
تحمل كل استجابة meta.billed. مع مفتاح حقيقي (live) تكون قيمته true حين يُحدَّد نوع الوثيقة وتُستخرج بيانات فعلًا: منطقة MRZ تجتاز أرقام تحققها، أو خمسة حقول على الأقل من المنطقة المطبوعة، أو رمز شريطي فُكّ ترميزه بشكل صحيح. كل ما عدا ذلك (لم يُعثر على وثيقة، صورة غير مقروءة، نوع غير مدعوم، خطأ داخلي، انتهاء المهلة) لا يكلّف شيئًا. سعر الوثيقة المحتسبة $0.01، ثابت، مهما كان الحجم.
مع أي من مفتاحَي الـsandbox لا يُخصم أي شيء إطلاقًا. ومع المفتاح العام sk_sandbox_public يبيّن billed ما إذا كان المسح نفسه سيُحتسب على مفتاح حقيقي، وهذا ما يجعله مفيدًا لاختبار هذا المؤشر. أما مفتاح الـsandbox الخاص بالحساب فلا يقرأ صورتك: بل يجيب عن كل استدعاء بنموذج مدمج واحد، ولذلك يصف billed هناك ذلك النموذج.
المؤشر خاص بكل استدعاء، فخزّنه بجانب النتيجة. وعندها، مع مفتاح حقيقي، يكون عددك أنت لصفوف billed: true في شهر تقويمي (UTC) هو الرقم نفسه الذي يعيده GET /v1/usage في scans.billed لذلك الشهر، دون أي خطوة مطابقة.
إعادة محاولة لا تخصم مرتين
إعادة المحاولة الخطرة هي التي تأتي بعد انتهاء المهلة: فأنت لا تعرف هل وصل الطلب الأول أم لا. تقبل الواجهة الترويسة Idempotency-Key على POST /v1/scans (من 1 إلى 255 محرفًا). مع مفتاح حقيقي، تعيد المحاولة بالمفتاح نفسه والجسم نفسه النتيجة الأولى المخزّنة (دون قصاصات الصور) بدلًا من تشغيل التعرّف والخصم مرة أخرى.
ثلاث تفاصيل من المرجع تغيّر طريقة كتابتك للعميل:
- يُطابَق المفتاح مع بصمة للجسم كله معًا. المفتاح نفسه مع صورة مختلفة يعطي
409 idempotency_conflict، لا إعادة صامتة للنتيجة. - يُحفظ المفتاح طوال مدة تخزين النتيجة. إذا أرسلت
retain_hours: 0(لا تحتفظ بشيء)، فإن المفتاح يبقى محفوظًا مع ذلك 24 ساعة: أي إعادة محاولة به خلالها تحصل على409 idempotency_replay_unavailable، فلا يُشغَّل المسح مرتين، لكن لا يوجد ما يُعاد إليك. وبعد انقضاء هذه الساعات الـ24 تكون إعادة المحاولة بالمفتاح نفسه مسحًا جديدًا. الاحتفاظ الصفري وإعادة المحاولة التي تسترجع النتيجة لا يجتمعان، فاختر أحدهما لكل حالة استخدام. - مع مفتاح الـsandbox تُقبل الترويسة لكن دون أثر، لأن لا شيء يُحتسب هناك. ومع ذلك يمكنك كتابة مسار الشيفرة هذا واختباره.
العميل كاملًا
هذه هي النسخة التي كنا سنضعها في خدمة. تولّد مفتاحًا واحدًا لكل صورة وتعيد استخدامه في كل محاولة، ولا تعيد المحاولة إلا مع رموز الأخطاء التي يقول التوثيق إنها قابلة لإعادة المحاولة، وتحترم 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;
}
// قد يجيب وسيط (proxy) على الطريق بصفحة 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);
// حد الـsandbox بالساعة قد يطلب انتظارًا يصل إلى ساعة؛ والانتظار كل هذا الوقت
// داخل استدعاء واحد لا يفيد أحدًا، لذا يُعد أي انتظار يتجاوز الدقيقة خطأً.
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. هذا ما طبعه لجواز سفر اختباري مُولَّد، باستخدام مفتاح الـsandbox العام، في 24 سبتمبر 2026. القيم المقروءة من الوثيقة استُبدلت بـ…؛ وكل ما عداها كما طُبع بالضبط:
{
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 مع مفتاح sandbox ليس خصمًا: بل يعني أن هذا المسح كان سيكلّف رصيدًا واحدًا على مفتاح حقيقي.
بعض الاختيارات التي تستحق الشرح:
fetchلا يرمي استثناءً عند 4xx أو 5xx. يرميه فقط عند فشل الشبكة، لذا يفحص العميلresponse.okويقرأ جسم الخطأ بصيغة JSON في الحالتين.- تفرّع بحسب
error.code، ولا تتفرع أبدًا بحسب حالة HTTP. علىPOST /v1/scansتتشارك ثلاثة رموز خاصة بـidempotency الحالة409، وتتشارك ثلاثة أعطال مختلفة الحالة503، وكل منها يحتاج إلى معالجة مختلفة. لكل أجسام الأخطاء الشكل نفسه:codeوmessageوdocs_urlوrequest_idوevent_id. سجّلrequest_id؛ فهو ما يحتاجه الدعم. الجدول الكامل في معالجة الأخطاء. - ليس كل شيء قابلًا لإعادة المحاولة.
validation_failedوpayload_too_largeوunauthorizedوinsufficient_creditsتحتاج إلى إصلاح، لا إلى حلقة تكرار. وعلى الـsandbox ستصادف أيضًا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، واقبل المقايضة المتعلقة بـidempotency المذكورة أعلاه. تجري المعالجة في الاتحاد الأوروبي.
return_portrait: false، المستخدم في العميل أعلاه، يستبعد images.main_photo، أي قصاصة صورة صاحب الوثيقة، وهذا شيء أقل يحتاج إلى عناية في سجلاتك وتخزينك. أما قصاصة الصفحة كاملة فما زالت تعود.
الانتقال إلى الإنتاج
استبدل sk_sandbox_public بمفتاحك الخاص عبر DOC_CHEAP_API_KEY ولا شيء آخر يتغير: نقطة النهاية نفسها، والشكل نفسه. المفتاح المسجَّل يسمح بـ60 طلبًا في الدقيقة. تُشترى الأرصدة بالعملات المشفرة (BTC أو ETH أو TRX، أو USDT على Ethereum أو Tron) بحد أدنى $1؛ ولا يوجد دفع بالبطاقة حاليًا، وهذا أمر يستحق المعرفة قبل أن تخطط لعرض توضيحي أمام فريق مالي. تعرض صفحة الأسعار السعر الوحيد وقاعدة الاحتساب عند النجاح فقط، وتعرض صفحة واجهة OCR المجانية لجوازات السفر الاستدعاء الأول دون تسجيل في أمر curl واحد.
إذا جرّبته ووجدت في شكل الاستجابة شيئًا مزعجًا في التعامل معه في Node، فاكتب إلى admin@doc.cheap. هذا بالضبط نوع الملاحظات الذي نبحث عنه.
شُغّل العميل أعلاه على الـsandbox الفعلي ونُسخت مخرجاته كما طُبعت؛ وكل عبارة عن doc.cheap جرى التحقق منها مقابل شيفرتها.