Port property assessor helpers to TypeScript

This commit is contained in:
2026-03-27 22:23:58 -05:00
parent 954374ce48
commit e6d987d725
14 changed files with 2155 additions and 202 deletions

View 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);
});

View 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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/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
};
}

View 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;
}