diff --git a/parse_text_pdf.py b/parse_text_pdf.py new file mode 100644 index 0000000..a52053e --- /dev/null +++ b/parse_text_pdf.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +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. + +Checkbox 'interested_in_updates' steht NICHT im Text (eingebettetes Bild) +-> bleibt vorerst 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 +""" +import argparse, re, json +import pdfplumber + +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", +] + +def is_empty_marker(s): + """'n', leere/whitespace, oder nur Platzhalter -> gilt als leer.""" + 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 + +def clean(s): + return None if is_empty_marker(s) else s.strip() + +def find_pages(pdf): + """Findet Info- und CA-Seite ueber Anker. Gibt (info_text, ca_text).""" + info_txt = ca_txt = None + for page in pdf.pages: + t = page.extract_text() or "" + 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 + +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. + """ + 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"): + 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")] + if debug: + print(" --- Werte-Block ---") + for j, l in enumerate(block): + print(f" [{j}] {l!r}") + return block + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pdf", required=True) + 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) + + 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, + "date_of_introduction": None, + "_checkbox_pending": True, # Checkbox spaeter per Crop-Vision + "_parser": "deterministic", + } + + 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 + + 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) + + print(json.dumps(result, indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/probe_checkbox.py b/probe_checkbox.py new file mode 100644 index 0000000..3a300bb --- /dev/null +++ b/probe_checkbox.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +probe_checkbox.py - Untersucht, was pdfplumber an Grafik/Annotationen auf der +Info-Seite sieht, speziell rund um die YES/NO-Checkbox. + +Aufruf: + python3 probe_checkbox.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" +""" +import argparse +import pdfplumber + +ANCHOR_INFO = "BUYER INFORMATION SHEET" + +ap = argparse.ArgumentParser() +ap.add_argument("--pdf", required=True) +args = ap.parse_args() + +with pdfplumber.open(args.pdf) as pdf: + # Info-Seite per Anker finden + info_page = None + for i, page in enumerate(pdf.pages): + txt = page.extract_text() or "" + if ANCHOR_INFO in txt: + info_page = page + print(f"Info-Seite gefunden: Seite {i+1}\n") + break + if info_page is None: + print("Keine Info-Seite gefunden!") + raise SystemExit(1) + + # Finde die Y-Position der Checkbox-Zeile ("INTEREST?" ... "YES" ... "NO") + words = info_page.extract_words() + yes_word = no_word = interest_word = None + for w in words: + t = w["text"].upper().strip(".:?") + if t == "YES" and yes_word is None: + yes_word = w + elif t == "NO" and no_word is None: + no_word = w + elif "INTEREST" in t and interest_word is None: + interest_word = w + + print("=== Position der Schluesselwoerter ===") + for label, w in [("INTEREST", interest_word), ("YES", yes_word), ("NO", no_word)]: + if w: + print(f" {label:10} x0={w['x0']:.0f} x1={w['x1']:.0f} top={w['top']:.0f} bottom={w['bottom']:.0f}") + else: + print(f" {label:10} NICHT GEFUNDEN") + + if not (yes_word and no_word): + print("\nYES/NO nicht beide gefunden - Analyse eingeschraenkt.") + + # Grafik-Elemente in der Naehe der YES/NO-Zeile untersuchen + print(f"\n=== Grafik-Elemente (rects/lines/curves) auf der Info-Seite ===") + print(f" rects: {len(info_page.rects)}") + print(f" lines: {len(info_page.lines)}") + print(f" curves: {len(info_page.curves)}") + + # Die Checkbox-Zeile hat ein bestimmtes 'top'. Zeige alle Rects/Lines in + # diesem Y-Bereich (kleine Quadrate = Checkboxen, Haekchen = curves/lines). + if yes_word: + y_lo = yes_word["top"] - 5 + y_hi = yes_word["bottom"] + 5 + print(f"\n=== Elemente im Y-Bereich der YES/NO-Zeile (top {y_lo:.0f}..{y_hi:.0f}) ===") + print(" --- Rechtecke (moegliche Checkboxen) ---") + for r in info_page.rects: + if y_lo <= r["top"] <= y_hi or y_lo <= r["bottom"] <= y_hi: + w_ = r["x1"]-r["x0"]; h_ = r["bottom"]-r["top"] + print(f" x0={r['x0']:.0f} top={r['top']:.0f} groesse={w_:.0f}x{h_:.0f}") + print(" --- Linien/Kurven (moegliche Haekchen) ---") + for el in info_page.lines + info_page.curves: + if y_lo <= el["top"] <= y_hi or y_lo <= el["bottom"] <= y_hi: + print(f" typ x0={el['x0']:.0f} x1={el['x1']:.0f} top={el['top']:.0f} bottom={el['bottom']:.0f}") + + # Annotationen (Formularfelder / Widgets)? + print(f"\n=== Annotationen (Formular-Widgets) ===") + annots = info_page.annots or [] + print(f" Anzahl: {len(annots)}") + for a in annots[:15]: + print(f" {a.get('data',{}).get('Subtype','?')} @ top={a.get('top',0):.0f} " + f"x0={a.get('x0',0):.0f} {str(a.get('data',{}).get('AS',''))[:30]}") \ No newline at end of file