Files
ai-bayarea/python/parse_text_pdf.py
2026-07-12 12:30:20 -05:00

184 lines
6.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.
#!/usr/bin/env python3
"""
parse_text_pdf.py - DETERMINISTISCHER Parser fuer die Text-PDFs (kein LLM).
Nutzt die feste Struktur digital ausgefuellter BizMatch-Formulare (pypdf):
- Info-Seite und CA-Seite werden ueber ANKER-Texte gefunden (positionsunabh.).
- Auf der Info-Seite folgt nach dem Template ein WERTE-BLOCK in fester
Feld-Reihenfolge.
- Zuordnung von VORNE (Name..Types, einzeilig, fix) und von HINTEN
(letzte 6 Felder: Price, DownPay, Income, Accountant, Attorney, Bank).
Dazwischen = mehrzeiliger Background.
Checkbox 'interested_in_updates' steht NICHT im Text (eingebettetes Bild)
-> null, Marker _checkbox_pending=true fuer spaeteres Crop-Vision.
Aufruf:
python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" [--debug]
"""
import argparse, re, json, os
from pypdf import PdfReader
ANCHOR_INFO = "BUYER INFORMATION SHEET"
ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL"
# Leer-Marker: n, na, n/a (case-insensitiv), leer
_EMPTY_RE = re.compile(r"^(n|na|n/a|n\.a\.?)$", re.IGNORECASE)
def is_empty(s):
if s is None:
return True
t = s.strip()
return (not t) or bool(_EMPTY_RE.match(t))
def clean(s):
return None if is_empty(s) else s.strip()
def find_pages(reader):
info_txt = ca_txt = None
info_pageno = ca_pageno = None
for i, page in enumerate(reader.pages):
t = page.extract_text() or ""
flat = t.replace("\n", " ")
if info_txt is None and ANCHOR_INFO in t:
info_txt, info_pageno = t, i + 1
if ca_txt is None and ANCHOR_CA in flat:
ca_txt, ca_pageno = t, i + 1
return info_txt, ca_txt, info_pageno, ca_pageno
def value_block(info_txt):
"""Zeilen des Werte-Blocks: nach 'BANK:' bis vor 'Doc ID'."""
lines = [l.rstrip() for l in info_txt.splitlines()]
bank_idx = None
for i, l in enumerate(lines):
if l.strip().upper().startswith("BANK:"):
bank_idx = i
if bank_idx is None:
return None
block = []
for l in lines[bank_idx + 1:]:
if l.strip().lower().startswith("doc id"):
break
if l.strip():
block.append(l.strip())
return block
def parse_down_payment(raw):
"""
Gibt (zahl_oder_none, rohwert) zurueck.
Eindeutige Zahl: '350000' -> '350000'; '$350,000' -> '350000';
'1.5M'/'1.5 mil' -> '1500000'; '500k' -> '500000'.
Spanne ('50-200k'), Text ('Depends on deal') -> None, Rohwert behalten.
"""
if is_empty(raw):
return None, None
s = raw.strip()
low = s.lower().replace(",", "").replace("$", "").replace(" ", "")
# Spanne (enthaelt Bindestrich zwischen Zahlen) -> nicht eindeutig
if re.search(r"\d\s*[-]\s*\d", low):
return None, s
m = re.fullmatch(r"(\d+(?:\.\d+)?)(k|m|mil|million)?", low)
if m:
num = float(m.group(1))
suffix = m.group(2)
if suffix == "k":
num *= 1_000
elif suffix in ("m", "mil", "million"):
num *= 1_000_000
return str(int(num)), s
return None, s # Text -> null, Rohwert behalten
def parse_info(info_txt, debug=False):
block = value_block(info_txt)
out = {}
if not block:
return out, None
if debug:
print(" --- Werte-Block ---")
for j, l in enumerate(block):
print(f" [{j}] {l!r}")
n = len(block)
# Von HINTEN: letzte 6 Felder
# Reihenfolge: Price, DownPay, Income, Accountant, Attorney, Bank
if n >= 6:
last6 = block[-6:]
out["total_purchase_price"] = clean(last6[0])
dp_num, dp_raw = parse_down_payment(last6[1])
out["down_payment"] = dp_num
out["down_payment_raw"] = dp_raw
# income/accountant/attorney/bank interessieren uns nicht als Zielfelder
front_end = n - 6
else:
front_end = n
# Von VORNE: feste einzeilige Felder
def g(i):
return clean(block[i]) if i < front_end else None
out["name_company"] = g(0)
# Phone-Zeile: "{PHONE} FAX CELL" bzw. mit Leer-Markern dazwischen
if 1 < front_end:
phone_line = block[1]
# grob: erstes Token=Phone, evtl. weitere; wir nehmen die Zeile roh und
# bereinigen Leer-Marker. Feinsplit spaeter falls noetig.
toks = phone_line.split()
toks = [t for t in toks if not is_empty(t)]
out["phone"] = toks[0] if toks else None
out["cell"] = toks[-1] if len(toks) > 1 else None
out["address"] = g(2)
out["email"] = g(3)
out["how_did_you_hear"] = g(4)
out["types_of_business_raw"] = g(5)
# Background = alles zwischen Index 6 und front_end (mehrzeilig)
if front_end > 6:
bg = " ".join(block[6:front_end]).strip()
out["background_experience"] = bg or None
else:
out["background_experience"] = None
return out, block
def parse_ca(ca_txt):
out = {}
lines = [l.strip() for l in ca_txt.splitlines() if l.strip()]
# Datum
for l in lines:
m = re.search(r"(\d{1,2})\s*/\s*(\d{1,2})\s*/\s*(\d{4})", l)
if m:
mm, dd, yy = m.groups()
out["date_of_introduction"] = f"{yy}-{int(mm):02d}-{int(dd):02d}"
break
# Prospective Buyer: erste Zeile nach dem Anker-Block (Zeile 1 ist der
# lange Vertragstext; Zeile 2 ist der Name)
if len(lines) >= 2 and ANCHOR_CA in lines[0].replace(" ", " "):
out["prospective_buyer"] = clean(lines[1])
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
ap.add_argument("--debug", action="store_true")
args = ap.parse_args()
reader = PdfReader(args.pdf)
info_txt, ca_txt, info_no, ca_no = find_pages(reader)
result = {
"is_buyer_sheet": info_txt is not None,
"name_company": None, "prospective_buyer": None, "company": None,
"phone": None, "cell": None, "email": None, "address": None,
"state": None, "how_did_you_hear": None, "interested_in_updates": None,
"types_of_business_raw": None, "background_experience": None,
"total_purchase_price": None, "down_payment": None, "down_payment_raw": None,
"date_of_introduction": None,
"_checkbox_pending": True, "_parser": "deterministic",
"_info_page": info_no, "_ca_page": ca_no,
}
if info_txt:
info_fields, _ = parse_info(info_txt, debug=args.debug)
result.update(info_fields)
if ca_txt:
result.update(parse_ca(ca_txt))
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()