Ein Reisepass-Leser in Python umfasst beim ersten Mal etwa zwanzig Zeilen und etwa hundert, sobald er im Produktivbetrieb bestehen muss. Die zusätzlichen achtzig haben nichts mit OCR zu tun. Sie drehen sich um drei Fragen, die jede kostenpflichtige API aufwirft: Was passiert, wenn das Foto schlecht ist? Was passiert, wenn ein Request in einen Timeout läuft und Sie ihn erneut senden? Und woher wissen Ihre eigenen Aufzeichnungen, welche Aufrufe Geld gekostet haben?
Dieses Tutorial baut dieses Skript mit requests und sonst nichts. Es verwendet doc.cheap, eine OCR-API für Reisepässe und Ausweise, und dies ist der eigene Blog von doc.cheap – gewichten Sie die Produktentscheidungen entsprechend. Die Muster (ein Idempotency-Key pro Bild, Verzweigung nach einem stabilen Fehlercode, ein Kosten-Flag pro Aufruf speichern) lassen sich auf jede kostenpflichtige API übertragen, die Sie aus Python aufrufen.
Ein Request mit dem Sandbox-Key
Die Dokumentation nennt einen öffentlichen Sandbox-Key, sk_sandbox_public. Er führt dieselbe Erkennung aus wie ein bezahlter Key und braucht kein Konto: insgesamt 10 kostenlos erkannte Dokumente pro IP-Adresse und höchstens 10 Requests pro Stunde, unabhängig von der Antwort. Die Seite zur kostenlosen Reisepass-OCR-API zeigt denselben ersten Aufruf als einzelnen curl-Befehl. Ein Konto bringt später jeden Monat 100 kostenlose Dokumente dazu, ohne Kreditkarte.
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= setzt Content-Type: application/json für Sie. Das Bild reist als base64 im Body, als JPEG oder PNG. Der Aufruf ist synchron: Die Felder kommen in genau dieser Antwort zurück, ohne Job-ID zum Abfragen und ohne Webhook, den Sie hosten müssten.
Testen Sie mit einem synthetischen Muster, nie mit Ihrem eigenen Pass. Die fiktiven „Utopia“-Dokumente der ICAO und die Musterseiten, die viele ausstellende Behörden veröffentlichen, gibt es genau dafür.
Verkleinern Sie das Foto zuerst. Ein Handyfoto kann mehrere Megabyte groß sein, bevor base64 noch ein Drittel draufschlägt. Der Reisepass-Leitfaden empfiehlt etwa 1600 px an der langen Kante bei JPEG-Qualität 85; ein Body über der Obergrenze wird mit payload_too_large abgewiesen, bevor irgendetwas läuft.
Die Antwort lesen
Jeder Schlüssel der Antwort ist immer vorhanden, und ein unbekannter Wert ist nach json() None, nie ein fehlender Schlüssel. Vier Teile entscheiden, was Ihr Code als Nächstes tut:
meta.statussagt, ob das Dokument gelesen wurde. Es ist einer von fünf Strings:recognized,no_document_found,unreadable,unsupported_document,rejected. Nur der erste liefert Daten.documentundholderenthalten die aufbereiteten Werte:document.kind(passport, ein Personalausweis und so weiter), das Land als ISO 3166-1 alpha-3, die Nummer, die Daten als ISOYYYY-MM-DD;holder.surname,holder.given_names,holder.birth_date. Jede Gruppe ist als GanzesNone, wenn der Scan für sie nichts ergeben hat.mrz.statusistpassed,failedoderabsent: ob die maschinenlesbare Zone gefunden wurde und ihre Prüfziffern stimmen.mrz.textist die Zone so, wie sie gelesen wurde, sodass Sie die Prüfziffern selbst nachrechnen können.meta.billedsagt, ob dieser Aufruf dem Guthaben belastet wurde.
Der Punkt, der den ganzen Client prägt: Ein Foto, das nicht gelesen werden konnte, ist HTTP 200, kein Fehler. Der Code verzweigt deshalb zweimal: nach dem HTTP-Fehlercode für Ablehnungen und nach meta.status für Ergebnisse. Werfen Sie bei no_document_found eine Exception, sendet Ihre Retry-Schleife ein Foto erneut, das nie lesbar sein wird. Behandeln Sie es als Erfolg, speichern Sie ein Dokument ohne Felder.
Das billed-Flag
Mit einem Live-Key ist billed nur dann True, wenn tatsächlich ein Dokument erkannt wurde. Nichts gefunden, ein unlesbares Bild, ein nicht unterstützter Typ, ein Fehler auf Seiten des Dienstes: Nichts davon wird berechnet. Ein abgerechnetes Dokument kostet pauschal $0.01, bei jedem Volumen; der Vergleich von Reisepass-OCR-APIs stellt das neben die Preise, die andere Dienste veröffentlichen.
Die Sandbox berechnet überhaupt nichts. Mit sk_sandbox_public sagt billed trotzdem, ob derselbe Scan mit einem Live-Key berechnet worden wäre – genau das macht sie zum Testen wertvoll. Speichern Sie das Flag neben jedem Ergebnis: Wer die eigenen billed-Zeilen eines Monats aufsummiert, muss dann nichts mehr mit einer Rechnung abgleichen.
Wiederholungen, die nicht doppelt abrechnen
Die riskante Wiederholung ist die nach einem Timeout. Sie wissen nicht, ob der erste Request den Server erreicht hat, und bei einer kostenpflichtigen API kann eine blinde Wiederholung für ein Bild zweimal bezahlen.
Die Lösung ist ein Idempotency-Key-Header auf POST /v1/scans, 1 bis 255 Zeichen lang. Erzeugen Sie ihn einmal pro Bild und senden Sie bei jedem Versuch denselben Wert. Mit einem Live-Key erhält eine Wiederholung mit demselben Key und demselben Body das erste Ergebnis zurück statt einer zweiten Erkennung, und eine solche Wiederholung kostet nichts. Drei Vorgaben aus der Referenz prägen den Code:
- Der Key ist an den Body gebunden. Derselbe Key mit einem anderen Bild oder anderen Optionen ergibt
409 idempotency_conflict. Bauen Sie den Body einmal, außerhalb der Schleife. - Ein zweiter Versuch kann ankommen, während der erste noch läuft. Das ist
409 idempotency_in_progress: warten und mit demselben Key wiederholen. - Kein gespeichertes Ergebnis, keine Wiederholung. Mit
retain_hours: 0wird nichts gespeichert, also erhält eine Wiederholung unter demselben Key innerhalb von 24 Stunden409 idempotency_replay_unavailablestatt eines zweiten Durchlaufs. Nichts aufbewahren und ein Ergebnis erneut ausliefern passen nicht zusammen; entscheiden Sie je nach Anwendungsfall.
Die Sandbox-Keys akzeptieren den Header, aber dort entscheidet er nichts, weil nichts berechnet wird. Den Codepfad zu testen lohnt sich trotzdem.
Welche Fehler eine Wiederholung lohnen. Jeder Fehler-Body hat dieselbe Form (code, message, docs_url, request_id, event_id), und verzweigt wird nach dem Code, denn ein HTTP-Status kann Codes tragen, die gegensätzlich behandelt werden müssen. Der Leitfaden Fehler behandeln ordnet sie in Gruppen:
| Codes | Was zu tun ist |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Warten (Retry-After beachten), dann wiederholen |
idempotency_in_progress |
Warten, mit demselben Key wiederholen |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Den Request korrigieren; eine Wiederholung scheitert erneut |
unauthorized, registration_required, insufficient_credits |
Key oder Konto korrigieren; Warten ändert nichts |
Retry-After wird in ganzen Sekunden angegeben. Das Stundenlimit der Sandbox kann fast eine ganze Stunde verlangen, und kein Aufrufer will, dass ein einzelner Funktionsaufruf so lange schläft – daher gibt der Client unten auf, wenn die Wartezeit über einer Minute liegt.
Der vollständige 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 # Sekunden; ein längeres Retry-After wird gemeldet, nicht abgewartet
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()), # ein Key pro Bild, bei jedem Versuch wiederverwendet
}
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):
# Ein Timeout, eine abgebrochene Verbindung oder ein Proxy, der mit HTML antwortet.
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)
Starten Sie ihn mit python scan.py specimen.jpg. Einige Entscheidungen, die eine Erklärung verdienen:
requestswirft bei 4xx oder 5xx keine Exception, solange Sie nichtraise_for_status()aufrufen. Der Client liest den JSON-Body in beiden Fällen, weil der Fehler-Body den Code trägt;raise_for_status()würde ihn verwerfen.- Body und Key werden einmal gebaut, vor der Schleife. Genau das macht jeden Versuch aus Sicht des Servers zum selben Request.
- Eine
Sessionnutzt die Verbindung wieder, über Wiederholungen hinweg und über einen ganzen Stapel von Bildern. Übergeben Sie eine, wenn Sie viele Dateien scannen. return_portrait: Falselässt den Ausschnitt mit dem Foto des Inhabers weg. Sie erhalten den Seitenausschnitt und die Felder, und in Ihren Logs und Ihrem Speicher liegt ein Gesicht weniger.referencekommt alsmeta.referencezurück (bis zu 128 Zeichen): der einfache Weg, einen Scan mit Ihrer eigenen Bestellung oder Ihrem eigenen Nutzer zu verknüpfen. Die beiden Seiten eines Personalausweises sind zwei Aufrufe; geben Sie beiden dieselbe Referenz.- Loggen Sie niemals den Request-Body. Er ist ein Ausweisdokument. Loggen Sie
code,request_idunddocs_url; mehr braucht der Support nicht.
Weniger Daten aufbewahren
Das hochgeladene Bild wird für die Dauer des Requests im Arbeitsspeicher gehalten und nie auf dauerhaften Speicher geschrieben. Das Ergebnis wird aufbewahrt, damit Sie es mit GET /v1/scans/{id} erneut abrufen können, für einen Zeitraum, den Sie wählen: Pro Request nimmt options.retain_hours Werte von 0 bis 8760 an, und 0 speichert gar nichts. Wenn Sie das JSON nur einmal brauchen, senden Sie retain_hours: 0 und nehmen den oben beschriebenen Kompromiss bei Wiederholungen in Kauf. Die Verarbeitung findet in der EU statt.
Live gehen
Setzen Sie DOC_CHEAP_API_KEY auf Ihren eigenen Key, und sonst ändert sich nichts: derselbe Endpoint, dieselbe Antwortstruktur, derselbe Client. Ein registrierter Key erlaubt 60 Requests pro Minute, sodass ein Batch-Job, der Retry-After beachtet, keinen eigenen Rate Limiter braucht. Guthaben wird mit Kryptowährung gekauft (BTC, ETH, TRX oder USDT auf Ethereum oder Tron), ab $1; einen Kartenzahlungs-Checkout gibt es derzeit nicht.
Wenn sich etwas an der Antwort aus Python umständlich verarbeiten lässt, schreiben Sie an admin@doc.cheap.