diff --git a/parse_text_pdf.py b/parse_text_pdf.py index a52053e..8ee572c 100644 --- a/parse_text_pdf.py +++ b/parse_text_pdf.py @@ -2,84 +2,155 @@ """ parse_text_pdf.py - DETERMINISTISCHER Parser fuer die Text-PDFs (kein LLM). -Nutzt die Struktur digital ausgefuellter BizMatch-Formulare: - - Die Info-Seite und die CA-Seite werden ueber ANKER-Texte gefunden - (positionsunabhaengig - koennen auf beliebigen Seiten liegen). - - Die eingegebenen Werte stehen als Block; wir ordnen sie den Feldern zu. +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) --> bleibt vorerst null, Marker _checkbox_pending=true fuer spaeteres Crop-Vision. +-> null, Marker _checkbox_pending=true fuer spaeteres Crop-Vision. Aufruf: - python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" - python3 parse_text_pdf.py --pdf ... --debug # zeigt Zwischenschritte + python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" [--debug] """ -import argparse, re, json -import pdfplumber +import argparse, re, json, os +from pypdf import PdfReader ANCHOR_INFO = "BUYER INFORMATION SHEET" ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL" -# Label-Zeilen des leeren Templates (Info-Seite), in Reihenfolge. -# Nach diesen Labels kommt (weiter unten) der Werte-Block. -INFO_LABELS = [ - "NAME / COMPANY", "PHONE", "ADDRESS", "EMAIL ADDRESS", - "HOW DID YOU HEAR ABOUT US", "TYPES OF BUSINESSES", - "BACKGROUND", "TOTAL PURCHASE PRICE", "DOWN PAYMENT", - "INCOME REQUIREMENTS", "ACCOUNTANT", "ATTORNEY", "BANK", -] +# 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_marker(s): - """'n', leere/whitespace, oder nur Platzhalter -> gilt als leer.""" +def is_empty(s): if s is None: return True t = s.strip() - if not t: - return True - # einzelne 'n' oder Folgen von 'n' (Platzhalter fuer leere Felder) - if re.fullmatch(r"[nN]( +[nN])*", t): - return True - return False + return (not t) or bool(_EMPTY_RE.match(t)) def clean(s): - return None if is_empty_marker(s) else s.strip() + return None if is_empty(s) else s.strip() -def find_pages(pdf): - """Findet Info- und CA-Seite ueber Anker. Gibt (info_text, ca_text).""" +def find_pages(reader): info_txt = ca_txt = None - for page in pdf.pages: + 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 = t - if ca_txt is None and ANCHOR_CA in t.replace("\n", " "): - ca_txt = t - return info_txt, ca_txt + 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 parse_info_values(info_txt, debug=False): - """ - Extrahiert den Werte-Block der Info-Seite. - Struktur (aus Analyse): nach der Label-Vorlage (endet mit 'BANK:') - folgt der Werte-Block. Die Werte stehen in fester Reihenfolge: - name, phone-zeile, address, email, how_heard, types, background(1-2 zeilen), - purchase_price, down_payment, income, accountant, attorney, bank - Da leere Felder als 'n' erscheinen, gehen wir zeilenweise vor. - """ +def value_block(info_txt): + """Zeilen des Werte-Blocks: nach 'BANK:' bis vor 'Doc ID'.""" lines = [l.rstrip() for l in info_txt.splitlines()] - # Werte-Block beginnt nach der letzten Label-Zeile ("BANK:") bank_idx = None for i, l in enumerate(lines): - if l.strip().upper().startswith("BANK"): + if l.strip().upper().startswith("BANK:"): bank_idx = i if bank_idx is None: return None - block = [l for l in lines[bank_idx+1:] if l.strip()] - # letzte Zeile ist oft "Doc ID: ..." -> entfernen - block = [l for l in block if not l.strip().lower().startswith("doc id")] + 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}") - return block + 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() @@ -87,8 +158,8 @@ def main(): ap.add_argument("--debug", action="store_true") args = ap.parse_args() - with pdfplumber.open(args.pdf) as pdf: - info_txt, ca_txt = find_pages(pdf) + reader = PdfReader(args.pdf) + info_txt, ca_txt, info_no, ca_no = find_pages(reader) result = { "is_buyer_sheet": info_txt is not None, @@ -96,39 +167,16 @@ def main(): "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, + "total_purchase_price": None, "down_payment": None, "down_payment_raw": None, "date_of_introduction": None, - "_checkbox_pending": True, # Checkbox spaeter per Crop-Vision - "_parser": "deterministic", + "_checkbox_pending": True, "_parser": "deterministic", + "_info_page": info_no, "_ca_page": ca_no, } - if info_txt: - block = parse_info_values(info_txt, debug=args.debug) - if block: - # Heuristische Zuordnung nach Reihenfolge. - # Wir mappen defensiv: bekannte Anker im Block suchen. - # block[0] = name, block[1] = phone-zeile, ... - def get(i): - return clean(block[i]) if i < len(block) else None - result["name_company"] = get(0) - # phone-zeile kann "(PHONE) n n" o.ae. sein - result["phone"] = None # wird unten aus phone-zeile gezogen - # Rest positionsbasiert - ACHTUNG: haengt von Leer-Feld-Verhalten ab - # Wir geben den Block auch roh mit, zum Debuggen der Zuordnung - result["_value_block"] = block - + info_fields, _ = parse_info(info_txt, debug=args.debug) + result.update(info_fields) if ca_txt: - # CA-Seite: Name + Datum aus dem Werte-Block nach dem Vertragstext - ca_lines = [l.strip() for l in ca_txt.splitlines() if l.strip()] - # Datum finden (MM / DD / YYYY) - for l in ca_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() - result["date_of_introduction"] = f"{yy}-{int(mm):02d}-{int(dd):02d}" - break - # Name: erste nicht-leere Zeile nach dem Anker-Block, die nicht 'n' ist - # (aus Analyse: direkt nach dem langen Vertragstext-Block) + result.update(parse_ca(ca_txt)) print(json.dumps(result, indent=2, ensure_ascii=False))