Files
GuidoandClaude Opus 4.8 5f88660bcc feat: PDF-Beleg-Parser blockbasiert (mehrzeilig) + Seiten als Bild
- Neuer Parser erkennt mehrzeilige Auszüge (N26 & Co.): Beschreibung → Value
  Date → Buchungsdatum → Betrag stehen auf eigenen Zeilen; Betrags-Zeile schließt
  eine Buchung ab, Kopf-/Fußzeilen (Name/Adresse/eigene IBAN/Seitenzahl/
  Spaltenköpfe) werden gefiltert, Gegen-IBAN und Zweck werden übernommen
- Alter Ein-Zeilen-Heuristik-Parser bleibt als Fallback
- /documents/preview liefert zusätzlich die Seiten als PNG-Data-URLs
  (PyMuPDF get_pixmap, kein poppler nötig)
- Tests: N26-artige mehrzeilige Fixture + Seiten-Rendering (74 grün);
  am echten 34-Seiten-Auszug 207 Buchungen erkannt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:14:12 +02:00

283 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDF-/Bild-Belege lokal auslesen (kein Cloud-Dienst).
- Digitale PDFs: Text direkt via PyMuPDF.
- Gescannte PDFs / Bilder: OCR via Tesseract (pytesseract).
Danach ein heuristischer Parser, der Zeilen mit Datum + Betrag findet. Das ist
bewusst Best-Effort – das Ergebnis landet in derselben Prüfansicht wie der
CSV-Import und wird dort korrigiert.
"""
import base64
import io
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
try: # PyMuPDF (Text + Rendern)
import fitz # type: ignore
except Exception: # noqa: BLE001
fitz = None
try: # OCR
import pytesseract # type: ignore
from PIL import Image # type: ignore
except Exception: # noqa: BLE001
pytesseract = None
Image = None
class ExtractionError(RuntimeError):
pass
def _image_to_text(data: bytes) -> str:
if pytesseract is None or Image is None:
raise ExtractionError("OCR nicht verfügbar (pytesseract/Pillow/Tesseract fehlen).")
img = Image.open(io.BytesIO(data))
return pytesseract.image_to_string(img, lang="deu+eng")
def _pdf_to_text(data: bytes) -> str:
if fitz is None:
raise ExtractionError("PDF-Verarbeitung nicht verfügbar (PyMuPDF fehlt).")
parts: list[str] = []
with fitz.open(stream=data, filetype="pdf") as doc:
for page in doc:
txt = page.get_text()
if txt and txt.strip():
parts.append(txt)
elif pytesseract is not None and Image is not None:
pix = page.get_pixmap(dpi=200)
img = Image.open(io.BytesIO(pix.tobytes("png")))
parts.append(pytesseract.image_to_string(img, lang="deu+eng"))
return "\n".join(parts)
def extract_text(data: bytes, filename: str, content_type: str) -> str:
name = (filename or "").lower()
if name.endswith(".pdf") or content_type == "application/pdf":
return _pdf_to_text(data)
return _image_to_text(data)
# --- Heuristischer Zeilen-Parser -------------------------------------------
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}[./]\d{1,2}[./]\d{2,4})\b")
# Betrag: entweder mit Tausender-Gruppen (1'234.56 / 1.234,56) oder schlicht (3000.00).
AMOUNT_RE = re.compile(r"[-+]?(?:\d{1,3}(?:['.,\s]\d{3})+|\d+)[.,]\d{2}(?!\d)")
VALUE_DATE_RE = re.compile(r"value\s*date\s*[:\s]\s*(\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{4}-\d{2}-\d{2})", re.I)
IBAN_RE = re.compile(r"IBAN[:\s]+([A-Z]{2}[A-Z0-9]{8,32})", re.I)
_DATE_FORMATS = ["%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y", "%d/%m/%Y", "%d/%m/%y"]
def _norm_date(token: str):
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(token, fmt).date()
except ValueError:
continue
return None
def _parse_amount_auto(s: str) -> int | None:
"""Betrag mit automatisch erkanntem Dezimaltrenner (CH/DE/US) -> Cent."""
s = s.strip()
neg = s.startswith("-") or (s.startswith("(") and s.endswith(")"))
m = re.search(r"[.,](\d{2})$", s)
if not m:
return None
int_digits = re.sub(r"\D", "", s[: m.start()])
frac = m.group(1)
try:
val = Decimal(f"{int_digits or '0'}.{frac}")
except InvalidOperation:
return None
cents = int(val * 100)
return -cents if neg else cents
def _line_amount(line: str) -> int | None:
"""Betrag, falls die ZEILE im Wesentlichen nur ein Betrag ist (evtl. mit
Währungssymbol/-code), z. B. „-97,00€" oder „1'234.56 CHF". Sonst None."""
s = re.sub(r"\s*(€|EUR|CHF|Fr\.?|\$|USD)\s*$", "", line.strip(), flags=re.I).strip()
if AMOUNT_RE.fullmatch(s):
return _parse_amount_auto(s)
return None
def _line_date(line: str):
"""Datum, falls die ZEILE nur aus einem Datum besteht (Buchungsdatum)."""
s = line.strip()
if DATE_RE.fullmatch(s):
return _norm_date(s)
return None
def _filter_noise(raw_lines: list[str]) -> list[str]:
"""Entfernt wiederkehrende Kopf-/Fußzeilen von Kontoauszügen (Name/Adresse/
eigene IBAN im Seitenkopf, „Issued on" + Ausstellungsdatum, Seitenzahl,
Spaltenüberschriften)."""
lines = [ln for ln in raw_lines if ln]
if not lines:
return []
# Halter-Kopf: die Zeilen bis vor „Issued on" (Name, Adresse, eigene IBAN).
holder: set[str] = set()
for ln in lines[:6]:
if ln.lower().startswith("issued on"):
break
holder.add(ln)
heads = {"description", "booking date", "amount", "account activity"}
out: list[str] = []
skip_date = False
for ln in lines:
low = ln.lower()
if skip_date:
skip_date = False
if _line_date(ln): # das Ausstellungsdatum direkt nach „Issued on"
continue
if ln in holder or low in heads:
continue
if low.startswith("issued on"):
skip_date = True
continue
if re.fullmatch(r"\d+\s*/\s*\d+", ln): # Seitenzahl „1 / 34"
continue
out.append(ln)
return out
def _make_candidate(block: list[str], amount: int) -> dict:
"""Baut aus den angesammelten Beschreibungszeilen + Betrag eine Kandidaten-
Buchung. Buchungsdatum = letzte reine Datumszeile (sonst Value-/Erst-Datum)."""
d = None
used: set[int] = set()
for j in range(len(block) - 1, -1, -1):
dd = _line_date(block[j])
if dd:
d, _ = dd, used.add(j)
break
if d is None:
for j, l in enumerate(block):
vm = VALUE_DATE_RE.search(l)
if vm:
d, _ = _norm_date(vm.group(1)), used.add(j)
break
if d is None:
for j, l in enumerate(block):
gm = DATE_RE.search(l)
if gm:
d, _ = _norm_date(gm.group(1)), used.add(j)
break
iban = None
parts: list[str] = []
for j, l in enumerate(block):
if j in used or VALUE_DATE_RE.search(l):
continue
m = IBAN_RE.search(l)
if m and iban is None:
iban = re.sub(r"[^A-Za-z0-9]", "", m.group(1)).upper()
continue
parts.append(l)
payee = parts[0] if parts else None
purpose = " · ".join(parts[1:]) if len(parts) > 1 else None
return {
"date": d.isoformat() if d else None,
"amount_cents": amount,
"payee": payee[:120] if payee else None,
"purpose": purpose[:200] if purpose else None,
"counterparty_iban": iban,
}
def _parse_blocks(text: str) -> list[dict]:
"""Blockweise: jede Zeile, die im Wesentlichen NUR ein Betrag ist, schließt
eine Buchung ab; die Zeilen davor sind Beschreibung/Datum/IBAN. Passt zu
mehrzeiligen Auszügen (N26 & Co.), bei denen Datum und Betrag eigene Zeilen sind."""
lines = _filter_noise([ln.strip() for ln in text.splitlines()])
rows: list[dict] = []
block: list[str] = []
for ln in lines:
amt = _line_amount(ln)
if amt is None or amt == 0:
block.append(ln)
continue
rows.append(_make_candidate(block, amt))
block = []
return rows
def _parse_lines(text: str) -> list[dict]:
"""Fallback: Datum UND Betrag in derselben Zeile (einfache Tabellen)."""
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if len(line) < 6:
continue
dm = DATE_RE.search(line)
if not dm:
continue
d = _norm_date(dm.group(0))
if d is None:
continue
rest = f"{line[: dm.start()]} {line[dm.end():]}"
amounts = AMOUNT_RE.findall(rest)
if not amounts:
continue
amount = _parse_amount_auto(amounts[0])
if amount is None or amount == 0:
continue
desc = rest
for a in amounts:
desc = desc.replace(a, " ")
desc = re.sub(r"\s+", " ", desc).strip(" -–|;,.")
rows.append({
"date": d.isoformat(),
"amount_cents": amount,
"payee": desc[:120] or None,
"purpose": None,
"counterparty_iban": None,
})
return rows
def parse_transactions(text: str) -> list[dict]:
"""Kandidaten-Buchungen aus dem Beleg-Text. Erst blockbasiert (mehrzeilig),
sonst der einfache Ein-Zeilen-Fallback."""
rows = _parse_blocks(text)
valid = [r for r in rows if r.get("date") and r.get("amount_cents")]
if valid:
return rows
return _parse_lines(text)
# --- Seiten als Bild (zur Kontrolle in der Prüfansicht) --------------------
def render_pages(data: bytes, filename: str, content_type: str,
max_pages: int = 6, dpi: int = 110) -> list[str]:
"""Rendert die Beleg-Seiten als PNG-Data-URLs (PDF via PyMuPDF, ohne poppler).
Bilder werden unverändert als Data-URL zurückgegeben. Best-Effort."""
name = (filename or "").lower()
out: list[str] = []
is_pdf = name.endswith(".pdf") or content_type == "application/pdf"
try:
if is_pdf:
if fitz is None:
return []
with fitz.open(stream=data, filetype="pdf") as doc:
for k, page in enumerate(doc):
if k >= max_pages:
break
pix = page.get_pixmap(dpi=dpi)
b64 = base64.b64encode(pix.tobytes("png")).decode("ascii")
out.append(f"data:image/png;base64,{b64}")
else:
mime = content_type or "image/png"
b64 = base64.b64encode(data).decode("ascii")
out.append(f"data:{mime};base64,{b64}")
except Exception: # noqa: BLE001 – Vorschau ist optional
return out
return out