- 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>
283 lines
9.5 KiB
Python
283 lines
9.5 KiB
Python
"""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
|