Un lector de pasaportes en Python ocupa unas veinte líneas la primera vez y unas cien cuando tiene que sobrevivir en producción. Las ochenta de más no tratan de OCR. Tratan de tres preguntas que plantea toda API de pago: qué pasa cuando la foto es mala, qué pasa cuando una petición agota el tiempo de espera y usted la vuelve a enviar, y cómo saben sus propios registros qué llamadas costaron dinero.
Este tutorial construye ese script con requests y nada más. Usa doc.cheap, una API de OCR para pasaportes y documentos de identidad, y este es el blog de doc.cheap, así que valore las decisiones de producto en consecuencia. Los patrones (una clave de idempotencia por imagen, ramificar según un código de error estable, guardar un indicador de coste por llamada) sirven para cualquier API de pago que llame desde Python.
Una petición con la clave sandbox
La documentación publica una clave sandbox pública, sk_sandbox_public. Ejecuta el mismo reconocimiento que una clave de pago y no requiere cuenta: 10 documentos reconocidos gratis por dirección IP en total, y como máximo 10 peticiones por hora sea cual sea la respuesta. La página de la API gratuita de OCR para pasaportes muestra la misma primera llamada como un único curl. Más adelante, una cuenta añade 100 documentos gratis cada mes, sin tarjeta.
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= establece Content-Type: application/json por usted. La imagen viaja en base64 dentro del cuerpo, en JPEG o PNG. La llamada es síncrona: los campos vuelven en esta misma respuesta, sin id de tarea que consultar ni webhook que alojar.
Pruebe con un espécimen sintético, nunca con su propio pasaporte. Los documentos ficticios "Utopia" de la OACI y las páginas de muestra que publican muchos emisores existen precisamente para esto.
Reduzca la foto primero. Una foto de móvil puede pesar varios megabytes antes de que base64 le añada un tercio. La guía de pasaportes recomienda unos 1600 px en el lado largo con calidad JPEG 85; un cuerpo que supera el límite se rechaza con payload_too_large antes de que se ejecute nada.
Leer la respuesta
Todas las claves de la respuesta están siempre presentes, y un valor desconocido es None tras json(), nunca una clave ausente. Cuatro partes deciden qué hace su código a continuación:
meta.statusindica si el documento se leyó. Es una de cinco cadenas:recognized,no_document_found,unreadable,unsupported_document,rejected. Solo la primera trae datos.documentyholdercontienen los valores depurados:document.kind(passport, un documento de identidad, etc.), el país en ISO 3166-1 alfa-3, el número, las fechas en ISOYYYY-MM-DD;holder.surname,holder.given_names,holder.birth_date. Cada grupo esNonepor completo cuando el escaneo no produjo nada para él.mrz.statusespassed,failedoabsent: si se encontró la zona de lectura mecánica y si sus dígitos de control coinciden.mrz.textes la zona tal como se leyó, así que puede verificar los dígitos de control usted mismo.meta.billedindica si esta llamada se cargó al saldo.
El punto que da forma a todo el cliente: una foto que no se pudo leer es HTTP 200, no un error. Por eso el código ramifica dos veces: según el código de error HTTP para los rechazos y según meta.status para los resultados. Si lanza una excepción con no_document_found, su bucle de reintentos reenviará una foto que nunca se podrá leer. Si lo trata como un éxito, guardará un documento sin campos.
El indicador billed
Con una clave real, billed es True solo cuando un documento se reconoció de verdad. Nada encontrado, una imagen ilegible, un tipo no admitido, un fallo del lado del servicio: nada de eso se cobra. Un documento cobrado cuesta $0.01, tarifa plana, a cualquier volumen; la comparativa de API de OCR para pasaportes pone ese precio junto a los que publican otros servicios.
El sandbox no cobra nada en absoluto. Con sk_sandbox_public, billed sigue indicando si el mismo escaneo se habría cobrado con una clave real, y eso es lo que hace que valga la pena probar contra él. Guarde el indicador junto a cada resultado: así, sumar sus propias filas con billed de un mes no requiere conciliarlas con ninguna factura.
Reintentos que no pueden cobrar dos veces
El reintento arriesgado es el que sigue a un tiempo de espera agotado. Usted no sabe si la primera petición llegó al servidor, y en una API de pago un reintento a ciegas puede pagar dos veces por una sola imagen.
La solución es una cabecera Idempotency-Key en POST /v1/scans, de 1 a 255 caracteres. Genérela una vez por imagen y envíe el mismo valor en cada intento. Con una clave real, un reintento con la misma clave y el mismo cuerpo recibe el primer resultado en lugar de un segundo reconocimiento, y esa repetición no cuesta nada. Tres normas de la referencia dan forma al código:
- La clave está ligada al cuerpo. La misma clave con otra imagen u otras opciones da
409 idempotency_conflict. Construya el cuerpo una sola vez, fuera del bucle. - Un segundo intento puede llegar mientras el primero aún se ejecuta. Eso es
409 idempotency_in_progress: espere y reintente con la misma clave. - Sin resultado guardado no hay repetición. Con
retain_hours: 0no se guarda nada, así que un reintento con la misma clave dentro de las 24 horas recibe409 idempotency_replay_unavailableen vez de una segunda ejecución. No guardar nada y repetir un resultado no van juntos; elija según el caso de uso.
Las claves sandbox aceptan la cabecera, pero allí no decide nada, porque no se cobra nada. Aun así, merece la pena probar esa ruta del código.
Qué errores merecen un reintento. Todo cuerpo de error tiene la misma forma (code, message, docs_url, request_id, event_id), y es el código lo que debe usar para ramificar, porque un mismo estado HTTP puede llevar códigos que exigen un tratamiento opuesto. La guía gestionar errores los agrupa así:
| Códigos | Qué hacer |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Esperar (respetando Retry-After) y reintentar |
idempotency_in_progress |
Esperar y reintentar con la misma clave |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Corregir la petición; un reintento vuelve a fallar |
unauthorized, registration_required, insufficient_credits |
Corregir la clave o la cuenta; esperar no cambia nada |
Retry-After se expresa en segundos enteros. El límite por hora del sandbox puede pedir casi una hora, y nadie quiere que una llamada a una función duerma tanto, así que el cliente de abajo se rinde cuando la espera supera un minuto.
El cliente 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 # segundos; un Retry-After más largo se notifica, no se espera
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 clave por imagen, reutilizada en cada intento
}
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 tiempo de espera agotado, una conexión caída o un proxy que responde 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)
Ejecútelo como python scan.py specimen.jpg. Algunas decisiones que conviene explicar:
requestsno lanza una excepción ante un 4xx o un 5xx salvo que llame araise_for_status(). El cliente lee el cuerpo JSON en ambos casos, porque el cuerpo de error lleva el código;raise_for_status()lo descartaría.- El cuerpo y la clave se construyen una vez, antes del bucle. Eso es lo que hace que cada intento sea la misma petición a ojos del servidor.
- Una
Sessionreutiliza la conexión entre reintentos y a lo largo de un lote de imágenes. Pase una cuando escanee muchos archivos. return_portrait: Falseomite el recorte de la foto del titular. Recibe el recorte de la página y los campos, y hay una cara menos en sus logs y en su almacenamiento.referencevuelve comometa.reference(hasta 128 caracteres): la forma sencilla de vincular un escaneo con su propio pedido o usuario. Las dos caras de un documento de identidad son dos llamadas; deles la misma referencia.- Nunca registre el cuerpo de la petición. Es un documento de identidad. Registre
code,request_idydocs_url; es todo lo que necesita el soporte.
Guardar menos datos
La imagen subida se mantiene en memoria durante la petición y nunca se escribe en almacenamiento persistente. El resultado se conserva para que pueda recuperarlo con GET /v1/scans/{id} durante el plazo que usted elija: por petición, options.retain_hours admite de 0 a 8760, y 0 no guarda nada. Si solo necesita el JSON una vez, envíe retain_hours: 0 y acepte la contrapartida de la repetición descrita arriba. El procesamiento se realiza en la UE.
Pasar a producción
Defina DOC_CHEAP_API_KEY con su propia clave y nada más cambia: el mismo endpoint, la misma forma de respuesta, el mismo cliente. Una clave registrada permite 60 peticiones por minuto, así que un proceso por lotes que respete Retry-After no necesitará su propio limitador de ritmo. Los créditos se compran con criptomonedas (BTC, ETH, TRX o USDT en Ethereum o Tron) desde $1; hoy no hay pago con tarjeta.
Si algo de la respuesta le resulta incómodo de manejar desde Python, escriba a admin@doc.cheap.