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.statusmenyatakan apakah dokumen berhasil dibaca. Nilainya salah satu dari lima string:recognized,no_document_found,unreadable,unsupported_document,rejected. Hanya yang pertama membawa data.documentdanholderberisi nilai yang sudah dirapikan:document.kind(passport, kartu identitas, dan seterusnya), negara dalam ISO 3166-1 alpha-3, nomor dokumen, tanggal dalam format ISOYYYY-MM-DD;holder.surname,holder.given_names,holder.birth_date. Setiap grup bernilaiNonesecara utuh ketika pemindaian tidak menghasilkan apa pun untuknya.mrz.statusbernilaipassed,failedatauabsent: apakah zona baca mesin ditemukan dan check digit-nya cocok.mrz.textadalah zona sebagaimana terbaca, jadi Anda bisa menghitung check digit sendiri.meta.billedmenyatakan 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: 0tidak ada yang disimpan, jadi retry dengan key yang sama dalam 24 jam mendapatkan409 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:
requeststidak melempar exception pada 4xx atau 5xx kecuali Anda memanggilraise_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.
Sessionmemakai ulang koneksi di antara retry dan di sepanjang satu batch gambar. Berikan satuSessionketika Anda memindai banyak file.return_portrait: Falsemenghilangkan potongan foto pemegang dokumen. Anda tetap mendapat potongan halaman dan field-nya, dan ada satu wajah lebih sedikit di log dan penyimpanan Anda.referencekembali sebagaimeta.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_iddandocs_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.