Một trình đọc hộ chiếu bằng Python lần đầu chỉ khoảng hai mươi dòng, nhưng khi phải chạy ổn định trên production thì thành khoảng một trăm dòng. Tám mươi dòng thêm vào không liên quan đến OCR. Chúng trả lời ba câu hỏi mà mọi API trả phí đều đặt ra: chuyện gì xảy ra khi ảnh xấu, chuyện gì xảy ra khi request bị timeout và bạn gửi lại, và hệ thống của bạn làm sao biết lệnh gọi nào tốn tiền.

Bài hướng dẫn này xây dựng script đó chỉ với requests. Bài dùng doc.cheap, một API OCR hộ chiếu và giấy tờ tùy thân, và đây là blog của chính doc.cheap, nên hãy cân nhắc các lựa chọn sản phẩm cho phù hợp. Các mẫu thiết kế (một idempotency key cho mỗi ảnh, rẽ nhánh theo mã lỗi ổn định, lưu cờ chi phí cho từng lệnh gọi) áp dụng được cho bất kỳ API trả phí nào bạn gọi từ Python.

Một request với sandbox key

Tài liệu công bố một sandbox key công khai, sk_sandbox_public. Key này chạy cùng một bộ nhận dạng như key trả phí và không cần tài khoản: tổng cộng 10 tài liệu được nhận dạng miễn phí cho mỗi địa chỉ IP, và tối đa 10 request mỗi giờ bất kể kết quả. Trang API OCR hộ chiếu miễn phí có cùng lệnh gọi đầu tiên dưới dạng một lệnh curl. Khi tạo tài khoản, bạn có thêm 100 tài liệu miễn phí mỗi tháng, không cần thẻ.

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= tự đặt Content-Type: application/json cho bạn. Ảnh được gửi dưới dạng base64 trong body, định dạng JPEG hoặc PNG. Lệnh gọi là đồng bộ: các trường được trả về ngay trong response này, không có job id để poll và không cần dựng webhook.

Hãy thử với mẫu giả lập, đừng bao giờ dùng hộ chiếu của chính bạn. Các tài liệu hư cấu "Utopia" của ICAO và các trang mẫu mà nhiều cơ quan cấp phát công bố tồn tại chính là cho mục đích này.

Thu nhỏ ảnh trước. Một ảnh chụp từ điện thoại có thể nặng vài megabyte, và base64 còn làm tăng thêm một phần ba. Hướng dẫn hộ chiếu khuyến nghị khoảng 1600 px ở cạnh dài với chất lượng JPEG 85; body vượt giới hạn sẽ bị từ chối với payload_too_large trước khi xử lý.

Đọc kết quả trả về

Mọi khóa của response luôn có mặt, và giá trị chưa biết sẽ là None sau json(), không bao giờ là khóa bị thiếu. Bốn phần quyết định bước tiếp theo của code:

  • meta.status cho biết tài liệu có được đọc hay không. Đây là một trong năm chuỗi: recognized, no_document_found, unreadable, unsupported_document, rejected. Chỉ giá trị đầu tiên có kèm dữ liệu.
  • document và holder chứa các giá trị đã chuẩn hóa: document.kind (passport, thẻ căn cước, v.v.), quốc gia theo ISO 3166-1 alpha-3, số giấy tờ, ngày tháng theo ISO YYYY-MM-DD; holder.surname, holder.given_names, holder.birth_date. Mỗi nhóm là None toàn bộ khi lần quét không cho ra gì cho nhóm đó.
  • mrz.status là passed, failed hoặc absent: vùng đọc máy có được tìm thấy không và các chữ số kiểm tra có khớp không. mrz.text là vùng đó như đã đọc, để bạn tự chạy kiểm tra chữ số.
  • meta.billed cho biết lệnh gọi này có bị trừ vào số dư hay không.

Điểm định hình toàn bộ client: một ảnh không đọc được vẫn là HTTP 200, không phải lỗi. Vì vậy code rẽ nhánh hai lần: theo mã lỗi HTTP cho các trường hợp bị từ chối, và theo meta.status cho kết quả. Nếu raise lỗi khi gặp no_document_found, vòng retry của bạn sẽ gửi lại một ảnh không bao giờ đọc được. Nếu coi nó là thành công, bạn sẽ lưu một tài liệu không có trường nào.

Cờ billed

Với key thật, billed chỉ là True khi tài liệu thực sự được nhận dạng. Không tìm thấy gì, ảnh không đọc được, loại tài liệu không hỗ trợ, lỗi phía dịch vụ: không trường hợp nào bị tính phí. Một tài liệu bị tính phí có giá $0.01, cố định, ở mọi khối lượng; trang so sánh API OCR hộ chiếu đặt mức giá đó cạnh giá mà các dịch vụ khác công bố.

Sandbox hoàn toàn không tính phí. Trên sk_sandbox_public, billed vẫn cho biết cùng lần quét đó có bị tính phí trên key thật hay không, và đó là lý do nên kiểm thử với nó. Hãy lưu cờ này cạnh mỗi kết quả: khi đó, cộng các dòng billed của chính bạn trong một tháng không cần đối soát với hóa đơn.

Retry không thể tính phí hai lần

Retry rủi ro nhất là retry sau timeout. Bạn không biết request đầu tiên đã đến server hay chưa, và với API trả phí, một lần retry mù có thể trả tiền hai lần cho một ảnh.

Giải pháp là header Idempotency-Key trên POST /v1/scans, dài từ 1 đến 255 ký tự. Tạo nó một lần cho mỗi ảnh và gửi cùng giá trị ở mọi lần thử. Với key thật, một lần retry có cùng key và cùng body sẽ nhận lại kết quả đầu tiên thay vì nhận dạng lần thứ hai, và việc phát lại không tốn gì. Ba quy tắc trong tài liệu tham chiếu định hình code:

  • Key gắn với body. Cùng key với ảnh khác hoặc tùy chọn khác sẽ là 409 idempotency_conflict. Hãy dựng body một lần, bên ngoài vòng lặp.
  • Lần thử thứ hai có thể đến khi lần đầu vẫn đang chạy. Khi đó là 409 idempotency_in_progress: chờ rồi retry với cùng key.
  • Không lưu kết quả thì không phát lại được. Với retain_hours: 0 không có gì được lưu, nên một lần retry với cùng key trong vòng 24 giờ sẽ nhận 409 idempotency_replay_unavailable thay vì chạy lần hai. Không lưu gì và phát lại kết quả không đi cùng nhau được; hãy chọn theo từng trường hợp sử dụng.

Các sandbox key chấp nhận header này nhưng nó không quyết định gì ở đó, vì không có gì bị tính phí. Dù vậy, nhánh code này vẫn đáng được kiểm thử.

Lỗi nào đáng retry. Mọi body lỗi có cùng cấu trúc (code, message, docs_url, request_id, event_id), và nên rẽ nhánh theo code, vì một mã trạng thái HTTP có thể mang những mã cần xử lý trái ngược nhau. Hướng dẫn xử lý lỗi chia chúng thành các nhóm:

Mã Cần làm gì
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Chờ (tuân theo Retry-After), rồi retry
idempotency_in_progress Chờ, retry với cùng key
validation_failed, invalid_request, payload_too_large, unsupported_media_type Sửa request; retry sẽ lại thất bại
unauthorized, registration_required, insufficient_credits Sửa key hoặc tài khoản; chờ không thay đổi được gì

Retry-After tính bằng số giây nguyên. Giới hạn theo giờ của sandbox có thể yêu cầu chờ gần một giờ, và không ai muốn một lệnh gọi hàm ngủ lâu như vậy, nên client dưới đây sẽ dừng khi thời gian chờ quá một phút.

Toàn bộ client

# 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  # giây; Retry-After dài hơn sẽ được báo lỗi, không ngủ chờ


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()),  # một key cho mỗi ảnh, dùng lại ở mọi lần thử
    }

    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, mất kết nối, hoặc proxy trả về 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)

Chạy bằng python scan.py specimen.jpg. Một vài lựa chọn cần giải thích:

  • requests không raise lỗi với 4xx hay 5xx trừ khi bạn gọi raise_for_status(). Client đọc body JSON trong mọi trường hợp, vì body lỗi mang mã lỗi; raise_for_status() sẽ bỏ mất thông tin đó.
  • Body và key được dựng một lần, trước vòng lặp. Nhờ vậy, mọi lần thử đều là cùng một request trong mắt server.
  • Một Session dùng lại kết nối qua các lần retry và qua cả một loạt ảnh. Hãy truyền vào một session khi bạn quét nhiều file.
  • return_portrait: False bỏ qua ảnh cắt chân dung của người mang giấy tờ. Bạn vẫn nhận ảnh cắt trang và các trường, và log cùng kho lưu trữ của bạn bớt đi một khuôn mặt.
  • reference được trả về dưới dạng meta.reference (tối đa 128 ký tự): cách đơn giản để gắn một lần quét với đơn hàng hoặc người dùng của bạn. Hai mặt của thẻ căn cước là hai lệnh gọi; hãy đặt cùng một reference cho chúng.
  • Đừng bao giờ ghi log body của request. Đó là giấy tờ tùy thân. Hãy log code, request_id và docs_url; đó là tất cả những gì bộ phận hỗ trợ cần.

Giữ ít dữ liệu hơn

Ảnh tải lên được giữ trong bộ nhớ trong suốt request và không bao giờ được ghi vào bộ lưu trữ lâu dài. Kết quả được giữ lại để bạn có thể lấy lại bằng GET /v1/scans/{id}, trong khoảng thời gian bạn chọn: với mỗi request, options.retain_hours nhận từ 0 đến 8760, và 0 không lưu gì cả. Nếu bạn chỉ cần JSON một lần, hãy gửi retain_hours: 0 và chấp nhận đánh đổi về phát lại đã nêu ở trên. Việc xử lý diễn ra tại EU.

Chuyển sang môi trường thật

Đặt DOC_CHEAP_API_KEY thành key của bạn và không có gì khác thay đổi: cùng endpoint, cùng cấu trúc response, cùng client. Một key đã đăng ký cho phép 60 request mỗi phút, nên một batch job tuân theo Retry-After sẽ không cần bộ giới hạn tốc độ riêng. Credit được mua bằng tiền mã hóa (BTC, ETH, TRX, hoặc USDT trên Ethereum hay Tron) từ $1; hiện chưa có thanh toán bằng thẻ.

Nếu có điều gì trong response khó xử lý từ Python, hãy viết cho admin@doc.cheap.