This commit is contained in:
2026-07-11 13:48:43 -05:00
parent 3b49970702
commit 76a22eb20d
2 changed files with 217 additions and 0 deletions

136
parse_text_pdf.py Normal file
View File

@@ -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()

81
probe_checkbox.py Normal file
View File

@@ -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]}")