Port property assessor helpers to TypeScript
This commit is contained in:
63
skills/property-assessor/src/cli.ts
Normal file
63
skills/property-assessor/src/cli.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import minimist from "minimist";
|
||||
|
||||
import { resolvePublicRecords } from "./public-records.js";
|
||||
import { ReportValidationError, loadReportPayload, renderReportPdf } from "./report-pdf.js";
|
||||
|
||||
function usage(): void {
|
||||
process.stdout.write(`property-assessor\n
|
||||
Commands:
|
||||
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
render-report --input "<payload.json>" --output "<report.pdf>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ["address", "parcel-id", "listing-geo-id", "listing-source-url", "input", "output"],
|
||||
alias: {
|
||||
h: "help"
|
||||
}
|
||||
});
|
||||
const [command] = argv._;
|
||||
|
||||
if (!command || argv.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "locate-public-records") {
|
||||
if (!argv.address) {
|
||||
throw new Error("Missing required option: --address");
|
||||
}
|
||||
const payload = await resolvePublicRecords(argv.address, {
|
||||
parcelId: argv["parcel-id"],
|
||||
listingGeoId: argv["listing-geo-id"],
|
||||
listingSourceUrl: argv["listing-source-url"]
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "render-report") {
|
||||
if (!argv.input || !argv.output) {
|
||||
throw new Error("Missing required options: --input and --output");
|
||||
}
|
||||
const payload = await loadReportPayload(argv.input);
|
||||
const outputPath = await renderReportPdf(payload, argv.output);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof ReportValidationError || error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
292
skills/property-assessor/src/public-records.ts
Normal file
292
skills/property-assessor/src/public-records.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
export const CENSUS_GEOCODER_URL =
|
||||
"https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress";
|
||||
export const TEXAS_COUNTY_DIRECTORY_URL =
|
||||
"https://comptroller.texas.gov/taxes/property-tax/county-directory/";
|
||||
export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
|
||||
|
||||
export class PublicRecordsLookupError extends Error {}
|
||||
|
||||
export interface PublicRecordsResolution {
|
||||
requestedAddress: string;
|
||||
matchedAddress: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
geoid: string | null;
|
||||
state: {
|
||||
name: string | null;
|
||||
code: string | null;
|
||||
fips: string | null;
|
||||
};
|
||||
county: {
|
||||
name: string | null;
|
||||
fips: string | null;
|
||||
geoid: string | null;
|
||||
};
|
||||
officialLinks: {
|
||||
censusGeocoder: string;
|
||||
texasCountyDirectory: string | null;
|
||||
texasPropertyTaxPortal: string | null;
|
||||
};
|
||||
appraisalDistrict: Record<string, unknown> | null;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
lookupRecommendations: string[];
|
||||
sourceIdentifierHints: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FetchLike {
|
||||
(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
const defaultFetchText: FetchLike = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"user-agent": "property-assessor/1.0"
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
function collapseWhitespace(value: string | null | undefined): string {
|
||||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeCountyName(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/ county\b/, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
let output = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
output = output
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">");
|
||||
output = collapseWhitespace(output.replace(/\n/g, ", "));
|
||||
output = output.replace(/\s*,\s*/g, ", ").replace(/(,\s*){2,}/g, ", ");
|
||||
return output.replace(/^,\s*|\s*,\s*$/g, "");
|
||||
}
|
||||
|
||||
function extractAnchorHref(fragment: string): string | null {
|
||||
const match = fragment.match(/<a[^>]+href="([^"]+)"/i);
|
||||
if (!match) return null;
|
||||
const href = match[1].trim();
|
||||
if (href.startsWith("//")) return `https:${href}`;
|
||||
return href;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
|
||||
match: any;
|
||||
censusGeocoderUrl: string;
|
||||
}> {
|
||||
const query = new URLSearchParams({
|
||||
address,
|
||||
benchmark: "Public_AR_Current",
|
||||
vintage: "Current_Current",
|
||||
format: "json"
|
||||
});
|
||||
const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`;
|
||||
const payload = JSON.parse(await fetchText(url));
|
||||
const matches = payload?.result?.addressMatches || [];
|
||||
if (!matches.length) {
|
||||
throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`);
|
||||
}
|
||||
return { match: matches[0], censusGeocoderUrl: url };
|
||||
}
|
||||
|
||||
async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise<string> {
|
||||
const html = await fetchText(TEXAS_COUNTY_DIRECTORY_URL);
|
||||
const countyNorm = normalizeCountyName(countyName);
|
||||
const matches = html.matchAll(/<a href="([^"]+\.php)">\s*\d+\s+([^<]+)\s*<\/a>/gi);
|
||||
for (const match of matches) {
|
||||
const href = match[1];
|
||||
const label = match[2];
|
||||
if (normalizeCountyName(label) === countyNorm) {
|
||||
return href.startsWith("http://") || href.startsWith("https://")
|
||||
? href
|
||||
: `${TEXAS_COUNTY_DIRECTORY_URL}${href.replace(/^\/+/, "")}`;
|
||||
}
|
||||
}
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not find Texas county directory page for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseTexasSection(sectionHtml: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const lastUpdated = sectionHtml.match(
|
||||
/<p class="file-info">\s*Last Updated:\s*([^<]+)<\/p>/i
|
||||
);
|
||||
if (lastUpdated) {
|
||||
result.lastUpdated = collapseWhitespace(lastUpdated[1]);
|
||||
}
|
||||
|
||||
const lead = sectionHtml.match(/<h4>\s*([^:<]+):\s*([^<]+)<\/h4>/i);
|
||||
if (lead) {
|
||||
result[lead[1].trim()] = collapseWhitespace(lead[2]);
|
||||
}
|
||||
|
||||
const infoBlock = sectionHtml.match(/<h4>\s*[^<]+<\/h4>\s*<p>(.*?)<\/p>/is);
|
||||
if (infoBlock) {
|
||||
for (const match of infoBlock[1].matchAll(
|
||||
/<strong>\s*([^:<]+):\s*<\/strong>\s*(.*?)(?:<br\s*\/?>|$)/gis
|
||||
)) {
|
||||
const key = collapseWhitespace(match[1]);
|
||||
const rawValue = match[2];
|
||||
const hrefValue = extractAnchorHref(rawValue);
|
||||
if (key.toLowerCase() === "website" && hrefValue) {
|
||||
result[key] = hrefValue;
|
||||
} else if (
|
||||
key.toLowerCase() === "email" &&
|
||||
hrefValue &&
|
||||
hrefValue.startsWith("mailto:")
|
||||
) {
|
||||
result[key] = hrefValue.replace(/^mailto:/i, "");
|
||||
} else {
|
||||
result[key] = stripHtml(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headings: Array<[string, string]> = [
|
||||
["Mailing Address", "mailingAddress"],
|
||||
["Street Address", "streetAddress"],
|
||||
["Collecting Unit", "collectingUnit"]
|
||||
];
|
||||
|
||||
for (const [heading, key] of headings) {
|
||||
const match = sectionHtml.match(
|
||||
new RegExp(`<h4>\\s*${heading}\\s*<\\/h4>\\s*<p>(.*?)<\\/p>`, "is")
|
||||
);
|
||||
if (match) {
|
||||
result[key] = stripHtml(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchTexasCountyOffices(
|
||||
countyName: string,
|
||||
fetchText: FetchLike
|
||||
): Promise<{
|
||||
directoryPage: string;
|
||||
appraisalDistrict: Record<string, unknown>;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
}> {
|
||||
const pageUrl = await findTexasCountyHref(countyName, fetchText);
|
||||
const html = await fetchText(pageUrl);
|
||||
const appraisalMatch = html.match(
|
||||
/<h3>\s*Appraisal District\s*<\/h3>(.*?)(?=<h3>\s*Tax Assessor\/Collector\s*<\/h3>)/is
|
||||
);
|
||||
const taxMatch = html.match(/<h3>\s*Tax Assessor\/Collector\s*<\/h3>(.*)$/is);
|
||||
if (!appraisalMatch) {
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not parse Appraisal District section for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
const appraisalDistrict = parseTexasSection(appraisalMatch[1]);
|
||||
appraisalDistrict.directoryPage = pageUrl;
|
||||
|
||||
const taxAssessorCollector = taxMatch ? parseTexasSection(taxMatch[1]) : null;
|
||||
if (taxAssessorCollector) {
|
||||
taxAssessorCollector.directoryPage = pageUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
directoryPage: pageUrl,
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePublicRecords(
|
||||
address: string,
|
||||
options: {
|
||||
parcelId?: string;
|
||||
listingGeoId?: string;
|
||||
listingSourceUrl?: string;
|
||||
fetchText?: FetchLike;
|
||||
} = {}
|
||||
): Promise<PublicRecordsResolution> {
|
||||
const fetchText = options.fetchText || defaultFetchText;
|
||||
const { match, censusGeocoderUrl } = await geocodeAddress(address, fetchText);
|
||||
const geographies = match.geographies || {};
|
||||
const state = (geographies.States || [{}])[0];
|
||||
const county = (geographies.Counties || [{}])[0];
|
||||
const block = (geographies["2020 Census Blocks"] || [{}])[0];
|
||||
const coordinates = match.coordinates || {};
|
||||
|
||||
let texasCountyDirectory: string | null = null;
|
||||
let texasPropertyTaxPortal: string | null = null;
|
||||
let appraisalDistrict: Record<string, unknown> | null = null;
|
||||
let taxAssessorCollector: Record<string, unknown> | null = null;
|
||||
|
||||
const lookupRecommendations = [
|
||||
"Start from the official public-record jurisdiction instead of a listing-site geo ID.",
|
||||
"Try official address search first on the appraisal district site.",
|
||||
"If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs."
|
||||
];
|
||||
|
||||
if (state.STUSAB === "TX" && county.NAME) {
|
||||
const offices = await fetchTexasCountyOffices(county.NAME, fetchText);
|
||||
texasCountyDirectory = offices.directoryPage;
|
||||
texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL;
|
||||
appraisalDistrict = offices.appraisalDistrict;
|
||||
taxAssessorCollector = offices.taxAssessorCollector;
|
||||
lookupRecommendations.push(
|
||||
"Use the Texas Comptroller county directory page as the official jurisdiction link in the final report.",
|
||||
"Attempt to retrieve assessed value, land value, improvement value, exemptions, and account number from the CAD website when a direct property page is publicly accessible."
|
||||
);
|
||||
}
|
||||
|
||||
const sourceIdentifierHints: Record<string, string> = {};
|
||||
if (options.parcelId) sourceIdentifierHints.parcelId = options.parcelId;
|
||||
if (options.listingGeoId) {
|
||||
sourceIdentifierHints.listingGeoId = options.listingGeoId;
|
||||
lookupRecommendations.push(
|
||||
"Treat listing geo IDs as regional hints only; do not use them as assessor record keys."
|
||||
);
|
||||
}
|
||||
if (options.listingSourceUrl) {
|
||||
sourceIdentifierHints.listingSourceUrl = options.listingSourceUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedAddress: address,
|
||||
matchedAddress: match.matchedAddress || address,
|
||||
latitude: coordinates.y ?? null,
|
||||
longitude: coordinates.x ?? null,
|
||||
geoid: block.GEOID || null,
|
||||
state: {
|
||||
name: state.NAME || null,
|
||||
code: state.STUSAB || null,
|
||||
fips: state.STATE || null
|
||||
},
|
||||
county: {
|
||||
name: county.NAME || null,
|
||||
fips: county.COUNTY || null,
|
||||
geoid: county.GEOID || null
|
||||
},
|
||||
officialLinks: {
|
||||
censusGeocoder: censusGeocoderUrl,
|
||||
texasCountyDirectory,
|
||||
texasPropertyTaxPortal
|
||||
},
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector,
|
||||
lookupRecommendations,
|
||||
sourceIdentifierHints
|
||||
};
|
||||
}
|
||||
343
skills/property-assessor/src/report-pdf.ts
Normal file
343
skills/property-assessor/src/report-pdf.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
export interface ReportPayload {
|
||||
recipientEmails?: string[] | string;
|
||||
reportTitle?: string;
|
||||
subtitle?: string;
|
||||
generatedAt?: string;
|
||||
preparedBy?: string;
|
||||
reportNotes?: string;
|
||||
subjectProperty?: Record<string, unknown>;
|
||||
verdict?: Record<string, unknown>;
|
||||
snapshot?: unknown;
|
||||
whatILike?: unknown;
|
||||
whatIDontLike?: unknown;
|
||||
compView?: unknown;
|
||||
carryView?: unknown;
|
||||
risksAndDiligence?: unknown;
|
||||
photoReview?: Record<string, unknown>;
|
||||
publicRecords?: Record<string, unknown>;
|
||||
sourceLinks?: unknown;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return [String(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
out.push(...asStringArray(item));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item != null && item !== "")
|
||||
.map(([key, item]) => `${key}: ${item}`);
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function currency(value: unknown): string {
|
||||
if (value == null || value === "") return "N/A";
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return `$${num.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function validateReportPayload(payload: ReportPayload): string[] {
|
||||
const recipients = asStringArray(payload.recipientEmails).map((item) => item.trim()).filter(Boolean);
|
||||
if (!recipients.length) {
|
||||
throw new ReportValidationError(
|
||||
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF."
|
||||
);
|
||||
}
|
||||
const address = payload.subjectProperty && typeof payload.subjectProperty.address === "string"
|
||||
? payload.subjectProperty.address.trim()
|
||||
: "";
|
||||
if (!address) {
|
||||
throw new ReportValidationError("The report payload must include subjectProperty.address.");
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||
const x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const height = 18;
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 3).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(title, x + 8, y + 4, { width: width - 16 });
|
||||
doc.restore();
|
||||
doc.moveDown(1.2);
|
||||
}
|
||||
|
||||
function drawBulletList(doc: PDFKit.PDFDocument, value: unknown, fallback = "Not provided."): void {
|
||||
const items = asStringArray(value);
|
||||
if (!items.length) {
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(fallback);
|
||||
doc.moveDown(0.6);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(item, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||
const left = doc.page.margins.left;
|
||||
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const keyWidth = 150;
|
||||
const valueWidth = totalWidth - keyWidth;
|
||||
for (const [key, value] of rows) {
|
||||
const startY = doc.y;
|
||||
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||
|
||||
doc.save();
|
||||
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
||||
doc
|
||||
.lineWidth(0.5)
|
||||
.strokeColor("#C7D0D9")
|
||||
.rect(left, startY, totalWidth, rowHeight)
|
||||
.stroke();
|
||||
doc
|
||||
.moveTo(left + keyWidth, startY)
|
||||
.lineTo(left + keyWidth, startY + rowHeight)
|
||||
.stroke();
|
||||
doc.restore();
|
||||
|
||||
doc.fillColor("#1E2329").font("Helvetica-Bold").fontSize(9.5).text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(9.5).text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
||||
doc.y = startY + rowHeight;
|
||||
}
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
function drawVerdictPanel(doc: PDFKit.PDFDocument, verdict: Record<string, unknown> | undefined): void {
|
||||
const decision = String(verdict?.decision || "pending").trim().toLowerCase();
|
||||
const badgeColor =
|
||||
decision === "buy" ? "#1E6B52" : decision === "pass" ? "#8B2E2E" : "#7A5D12";
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const top = doc.y;
|
||||
const bodyText = String(
|
||||
verdict?.offerGuidance || "Offer guidance not provided."
|
||||
);
|
||||
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20 }) + 16;
|
||||
|
||||
doc.save();
|
||||
doc.roundedRect(left, top, width, 26, 4).fill(badgeColor);
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(String(verdict?.decision || "N/A").toUpperCase(), left + 10, top + 7, {
|
||||
width: width - 20
|
||||
});
|
||||
|
||||
doc
|
||||
.roundedRect(left, top + 26, width, bodyHeight, 4)
|
||||
.fillAndStroke("#F4F6F8", "#C7D0D9");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(bodyText, left + 10, top + 36, {
|
||||
width: width - 20,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.restore();
|
||||
doc.y = top + 26 + bodyHeight + 10;
|
||||
}
|
||||
|
||||
function drawLinks(doc: PDFKit.PDFDocument, value: unknown): void {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
if (!items.length) {
|
||||
drawBulletList(doc, [], "Not provided.");
|
||||
return;
|
||||
}
|
||||
for (const item of items as Array<Record<string, unknown>>) {
|
||||
const label = typeof item.label === "string" ? item.label : "Link";
|
||||
const url = typeof item.url === "string" ? item.url : "";
|
||||
const line = url ? `${label}: ${url}` : label;
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5);
|
||||
if (url) {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2,
|
||||
link: url,
|
||||
underline: true
|
||||
});
|
||||
} else {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
}
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
export async function renderReportPdf(
|
||||
payload: ReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const recipients = validateReportPayload(payload);
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50,
|
||||
info: {
|
||||
Title: payload.reportTitle || "Property Assessment Report",
|
||||
Author: String(payload.preparedBy || "OpenClaw property-assessor")
|
||||
}
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
const generatedAt =
|
||||
payload.generatedAt ||
|
||||
new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
});
|
||||
const subject = payload.subjectProperty || {};
|
||||
const verdict = payload.verdict || {};
|
||||
const publicRecords = payload.publicRecords || {};
|
||||
|
||||
doc.fillColor("#123B5D").font("Helvetica-Bold").fontSize(22).text(payload.reportTitle || "Property Assessment Report");
|
||||
doc.moveDown(0.2);
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(11).text(
|
||||
String(
|
||||
payload.subtitle ||
|
||||
"Decision-grade acquisition review with listing, public-record, comp, and risk analysis."
|
||||
)
|
||||
);
|
||||
doc.moveDown(0.4);
|
||||
doc.fillColor("#5A6570").font("Helvetica").fontSize(9);
|
||||
doc.text(`Prepared for: ${recipients.join(", ")}`);
|
||||
doc.text(`Generated: ${generatedAt}`);
|
||||
doc.moveDown(0.8);
|
||||
|
||||
drawVerdictPanel(doc, verdict);
|
||||
|
||||
drawKeyValueTable(doc, [
|
||||
["Address", String(subject.address || "N/A")],
|
||||
["Ask / Last Price", currency(subject.listingPrice)],
|
||||
["Type", String(subject.propertyType || "N/A")],
|
||||
["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`],
|
||||
["Sqft", String(subject.squareFeet ?? "N/A")],
|
||||
["Year Built", String(subject.yearBuilt ?? "N/A")],
|
||||
["Verdict", String(verdict.decision || "N/A")],
|
||||
["Fair Value Range", String(verdict.fairValueRange || "N/A")],
|
||||
["Public-Record Jurisdiction", String(publicRecords.jurisdiction || "N/A")],
|
||||
["Assessed Total", currency(publicRecords.assessedTotalValue)]
|
||||
]);
|
||||
|
||||
const sections: Array<[string, unknown, "list" | "links"]> = [
|
||||
["Snapshot", payload.snapshot, "list"],
|
||||
["What I Like", payload.whatILike, "list"],
|
||||
["What I Do Not Like", payload.whatIDontLike, "list"],
|
||||
["Comp View", payload.compView, "list"],
|
||||
["Underwriting / Carry View", payload.carryView, "list"],
|
||||
["Risks and Diligence Items", payload.risksAndDiligence, "list"],
|
||||
[
|
||||
"Photo Review",
|
||||
[
|
||||
...(asStringArray((payload.photoReview || {}).status ? [`Photo review: ${String((payload.photoReview || {}).status)}${(payload.photoReview || {}).source ? ` via ${String((payload.photoReview || {}).source)}` : ""}`] : [])),
|
||||
...asStringArray((payload.photoReview || {}).attempts),
|
||||
...asStringArray((payload.photoReview || {}).summary ? [`Condition read: ${String((payload.photoReview || {}).summary)}`] : [])
|
||||
],
|
||||
"list"
|
||||
],
|
||||
[
|
||||
"Public Records",
|
||||
[
|
||||
...asStringArray({
|
||||
Jurisdiction: publicRecords.jurisdiction,
|
||||
"Account Number": publicRecords.accountNumber,
|
||||
"Owner Name": publicRecords.ownerName,
|
||||
"Land Value": publicRecords.landValue != null ? currency(publicRecords.landValue) : undefined,
|
||||
"Improvement Value":
|
||||
publicRecords.improvementValue != null ? currency(publicRecords.improvementValue) : undefined,
|
||||
"Assessed Total":
|
||||
publicRecords.assessedTotalValue != null ? currency(publicRecords.assessedTotalValue) : undefined,
|
||||
Exemptions: publicRecords.exemptions
|
||||
}),
|
||||
...asStringArray((publicRecords.links || []).map((item: any) => `${item.label}: ${item.url}`))
|
||||
],
|
||||
"list"
|
||||
],
|
||||
["Source Links", payload.sourceLinks, "links"]
|
||||
];
|
||||
|
||||
for (const [title, content, kind] of sections) {
|
||||
if (doc.y > 660) doc.addPage();
|
||||
drawSectionHeader(doc, title);
|
||||
if (kind === "links") {
|
||||
drawLinks(doc, content);
|
||||
} else {
|
||||
drawBulletList(doc, content);
|
||||
}
|
||||
}
|
||||
|
||||
doc.addPage();
|
||||
drawSectionHeader(doc, "Report Notes");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(
|
||||
String(
|
||||
payload.reportNotes ||
|
||||
"This report uses the property-assessor fixed PDF template. Listing data should be reconciled against official public records when available, and public-record links should be included in any delivered report."
|
||||
),
|
||||
{
|
||||
lineGap: 3
|
||||
}
|
||||
);
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
export async function loadReportPayload(inputPath: string): Promise<ReportPayload> {
|
||||
return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload;
|
||||
}
|
||||
Reference in New Issue
Block a user