Um leitor de passaportes em Python tem umas vinte linhas na primeira vez e umas cem quando precisa sobreviver em produção. As oitenta a mais não têm a ver com OCR. Têm a ver com três perguntas que toda API paga levanta: o que acontece quando a foto é ruim, o que acontece quando uma requisição estoura o timeout e você a envia de novo, e como os seus próprios registros sabem quais chamadas custaram dinheiro.
Este tutorial monta esse script com requests e nada mais. Ele usa o doc.cheap, uma API de OCR para passaportes e documentos de identidade, e este é o blog do próprio doc.cheap, então pese as escolhas de produto levando isso em conta. Os padrões (uma chave de idempotência por imagem, ramificar por um código de erro estável, guardar um flag de custo por chamada) valem para qualquer API paga que você chame a partir do Python.
Uma requisição com a chave sandbox
A documentação traz uma chave sandbox pública, sk_sandbox_public. Ela roda o mesmo reconhecimento que uma chave paga e não exige conta: 10 documentos reconhecidos grátis por endereço IP no total, e no máximo 10 requisições por hora, seja qual for a resposta. A página da API gratuita de OCR de passaporte mostra a mesma primeira chamada como um único curl. Depois, uma conta acrescenta 100 documentos grátis por mês, sem cartão.
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= define Content-Type: application/json para você. A imagem vai em base64 dentro do corpo, em JPEG ou PNG. A chamada é síncrona: os campos voltam nesta mesma resposta, sem id de job para consultar e sem webhook para hospedar.
Teste com um espécime sintético, nunca com o seu próprio passaporte. Os documentos fictícios "Utopia" da OACI e as páginas de amostra que muitos emissores publicam existem exatamente para isso.
Reduza a foto antes. Uma foto de celular pode ter vários megabytes antes de o base64 acrescentar mais um terço. O guia de passaporte recomenda cerca de 1600 px no lado maior com qualidade JPEG 85; um corpo acima do limite é recusado com payload_too_large antes que qualquer coisa seja executada.
Lendo a resposta
Todas as chaves da resposta estão sempre presentes, e um valor desconhecido vira None depois de json(), nunca uma chave ausente. Quatro partes decidem o que o seu código faz em seguida:
meta.statusdiz se o documento foi lido. É uma de cinco strings:recognized,no_document_found,unreadable,unsupported_document,rejected. Só a primeira traz dados.documenteholdertrazem os valores tratados:document.kind(passport, uma carteira de identidade e assim por diante), o país em ISO 3166-1 alfa-3, o número, as datas em ISOYYYY-MM-DD;holder.surname,holder.given_names,holder.birth_date. Cada grupo éNonepor inteiro quando a leitura não produziu nada para ele.mrz.statusépassed,failedouabsent: se a zona de leitura mecânica foi encontrada e se os dígitos verificadores batem.mrz.texté a zona como foi lida, então você pode conferir os dígitos verificadores por conta própria.meta.billeddiz se esta chamada foi debitada do saldo.
O ponto que define todo o cliente: uma foto que não pôde ser lida é HTTP 200, não um erro. Por isso o código ramifica duas vezes: pelo código de erro HTTP para as recusas e por meta.status para os resultados. Lance uma exceção em no_document_found e o seu loop de retentativas vai reenviar uma foto que nunca será lida. Trate isso como sucesso e você vai guardar um documento sem campos.
O flag billed
Com uma chave real, billed é True só quando um documento foi de fato reconhecido. Nada encontrado, uma imagem ilegível, um tipo não suportado, uma falha do lado do serviço: nada disso é cobrado. Um documento cobrado custa $0.01, preço fixo, em qualquer volume; a comparação de APIs de OCR de passaporte coloca esse valor ao lado dos preços que outros serviços publicam.
O sandbox não cobra nada. Com sk_sandbox_public, billed continua dizendo se a mesma leitura teria sido cobrada com uma chave real, e é isso que faz valer a pena testar com ele. Guarde o flag junto de cada resultado: somar as suas próprias linhas com billed de um mês passa a não exigir nenhuma conciliação com fatura.
Retentativas que não cobram duas vezes
A retentativa arriscada é a que vem depois de um timeout. Você não sabe se a primeira requisição chegou ao servidor, e numa API paga uma retentativa às cegas pode pagar duas vezes pela mesma imagem.
A solução é um cabeçalho Idempotency-Key em POST /v1/scans, de 1 a 255 caracteres. Gere-o uma vez por imagem e envie o mesmo valor em todas as tentativas. Com uma chave real, uma retentativa com a mesma chave e o mesmo corpo recebe de volta o primeiro resultado em vez de um segundo reconhecimento, e essa repetição não custa nada. Três pontos da referência moldam o código:
- A chave fica vinculada ao corpo. A mesma chave com outra imagem ou outras opções resulta em
409 idempotency_conflict. Monte o corpo uma vez só, fora do loop. - Uma segunda tentativa pode chegar enquanto a primeira ainda está em execução. Isso é
409 idempotency_in_progress: espere e tente de novo com a mesma chave. - Sem resultado guardado, sem repetição. Com
retain_hours: 0nada é guardado, então uma retentativa com a mesma chave dentro de 24 horas recebe409 idempotency_replay_unavailableem vez de uma segunda execução. Não guardar nada e repetir um resultado não combinam; escolha conforme o caso de uso.
As chaves sandbox aceitam o cabeçalho, mas lá ele não decide nada, já que nada é cobrado. Mesmo assim, vale a pena testar esse caminho do código.
Quais erros merecem retentativa. Todo corpo de erro tem o mesmo formato (code, message, docs_url, request_id, event_id), e é pelo código que você deve ramificar, porque um mesmo status HTTP pode trazer códigos que pedem tratamentos opostos. O guia tratar erros os separa em grupos:
| Códigos | O que fazer |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Esperar (respeitando Retry-After) e tentar de novo |
idempotency_in_progress |
Esperar e tentar de novo com a mesma chave |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Corrigir a requisição; uma retentativa falha de novo |
unauthorized, registration_required, insufficient_credits |
Corrigir a chave ou a conta; esperar não muda nada |
Retry-After vem em segundos inteiros. O limite por hora do sandbox pode pedir quase uma hora, e ninguém quer que uma chamada de função durma tanto tempo, então o cliente abaixo desiste quando a espera passa de um minuto.
O 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; um Retry-After maior é reportado, não esperado
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()), # uma chave por imagem, reutilizada em cada tentativa
}
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):
# Um timeout, uma conexão perdida ou um proxy respondendo com 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)
Rode com python scan.py specimen.jpg. Algumas escolhas que valem explicação:
requestsnão lança exceção num 4xx ou 5xx a menos que você chameraise_for_status(). O cliente lê o corpo JSON nos dois casos, porque o corpo de erro traz o código;raise_for_status()jogaria isso fora.- O corpo e a chave são montados uma vez, antes do loop. É isso que faz cada tentativa ser a mesma requisição aos olhos do servidor.
- Uma
Sessionreaproveita a conexão entre retentativas e ao longo de um lote de imagens. Passe uma quando for ler muitos arquivos. return_portrait: Falsedeixa de fora o recorte da foto do titular. Você recebe o recorte da página e os campos, e há um rosto a menos nos seus logs e no seu armazenamento.referencevolta comometa.reference(até 128 caracteres): o jeito fácil de ligar uma leitura ao seu próprio pedido ou usuário. Os dois lados de uma carteira de identidade são duas chamadas; dê a eles a mesma referência.- Nunca registre o corpo da requisição em log. Ele é um documento de identidade. Registre
code,request_idedocs_url; é tudo de que o suporte precisa.
Guardando menos dados
A imagem enviada fica em memória durante a requisição e nunca é gravada em armazenamento persistente. O resultado é mantido para que você possa buscá-lo de novo com GET /v1/scans/{id}, pelo prazo que você escolher: por requisição, options.retain_hours aceita de 0 a 8760, e 0 não guarda nada. Se você só precisa do JSON uma vez, envie retain_hours: 0 e aceite a troca descrita acima em relação à repetição. O processamento acontece na UE.
Indo para produção
Defina DOC_CHEAP_API_KEY com a sua própria chave e nada mais muda: o mesmo endpoint, o mesmo formato de resposta, o mesmo cliente. Uma chave registrada permite 60 requisições por minuto, então um job em lote que respeite Retry-After não vai precisar de um limitador de taxa próprio. Os créditos são comprados com criptomoedas (BTC, ETH, TRX, ou USDT em Ethereum ou Tron) a partir de $1; hoje não há checkout com cartão.
Se algo na resposta for incômodo de tratar a partir do Python, escreva para admin@doc.cheap.