قارئ جوازات السفر في Python يقارب عشرين سطرًا في المرة الأولى، ونحو مئة سطر حين يجب أن يصمد في بيئة الإنتاج. الأسطر الثمانون الإضافية لا علاقة لها بالتعرف الضوئي. إنها تتعلق بثلاثة أسئلة تطرحها كل واجهة API مدفوعة: ماذا يحدث حين تكون الصورة رديئة، وماذا يحدث حين تنتهي مهلة طلب فترسله مجددًا، وكيف تعرف سجلاتك أي الاستدعاءات كلّفت مالًا.

يبني هذا الدرس ذلك السكربت باستخدام requests وحدها. يستخدم doc.cheap، وهي واجهة OCR لجوازات السفر وبطاقات الهوية، وهذه مدونة doc.cheap نفسها، فقيّم اختيارات المنتج على هذا الأساس. أما الأنماط (مفتاح idempotency واحد لكل صورة، والتفرع على رمز خطأ ثابت، وحفظ علامة تكلفة لكل استدعاء) فتنطبق على أي واجهة API مدفوعة تستدعيها من Python.

طلب واحد بمفتاح الـ sandbox

تعرض الوثائق مفتاح sandbox عامًا هو sk_sandbox_public. يشغّل التعرف نفسه الذي يشغّله المفتاح المدفوع ولا يحتاج إلى حساب: 10 مستندات معترف بها مجانًا لكل عنوان IP إجمالًا، و10 طلبات في الساعة كحد أقصى أيًّا كانت الإجابة. وتعرض صفحة واجهة OCR المجانية لجوازات السفر الاستدعاء الأول نفسه كأمر curl واحد. ولاحقًا يضيف الحساب 100 مستند مجاني كل شهر، دون بطاقة.

import base64
import requests

with open("specimen.jpg", "rb") as f:
    image = base64.b64encode(f.read()).decode("ascii")

response = requests.post(
    "https://api.doc.cheap/v1/scans",
    headers={"Authorization": "Bearer sk_sandbox_public"},
    json={"image": image},
    timeout=60,
)
scan = response.json()
print(response.status_code, scan["meta"]["status"], scan["meta"]["billed"])

يضبط json= الترويسة Content-Type: application/json نيابةً عنك. تنتقل الصورة بترميز base64 داخل جسم الطلب، بصيغة JPEG أو PNG. الاستدعاء متزامن: تعود الحقول في هذه الاستجابة نفسها، فلا معرّف مهمة تستطلعه ولا webhook تستضيفه.

اختبر بعينة اصطناعية، ولا تختبر أبدًا بجواز سفرك. فمستندات "Utopia" الوهمية من ICAO وصفحات العينات التي تنشرها جهات إصدار كثيرة موجودة لهذا الغرض تحديدًا.

صغّر الصورة أولًا. قد يبلغ حجم صورة الهاتف عدة ميغابايتات قبل أن يضيف base64 ثلثًا إليها. يوصي دليل جواز السفر بنحو 1600 بكسل على الضلع الأطول بجودة JPEG تساوي 85؛ ويُرفض الجسم الذي يتجاوز الحد بالرمز payload_too_large قبل تشغيل أي شيء.

قراءة الإجابة

كل مفاتيح الاستجابة موجودة دائمًا، والقيمة غير المعروفة تكون None بعد json()، لا مفتاحًا مفقودًا أبدًا. أربعة أجزاء تحدد ما سيفعله الكود بعد ذلك:

  • meta.status يخبرك هل قُرئ المستند. وهو واحد من خمس سلاسل: recognized وno_document_found وunreadable وunsupported_document وrejected. الأولى وحدها تحمل بيانات.
  • document وholder يحملان القيم المنقّحة: document.kind (passport أو بطاقة هوية وما إلى ذلك)، والدولة بصيغة ISO 3166-1 alpha-3، والرقم، والتواريخ بصيغة ISO YYYY-MM-DD؛ وholder.surname وholder.given_names وholder.birth_date. وتكون كل مجموعة None بأكملها حين لا ينتج المسح شيئًا لها.
  • mrz.status قيمته passed أو failed أو absent: هل عُثر على المنطقة المقروءة آليًا وهل تتطابق أرقام التحقق فيها. وmrz.text هو المنطقة كما قُرئت، فيمكنك حساب أرقام التحقق بنفسك.
  • meta.billed يخبرك هل خُصم هذا الاستدعاء من الرصيد.

النقطة التي تحدد شكل العميل كله: الصورة التي تعذّرت قراءتها تعيد HTTP 200، لا خطأ. لذلك يتفرع الكود مرتين: على رمز خطأ HTTP لحالات الرفض، وعلى meta.status للنتائج. إن رميت استثناءً عند no_document_found فستعيد حلقة إعادة المحاولة إرسال صورة لن تُقرأ أبدًا. وإن عاملتها كنجاح فستحفظ مستندًا بلا حقول.

علامة billed

مع مفتاح حي، تكون billed مساوية لـ True فقط حين يُتعرّف على مستند فعلًا. عدم العثور على شيء، أو صورة غير مقروءة، أو نوع غير مدعوم، أو عطل من جهة الخدمة: لا شيء من ذلك يُحتسب. المستند المحتسب يكلّف $0.01 بسعر ثابت مهما كان الحجم؛ وتضع مقارنة واجهات OCR لجوازات السفر هذا السعر بجانب الأسعار التي تنشرها الخدمات الأخرى.

لا يحتسب الـ sandbox أي شيء. ومع ذلك تخبرك billed على sk_sandbox_public هل كان المسح نفسه سيُحتسب على مفتاح حي، وهذا ما يجعل الاختبار عليه مفيدًا. احفظ العلامة بجانب كل نتيجة: عندها لا يحتاج جمع صفوف billed الخاصة بك لشهر كامل إلى أي مطابقة مع فاتورة.

إعادة محاولة لا يمكن أن تخصم مرتين

إعادة المحاولة الخطرة هي التي تأتي بعد انتهاء المهلة. فأنت لا تعرف هل وصل الطلب الأول إلى الخادم، وفي واجهة API مدفوعة قد تجعلك إعادة المحاولة العمياء تدفع مرتين عن صورة واحدة.

الحل هو ترويسة Idempotency-Key على POST /v1/scans، بطول من 1 إلى 255 حرفًا. أنشئها مرة واحدة لكل صورة وأرسل القيمة نفسها في كل محاولة. مع مفتاح حي، تتلقى إعادة المحاولة بالمفتاح نفسه والجسم نفسه النتيجة الأولى بدلًا من تعرف ثانٍ، ولا تكلف الإعادة شيئًا. ثلاث قواعد من المرجع تحدد شكل الكود:

  • المفتاح مرتبط بالجسم. المفتاح نفسه مع صورة مختلفة أو خيارات مختلفة يعيد 409 idempotency_conflict. ابنِ الجسم مرة واحدة، خارج الحلقة.
  • قد تصل محاولة ثانية بينما الأولى لا تزال قيد التشغيل. هذه هي 409 idempotency_in_progress: انتظر وأعد المحاولة بالمفتاح نفسه.
  • لا نتيجة محفوظة، لا إعادة. مع retain_hours: 0 لا يُحفظ شيء، لذا فإن إعادة المحاولة بالمفتاح نفسه خلال 24 ساعة تتلقى 409 idempotency_replay_unavailable بدلًا من تشغيل ثانٍ. عدم الاحتفاظ بأي شيء وإعادة نتيجة لا يجتمعان؛ اختر حسب حالة الاستخدام.

مفاتيح الـ sandbox تقبل الترويسة لكنها لا تحسم شيئًا هناك، إذ لا شيء يُحتسب. ومع ذلك يستحق مسار الكود هذا الاختبار.

أي الأخطاء تستحق إعادة المحاولة. لكل جسم خطأ الشكل نفسه (code وmessage وdocs_url وrequest_id وevent_id)، والرمز هو ما يجب التفرع عليه، لأن حالة HTTP واحدة قد تحمل رموزًا تتطلب معالجة متعاكسة. ويقسّمها دليل معالجة الأخطاء إلى مجموعات:

الرموز ما العمل
rate_limited، document_repeated، internal_error، engine_unavailable، service_unavailable، maintenance انتظر (مع احترام Retry-After)، ثم أعد المحاولة
idempotency_in_progress انتظر، وأعد المحاولة بالمفتاح نفسه
validation_failed، invalid_request، payload_too_large، unsupported_media_type أصلح الطلب؛ فإعادة المحاولة ستفشل مجددًا
unauthorized، registration_required، insufficient_credits أصلح المفتاح أو الحساب؛ الانتظار لا يغيّر شيئًا

قيمة Retry-After ثوانٍ صحيحة. قد يطلب الحد الساعي للـ sandbox انتظار معظم ساعة، ولا أحد يريد أن ينام استدعاء دالة واحد كل هذا الوقت، لذا يستسلم العميل أدناه حين يتجاوز الانتظار دقيقة.

العميل كاملًا

# scan.py
import base64
import os
import time
import uuid

import requests

API = "https://api.doc.cheap/v1/scans"
KEY = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public")
RETRY = {
    "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
    "service_unavailable", "maintenance", "idempotency_in_progress",
}
MAX_WAIT = 60  # ثوانٍ؛ قيمة Retry-After الأطول يُبلَّغ عنها ولا يُنتظر انقضاؤها


class ScanError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{error['code']} ({status}): {error['message']}")
        self.code = error["code"]
        self.docs_url = error["docs_url"]
        self.request_id = error["request_id"]


def retry_after(response, attempt):
    try:
        seconds = int(response.headers.get("Retry-After", ""))
    except ValueError:
        seconds = 0
    return seconds if seconds > 0 else 2 ** attempt


def scan(path, reference=None, attempts=4, session=None):
    session = session or requests.Session()
    with open(path, "rb") as f:
        image = base64.b64encode(f.read()).decode("ascii")
    body = {"image": image, "reference": reference, "options": {"return_portrait": False}}
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Idempotency-Key": str(uuid.uuid4()),  # مفتاح واحد لكل صورة، يُعاد استخدامه في كل محاولة
    }

    for attempt in range(1, attempts + 1):
        last = attempt == attempts
        try:
            response = session.post(API, headers=headers, json=body, timeout=60)
            payload = response.json()
        except (requests.RequestException, ValueError):
            # انتهاء مهلة، أو اتصال مقطوع، أو وكيل يجيب بصفحة HTML.
            if last:
                raise
            time.sleep(2 ** attempt)
            continue

        if response.ok:
            return payload
        error = payload["error"]
        if error["code"] not in RETRY or last:
            raise ScanError(response.status_code, error)
        wait = retry_after(response, attempt)
        if wait > MAX_WAIT:
            raise ScanError(response.status_code, error)
        time.sleep(wait)


def summarise(scan):
    meta, document, holder, mrz = scan["meta"], scan["document"], scan["holder"], scan["mrz"]
    if meta["status"] != "recognized":
        return {"ok": False, "status": meta["status"], "billed": meta["billed"]}
    document, holder = document or {}, holder or {}
    return {
        "ok": True,
        "billed": meta["billed"],
        "kind": document.get("kind"),
        "country": document.get("country"),
        "expiry_date": document.get("expiry_date"),
        "surname": holder.get("surname"),
        "birth_date": holder.get("birth_date"),
        "mrz": mrz["status"],
    }


if __name__ == "__main__":
    import sys

    try:
        print(summarise(scan(sys.argv[1], reference="demo-1")))
    except ScanError as err:
        print(err, err.docs_url, err.request_id, file=sys.stderr)
        sys.exit(1)

شغّله بالأمر python scan.py specimen.jpg. بعض الاختيارات تستحق الشرح:

  • لا ترمي requests استثناءً عند 4xx أو 5xx ما لم تستدعِ raise_for_status(). يقرأ العميل جسم JSON في الحالتين، لأن جسم الخطأ هو الذي يحمل الرمز؛ وraise_for_status() كانت ستتخلص منه.
  • يُبنى الجسم والمفتاح مرة واحدة، قبل الحلقة. هذا ما يجعل كل محاولة الطلب نفسه في نظر الخادم.
  • يعيد Session استخدام الاتصال بين محاولات الإعادة وعبر دفعة من الصور. مرّر واحدًا حين تمسح ملفات كثيرة.
  • return_portrait: False يستبعد قصاصة صورة حامل المستند. تحصل على قصاصة الصفحة والحقول، ويقل وجه واحد في سجلاتك ومخزنك.
  • reference يعود بوصفه meta.reference (حتى 128 حرفًا): الطريقة السهلة لربط عملية مسح بطلبك أو بمستخدمك. وجها بطاقة الهوية استدعاءان؛ أعطهما المرجع نفسه.
  • لا تسجّل جسم الطلب أبدًا. إنه مستند هوية. سجّل code وrequest_id وdocs_url؛ فهذا كل ما يحتاجه الدعم.

الاحتفاظ ببيانات أقل

تبقى الصورة المرفوعة في الذاكرة طوال مدة الطلب ولا تُكتب أبدًا في تخزين دائم. أما النتيجة فتُحفظ كي تتمكن من جلبها مجددًا عبر GET /v1/scans/{id}، لمدة تختارها: لكل طلب، يقبل options.retain_hours قيمة من 0 إلى 8760، و0 لا يحفظ شيئًا إطلاقًا. إن كنت تحتاج إلى JSON مرة واحدة فقط، فأرسل retain_hours: 0 واقبل المقايضة المتعلقة بالإعادة الموضحة أعلاه. تجري المعالجة في الاتحاد الأوروبي.

الانتقال إلى البيئة الحية

اضبط DOC_CHEAP_API_KEY على مفتاحك الخاص ولن يتغير شيء آخر: نقطة النهاية نفسها، وشكل الاستجابة نفسه، والعميل نفسه. يسمح المفتاح المسجّل بـ 60 طلبًا في الدقيقة، لذا لن تحتاج مهمة الدفعات التي تحترم Retry-After إلى محدِّد معدل خاص بها. تُشترى الأرصدة بالعملات المشفرة (BTC أو ETH أو TRX أو USDT على Ethereum أو Tron) بدءًا من $1؛ ولا يوجد دفع بالبطاقة حاليًا.

إن كان في الاستجابة شيء يصعب التعامل معه من Python، فاكتب إلى admin@doc.cheap.