551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { extractZillowIdentifierHints } from "../../web-automation/scripts/zillow-identifiers.js";
|
|
import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js";
|
|
import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js";
|
|
import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js";
|
|
import {
|
|
isDecisionGradeReportPayload,
|
|
renderReportPdf,
|
|
type ReportPayload
|
|
} from "./report-pdf.js";
|
|
|
|
export interface AssessPropertyOptions {
|
|
address: string;
|
|
assessmentPurpose?: string;
|
|
recipientEmails?: string[] | string;
|
|
output?: string;
|
|
parcelId?: string;
|
|
listingGeoId?: string;
|
|
listingSourceUrl?: string;
|
|
}
|
|
|
|
export interface AssessPropertyResult {
|
|
ok: true;
|
|
needsAssessmentPurpose: boolean;
|
|
needsRecipientEmails: boolean;
|
|
pdfReady: boolean;
|
|
message: string;
|
|
outputPath: string | null;
|
|
reportPayload: ReportPayload | null;
|
|
publicRecords: PublicRecordsResolution | null;
|
|
}
|
|
|
|
interface AssessPropertyDeps {
|
|
resolvePublicRecordsFn?: typeof resolvePublicRecords;
|
|
renderReportPdfFn?: typeof renderReportPdf;
|
|
discoverListingSourcesFn?: typeof discoverListingSources;
|
|
extractPhotoDataFn?: typeof extractPhotoData;
|
|
extractZillowIdentifierHintsFn?: typeof extractZillowIdentifierHints;
|
|
}
|
|
|
|
interface PurposeGuidance {
|
|
label: string;
|
|
snapshot: string;
|
|
like: string;
|
|
caution: string;
|
|
comp: string;
|
|
carry: string;
|
|
diligence: string;
|
|
verdict: string;
|
|
}
|
|
|
|
function asStringArray(value: unknown): string[] {
|
|
if (value == null) return [];
|
|
if (typeof value === "string") {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item) => asStringArray(item));
|
|
}
|
|
return [String(value).trim()].filter(Boolean);
|
|
}
|
|
|
|
function shouldRenderPdf(
|
|
options: AssessPropertyOptions,
|
|
recipientEmails: string[]
|
|
): boolean {
|
|
return Boolean(options.output || recipientEmails.length);
|
|
}
|
|
|
|
function slugify(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 80) || "property";
|
|
}
|
|
|
|
function pushLink(
|
|
target: Array<{ label: string; url: string }>,
|
|
label: string,
|
|
url: unknown
|
|
): void {
|
|
if (typeof url !== "string" || !url.trim()) return;
|
|
const normalized = url.trim();
|
|
if (!target.some((item) => item.url === normalized)) {
|
|
target.push({ label, url: normalized });
|
|
}
|
|
}
|
|
|
|
function buildPublicRecordLinks(
|
|
publicRecords: PublicRecordsResolution
|
|
): Array<{ label: string; url: string }> {
|
|
const links: Array<{ label: string; url: string }> = [];
|
|
pushLink(links, "Census Geocoder", publicRecords.officialLinks.censusGeocoder);
|
|
pushLink(
|
|
links,
|
|
"Texas Comptroller County Directory",
|
|
publicRecords.officialLinks.texasCountyDirectory
|
|
);
|
|
pushLink(
|
|
links,
|
|
"Texas Property Tax Portal",
|
|
publicRecords.officialLinks.texasPropertyTaxPortal
|
|
);
|
|
pushLink(links, "Appraisal District Website", publicRecords.appraisalDistrict?.Website);
|
|
pushLink(
|
|
links,
|
|
"Appraisal District Directory Page",
|
|
publicRecords.appraisalDistrict?.directoryPage
|
|
);
|
|
pushLink(
|
|
links,
|
|
"Tax Assessor / Collector Website",
|
|
publicRecords.taxAssessorCollector?.Website
|
|
);
|
|
pushLink(
|
|
links,
|
|
"Tax Assessor / Collector Directory Page",
|
|
publicRecords.taxAssessorCollector?.directoryPage
|
|
);
|
|
pushLink(links, "CAD Property Detail", publicRecords.propertyDetails?.sourceUrl);
|
|
return links;
|
|
}
|
|
|
|
function normalizePurpose(value: string): string {
|
|
return value.trim().replace(/\s+/g, " ");
|
|
}
|
|
|
|
function getPurposeGuidance(purpose: string): PurposeGuidance {
|
|
const normalized = purpose.toLowerCase();
|
|
|
|
if (/(daughter|son|college|student|school|campus)/i.test(normalized)) {
|
|
return {
|
|
label: purpose,
|
|
snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.",
|
|
like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.",
|
|
caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.",
|
|
comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.",
|
|
carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.",
|
|
diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.",
|
|
verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.`
|
|
};
|
|
}
|
|
|
|
if (/(vacation|second home|weekend|personal use|beach|getaway)/i.test(normalized)) {
|
|
return {
|
|
label: purpose,
|
|
snapshot: "Purpose fit: evaluate this as a vacation home with personal-use fit and carrying-cost tolerance in mind.",
|
|
like: "A vacation-home decision can justify paying for lifestyle fit, but only if ongoing friction is acceptable.",
|
|
caution: "Vacation home ownership can hide real recurring cost drag when insurance, HOA, storm exposure, and deferred maintenance are under-modeled.",
|
|
comp: "Comp work should focus on lifestyle alternatives, micro-location quality, and whether the premium over substitutes is defensible for a vacation home.",
|
|
carry: "Carry view should stress-test second-home costs, especially insurance, HOA, special assessments, and low-utilization months.",
|
|
diligence: "Confirm rules, reserves, storm or flood exposure, and whether the property still makes sense if usage ends up lower than expected.",
|
|
verdict: `Assessment purpose: ${purpose}. The final call should weigh personal-use fit against ongoing friction, not just headline list price.`
|
|
};
|
|
}
|
|
|
|
if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) {
|
|
return {
|
|
label: purpose,
|
|
snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`,
|
|
like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.",
|
|
caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.",
|
|
comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.",
|
|
carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.",
|
|
diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.",
|
|
verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.`
|
|
};
|
|
}
|
|
|
|
return {
|
|
label: purpose,
|
|
snapshot: `Purpose fit: ${purpose}. The final recommendation should be explicitly tested against that goal.`,
|
|
like: "The assessment should stay anchored to the stated purpose rather than defaulting to generic market commentary.",
|
|
caution: "Even a clean public-record and photo intake is not enough if the property does not fit the stated purpose.",
|
|
comp: "Comp work should compare against alternatives that solve the same purpose, not just nearby listings.",
|
|
carry: "Carry view should reflect the stated purpose and the real friction it implies.",
|
|
diligence: "Purpose-specific diligence should be listed explicitly before a final buy/pass/offer recommendation.",
|
|
verdict: `Assessment purpose: ${purpose}. The final conclusion must be explained in terms of that stated objective.`
|
|
};
|
|
}
|
|
|
|
function inferSourceFromUrl(rawUrl: string): PhotoSource | null {
|
|
try {
|
|
const url = new URL(rawUrl);
|
|
const host = url.hostname.toLowerCase();
|
|
if (host.includes("zillow.com")) return "zillow";
|
|
if (host.includes("har.com")) return "har";
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface ResolvedListingCandidates {
|
|
attempts: string[];
|
|
listingUrls: Array<{ label: string; url: string }>;
|
|
zillowUrl: string | null;
|
|
harUrl: string | null;
|
|
}
|
|
|
|
async function resolveListingCandidates(
|
|
options: AssessPropertyOptions,
|
|
discoverListingSourcesFn: typeof discoverListingSources
|
|
): Promise<ResolvedListingCandidates> {
|
|
const attempts: string[] = [];
|
|
const listingUrls: Array<{ label: string; url: string }> = [];
|
|
|
|
const addListingUrl = (label: string, url: string | null | undefined): void => {
|
|
if (!url) return;
|
|
if (!listingUrls.some((item) => item.url === url)) {
|
|
listingUrls.push({ label, url });
|
|
}
|
|
};
|
|
|
|
let zillowUrl: string | null = null;
|
|
let harUrl: string | null = null;
|
|
|
|
if (options.listingSourceUrl) {
|
|
const explicitSource = inferSourceFromUrl(options.listingSourceUrl);
|
|
addListingUrl("Explicit Listing Source", options.listingSourceUrl);
|
|
if (explicitSource === "zillow") {
|
|
zillowUrl = options.listingSourceUrl;
|
|
attempts.push(`Using explicit Zillow listing URL: ${options.listingSourceUrl}`);
|
|
} else if (explicitSource === "har") {
|
|
harUrl = options.listingSourceUrl;
|
|
attempts.push(`Using explicit HAR listing URL: ${options.listingSourceUrl}`);
|
|
} else {
|
|
attempts.push(
|
|
`Explicit listing URL was provided but is not a supported Zillow/HAR photo source: ${options.listingSourceUrl}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!zillowUrl && !harUrl) {
|
|
const discovered = await discoverListingSourcesFn(options.address);
|
|
attempts.push(...discovered.attempts);
|
|
zillowUrl = discovered.zillowUrl;
|
|
harUrl = discovered.harUrl;
|
|
addListingUrl("Discovered Zillow Listing", zillowUrl);
|
|
addListingUrl("Discovered HAR Listing", harUrl);
|
|
}
|
|
|
|
return {
|
|
attempts,
|
|
listingUrls,
|
|
zillowUrl,
|
|
harUrl,
|
|
};
|
|
}
|
|
|
|
async function resolvePhotoReview(
|
|
listingCandidates: ResolvedListingCandidates,
|
|
extractPhotoDataFn: typeof extractPhotoData,
|
|
additionalAttempts: string[] = []
|
|
): Promise<{
|
|
listingUrls: Array<{ label: string; url: string }>;
|
|
photoReview: Record<string, unknown>;
|
|
}> {
|
|
const attempts: string[] = [...listingCandidates.attempts, ...additionalAttempts];
|
|
const listingUrls = [...listingCandidates.listingUrls];
|
|
const candidates: Array<{ source: PhotoSource; url: string }> = [];
|
|
if (listingCandidates.zillowUrl) candidates.push({ source: "zillow", url: listingCandidates.zillowUrl });
|
|
if (listingCandidates.harUrl) candidates.push({ source: "har", url: listingCandidates.harUrl });
|
|
|
|
let extracted: PhotoExtractionResult | null = null;
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const result = await extractPhotoDataFn(candidate.source, candidate.url);
|
|
extracted = result;
|
|
attempts.push(
|
|
`${candidate.source} photo extraction succeeded with ${result.photoCount} photos.`
|
|
);
|
|
if (result.notes.length) {
|
|
attempts.push(...result.notes);
|
|
}
|
|
break;
|
|
} catch (error) {
|
|
attempts.push(
|
|
`${candidate.source} photo extraction failed: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (extracted) {
|
|
return {
|
|
listingUrls,
|
|
photoReview: {
|
|
status: "completed",
|
|
source: extracted.source,
|
|
photoCount: extracted.photoCount,
|
|
expectedPhotoCount: extracted.expectedPhotoCount ?? null,
|
|
imageUrls: extracted.imageUrls,
|
|
attempts,
|
|
summary:
|
|
"Photo URLs were collected successfully. A decision-grade condition read still requires reviewing the extracted image set.",
|
|
}
|
|
};
|
|
}
|
|
|
|
if (!candidates.length) {
|
|
attempts.push(
|
|
"No supported Zillow or HAR listing URL was available for photo extraction."
|
|
);
|
|
}
|
|
|
|
return {
|
|
listingUrls,
|
|
photoReview: {
|
|
status: "not completed",
|
|
source: candidates.length ? "listing source attempted" : "no supported listing source",
|
|
attempts,
|
|
summary:
|
|
"Condition review is incomplete until Zillow or HAR photos are extracted and inspected."
|
|
}
|
|
};
|
|
}
|
|
|
|
export function buildAssessmentReportPayload(
|
|
options: AssessPropertyOptions,
|
|
publicRecords: PublicRecordsResolution,
|
|
listingUrls: Array<{ label: string; url: string }>,
|
|
photoReview: Record<string, unknown>
|
|
): ReportPayload {
|
|
const recipientEmails = asStringArray(options.recipientEmails);
|
|
const matchedAddress = publicRecords.matchedAddress || options.address;
|
|
const publicRecordLinks = buildPublicRecordLinks(publicRecords);
|
|
const sourceLinks = [...publicRecordLinks];
|
|
const purpose = normalizePurpose(options.assessmentPurpose || "");
|
|
const purposeGuidance = getPurposeGuidance(purpose);
|
|
|
|
for (const item of listingUrls) {
|
|
pushLink(sourceLinks, item.label, item.url);
|
|
}
|
|
|
|
const jurisdiction =
|
|
publicRecords.county.name && publicRecords.appraisalDistrict
|
|
? `${publicRecords.county.name} Appraisal District`
|
|
: publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction";
|
|
const assessedTotalValue = publicRecords.propertyDetails?.assessedTotalValue ?? null;
|
|
const ownerName = publicRecords.propertyDetails?.ownerName ?? undefined;
|
|
const landValue = publicRecords.propertyDetails?.landValue ?? undefined;
|
|
const improvementValue = publicRecords.propertyDetails?.improvementValue ?? undefined;
|
|
const exemptions = publicRecords.propertyDetails?.exemptions?.length
|
|
? publicRecords.propertyDetails.exemptions
|
|
: undefined;
|
|
|
|
return {
|
|
recipientEmails,
|
|
assessmentPurpose: purposeGuidance.label,
|
|
reportTitle: "Property Assessment Report",
|
|
subtitle: "Address-first intake with public-record enrichment and approval-safe photo-source orchestration.",
|
|
subjectProperty: {
|
|
address: matchedAddress,
|
|
county: publicRecords.county.name || "N/A",
|
|
state: publicRecords.state.code || publicRecords.state.name || "N/A",
|
|
geoid: publicRecords.geoid || "N/A"
|
|
},
|
|
verdict: {
|
|
decision: "pending",
|
|
fairValueRange: "Not established",
|
|
offerGuidance: purposeGuidance.verdict
|
|
},
|
|
snapshot: [
|
|
`Matched address: ${matchedAddress}`,
|
|
`Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`,
|
|
publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned.",
|
|
assessedTotalValue != null
|
|
? `Official CAD assessed value: $${assessedTotalValue.toLocaleString("en-US")}.`
|
|
: "Official CAD assessed value was not retrieved.",
|
|
purposeGuidance.snapshot
|
|
],
|
|
whatILike: [
|
|
"Address-first flow avoids treating listing-site geo IDs as assessor record keys.",
|
|
publicRecords.appraisalDistrict
|
|
? "Official appraisal-district contact and website were identified from public records."
|
|
: "Official public-record geography was identified.",
|
|
purposeGuidance.like
|
|
],
|
|
whatIDontLike: [
|
|
"This assess helper still needs listing facts, comp analysis, and a human/model review of the extracted photo set before any final valuation claim.",
|
|
purposeGuidance.caution
|
|
],
|
|
compView: [purposeGuidance.comp],
|
|
carryView: [purposeGuidance.carry],
|
|
risksAndDiligence: [
|
|
...publicRecords.lookupRecommendations,
|
|
...(publicRecords.propertyDetails?.notes || []),
|
|
purposeGuidance.diligence
|
|
],
|
|
photoReview,
|
|
publicRecords: {
|
|
jurisdiction,
|
|
accountNumber:
|
|
publicRecords.propertyDetails?.propertyId ||
|
|
options.parcelId ||
|
|
publicRecords.sourceIdentifierHints.parcelId,
|
|
ownerName,
|
|
landValue,
|
|
improvementValue,
|
|
assessedTotalValue,
|
|
exemptions,
|
|
links: publicRecordLinks
|
|
},
|
|
sourceLinks
|
|
};
|
|
}
|
|
|
|
export async function assessProperty(
|
|
options: AssessPropertyOptions,
|
|
deps: AssessPropertyDeps = {}
|
|
): Promise<AssessPropertyResult> {
|
|
const purpose = normalizePurpose(options.assessmentPurpose || "");
|
|
if (!purpose) {
|
|
return {
|
|
ok: true,
|
|
needsAssessmentPurpose: true,
|
|
needsRecipientEmails: false,
|
|
pdfReady: false,
|
|
message:
|
|
"Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.",
|
|
outputPath: null,
|
|
reportPayload: null,
|
|
publicRecords: null
|
|
};
|
|
}
|
|
|
|
const resolvePublicRecordsFn = deps.resolvePublicRecordsFn || resolvePublicRecords;
|
|
const renderReportPdfFn = deps.renderReportPdfFn || renderReportPdf;
|
|
const discoverListingSourcesFn = deps.discoverListingSourcesFn || discoverListingSources;
|
|
const extractPhotoDataFn = deps.extractPhotoDataFn || extractPhotoData;
|
|
const extractZillowIdentifierHintsFn =
|
|
deps.extractZillowIdentifierHintsFn || extractZillowIdentifierHints;
|
|
|
|
const listingCandidates = await resolveListingCandidates(
|
|
{ ...options, assessmentPurpose: purpose },
|
|
discoverListingSourcesFn
|
|
);
|
|
|
|
const identifierAttempts: string[] = [];
|
|
let effectiveParcelId = options.parcelId;
|
|
if (!effectiveParcelId && listingCandidates.zillowUrl) {
|
|
try {
|
|
const hints = await extractZillowIdentifierHintsFn(listingCandidates.zillowUrl);
|
|
effectiveParcelId = hints.parcelId || hints.apn || effectiveParcelId;
|
|
if (Array.isArray(hints.notes) && hints.notes.length) {
|
|
identifierAttempts.push(...hints.notes);
|
|
}
|
|
} catch (error) {
|
|
identifierAttempts.push(
|
|
`Zillow parcel/APN extraction failed: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const effectiveListingSourceUrl =
|
|
options.listingSourceUrl || listingCandidates.zillowUrl || listingCandidates.harUrl || undefined;
|
|
|
|
const publicRecords = await resolvePublicRecordsFn(options.address, {
|
|
parcelId: effectiveParcelId,
|
|
listingGeoId: options.listingGeoId,
|
|
listingSourceUrl: effectiveListingSourceUrl
|
|
});
|
|
|
|
const photoResolution = await resolvePhotoReview(
|
|
listingCandidates,
|
|
extractPhotoDataFn,
|
|
identifierAttempts
|
|
);
|
|
|
|
const reportPayload = buildAssessmentReportPayload(
|
|
{
|
|
...options,
|
|
assessmentPurpose: purpose,
|
|
parcelId: effectiveParcelId,
|
|
listingSourceUrl: effectiveListingSourceUrl
|
|
},
|
|
publicRecords,
|
|
photoResolution.listingUrls,
|
|
photoResolution.photoReview
|
|
);
|
|
const recipientEmails = asStringArray(options.recipientEmails);
|
|
const renderPdf = shouldRenderPdf(options, recipientEmails);
|
|
|
|
if (renderPdf && !recipientEmails.length) {
|
|
return {
|
|
ok: true,
|
|
needsAssessmentPurpose: false,
|
|
needsRecipientEmails: true,
|
|
pdfReady: false,
|
|
message:
|
|
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.",
|
|
outputPath: null,
|
|
reportPayload,
|
|
publicRecords
|
|
};
|
|
}
|
|
|
|
if (!renderPdf) {
|
|
return {
|
|
ok: true,
|
|
needsAssessmentPurpose: false,
|
|
needsRecipientEmails: false,
|
|
pdfReady: true,
|
|
message:
|
|
"Assessment payload is ready to render later. Review the analysis now; recipient email is only needed when you want the PDF.",
|
|
outputPath: null,
|
|
reportPayload,
|
|
publicRecords
|
|
};
|
|
}
|
|
|
|
if (!isDecisionGradeReportPayload(reportPayload)) {
|
|
return {
|
|
ok: true,
|
|
needsAssessmentPurpose: false,
|
|
needsRecipientEmails: false,
|
|
pdfReady: false,
|
|
message:
|
|
"The report payload is still preliminary. Do not render or send the PDF until comps, valuation, and a decision-grade verdict are completed.",
|
|
outputPath: null,
|
|
reportPayload,
|
|
publicRecords
|
|
};
|
|
}
|
|
|
|
const outputPath =
|
|
options.output ||
|
|
path.join(
|
|
os.tmpdir(),
|
|
`property-assessment-${slugify(publicRecords.matchedAddress || options.address)}-${Date.now()}.pdf`
|
|
);
|
|
|
|
const renderedPath = await renderReportPdfFn(reportPayload, outputPath);
|
|
return {
|
|
ok: true,
|
|
needsAssessmentPurpose: false,
|
|
needsRecipientEmails: false,
|
|
pdfReady: true,
|
|
message: `Property assessment PDF rendered: ${renderedPath}`,
|
|
outputPath: renderedPath,
|
|
reportPayload,
|
|
publicRecords
|
|
};
|
|
}
|