Un lettore di passaporti in Python sono una ventina di righe la prima volta e circa un centinaio quando deve reggere in produzione. Le ottanta righe in più non riguardano l'OCR. Riguardano tre domande che ogni API a pagamento pone: cosa succede quando la foto è pessima, cosa succede quando una richiesta va in timeout e la rispedite, e come fanno i vostri registri a sapere quali chiamate sono costate denaro.

Questo tutorial costruisce lo script con requests e nient'altro. Usa doc.cheap, un'API OCR per passaporti e documenti d'identità, e questo è il blog di doc.cheap, quindi valutate le scelte di prodotto di conseguenza. Gli schemi (una chiave di idempotenza per immagine, la ramificazione su un codice di errore stabile, la memorizzazione di un flag di costo per chiamata) valgono per qualsiasi API a pagamento che chiamate da Python.

Una richiesta con la chiave sandbox

La documentazione riporta una chiave sandbox pubblica, sk_sandbox_public. Esegue lo stesso riconoscimento di una chiave a pagamento e non richiede un account: 10 documenti riconosciuti gratuiti per indirizzo IP in totale e al massimo 10 richieste all'ora, qualunque sia la risposta. La pagina dell'API OCR gratuita per passaporti mostra la stessa prima chiamata come un unico comando curl. Un account, più avanti, aggiunge 100 documenti gratuiti ogni mese, senza carta.

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= imposta Content-Type: application/json al posto vostro. L'immagine viaggia in base64 nel corpo, JPEG o PNG. La chiamata è sincrona: i campi tornano in questa risposta, senza un id di job da interrogare e senza un webhook da ospitare.

Fate i test con un campione sintetico, mai con il vostro passaporto. I documenti fittizi "Utopia" dell'ICAO e le pagine di esempio pubblicate da molti enti emittenti esistono proprio per questo.

Prima riducete la foto. Una foto da telefono può pesare diversi megabyte prima ancora che il base64 aggiunga un terzo. La guida al passaporto consiglia circa 1600 px sul lato lungo con qualità JPEG 85; un corpo oltre il limite viene rifiutato con payload_too_large prima che parta qualsiasi elaborazione.

Leggere la risposta

Ogni chiave della risposta è sempre presente, e un valore non noto è None dopo json(), mai una chiave mancante. Quattro parti decidono cosa fa il vostro codice dopo:

  • meta.status dice se il documento è stato letto. È una di cinque stringhe: recognized, no_document_found, unreadable, unsupported_document, rejected. Solo la prima porta dati.
  • document e holder contengono i valori elaborati: document.kind (passport, una carta d'identità e così via), il paese in ISO 3166-1 alpha-3, il numero, le date in ISO YYYY-MM-DD; holder.surname, holder.given_names, holder.birth_date. Ciascun gruppo è None per intero quando la scansione non ha prodotto nulla per esso.
  • mrz.status è passed, failed o absent: se la zona a lettura ottica è stata trovata e se le sue cifre di controllo tornano. mrz.text è la zona così come è stata letta, quindi potete verificare voi stessi le cifre di controllo.
  • meta.billed dice se questa chiamata è stata addebitata sul saldo.

Il punto che dà forma a tutto il client: una foto che non si è potuta leggere è un HTTP 200, non un errore. Il codice quindi si ramifica due volte: sul codice di errore HTTP per i rifiuti e su meta.status per gli esiti. Se sollevate un'eccezione su no_document_found, il ciclo di retry rispedisce una foto che non verrà mai letta. Se lo trattate come un successo, salvate un documento senza campi.

Il flag billed

Con una chiave live, billed è True solo quando un documento è stato effettivamente riconosciuto. Nessun documento trovato, un'immagine illeggibile, un tipo non supportato, un guasto lato servizio: niente di tutto ciò viene addebitato. Un documento addebitato costa $0.01, fisso, a qualsiasi volume; il confronto tra API OCR per passaporti lo mette accanto ai prezzi pubblicati dagli altri servizi.

La sandbox non addebita nulla. Su sk_sandbox_public, billed vi dice comunque se la stessa scansione sarebbe stata addebitata con una chiave live, ed è questo che rende utile testarla. Salvate il flag accanto a ogni risultato: sommare le vostre righe billed di un mese non richiede poi alcuna riconciliazione con una fattura.

Retry che non possono addebitare due volte

Il retry rischioso è quello dopo un timeout. Non sapete se la prima richiesta ha raggiunto il server, e su un'API a pagamento un retry alla cieca può farvi pagare due volte la stessa immagine.

La risposta è un header Idempotency-Key su POST /v1/scans, da 1 a 255 caratteri. Generatelo una volta per immagine e inviate lo stesso valore a ogni tentativo. Con una chiave live, un retry con la stessa chiave e lo stesso corpo riceve il primo risultato invece di un secondo riconoscimento, e la ripetizione non costa nulla. Tre principi della reference danno forma al codice:

  • La chiave è legata al corpo. La stessa chiave con un'immagine diversa o opzioni diverse dà 409 idempotency_conflict. Costruite il corpo una sola volta, fuori dal ciclo.
  • Un secondo tentativo può arrivare mentre il primo è ancora in corso. È 409 idempotency_in_progress: attendete e riprovate con la stessa chiave.
  • Nessun risultato salvato, nessuna ripetizione. Con retain_hours: 0 non viene salvato nulla, quindi un retry con la stessa chiave entro 24 ore riceve 409 idempotency_replay_unavailable invece di una seconda esecuzione. Non conservare nulla e ripetere un risultato non vanno d'accordo; scegliete caso per caso.

Le chiavi sandbox accettano l'header, ma lì non decide nulla, visto che non viene addebitato niente. Vale comunque la pena testare quel percorso del codice.

Quali errori meritano un retry. Ogni corpo di errore ha la stessa forma (code, message, docs_url, request_id, event_id), ed è sul codice che conviene ramificare, perché uno stesso stato HTTP può portare codici che richiedono una gestione opposta. La guida gestire gli errori li divide in gruppi:

Codici Cosa fare
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Attendere (rispettando Retry-After), poi riprovare
idempotency_in_progress Attendere, riprovare con la stessa chiave
validation_failed, invalid_request, payload_too_large, unsupported_media_type Correggere la richiesta; un retry fallisce di nuovo
unauthorized, registration_required, insufficient_credits Correggere la chiave o l'account; attendere non cambia nulla

Retry-After è espresso in secondi interi. Il limite orario della sandbox può chiedere quasi un'ora di attesa, e nessun chiamante vuole che una singola chiamata di funzione dorma così a lungo, quindi il client qui sotto rinuncia quando l'attesa supera un minuto.

Il client completo

# 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  # secondi; un Retry-After più lungo viene segnalato, non atteso


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()),  # una chiave per immagine, riusata a ogni tentativo
    }

    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):
            # Un timeout, una connessione caduta o un proxy che risponde con 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)

Eseguitelo con python scan.py specimen.jpg. Alcune scelte meritano una spiegazione:

  • requests non solleva eccezioni su un 4xx o un 5xx a meno che non chiamiate raise_for_status(). Il client legge il corpo JSON in ogni caso, perché è il corpo dell'errore a contenere il codice; raise_for_status() lo butterebbe via.
  • Il corpo e la chiave vengono costruiti una volta, prima del ciclo. È questo che rende ogni tentativo la stessa richiesta agli occhi del server.
  • Una Session riusa la connessione tra i retry e lungo un lotto di immagini. Passatene una quando scansionate molti file.
  • return_portrait: False esclude il ritaglio della foto del titolare. Ricevete il ritaglio della pagina e i campi, e nei vostri log e nel vostro storage c'è un volto in meno.
  • reference torna come meta.reference (fino a 128 caratteri): il modo semplice per collegare una scansione a un vostro ordine o utente. I due lati di una carta d'identità sono due chiamate; date loro lo stesso reference.
  • Non registrate mai il corpo della richiesta nei log. È un documento d'identità. Registrate code, request_id e docs_url; è tutto ciò che serve all'assistenza.

Conservare meno dati

L'immagine caricata resta in memoria per la durata della richiesta e non viene mai scritta su uno storage persistente. Il risultato viene conservato perché possiate recuperarlo di nuovo con GET /v1/scans/{id}, per un periodo che scegliete voi: per ogni richiesta, options.retain_hours accetta da 0 a 8760, e 0 non conserva nulla. Se il JSON vi serve una sola volta, inviate retain_hours: 0 e accettate il compromesso sulla ripetizione descritto sopra. L'elaborazione avviene nell'UE.

Passare in produzione

Impostate DOC_CHEAP_API_KEY sulla vostra chiave e non cambia nient'altro: stesso endpoint, stessa forma della risposta, stesso client. Una chiave registrata consente 60 richieste al minuto, quindi un job batch che rispetta Retry-After non avrà bisogno di un proprio limitatore di frequenza. I crediti si acquistano in criptovaluta (BTC, ETH, TRX o USDT su Ethereum o Tron) a partire da $1; oggi non c'è un pagamento con carta.

Se qualcosa nella risposta è scomodo da gestire in Python, scrivete a admin@doc.cheap.