new
This commit is contained in:
136
parse_text_pdf.py
Normal file
136
parse_text_pdf.py
Normal 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()
|
||||
Reference in New Issue
Block a user