104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""mp-ocr – OCR-Dienst für das Mieterportal (Beleg-Analyse).
|
||
|
||
Nimmt eine Datei-URL (oder Upload) entgegen, lädt den Beleg (PDF/Bild),
|
||
extrahiert den Text mit Tesseract (Deutsch) und liefert ihn zurück.
|
||
Nur intern im Docker-Netz erreichbar (mp-api ruft ihn auf).
|
||
"""
|
||
|
||
import base64
|
||
import binascii
|
||
import io
|
||
import urllib.parse
|
||
|
||
import fitz # PyMuPDF
|
||
import httpx
|
||
import pytesseract
|
||
from fastapi import FastAPI, HTTPException, UploadFile
|
||
from PIL import Image
|
||
from pydantic import BaseModel
|
||
|
||
MAX_DATEI_BYTES = 20 * 1024 * 1024 # 20 MB
|
||
DOWNLOAD_TIMEOUT = 20.0
|
||
|
||
app = FastAPI(title="mp-ocr", docs_url=None, redoc_url=None)
|
||
|
||
|
||
class Anfrage(BaseModel):
|
||
url: str
|
||
|
||
|
||
@app.get("/healthz")
|
||
def healthz():
|
||
return {"ok": True, "dienst": "mp-ocr"}
|
||
|
||
|
||
def bild_text(img: Image.Image) -> str:
|
||
return pytesseract.image_to_string(img, lang="deu").strip()
|
||
|
||
|
||
def pdf_text(daten: bytes) -> str:
|
||
teile = []
|
||
with fitz.open(stream=daten, filetype="pdf") as doc:
|
||
for seite in doc:
|
||
pix = seite.get_pixmap(dpi=200)
|
||
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
||
teile.append(bild_text(img))
|
||
return "\n".join(t for t in teile if t).strip()
|
||
|
||
|
||
def datei_text(daten: bytes, dateiname: str) -> str:
|
||
if dateiname.lower().endswith(".pdf") or daten[:5] == b"%PDF-":
|
||
return pdf_text(daten)
|
||
try:
|
||
with Image.open(io.BytesIO(daten)) as img:
|
||
return bild_text(img.convert("RGB"))
|
||
except Exception:
|
||
raise HTTPException(400, "Dateiformat nicht unterstützt (PDF/JPG/PNG erwartet).")
|
||
|
||
|
||
def datenVonDataUrl(url: str) -> tuple[bytes, str]:
|
||
"""data:application/pdf;base64,…. → (bytes, name)."""
|
||
kopf, _, payload = url.partition(",")
|
||
if not payload:
|
||
raise HTTPException(400, "Leere data-URL.")
|
||
typ = kopf[5:].split(";")[0] or "application/octet-stream"
|
||
if ";base64" in kopf:
|
||
try:
|
||
return base64.b64decode(urllib.parse.unquote(payload)), "beleg-data"
|
||
except (binascii.Error, ValueError):
|
||
raise HTTPException(400, "Ungültige base64-data-URL.")
|
||
return urllib.parse.unquote_to_bytes(payload), "beleg-data"
|
||
|
||
|
||
@app.post("/ocr")
|
||
async def ocr_url(anfrage: Anfrage):
|
||
"""Beleg von einer URL herunterladen und per OCR auswerten."""
|
||
url = anfrage.url
|
||
if url.startswith("data:"):
|
||
daten, name = datenVonDataUrl(url)
|
||
if len(daten) > MAX_DATEI_BYTES:
|
||
raise HTTPException(413, "Datei größer als 20 MB.")
|
||
return {"text": datei_text(daten, name)}
|
||
if not url.startswith(("http://", "https://")):
|
||
raise HTTPException(400, "Nur http(s)- oder data-URLs erlaubt.")
|
||
try:
|
||
async with httpx.AsyncClient(timeout=DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||
resp = await client.get(url)
|
||
except Exception as e:
|
||
raise HTTPException(502, f"Download fehlgeschlagen: {e}")
|
||
if resp.status_code != 200:
|
||
raise HTTPException(502, f"Download: HTTP {resp.status_code}")
|
||
if len(resp.content) > MAX_DATEI_BYTES:
|
||
raise HTTPException(413, "Datei größer als 20 MB.")
|
||
name = url.split("?")[0].rsplit("/", 1)[-1] or "beleg"
|
||
return {"text": datei_text(resp.content, name)}
|
||
|
||
|
||
@app.post("/ocr/upload")
|
||
async def ocr_upload(datei: UploadFile):
|
||
"""Beleg direkt als Upload (multipart) auswerten."""
|
||
daten = await datei.read(MAX_DATEI_BYTES + 1)
|
||
if len(daten) > MAX_DATEI_BYTES:
|
||
raise HTTPException(413, "Datei größer als 20 MB.")
|
||
return {"text": datei_text(daten, datei.filename or "beleg")}
|