typescript ansatz

This commit is contained in:
2026-07-11 15:56:14 -05:00
parent 0df7c36fbd
commit b8c862885e
4 changed files with 229 additions and 1 deletions

184
BuyerSheetParser.ts Normal file
View File

@@ -0,0 +1,184 @@
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
import * as fs from 'fs';
export interface BuyerSheetData {
is_buyer_sheet: boolean;
name_company: string | null;
prospective_buyer: string | null;
company: string | null;
phone: string | null;
cell: string | null;
email: string | null;
address: string | null;
state: string | null;
how_did_you_hear: string | null;
interested_in_updates: boolean | null;
types_of_business_raw: string | null;
background_experience: string | null;
total_purchase_price: string | null;
down_payment: string | null;
down_payment_raw: string | null;
date_of_introduction: string | null;
_checkbox_pending: boolean;
_parser: string;
_info_page: number;
_ca_page: number;
}
const LABELS = [
"NAME / COMPANY", "PHONE", "ADDRESS", "EMAIL ADDRESS",
"HOW DID YOU HEAR", "ARE YOU INTERESTED", "TYPES OF BUSINESSES",
"BACKGROUND", "TOTAL PURCHASE PRICE", "DOWN PAYMENT",
"INCOME REQUIREMENTS", "ACCOUNTANT", "ATTORNEY", "BANK",
];
export class DeterministicParser {
public async parsePdf(filePath: string): Promise<BuyerSheetData> {
const dataBuffer = fs.readFileSync(filePath);
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(dataBuffer) });
const pdfDocument = await loadingTask.promise;
const page = await pdfDocument.getPage(1);
const textContent = await page.getTextContent();
// 1. Textfragmente mit X, Y und Breite (Width) auslesen
const mappedItems = textContent.items
.filter((item: any) => item.str.trim() !== '')
.map((item: any) => ({
text: item.str,
x: item.transform[4],
y: item.transform[5],
width: item.width
}))
.sort((a, b) => b.y - a.y || a.x - b.x); // Y absteigend (oben nach unten)
// 2. Zeilen bilden (Y-Toleranz)
const linesGrouped: {y: number, items: any[]}[] = [];
let currentY: number | null = null;
let currentItems = [];
for (const item of mappedItems) {
if (currentY === null || Math.abs(item.y - currentY) <= 3) {
currentItems.push(item);
currentY = currentY === null ? item.y : currentY;
} else {
linesGrouped.push({ y: currentY, items: currentItems });
currentItems = [item];
currentY = item.y;
}
}
if (currentItems.length > 0 && currentY !== null) {
linesGrouped.push({ y: currentY, items: currentItems });
}
// 3. Zerrissene Wörter reparieren (Kerning)
const lines: { y: number, text: string }[] = [];
for (const group of linesGrouped) {
group.items.sort((a, b) => a.x - b.x);
let lineStr = "";
let prevEnd = -1;
for (const item of group.items) {
if (prevEnd !== -1) {
const gap = item.x - prevEnd;
// Wenn die Lücke größer als ~4 Pixel ist, ist es ein echtes Leerzeichen
if (gap > 4) {
lineStr += " ";
}
}
lineStr += item.text;
prevEnd = item.x + item.width;
}
lines.push({ y: group.y, text: lineStr });
}
// 4. Y-Intervall Parsing (Die "Nutzer-Idee")
const rawResults: Record<string, string[]> = {};
LABELS.forEach(lbl => rawResults[lbl] = []);
let currentLabel: string | null = null;
// Längste Labels zuerst suchen, damit "EMAIL ADDRESS" vor "ADDRESS" gefunden wird
const sortedLabels = [...LABELS].sort((a, b) => b.length - a.length);
for (const line of lines) {
const textUpper = line.text.toUpperCase().replace(/\s+/g, '');
// Boilerplate ignorieren, der Labels enthält ("VERIFICATION OF DOWN PAYMENT")
if (textUpper.includes("SELLERMAYREQUIREVERIFICATION")) continue;
let foundLabel: string | null = null;
for (const lbl of sortedLabels) {
const lblClean = lbl.toUpperCase().replace(/\s+/g, '');
if (textUpper.includes(lblClean)) {
foundLabel = lbl;
break;
}
}
if (foundLabel) {
currentLabel = foundLabel;
// Falls der Wert direkt auf derselben Zeile steht (z.B. "NAME/COMPANY: Gunnar Schultz")
if (line.text.includes(':')) {
const parts = line.text.split(':');
const val = parts.slice(1).join(':').replace(/_+/g, '').trim(); // Unterstriche entfernen
if (val.length > 0) {
rawResults[currentLabel].push(val);
}
}
} else if (currentLabel) {
// Diese Zeile ist kein Label, gehört also zum aktuellen Bereich!
const cleanVal = line.text.replace(/_+/g, '').trim();
// Footer und Artefakte ignorieren
if (cleanVal.length > 0 && !cleanVal.includes("Doc ID") && !cleanVal.includes("Bizmatch")) {
rawResults[currentLabel].push(cleanVal);
}
}
}
return this.mapToTargetStructure(rawResults, filePath);
}
private mapToTargetStructure(raw: Record<string, string[]>, filePath: string): BuyerSheetData {
const getVal = (label: string) => raw[label] && raw[label].length > 0 ? raw[label].join(' ') : null;
const nameCompany = getVal("NAME / COMPANY");
// Down Payment normalisieren
const downPaymentRaw = getVal("DOWN PAYMENT")?.replace(/[^0-9,]/g, '') || null;
const downPaymentClean = downPaymentRaw ? downPaymentRaw.replace(/,/g, '') : null;
// Datum aus Dateinamen extrahieren
let dateOfIntro = null;
const dateMatch = filePath.match(/(\d{2})(\d{2})(\d{2})\.pdf$/);
if (dateMatch) {
dateOfIntro = `20${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}`;
}
return {
is_buyer_sheet: true,
name_company: nameCompany,
prospective_buyer: nameCompany ? nameCompany.split('/')[0].trim() : null,
company: null,
phone: getVal("PHONE"),
cell: null,
email: getVal("EMAIL ADDRESS"),
address: getVal("ADDRESS"),
state: null,
how_did_you_hear: getVal("HOW DID YOU HEAR"),
interested_in_updates: null,
types_of_business_raw: getVal("TYPES OF BUSINESSES"),
background_experience: getVal("BACKGROUND"),
total_purchase_price: getVal("TOTAL PURCHASE PRICE"),
down_payment: downPaymentClean,
down_payment_raw: downPaymentRaw,
date_of_introduction: dateOfIntro,
_checkbox_pending: true,
_parser: "deterministic",
_info_page: 1,
_ca_page: 2
};
}
}