Pembaca paspor di Python kira-kira dua puluh baris pada percobaan pertama, dan kira-kira seratus baris ketika harus bertahan di produksi. Delapan puluh baris tambahan itu bukan soal OCR. Semuanya soal tiga pertanyaan yang muncul di setiap API berbayar: apa yang terjadi kalau fotonya buruk, apa yang terjadi kalau request kena timeout lalu Anda mengirimnya lagi, dan bagaimana catatan Anda sendiri tahu panggilan mana yang memakan biaya.

Tutorial ini membangun skrip tersebut hanya dengan requests. Skrip ini memakai doc.cheap, sebuah API OCR paspor dan kartu identitas, dan ini adalah blog milik doc.cheap sendiri, jadi timbang pilihan produknya dengan mempertimbangkan hal itu. Polanya (satu idempotency key per gambar, percabangan berdasarkan kode error yang stabil, menyimpan flag biaya per panggilan) berlaku untuk API berbayar apa pun yang Anda panggil dari Python.

Satu request dengan kunci sandbox

Dokumentasi mencantumkan kunci sandbox publik, sk_sandbox_public. Kunci ini menjalankan pengenalan yang sama dengan kunci berbayar dan tidak memerlukan akun: total 10 dokumen yang dikenali gratis per alamat IP, dan paling banyak 10 request per jam apa pun jawabannya. Halaman API OCR paspor gratis menampilkan panggilan pertama yang sama sebagai satu perintah curl. Nanti, sebuah akun menambahkan 100 dokumen gratis setiap bulan, tanpa kartu.

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= mengatur Content-Type: application/json untuk Anda. Gambar dikirim sebagai base64 di dalam body, dalam format JPEG atau PNG. Panggilannya sinkron: field langsung kembali di response ini, tanpa job id yang perlu di-poll dan tanpa webhook yang perlu di-host.

Uji dengan spesimen sintetis, jangan pernah dengan paspor Anda sendiri. Dokumen fiktif "Utopia" dari ICAO dan halaman spesimen yang diterbitkan banyak penerbit dokumen memang ada untuk keperluan ini.

Perkecil fotonya dulu. Foto dari ponsel bisa berukuran beberapa megabyte sebelum base64 menambah sepertiganya. Panduan paspor menyarankan sekitar 1600 px di sisi terpanjang dengan kualitas JPEG 85; body yang melewati batas ditolak dengan payload_too_large sebelum apa pun dijalankan.

Membaca jawabannya

Setiap key dalam response selalu ada, dan nilai yang tidak diketahui menjadi None setelah json(), tidak pernah berupa key yang hilang. Empat bagian menentukan langkah kode Anda selanjutnya:

  • meta.status menyatakan apakah dokumen berhasil dibaca. Nilainya salah satu dari lima string: recognized, no_document_found, unreadable, unsupported_document, rejected. Hanya yang pertama membawa data.
  • document dan holder berisi nilai yang sudah dirapikan: document.kind (passport, kartu identitas, dan seterusnya), negara dalam ISO 3166-1 alpha-3, nomor dokumen, tanggal dalam format ISO YYYY-MM-DD; holder.surname, holder.given_names, holder.birth_date. Setiap grup bernilai None secara utuh ketika pemindaian tidak menghasilkan apa pun untuknya.
  • mrz.status bernilai passed, failed atau absent: apakah zona baca mesin ditemukan dan check digit-nya cocok. mrz.text adalah zona sebagaimana terbaca, jadi Anda bisa menghitung check digit sendiri.
  • meta.billed menyatakan apakah panggilan ini ditagihkan ke saldo.

Poin yang membentuk seluruh klien: foto yang tidak bisa dibaca adalah HTTP 200, bukan error. Jadi kode bercabang dua kali: berdasarkan kode error HTTP untuk penolakan, dan berdasarkan meta.status untuk hasil. Kalau Anda melempar exception pada no_document_found, loop retry Anda akan mengirim ulang foto yang tidak akan pernah terbaca. Kalau Anda menganggapnya sukses, Anda akan menyimpan dokumen tanpa field.

Flag billed

Dengan kunci live, billed bernilai True hanya ketika sebuah dokumen benar-benar dikenali. Tidak ada yang ditemukan, gambar tidak terbaca, jenis yang tidak didukung, kegagalan di sisi layanan: tidak satu pun ditagih. Satu dokumen yang ditagih berharga $0.01, tarif tetap, berapa pun volumenya; perbandingan API OCR paspor menaruh angka itu di samping harga yang dipublikasikan layanan lain.

Sandbox sama sekali tidak menagih apa pun. Dengan sk_sandbox_public, billed tetap memberi tahu apakah pemindaian yang sama akan ditagih dengan kunci live, dan itulah yang membuatnya layak dipakai untuk pengujian. Simpan flag ini di samping setiap hasil: menjumlahkan baris billed Anda sendiri selama sebulan tidak lagi memerlukan rekonsiliasi dengan invoice.

Retry yang tidak bisa menagih dua kali

Retry yang berisiko adalah retry setelah timeout. Anda tidak tahu apakah request pertama sampai ke server, dan pada API berbayar, retry yang membabi buta bisa membayar dua kali untuk satu gambar.

Solusinya adalah header Idempotency-Key pada POST /v1/scans, sepanjang 1 sampai 255 karakter. Buat sekali per gambar dan kirim nilai yang sama pada setiap percobaan. Dengan kunci live, retry dengan key yang sama dan body yang sama mendapatkan kembali hasil pertama alih-alih pengenalan kedua, dan replay itu tidak dikenai biaya. Tiga aturan dari referensi membentuk kodenya:

  • Key terikat pada body. Key yang sama dengan gambar lain atau opsi lain menghasilkan 409 idempotency_conflict. Bangun body sekali saja, di luar loop.
  • Percobaan kedua bisa tiba saat yang pertama masih berjalan. Itulah 409 idempotency_in_progress: tunggu, lalu retry dengan key yang sama.
  • Tanpa hasil yang tersimpan, tidak ada replay. Dengan retain_hours: 0 tidak ada yang disimpan, jadi retry dengan key yang sama dalam 24 jam mendapatkan 409 idempotency_replay_unavailable, bukan eksekusi kedua. Tidak menyimpan apa pun dan memutar ulang hasil tidak bisa berjalan bersamaan; pilih sesuai kasus penggunaan.

Kunci sandbox menerima header ini, tetapi di sana header itu tidak menentukan apa pun, karena tidak ada yang ditagih. Jalur kode ini tetap layak diuji.

Error mana yang layak di-retry. Setiap body error punya bentuk yang sama (code, message, docs_url, request_id, event_id), dan kodenya yang menjadi dasar percabangan, karena satu status HTTP bisa membawa kode yang perlu ditangani secara berlawanan. Panduan menangani error mengelompokkannya seperti ini:

Kode Yang harus dilakukan
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Tunggu (patuhi Retry-After), lalu retry
idempotency_in_progress Tunggu, retry dengan key yang sama
validation_failed, invalid_request, payload_too_large, unsupported_media_type Perbaiki request; retry akan gagal lagi
unauthorized, registration_required, insufficient_credits Perbaiki kunci atau akunnya; menunggu tidak mengubah apa pun

Retry-After dinyatakan dalam detik bulat. Batas per jam di sandbox bisa meminta waktu tunggu hampir satu jam, dan tidak ada pemanggil yang ingin satu panggilan fungsi tidur selama itu, jadi klien di bawah menyerah ketika waktu tunggunya lebih dari satu menit.

Klien lengkap

# 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  # detik; Retry-After yang lebih lama dilaporkan, tidak ditunggu


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()),  # satu key per gambar, dipakai ulang di setiap percobaan
    }

    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):
            # Timeout, koneksi terputus, atau proxy yang menjawab dengan 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)

Jalankan dengan python scan.py specimen.jpg. Beberapa pilihan yang perlu dijelaskan:

  • requests tidak melempar exception pada 4xx atau 5xx kecuali Anda memanggil raise_for_status(). Klien membaca body JSON dalam kedua kasus, karena body error membawa kodenya; raise_for_status() akan membuang informasi itu.
  • Body dan key dibangun sekali, sebelum loop. Itulah yang membuat setiap percobaan menjadi request yang sama di mata server.
  • Session memakai ulang koneksi di antara retry dan di sepanjang satu batch gambar. Berikan satu Session ketika Anda memindai banyak file.
  • return_portrait: False menghilangkan potongan foto pemegang dokumen. Anda tetap mendapat potongan halaman dan field-nya, dan ada satu wajah lebih sedikit di log dan penyimpanan Anda.
  • reference kembali sebagai meta.reference (hingga 128 karakter): cara mudah untuk menghubungkan pemindaian dengan pesanan atau pengguna Anda sendiri. Dua sisi kartu identitas adalah dua panggilan; beri keduanya reference yang sama.
  • Jangan pernah mencatat body request ke log. Isinya adalah dokumen identitas. Catat code, request_id dan docs_url; hanya itu yang dibutuhkan tim support.

Menyimpan lebih sedikit data

Gambar yang diunggah disimpan di memori selama request berlangsung dan tidak pernah ditulis ke penyimpanan permanen. Hasilnya disimpan agar Anda bisa mengambilnya lagi dengan GET /v1/scans/{id}, selama jangka waktu yang Anda pilih: per request, options.retain_hours menerima 0 sampai 8760, dan 0 tidak menyimpan apa pun. Kalau Anda hanya butuh JSON-nya sekali, kirim retain_hours: 0 dan terima konsekuensi soal replay di atas. Pemrosesan dilakukan di UE.

Beralih ke live

Isi DOC_CHEAP_API_KEY dengan kunci Anda sendiri dan tidak ada lagi yang berubah: endpoint yang sama, bentuk response yang sama, klien yang sama. Kunci terdaftar mengizinkan 60 request per menit, jadi job batch yang mematuhi Retry-After tidak memerlukan rate limiter sendiri. Kredit dibeli dengan mata uang kripto (BTC, ETH, TRX, atau USDT di Ethereum atau Tron) mulai dari $1; saat ini belum ada pembayaran dengan kartu.

Kalau ada bagian response yang merepotkan untuk ditangani dari Python, tulis ke admin@doc.cheap.