Python में पासपोर्ट रीडर पहली बार लगभग बीस पंक्तियों का होता है, और जब उसे प्रोडक्शन में टिकना होता है तो लगभग सौ पंक्तियों का। अतिरिक्त अस्सी पंक्तियाँ OCR के बारे में नहीं हैं। वे उन तीन सवालों के बारे में हैं जो हर पेड API उठाता है: फ़ोटो ख़राब हो तो क्या होता है, अनुरोध टाइमआउट हो जाए और आप उसे दोबारा भेजें तो क्या होता है, और आपके अपने रिकॉर्ड कैसे जानते हैं कि किन कॉल पर पैसे लगे।

यह ट्यूटोरियल वह स्क्रिप्ट केवल requests से बनाता है। इसमें doc.cheap का उपयोग होता है, जो एक पासपोर्ट और आईडी OCR API है, और यह doc.cheap का अपना ब्लॉग है, इसलिए प्रोडक्ट से जुड़े चुनावों को इसी नज़र से परखें। ये पैटर्न (हर इमेज के लिए एक idempotency key, एक स्थिर त्रुटि कोड पर ब्रांचिंग, हर कॉल के साथ लागत का फ़्लैग सहेजना) Python से कॉल किए जाने वाले किसी भी पेड API पर लागू होते हैं।

सैंडबॉक्स कुंजी के साथ एक अनुरोध

दस्तावेज़ में एक सार्वजनिक सैंडबॉक्स कुंजी दी गई है: sk_sandbox_public। यह पेड कुंजी जैसी ही पहचान चलाती है और इसके लिए खाते की ज़रूरत नहीं: हर IP पते पर कुल 10 मुफ़्त पहचाने गए दस्तावेज़, और जवाब कुछ भी हो, एक घंटे में अधिकतम 10 अनुरोध। मुफ़्त पासपोर्ट OCR API पेज पर यही पहला कॉल एक 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। कॉल सिंक्रोनस है: फ़ील्ड इसी जवाब में लौटते हैं, न कोई job id जिसे बार-बार जाँचना पड़े, न कोई webhook जिसे होस्ट करना पड़े।

किसी सिंथेटिक नमूने से टेस्ट करें, अपने पासपोर्ट से कभी नहीं। ICAO के काल्पनिक "Utopia" दस्तावेज़ और कई जारीकर्ताओं के प्रकाशित नमूना पेज ठीक इसी काम के लिए हैं।

पहले फ़ोटो छोटी करें। फ़ोन की एक तस्वीर base64 के एक-तिहाई जोड़ने से पहले ही कई मेगाबाइट की हो सकती है। पासपोर्ट गाइड लंबे किनारे पर लगभग 1600 px और JPEG गुणवत्ता 85 की सलाह देती है; सीमा से बड़ी बॉडी कुछ भी चलने से पहले payload_too_large के साथ अस्वीकार हो जाती है।

जवाब पढ़ना

जवाब की हर कुंजी हमेशा मौजूद रहती है, और जो मान ज्ञात नहीं है वह json() के बाद None होता है, कभी ग़ायब कुंजी नहीं। चार हिस्से तय करते हैं कि आपका कोड आगे क्या करेगा:

  • 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 पर exception फेंकेंगे तो आपका रीट्राई लूप ऐसी फ़ोटो दोबारा भेजता रहेगा जो कभी नहीं पढ़ी जाएगी। इसे सफलता मानेंगे तो आप बिना फ़ील्ड का दस्तावेज़ सहेज लेंगे।

billed फ़्लैग

लाइव कुंजी पर billed तभी True होता है जब कोई दस्तावेज़ सचमुच पहचाना गया हो। कुछ न मिलना, न पढ़ी जा सकने वाली इमेज, असमर्थित प्रकार, सेवा की ओर से कोई गड़बड़ी: इनमें से किसी का शुल्क नहीं लगता। शुल्क वाला एक दस्तावेज़ हर वॉल्यूम पर एक समान $0.01 का है; पासपोर्ट OCR API तुलना इसे दूसरी सेवाओं की प्रकाशित क़ीमतों के साथ रखती है।

सैंडबॉक्स कोई शुल्क नहीं लेता। sk_sandbox_public पर भी billed बताता है कि वही स्कैन लाइव कुंजी पर शुल्क वाला होता या नहीं, और यही इसे टेस्ट करने लायक बनाता है। हर नतीजे के साथ फ़्लैग सहेजें: फिर महीने भर की अपनी billed पंक्तियों का जोड़ निकालने के लिए इनवॉइस से मिलान की ज़रूरत नहीं पड़ती।

ऐसे रीट्राई जो दो बार शुल्क नहीं ले सकते

जोखिम वाला रीट्राई टाइमआउट के बाद वाला होता है। आपको नहीं पता कि पहला अनुरोध सर्वर तक पहुँचा या नहीं, और पेड API पर आँख मूँदकर किया गया रीट्राई एक इमेज के लिए दो बार भुगतान करा सकता है।

इसका जवाब POST /v1/scans पर Idempotency-Key हेडर है, 1 से 255 अक्षर। इसे हर इमेज के लिए एक बार बनाएँ और हर प्रयास में वही मान भेजें। लाइव कुंजी पर, उसी कुंजी और उसी बॉडी के साथ किया गया रीट्राई दूसरी पहचान की जगह पहला नतीजा वापस पाता है, और इस दोहराव पर कोई ख़र्च नहीं। संदर्भ दस्तावेज़ के तीन नियम कोड को आकार देते हैं:

  • कुंजी बॉडी से बंधी है। वही कुंजी अलग इमेज या अलग विकल्पों के साथ 409 idempotency_conflict देती है। बॉडी एक बार, लूप के बाहर बनाएँ।
  • दूसरा प्रयास तब भी आ सकता है जब पहला अभी चल रहा हो। यह 409 idempotency_in_progress है: रुकें और उसी कुंजी से दोबारा कोशिश करें।
  • सहेजा नतीजा नहीं, तो दोहराव नहीं। retain_hours: 0 के साथ कुछ भी सहेजा नहीं जाता, इसलिए 24 घंटे के भीतर उसी कुंजी से किया गया रीट्राई दूसरी बार चलने की जगह 409 idempotency_replay_unavailable पाता है। कुछ न रखना और नतीजा दोहराना साथ नहीं चलते; हर उपयोग के हिसाब से चुनें।

सैंडबॉक्स कुंजियाँ हेडर स्वीकार करती हैं, पर वहाँ वह कुछ तय नहीं करता, क्योंकि शुल्क कुछ भी नहीं लगता। फिर भी यह कोड पाथ टेस्ट करने लायक है।

किन त्रुटियों पर रीट्राई करना ठीक है। हर त्रुटि बॉडी का आकार एक जैसा होता है (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 पूरे सेकंड में होता है। सैंडबॉक्स की घंटे वाली सीमा लगभग एक घंटे का इंतज़ार माँग सकती है, और कोई भी कॉलर नहीं चाहता कि एक फ़ंक्शन कॉल इतनी देर सोए, इसलिए नीचे का क्लाइंट इंतज़ार एक मिनट से ज़्यादा होने पर हार मान लेता है।

पूरा क्लाइंट

# 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 पर exception नहीं फेंकता, जब तक आप raise_for_status() न बुलाएँ। क्लाइंट हर हाल में JSON बॉडी पढ़ता है, क्योंकि कोड त्रुटि बॉडी में होता है; raise_for_status() उसे फेंक देता।
  • बॉडी और कुंजी लूप से पहले एक बार बनती हैं। इसी से सर्वर की नज़र में हर प्रयास एक ही अनुरोध होता है।
  • Session कनेक्शन को दोबारा इस्तेमाल करता है, रीट्राई के बीच और इमेज के पूरे बैच में। कई फ़ाइलें स्कैन करते समय एक Session पास करें।
  • return_portrait: False धारक की फ़ोटो का क्रॉप छोड़ देता है। आपको पेज का क्रॉप और फ़ील्ड मिलते हैं, और आपके लॉग और स्टोरेज में एक चेहरा कम रहता है।
  • reference meta.reference के रूप में लौटता है (128 अक्षर तक): किसी स्कैन को अपने ऑर्डर या उपयोगकर्ता से जोड़ने का आसान तरीक़ा। आईडी कार्ड के दो पहलू दो कॉल हैं; दोनों को वही reference दें।
  • अनुरोध की बॉडी कभी लॉग न करें। वह एक पहचान दस्तावेज़ है। code, request_id और docs_url लॉग करें; सपोर्ट को बस इतना ही चाहिए।

कम डेटा रखना

अपलोड की गई इमेज अनुरोध के दौरान मेमोरी में रहती है और कभी स्थायी स्टोरेज में नहीं लिखी जाती। नतीजा इसलिए रखा जाता है कि आप उसे GET /v1/scans/{id} से फिर पा सकें, उतनी अवधि तक जितनी आप चुनें: हर अनुरोध में options.retain_hours 0 से 8760 तक लेता है, और 0 कुछ भी नहीं रखता। अगर JSON की ज़रूरत सिर्फ़ एक बार है, तो retain_hours: 0 भेजें और ऊपर बताया दोहराव वाला समझौता स्वीकार करें। प्रोसेसिंग EU में होती है।

लाइव होना

DOC_CHEAP_API_KEY में अपनी कुंजी डालें, और कुछ नहीं बदलता: वही endpoint, जवाब का वही आकार, वही क्लाइंट। एक पंजीकृत कुंजी मिनट में 60 अनुरोधों की अनुमति देती है, इसलिए Retry-After का पालन करने वाले बैच जॉब को अपने rate limiter की ज़रूरत नहीं पड़ेगी। क्रेडिट क्रिप्टोकरेंसी (BTC, ETH, TRX, या Ethereum अथवा Tron पर USDT) से $1 से ख़रीदे जाते हैं; आज कार्ड से भुगतान उपलब्ध नहीं है।

अगर जवाब में कुछ Python से संभालना मुश्किल लगे, तो admin@doc.cheap पर लिखें।