This commit is contained in:
2026-07-11 14:23:52 -05:00
parent 0b84e2c59f
commit 5fe2e37fae

View File

@@ -2,84 +2,155 @@
""" """
parse_text_pdf.py - DETERMINISTISCHER Parser fuer die Text-PDFs (kein LLM). parse_text_pdf.py - DETERMINISTISCHER Parser fuer die Text-PDFs (kein LLM).
Nutzt die Struktur digital ausgefuellter BizMatch-Formulare: Nutzt die feste Struktur digital ausgefuellter BizMatch-Formulare (pypdf):
- Die Info-Seite und die CA-Seite werden ueber ANKER-Texte gefunden - Info-Seite und CA-Seite werden ueber ANKER-Texte gefunden (positionsunabh.).
(positionsunabhaengig - koennen auf beliebigen Seiten liegen). - Auf der Info-Seite folgt nach dem Template ein WERTE-BLOCK in fester
- Die eingegebenen Werte stehen als Block; wir ordnen sie den Feldern zu. 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) 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: Aufruf:
python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" python3 parse_text_pdf.py --pdf ~/data/S/"Sturgill, Garett 040126.pdf" [--debug]
python3 parse_text_pdf.py --pdf ... --debug # zeigt Zwischenschritte
""" """
import argparse, re, json import argparse, re, json, os
import pdfplumber from pypdf import PdfReader
ANCHOR_INFO = "BUYER INFORMATION SHEET" ANCHOR_INFO = "BUYER INFORMATION SHEET"
ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL" ANCHOR_CA = "PROSPECTIVE BUYER AGREES TO KEEP AND HOLD CONFIDENTIAL"
# Label-Zeilen des leeren Templates (Info-Seite), in Reihenfolge. # Leer-Marker: n, na, n/a (case-insensitiv), leer
# Nach diesen Labels kommt (weiter unten) der Werte-Block. _EMPTY_RE = re.compile(r"^(n|na|n/a|n\.a\.?)$", re.IGNORECASE)
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): def is_empty(s):
"""'n', leere/whitespace, oder nur Platzhalter -> gilt als leer."""
if s is None: if s is None:
return True return True
t = s.strip() t = s.strip()
if not t: return (not t) or bool(_EMPTY_RE.match(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): 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): def find_pages(reader):
"""Findet Info- und CA-Seite ueber Anker. Gibt (info_text, ca_text)."""
info_txt = ca_txt = None 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 "" t = page.extract_text() or ""
flat = t.replace("\n", " ")
if info_txt is None and ANCHOR_INFO in t: if info_txt is None and ANCHOR_INFO in t:
info_txt = t info_txt, info_pageno = t, i + 1
if ca_txt is None and ANCHOR_CA in t.replace("\n", " "): if ca_txt is None and ANCHOR_CA in flat:
ca_txt = t ca_txt, ca_pageno = t, i + 1
return info_txt, ca_txt return info_txt, ca_txt, info_pageno, ca_pageno
def parse_info_values(info_txt, debug=False): def value_block(info_txt):
""" """Zeilen des Werte-Blocks: nach 'BANK:' bis vor 'Doc ID'."""
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()] lines = [l.rstrip() for l in info_txt.splitlines()]
# Werte-Block beginnt nach der letzten Label-Zeile ("BANK:")
bank_idx = None bank_idx = None
for i, l in enumerate(lines): for i, l in enumerate(lines):
if l.strip().upper().startswith("BANK"): if l.strip().upper().startswith("BANK:"):
bank_idx = i bank_idx = i
if bank_idx is None: if bank_idx is None:
return None return None
block = [l for l in lines[bank_idx+1:] if l.strip()] block = []
# letzte Zeile ist oft "Doc ID: ..." -> entfernen for l in lines[bank_idx + 1:]:
block = [l for l in block if not l.strip().lower().startswith("doc id")] 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: if debug:
print(" --- Werte-Block ---") print(" --- Werte-Block ---")
for j, l in enumerate(block): for j, l in enumerate(block):
print(f" [{j}] {l!r}") 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(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
@@ -87,8 +158,8 @@ def main():
ap.add_argument("--debug", action="store_true") ap.add_argument("--debug", action="store_true")
args = ap.parse_args() args = ap.parse_args()
with pdfplumber.open(args.pdf) as pdf: reader = PdfReader(args.pdf)
info_txt, ca_txt = find_pages(pdf) info_txt, ca_txt, info_no, ca_no = find_pages(reader)
result = { result = {
"is_buyer_sheet": info_txt is not None, "is_buyer_sheet": info_txt is not None,
@@ -96,39 +167,16 @@ def main():
"phone": None, "cell": None, "email": None, "address": None, "phone": None, "cell": None, "email": None, "address": None,
"state": None, "how_did_you_hear": None, "interested_in_updates": None, "state": None, "how_did_you_hear": None, "interested_in_updates": None,
"types_of_business_raw": None, "background_experience": 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, "date_of_introduction": None,
"_checkbox_pending": True, # Checkbox spaeter per Crop-Vision "_checkbox_pending": True, "_parser": "deterministic",
"_parser": "deterministic", "_info_page": info_no, "_ca_page": ca_no,
} }
if info_txt: if info_txt:
block = parse_info_values(info_txt, debug=args.debug) info_fields, _ = parse_info(info_txt, debug=args.debug)
if block: result.update(info_fields)
# 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: if ca_txt:
# CA-Seite: Name + Datum aus dem Werte-Block nach dem Vertragstext result.update(parse_ca(ca_txt))
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)) print(json.dumps(result, indent=2, ensure_ascii=False))