Files
business-card-scanner/app/main.py
Lucas Orth ab993e98e1 Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest
und als Kontakt bereitstellt.

- Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken,
  entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt
  das ganze Foto als eine Karte.
- Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit
  JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und
  Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht.
- Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname
  über Nominatim, Browserstandort nur als Rückfallebene.
- Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske.
- Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster
  Termine nach einem Neustart.
- vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert.
- Anmeldung über ein Passwort, Sitzung als signiertes Cookie.
- Deployment per Tag-Push nach den Konventionen in DEPLOY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:45:56 +02:00

395 lines
13 KiB
Python

"""HTTP-Schnittstelle und Auslieferung der PWA."""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
import cv2
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from . import config, db, extract, geocode, imaging, reminders, security, segment, vcard
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("bcs")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
EDITABLE = [
"full_name", "first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website",
"street", "postal_code", "city", "country", "notes",
]
@asynccontextmanager
async def lifespan(app: FastAPI):
config.ensure_dirs()
db.connect()
if not security.enabled():
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
if not extract.available():
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
task = asyncio.create_task(reminders.run_forever())
try:
yield
finally:
task.cancel()
app = FastAPI(title="Business Card Scanner", lifespan=lifespan, docs_url=None, redoc_url=None)
auth = Depends(security.require_auth)
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def card_dict(row) -> dict:
card = dict(row)
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
card["has_contact"] = any(
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
)
return card
# --------------------------------------------------------------------------- Sitzung
@app.get("/healthz")
def healthz() -> dict:
db.query_one("SELECT 1 AS ok")
return {"status": "ok"}
@app.get("/api/session")
def session(request: Request) -> dict:
return {
"auth_required": security.enabled(),
"authenticated": not security.enabled()
or security.valid_token(request.cookies.get(security.COOKIE)),
"extraction_available": extract.available(),
"mail_configured": bool(config.SMTP_HOST and config.REMINDER_TO),
}
@app.post("/api/login")
def login(response: Response, password: str = Form(...)) -> dict:
if not security.enabled():
return {"ok": True}
if not security.check_password(password):
raise HTTPException(status_code=401, detail="Passwort stimmt nicht")
response.set_cookie(
security.COOKIE,
security.issue_token(),
max_age=config.SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
secure=True,
path="/",
)
return {"ok": True}
@app.post("/api/logout")
def logout(response: Response) -> dict:
response.delete_cookie(security.COOKIE, path="/")
return {"ok": True}
# ------------------------------------------------------------------------------ Scan
def _process_photo(raw: bytes) -> tuple:
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren."""
meta = imaging.read_metadata(raw)
image = imaging.decode(raw)
crops, fallback = segment.segment(image)
encoded = [
(
imaging.encode_jpeg(crop),
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
)
for crop in crops
]
return meta, encoded, fallback
@app.post("/api/scan", dependencies=[auth])
async def scan(
file: UploadFile,
lat: float = Form(None),
lon: float = Form(None),
) -> JSONResponse:
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Leere Datei")
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
try:
meta, encoded, fallback = await asyncio.to_thread(_process_photo, raw)
except Exception as exc:
log.exception("Foto konnte nicht verarbeitet werden")
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
# Der Ort des Fotos schlaegt den des Uploads: Karten werden oft erst
# abends am Schreibtisch abfotografiert, nicht auf der Messe.
if meta["lat"] is not None:
geo_source = "exif"
elif lat is not None and lon is not None:
meta["lat"], meta["lon"], geo_source = lat, lon, "browser"
else:
geo_source = None
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
results = await extract.extract_all(encoded)
batch_id = uuid.uuid4().hex
created = now_iso()
source = "einzel" if fallback else "stapel"
cards = []
for (upright, _), result in zip(encoded, results):
card_id = uuid.uuid4().hex
image_file = f"{card_id}.jpg"
(config.IMAGE_DIR / image_file).write_bytes(upright)
columns = {
"id": card_id,
"created_at": created,
"captured_at": meta["captured_at"] or created,
"batch_id": batch_id,
"image_file": image_file,
"source": source,
"notes": "",
"lat": meta["lat"],
"lon": meta["lon"],
"place": place,
"geo_source": geo_source,
"extract_status": "fehler" if result.get("_error") else "ok",
"extract_error": result.get("_error"),
}
for field in extract.FIELDS:
columns[field] = (result.get(field) or None)
names = ", ".join(columns)
placeholders = ", ".join("?" for _ in columns)
db.execute(
f"INSERT INTO cards ({names}) VALUES ({placeholders})", list(columns.values())
)
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
return JSONResponse(
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
)
# ----------------------------------------------------------------------------- Karten
@app.get("/api/cards", dependencies=[auth])
def list_cards(q: str = "", filter: str = "alle") -> dict:
sql = "SELECT * FROM cards"
where, params = [], []
if q.strip():
needle = f"%{q.strip()}%"
searchable = [
"full_name", "company", "job_title", "email", "phone", "mobile",
"website", "city", "place", "notes", "department", "street",
]
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
params += [needle] * len(searchable)
if filter == "offen":
where.append("exported_at IS NULL")
elif filter == "unvollstaendig":
where.append(
"(extract_status <> 'ok' OR (full_name IS NULL AND company IS NULL))"
)
elif filter == "erinnerung":
where.append(
"id IN (SELECT card_id FROM reminders WHERE sent_at IS NULL)"
)
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY COALESCE(captured_at, created_at) DESC, rowid DESC"
rows = [card_dict(r) for r in db.query(sql, params)]
totals = db.query_one(
"""
SELECT COUNT(*) AS total,
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen
FROM cards
"""
)
pending = db.query_one(
"SELECT COUNT(*) AS n FROM reminders WHERE sent_at IS NULL"
)
return {
"cards": rows,
"stats": {
"total": totals["total"] or 0,
"offen": totals["offen"] or 0,
"erinnerungen": pending["n"] or 0,
},
}
def _card_or_404(card_id: str):
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
if row is None:
raise HTTPException(status_code=404, detail="Karte nicht gefunden")
return row
@app.get("/api/cards/{card_id}", dependencies=[auth])
def get_card(card_id: str) -> dict:
card = card_dict(_card_or_404(card_id))
card["reminders"] = [
dict(r)
for r in db.query(
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
)
]
return card
@app.patch("/api/cards/{card_id}", dependencies=[auth])
async def update_card(card_id: str, request: Request) -> dict:
_card_or_404(card_id)
payload = await request.json()
changes = {k: (payload[k] or None) for k in EDITABLE if k in payload}
if "notes" in changes:
changes["notes"] = changes["notes"] or ""
if not changes:
raise HTTPException(status_code=400, detail="Keine bekannten Felder im Aufruf")
assignments = ", ".join(f"{k} = ?" for k in changes)
db.execute(
f"UPDATE cards SET {assignments} WHERE id = ?", [*changes.values(), card_id]
)
return card_dict(_card_or_404(card_id))
@app.delete("/api/cards/{card_id}", dependencies=[auth])
def delete_card(card_id: str) -> dict:
row = _card_or_404(card_id)
if row["image_file"]:
(config.IMAGE_DIR / row["image_file"]).unlink(missing_ok=True)
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
return {"ok": True}
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
def card_image(card_id: str) -> FileResponse:
row = _card_or_404(card_id)
path = config.IMAGE_DIR / (row["image_file"] or "")
if not row["image_file"] or not path.exists():
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
return FileResponse(path, media_type="image/jpeg")
# ---------------------------------------------------------------------------- Export
def _mark_exported(ids: list) -> None:
stamp = now_iso()
db.execute_many(
[("UPDATE cards SET exported_at = ? WHERE id = ?", (stamp, i)) for i in ids]
)
@app.get("/vcf/{card_id}", dependencies=[auth])
def download_vcard(card_id: str) -> Response:
card = dict(_card_or_404(card_id))
_mark_exported([card_id])
return Response(
content=vcard.build(card),
media_type="text/vcard; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{vcard.filename(card)}"'},
)
@app.get("/vcf", dependencies=[auth])
def download_vcards(ids: str = "") -> Response:
wanted = [i for i in ids.split(",") if i.strip()]
if not wanted:
raise HTTPException(status_code=400, detail="Keine Karten ausgewaehlt")
placeholders = ", ".join("?" for _ in wanted)
rows = db.query(f"SELECT * FROM cards WHERE id IN ({placeholders})", wanted)
if not rows:
raise HTTPException(status_code=404, detail="Karten nicht gefunden")
_mark_exported([r["id"] for r in rows])
return Response(
content=vcard.build_many([dict(r) for r in rows]),
media_type="text/vcard; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="kontakte.vcf"'},
)
# ----------------------------------------------------------------------- Erinnerungen
@app.get("/api/reminders", dependencies=[auth])
def list_reminders() -> dict:
rows = db.query(
"""
SELECT r.*, c.full_name, c.company
FROM reminders r JOIN cards c ON c.id = r.card_id
ORDER BY r.sent_at IS NOT NULL, r.due_at
"""
)
return {"reminders": [dict(r) for r in rows], "mail_configured": bool(config.SMTP_HOST)}
@app.post("/api/cards/{card_id}/reminders", dependencies=[auth])
async def create_reminder(card_id: str, request: Request) -> dict:
_card_or_404(card_id)
payload = await request.json()
text = (payload.get("text") or "").strip()
due_at = (payload.get("due_at") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Erinnerungstext fehlt")
try:
parsed = datetime.fromisoformat(due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Datum nicht lesbar") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
reminder_id = uuid.uuid4().hex
db.execute(
"INSERT INTO reminders (id, card_id, text, due_at, created_at) VALUES (?, ?, ?, ?, ?)",
(reminder_id, card_id, text, parsed.astimezone(timezone.utc).isoformat(), now_iso()),
)
return dict(db.query_one("SELECT * FROM reminders WHERE id = ?", (reminder_id,)))
@app.delete("/api/reminders/{reminder_id}", dependencies=[auth])
def delete_reminder(reminder_id: str) -> dict:
db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
return {"ok": True}
# --------------------------------------------------------------------------- Statisch
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/sw.js")
def service_worker() -> FileResponse:
# Muss von der Wurzel kommen, sonst gilt der Service Worker nur fuer /static.
return FileResponse(STATIC_DIR / "sw.js", media_type="application/javascript")
@app.get("/manifest.webmanifest")
def manifest() -> FileResponse:
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
@app.get("/robots.txt")
def robots() -> PlainTextResponse:
return PlainTextResponse("User-agent: *\nDisallow: /\n")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")