@parseo/appraisals 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/form-1004mc/extract-checkboxes.d.ts +43 -0
- package/dist/form-1004mc/extract-checkboxes.d.ts.map +1 -0
- package/dist/form-1004mc/extract-checkboxes.js +145 -0
- package/dist/form-1004mc/index.d.ts +3 -0
- package/dist/form-1004mc/index.d.ts.map +1 -0
- package/dist/form-1004mc/index.js +1 -0
- package/dist/form-1004mc/parse-page1.d.ts +8 -0
- package/dist/form-1004mc/parse-page1.d.ts.map +1 -0
- package/dist/form-1004mc/parse-page1.js +760 -0
- package/dist/form-1004mc/parse-sales.d.ts +6 -0
- package/dist/form-1004mc/parse-sales.d.ts.map +1 -0
- package/dist/form-1004mc/parse-sales.js +505 -0
- package/dist/form-1004mc/parser.d.ts +5 -0
- package/dist/form-1004mc/parser.d.ts.map +1 -0
- package/dist/form-1004mc/parser.js +437 -0
- package/dist/form-1004mc/types.d.ts +302 -0
- package/dist/form-1004mc/types.d.ts.map +1 -0
- package/dist/form-1004mc/types.js +1 -0
- package/dist/form-1073/index.d.ts +3 -0
- package/dist/form-1073/index.d.ts.map +1 -0
- package/dist/form-1073/index.js +1 -0
- package/dist/form-1073/parse-page1.d.ts +8 -0
- package/dist/form-1073/parse-page1.d.ts.map +1 -0
- package/dist/form-1073/parse-page1.js +704 -0
- package/dist/form-1073/parse-page2.d.ts +6 -0
- package/dist/form-1073/parse-page2.d.ts.map +1 -0
- package/dist/form-1073/parse-page2.js +438 -0
- package/dist/form-1073/parse-sales.d.ts +7 -0
- package/dist/form-1073/parse-sales.d.ts.map +1 -0
- package/dist/form-1073/parse-sales.js +477 -0
- package/dist/form-1073/parser.d.ts +5 -0
- package/dist/form-1073/parser.d.ts.map +1 -0
- package/dist/form-1073/parser.js +102 -0
- package/dist/form-1073/types.d.ts +300 -0
- package/dist/form-1073/types.d.ts.map +1 -0
- package/dist/form-1073/types.js +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/richer-values/index.d.ts +3 -0
- package/dist/richer-values/index.d.ts.map +1 -0
- package/dist/richer-values/index.js +1 -0
- package/dist/richer-values/parser.d.ts +5 -0
- package/dist/richer-values/parser.d.ts.map +1 -0
- package/dist/richer-values/parser.js +1067 -0
- package/dist/richer-values/types.d.ts +225 -0
- package/dist/richer-values/types.d.ts.map +1 -0
- package/dist/richer-values/types.js +1 -0
- package/package.json +24 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TextLine } from "@parseo/shared";
|
|
2
|
+
import type { SalesComparisonSection, ReconciliationSection, CostApproachSection } from "./types.js";
|
|
3
|
+
export declare function parseSalesComparisonSection(lines: TextLine[], compStartNum?: number): SalesComparisonSection;
|
|
4
|
+
export declare function parseReconciliationSection(lines: TextLine[]): ReconciliationSection;
|
|
5
|
+
export declare function parseCostApproachSection(lines: TextLine[]): CostApproachSection;
|
|
6
|
+
//# sourceMappingURL=parse-sales.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-sales.d.ts","sourceRoot":"","sources":["../../src/form-1004mc/parse-sales.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAe,MAAM,gBAAgB,CAAC;AAC5D,OAAO,KAAK,EACV,sBAAsB,EAEtB,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAoOpB,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,QAAQ,EAAE,EACjB,YAAY,GAAE,MAAU,GACvB,sBAAsB,CA6HxB;AAID,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,qBAAqB,CA2EnF;AAID,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,mBAAmB,CAoF/E"}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { toBBox } from "@parseo/shared";
|
|
2
|
+
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
3
|
+
function parseNum(raw) {
|
|
4
|
+
if (!raw)
|
|
5
|
+
return null;
|
|
6
|
+
const cleaned = raw.replace(/[$,%]/g, "").replace(/,/g, "").trim();
|
|
7
|
+
if (!cleaned || /^n\/?a$/i.test(cleaned))
|
|
8
|
+
return null;
|
|
9
|
+
const n = Number(cleaned);
|
|
10
|
+
return Number.isNaN(n) ? null : n;
|
|
11
|
+
}
|
|
12
|
+
function parseCurrency(raw) {
|
|
13
|
+
const match = raw.match(/\$?\s*([\d,]+(?:\.\d+)?)/);
|
|
14
|
+
return match ? parseNum(match[1]) : null;
|
|
15
|
+
}
|
|
16
|
+
// Column x-boundaries for Form 1004 sales comparison grid
|
|
17
|
+
// Subject ~131, Comp1 ~197, Comp2 ~328, Comp3 ~459
|
|
18
|
+
// Adjustment columns follow each comp description
|
|
19
|
+
const SUBJECT_X = { min: 120, max: 195 };
|
|
20
|
+
const COMP_COLS = [
|
|
21
|
+
{ desc: { min: 195, max: 295 }, adj: { min: 295, max: 325 } }, // Comp 1
|
|
22
|
+
{ desc: { min: 325, max: 430 }, adj: { min: 430, max: 457 } }, // Comp 2
|
|
23
|
+
{ desc: { min: 457, max: 560 }, adj: { min: 560, max: 600 } }, // Comp 3
|
|
24
|
+
];
|
|
25
|
+
function segInRange(line, min, max) {
|
|
26
|
+
const segs = line.segments.filter((s) => s.x >= min && s.x < max);
|
|
27
|
+
return segs.map((s) => s.text.trim()).join(" ").trim();
|
|
28
|
+
}
|
|
29
|
+
function segWithBBox(line, min, max) {
|
|
30
|
+
const seg = line.segments.find((s) => s.x >= min && s.x < max);
|
|
31
|
+
return { text: seg ? seg.text.trim() : "", seg: seg ?? null };
|
|
32
|
+
}
|
|
33
|
+
function findLine(lines, pattern) {
|
|
34
|
+
return lines.find((l) => pattern.test(l.fullText));
|
|
35
|
+
}
|
|
36
|
+
// ── Parse comparable from a column ────────────────────────────────────────
|
|
37
|
+
function parseComparable(lines, compNum, col) {
|
|
38
|
+
const bb = {};
|
|
39
|
+
const prefix = `comp${compNum}`;
|
|
40
|
+
function getDesc(line) {
|
|
41
|
+
return line ? segInRange(line, col.desc.min, col.adj.min) : "";
|
|
42
|
+
}
|
|
43
|
+
function getAdj(line) {
|
|
44
|
+
if (!line)
|
|
45
|
+
return null;
|
|
46
|
+
const text = segInRange(line, col.adj.min, col.adj.min + 60);
|
|
47
|
+
return parseNum(text);
|
|
48
|
+
}
|
|
49
|
+
function getDescWithBB(line, key) {
|
|
50
|
+
if (!line)
|
|
51
|
+
return "";
|
|
52
|
+
const r = segWithBBox(line, col.desc.min, col.adj.min);
|
|
53
|
+
if (r.seg)
|
|
54
|
+
bb[`${prefix}_${key}`] = toBBox(r.seg, line);
|
|
55
|
+
return r.text;
|
|
56
|
+
}
|
|
57
|
+
const addrLine = findLine(lines, /^Address/i);
|
|
58
|
+
const address = getDescWithBB(addrLine, "address");
|
|
59
|
+
// City is on the next line after address
|
|
60
|
+
const addrIdx = addrLine ? lines.indexOf(addrLine) : -1;
|
|
61
|
+
const cityLine = addrIdx >= 0 ? lines[addrIdx + 1] : undefined;
|
|
62
|
+
const cityVal = cityLine ? segInRange(cityLine, col.desc.min, col.adj.min) : "";
|
|
63
|
+
const proxLine = findLine(lines, /^Proximity to Subject/i);
|
|
64
|
+
const proximityToSubject = getDescWithBB(proxLine, "proximity");
|
|
65
|
+
const priceLine = findLine(lines, /^Sale Price\b/i);
|
|
66
|
+
let salePrice = null;
|
|
67
|
+
if (priceLine) {
|
|
68
|
+
// Price segments have $ and number in separate segments
|
|
69
|
+
const priceText = segInRange(priceLine, col.desc.min - 30, col.adj.min);
|
|
70
|
+
salePrice = parseCurrency(priceText);
|
|
71
|
+
const pSeg = segWithBBox(priceLine, col.desc.min, col.adj.min);
|
|
72
|
+
if (pSeg.seg)
|
|
73
|
+
bb[`${prefix}_salePrice`] = toBBox(pSeg.seg, priceLine);
|
|
74
|
+
}
|
|
75
|
+
const ppsLine = findLine(lines, /^Sale Price\/Gross Liv/i);
|
|
76
|
+
let salePricePerSqft = null;
|
|
77
|
+
if (ppsLine) {
|
|
78
|
+
const txt = segInRange(ppsLine, col.desc.min - 30, col.adj.min);
|
|
79
|
+
const m = txt.match(/([\d,.]+)\s*sq\.?\s*ft/i);
|
|
80
|
+
if (m)
|
|
81
|
+
salePricePerSqft = parseNum(m[1]);
|
|
82
|
+
}
|
|
83
|
+
const dsLine = findLine(lines, /^Data Source\(s\)/i);
|
|
84
|
+
const dataSources = getDescWithBB(dsLine, "dataSources");
|
|
85
|
+
const vsLine = findLine(lines, /^Verification Source/i);
|
|
86
|
+
const verificationSources = getDesc(vsLine);
|
|
87
|
+
const sfLine = findLine(lines, /^Sales or Financing/i);
|
|
88
|
+
const salesOrFinancing = getDesc(sfLine);
|
|
89
|
+
const concLine = findLine(lines, /^Concessions/i);
|
|
90
|
+
const concessions = getDesc(concLine);
|
|
91
|
+
const dateLine = findLine(lines, /^Date of Sale\/Time/i);
|
|
92
|
+
const dateOfSaleTime = getDescWithBB(dateLine, "dateOfSale");
|
|
93
|
+
const locLine = findLine(lines, /^Location\b/i);
|
|
94
|
+
const location = getDesc(locLine);
|
|
95
|
+
const feeLine = findLine(lines, /^Leasehold\/Fee Simple/i);
|
|
96
|
+
const leaseholdFeeSimple = getDesc(feeLine);
|
|
97
|
+
const siteLine = findLine(lines, /^Site\b/i);
|
|
98
|
+
const site = getDesc(siteLine);
|
|
99
|
+
const siteAdjustment = getAdj(siteLine);
|
|
100
|
+
const viewLine = findLine(lines, /^View\b/i);
|
|
101
|
+
const viewVal = getDesc(viewLine);
|
|
102
|
+
const viewAdjustment = getAdj(viewLine);
|
|
103
|
+
const styleLine = findLine(lines, /^Design \(Style\)/i);
|
|
104
|
+
const designStyle = getDesc(styleLine);
|
|
105
|
+
const qualLine = findLine(lines, /^Quality of Construction/i);
|
|
106
|
+
const qualityOfConstruction = getDesc(qualLine);
|
|
107
|
+
const ageLine = findLine(lines, /^Actual Age\b/i);
|
|
108
|
+
const actualAge = parseNum(getDesc(ageLine));
|
|
109
|
+
const ageAdjustment = getAdj(ageLine);
|
|
110
|
+
const condLine = findLine(lines, /^Condition\b/i);
|
|
111
|
+
const condition = getDescWithBB(condLine, "condition");
|
|
112
|
+
// Room count line
|
|
113
|
+
const rcLine = findLine(lines, /^Room Count/i);
|
|
114
|
+
let roomCountTotal = null, roomCountBedrooms = null, roomCountBaths = null, roomCountAdjustment = null;
|
|
115
|
+
if (rcLine) {
|
|
116
|
+
const text = segInRange(rcLine, col.desc.min, col.adj.min);
|
|
117
|
+
const parts = text.split(/\s+/).map((s) => parseNum(s)).filter((n) => n !== null);
|
|
118
|
+
if (parts.length >= 3) {
|
|
119
|
+
roomCountTotal = parts[0];
|
|
120
|
+
roomCountBedrooms = parts[1];
|
|
121
|
+
roomCountBaths = parts[2];
|
|
122
|
+
}
|
|
123
|
+
roomCountAdjustment = getAdj(rcLine);
|
|
124
|
+
}
|
|
125
|
+
const glaLine = findLine(lines, /^Gross Living Area/i);
|
|
126
|
+
let grossLivingArea = null, grossLivingAreaAdjustment = null;
|
|
127
|
+
if (glaLine) {
|
|
128
|
+
const text = segInRange(glaLine, col.desc.min - 30, col.adj.min);
|
|
129
|
+
const m = text.match(/([\d,]+)\s*sq/i);
|
|
130
|
+
if (m)
|
|
131
|
+
grossLivingArea = parseNum(m[1]);
|
|
132
|
+
grossLivingAreaAdjustment = getAdj(glaLine);
|
|
133
|
+
const glaSeg = segWithBBox(glaLine, col.desc.min, col.adj.min);
|
|
134
|
+
if (glaSeg.seg)
|
|
135
|
+
bb[`${prefix}_gla`] = toBBox(glaSeg.seg, glaLine);
|
|
136
|
+
}
|
|
137
|
+
const bfLine = findLine(lines, /^Basement & Finished/i);
|
|
138
|
+
const basementFinished = getDesc(bfLine);
|
|
139
|
+
const fuLine = findLine(lines, /^Functional Utility/i);
|
|
140
|
+
const functionalUtility = getDesc(fuLine);
|
|
141
|
+
const hcLine = findLine(lines, /^Heating\/Cooling/i);
|
|
142
|
+
const heatingCooling = getDesc(hcLine);
|
|
143
|
+
const eeLine = findLine(lines, /^Energy Efficient/i);
|
|
144
|
+
const energyEfficientItems = getDesc(eeLine);
|
|
145
|
+
const gcLine = findLine(lines, /^Garage\/Carport/i);
|
|
146
|
+
const garageCarport = getDesc(gcLine);
|
|
147
|
+
const garageCarportAdjustment = getAdj(gcLine);
|
|
148
|
+
const ppLine = findLine(lines, /^Porch\/Patio\/Deck/i);
|
|
149
|
+
const porchPatioDeck = getDesc(ppLine);
|
|
150
|
+
const poolLine = findLine(lines, /^POOL\b/i);
|
|
151
|
+
const poolVal = getDesc(poolLine);
|
|
152
|
+
const poolAdjustment = getAdj(poolLine);
|
|
153
|
+
const olpLine = findLine(lines, /^ORIGINAL LIST PRICE/i);
|
|
154
|
+
const originalListPrice = getDesc(olpLine);
|
|
155
|
+
// Net/Gross adjustment
|
|
156
|
+
const netLine = findLine(lines, /^Net Adjustment \(Total\)/i);
|
|
157
|
+
let netAdjustmentTotal = null;
|
|
158
|
+
if (netLine) {
|
|
159
|
+
const text = segInRange(netLine, col.adj.min - 10, col.adj.min + 60);
|
|
160
|
+
netAdjustmentTotal = parseNum(text);
|
|
161
|
+
}
|
|
162
|
+
const adjPriceLine = findLine(lines, /^Adjusted Sale Price/i);
|
|
163
|
+
let netAdjustmentPercent = null;
|
|
164
|
+
if (adjPriceLine) {
|
|
165
|
+
const text = segInRange(adjPriceLine, col.desc.min, col.adj.min + 20);
|
|
166
|
+
const pctMatch = text.match(/([\d.]+)\s*%/);
|
|
167
|
+
if (pctMatch)
|
|
168
|
+
netAdjustmentPercent = parseNum(pctMatch[1]);
|
|
169
|
+
}
|
|
170
|
+
const grossLine = findLine(lines, /^of Comparables/i);
|
|
171
|
+
let grossAdjustmentPercent = null, adjustedSalePrice = null;
|
|
172
|
+
if (grossLine) {
|
|
173
|
+
const text = segInRange(grossLine, col.desc.min, col.adj.min + 60);
|
|
174
|
+
const pctMatch = text.match(/([\d.]+)\s*%/);
|
|
175
|
+
if (pctMatch)
|
|
176
|
+
grossAdjustmentPercent = parseNum(pctMatch[1]);
|
|
177
|
+
const priceMatch = text.match(/\$\s*([\d,]+)/);
|
|
178
|
+
if (priceMatch)
|
|
179
|
+
adjustedSalePrice = parseNum(priceMatch[1]);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
number: compNum,
|
|
183
|
+
address: address + (cityVal ? `, ${cityVal}` : ""),
|
|
184
|
+
proximityToSubject, salePrice, salePricePerSqft, dataSources, verificationSources,
|
|
185
|
+
salesOrFinancing, concessions, dateOfSaleTime, location, leaseholdFeeSimple,
|
|
186
|
+
site, siteAdjustment, view: viewVal, viewAdjustment, designStyle, qualityOfConstruction,
|
|
187
|
+
actualAge, ageAdjustment, condition,
|
|
188
|
+
roomCountTotal, roomCountBedrooms, roomCountBaths, roomCountAdjustment,
|
|
189
|
+
grossLivingArea, grossLivingAreaAdjustment, basementFinished, functionalUtility,
|
|
190
|
+
heatingCooling, energyEfficientItems, garageCarport, garageCarportAdjustment,
|
|
191
|
+
porchPatioDeck, pool: poolVal, poolAdjustment, originalListPrice,
|
|
192
|
+
netAdjustmentTotal, netAdjustmentPercent, grossAdjustmentPercent, adjustedSalePrice,
|
|
193
|
+
boundingBoxes: bb,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ── Sales Comparison ──────────────────────────────────────────────────────
|
|
197
|
+
export function parseSalesComparisonSection(lines, compStartNum = 1) {
|
|
198
|
+
const bb = {};
|
|
199
|
+
// Active listings / comparable sales ranges
|
|
200
|
+
const alLine = findLine(lines, /comparable properties currently offered/i);
|
|
201
|
+
let activeListingsLow = null, activeListingsHigh = null;
|
|
202
|
+
if (alLine) {
|
|
203
|
+
const m = alLine.fullText.match(/\$\s*([\d,]+).*?to\s*\$\s*([\d,]+)/i);
|
|
204
|
+
if (m) {
|
|
205
|
+
activeListingsLow = parseNum(m[1]);
|
|
206
|
+
activeListingsHigh = parseNum(m[2]);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const csLine = findLine(lines, /comparable sales in the subject/i);
|
|
210
|
+
let comparableSalesLow = null, comparableSalesHigh = null;
|
|
211
|
+
if (csLine) {
|
|
212
|
+
const m = csLine.fullText.match(/\$\s*([\d,]+).*?to\s*\$\s*([\d,]+)/i);
|
|
213
|
+
if (m) {
|
|
214
|
+
comparableSalesLow = parseNum(m[1]);
|
|
215
|
+
comparableSalesHigh = parseNum(m[2]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Subject column
|
|
219
|
+
const subBB = {};
|
|
220
|
+
function getSubject(line) {
|
|
221
|
+
return line ? segInRange(line, SUBJECT_X.min, SUBJECT_X.max) : "";
|
|
222
|
+
}
|
|
223
|
+
function getSubjectWithBB(line, key) {
|
|
224
|
+
if (!line)
|
|
225
|
+
return "";
|
|
226
|
+
const r = segWithBBox(line, SUBJECT_X.min, SUBJECT_X.max);
|
|
227
|
+
if (r.seg)
|
|
228
|
+
subBB[key] = toBBox(r.seg, line);
|
|
229
|
+
return r.text;
|
|
230
|
+
}
|
|
231
|
+
const addrLine = findLine(lines, /^Address/i);
|
|
232
|
+
const subjAddress = getSubjectWithBB(addrLine, "address");
|
|
233
|
+
const priceLine = findLine(lines, /^Sale Price\b/i);
|
|
234
|
+
const subjPrice = priceLine ? parseCurrency(segInRange(priceLine, SUBJECT_X.min - 20, SUBJECT_X.max)) : null;
|
|
235
|
+
const ppsLine = findLine(lines, /^Sale Price\/Gross Liv/i);
|
|
236
|
+
let subjPPS = null;
|
|
237
|
+
if (ppsLine) {
|
|
238
|
+
const m = segInRange(ppsLine, SUBJECT_X.min, SUBJECT_X.max).match(/([\d,.]+)/);
|
|
239
|
+
if (m)
|
|
240
|
+
subjPPS = parseNum(m[1]);
|
|
241
|
+
}
|
|
242
|
+
const subject = {
|
|
243
|
+
address: subjAddress,
|
|
244
|
+
salePrice: subjPrice,
|
|
245
|
+
salePricePerSqft: subjPPS,
|
|
246
|
+
location: getSubject(findLine(lines, /^Location\b/i)),
|
|
247
|
+
leaseholdFeeSimple: getSubject(findLine(lines, /^Leasehold\/Fee/i)),
|
|
248
|
+
site: getSubject(findLine(lines, /^Site\b/i)),
|
|
249
|
+
view: getSubject(findLine(lines, /^View\b/i)),
|
|
250
|
+
designStyle: getSubject(findLine(lines, /^Design \(Style\)/i)),
|
|
251
|
+
qualityOfConstruction: getSubject(findLine(lines, /^Quality of Construction/i)),
|
|
252
|
+
actualAge: parseNum(getSubject(findLine(lines, /^Actual Age/i))),
|
|
253
|
+
condition: getSubjectWithBB(findLine(lines, /^Condition\b/i), "condition"),
|
|
254
|
+
roomCountTotal: null,
|
|
255
|
+
roomCountBedrooms: null,
|
|
256
|
+
roomCountBaths: null,
|
|
257
|
+
grossLivingArea: null,
|
|
258
|
+
basementFinished: getSubject(findLine(lines, /^Basement & Finished/i)),
|
|
259
|
+
functionalUtility: getSubject(findLine(lines, /^Functional Utility/i)),
|
|
260
|
+
heatingCooling: getSubject(findLine(lines, /^Heating\/Cooling/i)),
|
|
261
|
+
energyEfficientItems: getSubject(findLine(lines, /^Energy Efficient/i)),
|
|
262
|
+
garageCarport: getSubject(findLine(lines, /^Garage\/Carport/i)),
|
|
263
|
+
porchPatioDeck: getSubject(findLine(lines, /^Porch\/Patio/i)),
|
|
264
|
+
pool: getSubject(findLine(lines, /^POOL\b/i)),
|
|
265
|
+
originalListPrice: getSubject(findLine(lines, /^ORIGINAL LIST PRICE/i)),
|
|
266
|
+
boundingBoxes: subBB,
|
|
267
|
+
};
|
|
268
|
+
// Parse room count for subject
|
|
269
|
+
const rcLine = findLine(lines, /^Room Count/i);
|
|
270
|
+
if (rcLine) {
|
|
271
|
+
const text = segInRange(rcLine, SUBJECT_X.min, SUBJECT_X.max);
|
|
272
|
+
const parts = text.split(/\s+/).map((s) => parseNum(s)).filter((n) => n !== null);
|
|
273
|
+
if (parts.length >= 3) {
|
|
274
|
+
subject.roomCountTotal = parts[0];
|
|
275
|
+
subject.roomCountBedrooms = parts[1];
|
|
276
|
+
subject.roomCountBaths = parts[2];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const glaLine = findLine(lines, /^Gross Living Area/i);
|
|
280
|
+
if (glaLine) {
|
|
281
|
+
const m = segInRange(glaLine, SUBJECT_X.min, SUBJECT_X.max).match(/([\d,]+)/);
|
|
282
|
+
if (m)
|
|
283
|
+
subject.grossLivingArea = parseNum(m[1]);
|
|
284
|
+
}
|
|
285
|
+
// Parse 3 comparables
|
|
286
|
+
const comparables = [];
|
|
287
|
+
for (let i = 0; i < 3; i++) {
|
|
288
|
+
comparables.push(parseComparable(lines, compStartNum + i, COMP_COLS[i]));
|
|
289
|
+
}
|
|
290
|
+
// Prior sale analysis
|
|
291
|
+
const priorLine = findLine(lines, /Analysis of prior sale or transfer/i);
|
|
292
|
+
let priorSaleAnalysis = "";
|
|
293
|
+
if (priorLine) {
|
|
294
|
+
const idx = lines.indexOf(priorLine);
|
|
295
|
+
const parts = [];
|
|
296
|
+
const valueSeg = priorLine.segments.find((s) => s.x > 280);
|
|
297
|
+
if (valueSeg)
|
|
298
|
+
parts.push(valueSeg.text.trim());
|
|
299
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
300
|
+
if (/^Summary of Sales|^Analysis\/Comments|^Indicated Value/i.test(lines[i].fullText))
|
|
301
|
+
break;
|
|
302
|
+
const t = lines[i].fullText.trim();
|
|
303
|
+
if (t && !/^AFFECT FINAL/.test(t) || /AFFECT FINAL/.test(t))
|
|
304
|
+
parts.push(t);
|
|
305
|
+
}
|
|
306
|
+
priorSaleAnalysis = parts.join(" ").trim();
|
|
307
|
+
}
|
|
308
|
+
// Summary
|
|
309
|
+
const sumLine = findLine(lines, /^Summary of Sales Comparison|^Analysis\/Comments/i);
|
|
310
|
+
let summaryOfSalesComparison = "";
|
|
311
|
+
if (sumLine) {
|
|
312
|
+
const idx = lines.indexOf(sumLine);
|
|
313
|
+
const parts = [];
|
|
314
|
+
const valueSeg = sumLine.segments.find((s) => s.x > 150);
|
|
315
|
+
if (valueSeg)
|
|
316
|
+
parts.push(valueSeg.text.trim());
|
|
317
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
318
|
+
if (/^Indicated Value|^AS-IS APPRAISED/i.test(lines[i].fullText))
|
|
319
|
+
break;
|
|
320
|
+
const t = lines[i].fullText.trim();
|
|
321
|
+
if (t)
|
|
322
|
+
parts.push(t);
|
|
323
|
+
}
|
|
324
|
+
summaryOfSalesComparison = parts.join(" ").trim();
|
|
325
|
+
if (sumLine.segments[0])
|
|
326
|
+
bb.summaryOfSalesComparison = toBBox(sumLine.segments[0], sumLine);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
activeListingsLow, activeListingsHigh, comparableSalesLow, comparableSalesHigh,
|
|
330
|
+
subject, comparables, priorSaleAnalysis, summaryOfSalesComparison,
|
|
331
|
+
boundingBoxes: bb,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// ── Reconciliation ────────────────────────────────────────────────────────
|
|
335
|
+
export function parseReconciliationSection(lines) {
|
|
336
|
+
const bb = {};
|
|
337
|
+
const indLine = findLine(lines, /^Indicated Value by Sales Comparison Approach\s*\$/i);
|
|
338
|
+
let indicatedValueBySalesComparison = null;
|
|
339
|
+
if (indLine) {
|
|
340
|
+
const seg = indLine.segments.find((s) => s.x > 150 && /^\d/.test(s.text.trim()));
|
|
341
|
+
if (seg) {
|
|
342
|
+
indicatedValueBySalesComparison = parseNum(seg.text);
|
|
343
|
+
bb.indicatedBySales = toBBox(seg, indLine);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const indAllLine = findLine(lines, /^Indicated Value by: Sales Comparison/i);
|
|
347
|
+
let indicatedValueByCostApproach = null, indicatedValueByIncomeApproach = null;
|
|
348
|
+
if (indAllLine) {
|
|
349
|
+
for (const seg of indAllLine.segments) {
|
|
350
|
+
if (/Cost Approach.*\$\s*([\d,]+)/i.test(seg.text)) {
|
|
351
|
+
const m = seg.text.match(/\$\s*([\d,]+)/);
|
|
352
|
+
if (m) {
|
|
353
|
+
indicatedValueByCostApproach = parseNum(m[1]);
|
|
354
|
+
bb.indicatedByCost = toBBox(seg, indAllLine);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (/Income Approach.*\$\s*([\d,]+)/i.test(seg.text)) {
|
|
358
|
+
const m = seg.text.match(/\$\s*([\d,]+)/);
|
|
359
|
+
if (m)
|
|
360
|
+
indicatedValueByIncomeApproach = parseNum(m[1]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Also get sales comparison from this line
|
|
364
|
+
if (!indicatedValueBySalesComparison) {
|
|
365
|
+
const salesSeg = indAllLine.segments.find((s) => s.x > 180 && s.x < 255 && /^\d/.test(s.text.trim()));
|
|
366
|
+
if (salesSeg)
|
|
367
|
+
indicatedValueBySalesComparison = parseNum(salesSeg.text);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Reconciliation comments
|
|
371
|
+
const recLine = findLine(lines, /ALL WEIGHT GIVEN|reconciliation|weight.*given/i);
|
|
372
|
+
let reconciliationComments = "";
|
|
373
|
+
if (recLine) {
|
|
374
|
+
const idx = lines.indexOf(recLine);
|
|
375
|
+
const parts = [recLine.fullText.trim()];
|
|
376
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
377
|
+
if (/^This appraisal is made|^Based on a complete/i.test(lines[i].fullText))
|
|
378
|
+
break;
|
|
379
|
+
const t = lines[i].fullText.trim();
|
|
380
|
+
if (t)
|
|
381
|
+
parts.push(t);
|
|
382
|
+
}
|
|
383
|
+
reconciliationComments = parts.join(" ").trim();
|
|
384
|
+
bb.reconciliationComments = toBBox(recLine.segments[0], recLine);
|
|
385
|
+
}
|
|
386
|
+
// Appraisal basis
|
|
387
|
+
const basisLine = findLine(lines, /This appraisal is made/i);
|
|
388
|
+
let appraisalBasis = "";
|
|
389
|
+
if (basisLine) {
|
|
390
|
+
const idx = lines.indexOf(basisLine);
|
|
391
|
+
const parts = [basisLine.fullText.trim()];
|
|
392
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
393
|
+
if (/^Based on a complete|^\$/i.test(lines[i].fullText))
|
|
394
|
+
break;
|
|
395
|
+
parts.push(lines[i].fullText.trim());
|
|
396
|
+
}
|
|
397
|
+
appraisalBasis = parts.join(" ").trim();
|
|
398
|
+
}
|
|
399
|
+
// Final value and effective date
|
|
400
|
+
const valueLine = findLine(lines, /^\$\s*[\d,]+\s*,\s*as of/i);
|
|
401
|
+
let finalValue = null, effectiveDate = "";
|
|
402
|
+
if (valueLine) {
|
|
403
|
+
for (const seg of valueLine.segments) {
|
|
404
|
+
if (/^\d[\d,]*$/.test(seg.text.trim())) {
|
|
405
|
+
finalValue = parseNum(seg.text);
|
|
406
|
+
bb.finalValue = toBBox(seg, valueLine);
|
|
407
|
+
}
|
|
408
|
+
if (/^\d{2}\/\d{2}\/\d{4}$/.test(seg.text.trim())) {
|
|
409
|
+
effectiveDate = seg.text.trim();
|
|
410
|
+
bb.effectiveDate = toBBox(seg, valueLine);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { indicatedValueBySalesComparison, indicatedValueByCostApproach, indicatedValueByIncomeApproach, reconciliationComments, appraisalBasis, finalValue, effectiveDate, boundingBoxes: bb };
|
|
415
|
+
}
|
|
416
|
+
// ── Cost Approach (Page 3) ────────────────────────────────────────────────
|
|
417
|
+
export function parseCostApproachSection(lines) {
|
|
418
|
+
const bb = {};
|
|
419
|
+
function findCostVal(pattern) {
|
|
420
|
+
for (const l of lines) {
|
|
421
|
+
if (!pattern.test(l.fullText))
|
|
422
|
+
continue;
|
|
423
|
+
// Dollar value is usually at the far right (x > 500)
|
|
424
|
+
const valSeg = l.segments.filter((s) => s.x > 480);
|
|
425
|
+
for (const seg of valSeg) {
|
|
426
|
+
const n = parseNum(seg.text);
|
|
427
|
+
if (n !== null)
|
|
428
|
+
return n;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const siteValueLine = findLine(lines, /support for the opinion of site value/i);
|
|
434
|
+
let siteValueSupport = "";
|
|
435
|
+
if (siteValueLine) {
|
|
436
|
+
const valueSeg = siteValueLine.segments.find((s) => s.x > 250);
|
|
437
|
+
if (valueSeg)
|
|
438
|
+
siteValueSupport = valueSeg.text.trim();
|
|
439
|
+
// Continue on next lines
|
|
440
|
+
const idx = lines.indexOf(siteValueLine);
|
|
441
|
+
const parts = siteValueSupport ? [siteValueSupport] : [];
|
|
442
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
443
|
+
if (/^ESTIMATED|^OPINION OF SITE/i.test(lines[i].fullText))
|
|
444
|
+
break;
|
|
445
|
+
parts.push(lines[i].fullText.trim());
|
|
446
|
+
}
|
|
447
|
+
siteValueSupport = parts.join(" ").trim();
|
|
448
|
+
}
|
|
449
|
+
const siteValue = findCostVal(/OPINION OF SITE VALUE/i);
|
|
450
|
+
// Dwelling cost
|
|
451
|
+
const dwellingLine = findLine(lines, /^DWELLING/i);
|
|
452
|
+
let dwellingSqft = null, dwellingCostPerSqft = null, dwellingCost = null;
|
|
453
|
+
if (dwellingLine) {
|
|
454
|
+
const sqftMatch = dwellingLine.fullText.match(/([\d,]+)\s*Sq\.?\s*Ft/i);
|
|
455
|
+
if (sqftMatch)
|
|
456
|
+
dwellingSqft = parseNum(sqftMatch[1]);
|
|
457
|
+
const costMatch = dwellingLine.fullText.match(/@\s*\$?\s*([\d,.]+)/i) || dwellingLine.fullText.match(/(\d+\.?\d*)\s*=\s*\$/);
|
|
458
|
+
if (costMatch)
|
|
459
|
+
dwellingCostPerSqft = parseNum(costMatch[1]);
|
|
460
|
+
const valSegs = dwellingLine.segments.filter((s) => s.x > 480);
|
|
461
|
+
for (const seg of valSegs) {
|
|
462
|
+
const n = parseNum(seg.text);
|
|
463
|
+
if (n !== null) {
|
|
464
|
+
dwellingCost = n;
|
|
465
|
+
bb.dwellingCost = toBBox(seg, dwellingLine);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const amenitiesCost = findCostVal(/AMENITIES/i);
|
|
470
|
+
// Garage/Carport
|
|
471
|
+
const garageLine = findLine(lines, /^Garage\/Carport/i);
|
|
472
|
+
let garageCarportSqft = null, garageCarportCostPerSqft = null, garageCarportCost = null;
|
|
473
|
+
if (garageLine) {
|
|
474
|
+
const sqftMatch = garageLine.fullText.match(/([\d,]+)\s*Sq\.?\s*Ft/i);
|
|
475
|
+
if (sqftMatch)
|
|
476
|
+
garageCarportSqft = parseNum(sqftMatch[1]);
|
|
477
|
+
const costMatch = garageLine.fullText.match(/@\s*\$?\s*([\d,.]+)/i) || garageLine.fullText.match(/(\d+\.?\d*)\s*=\s*\$/);
|
|
478
|
+
if (costMatch)
|
|
479
|
+
garageCarportCostPerSqft = parseNum(costMatch[1]);
|
|
480
|
+
const valSegs = garageLine.segments.filter((s) => s.x > 480);
|
|
481
|
+
for (const seg of valSegs) {
|
|
482
|
+
const n = parseNum(seg.text);
|
|
483
|
+
if (n !== null)
|
|
484
|
+
garageCarportCost = n;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const totalCostNew = findCostVal(/Total Estimate of Cost-?New/i);
|
|
488
|
+
const depreciation = findCostVal(/^Depreciation\b/i);
|
|
489
|
+
const depreciatedCostOfImprovements = findCostVal(/Depreciated Cost of Improvements/i);
|
|
490
|
+
const asIsValueOfSiteImprovements = findCostVal(/As-?is.*Value of Site Improvements/i);
|
|
491
|
+
const indicatedValueByCostApproach = findCostVal(/INDICATED VALUE BY COST APPROACH/i);
|
|
492
|
+
const erlLine = findLine(lines, /Estimated Remaining Economic Life/i);
|
|
493
|
+
let estimatedRemainingEconomicLife = null;
|
|
494
|
+
if (erlLine) {
|
|
495
|
+
const m = erlLine.fullText.match(/(\d+)\s*Years/i);
|
|
496
|
+
if (m)
|
|
497
|
+
estimatedRemainingEconomicLife = parseNum(m[1]);
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
siteValueSupport, siteValue, dwellingSqft, dwellingCostPerSqft, dwellingCost,
|
|
501
|
+
amenitiesCost, garageCarportSqft, garageCarportCostPerSqft, garageCarportCost,
|
|
502
|
+
totalCostNew, depreciation, depreciatedCostOfImprovements, asIsValueOfSiteImprovements,
|
|
503
|
+
indicatedValueByCostApproach, estimatedRemainingEconomicLife, boundingBoxes: bb,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TextLine } from "@parseo/shared";
|
|
2
|
+
import type { Form1004MCReport } from "./types.js";
|
|
3
|
+
export declare function parseForm1004MC(buffer: Buffer): Promise<Form1004MCReport>;
|
|
4
|
+
export declare function parseForm1004MCFromLines(lines: TextLine[], buffer: Buffer, pageOffset?: number): Promise<Form1004MCReport>;
|
|
5
|
+
//# sourceMappingURL=parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/form-1004mc/parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AACzE,OAAO,KAAK,EACV,gBAAgB,EASjB,MAAM,YAAY,CAAC;AAKpB,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAG/E;AAED,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,QAAQ,EAAE,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,GAAE,MAAU,GACrB,OAAO,CAAC,gBAAgB,CAAC,CAgJ3B"}
|