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 { 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; }> { 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 ): 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 { 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 }; }