From 7690dc259b29a0d8776ce522f4ffc7aaac2d6426 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Sat, 28 Mar 2026 02:07:18 -0500 Subject: [PATCH] Enrich property assessor with CAD detail data --- docs/property-assessor.md | 9 +- skills/property-assessor/SKILL.md | 3 + skills/property-assessor/src/assessment.ts | 27 +- .../property-assessor/src/public-records.ts | 412 +++++++++++++++++- .../tests/assessment.test.ts | 28 ++ .../tests/public-records.test.ts | 95 ++++ 6 files changed, 561 insertions(+), 13 deletions(-) diff --git a/docs/property-assessor.md b/docs/property-assessor.md index 57a734e..db440e0 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -92,6 +92,8 @@ Current behavior: - requires an assessment purpose for decision-grade analysis - does not assume the assessment purpose from prior thread context unless the user explicitly says the purpose is unchanged - automatically runs public-record / appraisal-district lookup +- keeps CAD-site selection address-driven and jurisdiction-specific; it does not hardcode one county's CAD as the global source +- when a supported official CAD detail host is found, captures direct property facts such as property ID/account, owner, legal description, assessed value, exemptions, and the official property-detail URL - automatically tries to discover Zillow and HAR listing URLs from the address when no listing URL is provided - runs Zillow photo extraction first, then HAR as fallback when available - reuses the OpenClaw web-automation logic in-process instead of spawning nested helper commands @@ -156,6 +158,7 @@ Current behavior: - Texas Comptroller county directory page - appraisal district contact/site details - tax assessor/collector contact/site details + - official CAD property-detail facts when a supported county adapter can retrieve them from the discovered CAD site Important rules: @@ -174,12 +177,14 @@ For Texas addresses, the helper resolves: - the official Texas Comptroller county directory page - the appraisal district website - the tax assessor/collector website +- the official CAD property-detail page when a supported adapter can identify and retrieve the subject record That output should be used by the skill to: - identify the correct CAD -- attempt address / parcel / account lookup on the CAD site -- capture official assessed values and exemptions when a public detail page is available +- attempt address / parcel / account lookup on the discovered CAD site for that county +- capture official owner / legal / assessed-value evidence when a public detail page is available +- treat county-specific CAD detail retrieval as an adapter layer on top of generic county/jurisdiction resolution Recommended fields to capture from official records when accessible: diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index 2beddac..6855acb 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -113,6 +113,7 @@ scripts/property-assessor render-report --input "" --output - require the assessment purpose - treat the assessment purpose as missing unless it is present in the current request or explicitly confirmed as unchanged from earlier context - resolve official public-record jurisdiction automatically from the address +- keep CAD discovery jurisdiction-specific from the address; do not hardcode one county CAD for every property - try to discover Zillow and HAR listing URLs from the address when no listing URL is provided - run the approval-safe Zillow/HAR photo extractor chain automatically - build a purpose-aware report payload @@ -180,11 +181,13 @@ This command currently: - returns county/state/FIPS/GEOID context - for Texas, resolves the official Texas Comptroller county directory page - returns the county appraisal district and tax assessor/collector links when available +- when a supported official CAD detail host is found, retrieves subject-property facts from that county CAD and includes them in the assessment payload Important rules: - listing-site geo IDs are hints only; do **not** treat them as assessor record keys - parcel/APN/account identifiers from Zillow/HAR/Redfin are much stronger keys than listing geo IDs - if a direct public-record property page is available, use its data in the assessment and link it explicitly +- when the helper exposes official CAD owner, legal-description, property-ID/account, value, or exemption data, treat those as primary-source facts in the model's assessment - if the jurisdiction can be identified but the property detail page is not directly retrievable, still link the official jurisdiction page and say what could not be confirmed - a host approval prompt triggered by an ad hoc shell snippet is workflow drift; return to `locate-public-records`, `assess`, `web_fetch`, or a file-based helper instead of approving the inline probe by default diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts index efbc4ee..2cb2bab 100644 --- a/skills/property-assessor/src/assessment.ts +++ b/skills/property-assessor/src/assessment.ts @@ -121,6 +121,7 @@ function buildPublicRecordLinks( "Tax Assessor / Collector Directory Page", publicRecords.taxAssessorCollector?.directoryPage ); + pushLink(links, "CAD Property Detail", publicRecords.propertyDetails?.sourceUrl); return links; } @@ -318,6 +319,13 @@ export function buildAssessmentReportPayload( 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, @@ -339,6 +347,9 @@ export function buildAssessmentReportPayload( `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: [ @@ -356,17 +367,21 @@ export function buildAssessmentReportPayload( carryView: [purposeGuidance.carry], risksAndDiligence: [ ...publicRecords.lookupRecommendations, + ...(publicRecords.propertyDetails?.notes || []), purposeGuidance.diligence ], photoReview, publicRecords: { jurisdiction, - accountNumber: options.parcelId || publicRecords.sourceIdentifierHints.parcelId, - ownerName: undefined, - landValue: undefined, - improvementValue: undefined, - assessedTotalValue: undefined, - exemptions: undefined, + accountNumber: + publicRecords.propertyDetails?.propertyId || + options.parcelId || + publicRecords.sourceIdentifierHints.parcelId, + ownerName, + landValue, + improvementValue, + assessedTotalValue, + exemptions, links: publicRecordLinks }, sourceLinks diff --git a/skills/property-assessor/src/public-records.ts b/skills/property-assessor/src/public-records.ts index 27e5aff..3046fdc 100644 --- a/skills/property-assessor/src/public-records.ts +++ b/skills/property-assessor/src/public-records.ts @@ -9,6 +9,21 @@ export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes"; export class PublicRecordsLookupError extends Error {} +export interface PropertyDetailsResolution { + source: string; + sourceUrl: string; + propertyId: string | null; + ownerName: string | null; + situsAddress: string | null; + legalDescription: string | null; + landValue: number | null; + improvementValue: number | null; + marketValue: number | null; + assessedTotalValue: number | null; + exemptions: string[]; + notes: string[]; +} + export interface PublicRecordsResolution { requestedAddress: string; matchedAddress: string; @@ -34,17 +49,27 @@ export interface PublicRecordsResolution { taxAssessorCollector: Record | null; lookupRecommendations: string[]; sourceIdentifierHints: Record; + propertyDetails: PropertyDetailsResolution | null; +} + +interface FetchTextInit { + body?: string; + headers?: Record; + method?: string; } interface FetchLike { - (url: string): Promise; + (url: string, init?: FetchTextInit): Promise; } -const defaultFetchText: FetchLike = async (url) => { +const defaultFetchText: FetchLike = async (url, init = {}) => { const response = await fetch(url, { + body: init.body, headers: { - "user-agent": "property-assessor/1.0" - } + "user-agent": "property-assessor/1.0", + ...(init.headers || {}) + }, + method: init.method || "GET" }); if (!response.ok) { throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`); @@ -72,6 +97,8 @@ function stripHtml(value: string): string { .replace(/&/gi, "&") .replace(/"/gi, '"') .replace(/'/g, "'") + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10))) .replace(/</gi, "<") .replace(/>/gi, ">"); output = collapseWhitespace(output.replace(/\n/g, ", ")); @@ -109,6 +136,366 @@ function extractAnchorHref(fragment: string): string | null { return href; } +function normalizeUrl(rawUrl: string): string { + const value = collapseWhitespace(rawUrl); + if (!value) return value; + if (/^https?:\/\//i.test(value)) return value; + return `https://${value.replace(/^\/+/, "")}`; +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10))) + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">"); +} + +function parseCurrencyValue(value: string | null | undefined): number | null { + const normalized = collapseWhitespace(value); + if (!normalized) return null; + const numeric = normalized.replace(/[^0-9.-]/g, ""); + if (!numeric) return null; + const parsed = Number(numeric); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseCurrentYearFromSearchHome(searchHomeHtml: string): number { + const configuredYear = searchHomeHtml.match(/"DefaultYear"\s*:\s*(\d{4})/i); + if (configuredYear) { + return Number(configuredYear[1]); + } + return new Date().getFullYear(); +} + +function buildCadSearchKeywords(address: string, year: number): string { + return `${collapseWhitespace(address)} Year:${year}`.trim(); +} + +function parseAddressForCadSearch(address: string): { + rawAddress: string; + streetNumber: string | null; + streetName: string | null; + unit: string | null; +} { + const rawAddress = collapseWhitespace(address); + const streetPart = collapseWhitespace(rawAddress.split(",")[0] || rawAddress); + const unitMatch = streetPart.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i); + const unit = unitMatch ? unitMatch[1].toUpperCase() : null; + + const withoutUnit = collapseWhitespace( + streetPart + .replace(/\b(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+/gi, "") + .replace(/#\s*[a-z0-9-]+/gi, "") + ); + const numberMatch = withoutUnit.match(/^(\d+[a-z]?)/i); + const streetNumber = numberMatch ? numberMatch[1] : null; + const suffixes = new Set([ + "rd", + "road", + "dr", + "drive", + "st", + "street", + "ave", + "avenue", + "blvd", + "boulevard", + "ct", + "court", + "cir", + "circle", + "ln", + "lane", + "trl", + "trail", + "way", + "pkwy", + "parkway", + "pl", + "place", + "ter", + "terrace", + "loop", + "hwy", + "highway" + ]); + + const streetTokens = withoutUnit + .replace(/^(\d+[a-z]?)\s*/i, "") + .split(/\s+/) + .filter(Boolean); + while (streetTokens.length && suffixes.has(streetTokens[streetTokens.length - 1].toLowerCase())) { + streetTokens.pop(); + } + + return { + rawAddress, + streetNumber, + streetName: streetTokens.length ? streetTokens.join(" ") : null, + unit + }; +} + +function extractSearchToken(searchHomeHtml: string): string | null { + const match = searchHomeHtml.match(/meta name="search-token" content="([^"]+)"/i); + return match ? decodeHtmlEntities(match[1]) : null; +} + +function extractPropertySearchUrl(homepageHtml: string): string | null { + const preferred = homepageHtml.match(/href="(https:\/\/[^"]*esearch[^"]*)"/i); + if (preferred) { + return preferred[1]; + } + const generic = homepageHtml.match(/href="([^"]+)"[^>]*>\s*(?:SEARCH NOW|Property Search)\s*]*>\\s*${escaped}\\s*<\\/div>\\s*]*>(.*?)<\\/div>`, "is"), + new RegExp(`\\s*${escaped}\\s*:?\\s*<\\/strong>\\s*(.*?)(?:|<\\/p>|<\\/div>)`, "is"), + new RegExp(`${escaped}\\s*:?\\s*<\\/[^>]+>\\s*<[^>]+>(.*?)<\\/[^>]+>`, "is") + ]; + + for (const pattern of patterns) { + const match = detailHtml.match(pattern); + if (match) { + return stripHtml(match[1]); + } + } + return null; +} + +function extractExemptions(detailHtml: string): string[] { + const raw = extractDetailField(detailHtml, "Exemptions"); + if (!raw) return []; + return raw + .split(/[,;|]/) + .map((item) => collapseWhitespace(item)) + .filter(Boolean); +} + +function scoreAddressMatch(needle: string, haystack: string): number { + const normalizedNeedle = collapseWhitespace(needle).toLowerCase(); + const normalizedHaystack = collapseWhitespace(haystack).toLowerCase(); + if (!normalizedNeedle || !normalizedHaystack) return 0; + + let score = 0; + const tokens = normalizedNeedle.split(/[\s,]+/).filter(Boolean); + for (const token of tokens) { + if (normalizedHaystack.includes(token)) { + score += token.length > 3 ? 2 : 1; + } + } + + const unitMatch = normalizedNeedle.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i); + if (unitMatch) { + score += normalizedHaystack.includes(unitMatch[1].toLowerCase()) ? 4 : -4; + } + + return score; +} + +function pickBestCadResult( + address: string, + results: Array> +): Record | null { + const scored = results + .map((result) => { + const candidateText = [ + result.address, + result.legalDescription, + result.ownerName, + result.condo, + result.geoId, + result.propertyId + ] + .map((item) => collapseWhitespace(String(item || ""))) + .join(" "); + return { result, score: scoreAddressMatch(address, candidateText) }; + }) + .sort((a, b) => b.score - a.score); + + return scored[0]?.score > 0 ? scored[0].result : null; +} + +async function enrichNuecesCadPropertyDetails( + address: string, + appraisalDistrictWebsite: string, + fetchText: FetchLike +): Promise { + const parsedAddress = parseAddressForCadSearch(address); + const homepageUrl = normalizeUrl(appraisalDistrictWebsite); + const homepageHtml = await fetchText(homepageUrl); + const propertySearchUrl = extractPropertySearchUrl(homepageHtml); + if (!propertySearchUrl) return null; + + const normalizedPropertySearchUrl = normalizeUrl(propertySearchUrl).replace(/\/+$/, ""); + const searchHomeHtml = await fetchText(`${normalizedPropertySearchUrl}/`); + const searchToken = extractSearchToken(searchHomeHtml); + if (!searchToken) return null; + + const searchYear = parseCurrentYearFromSearchHome(searchHomeHtml); + const searchKeywords = + parsedAddress.streetNumber && parsedAddress.streetName + ? `StreetNumber:${parsedAddress.streetNumber} StreetName:"${parsedAddress.streetName}"` + : buildCadSearchKeywords(address, searchYear); + + const fetchSearchPage = async (page: number): Promise => { + const searchResultsUrl = `${normalizedPropertySearchUrl}/search/SearchResults?keywords=${encodeURIComponent(searchKeywords)}`; + if (fetchText === defaultFetchText) { + const sessionTokenResponse = await fetch( + `${normalizedPropertySearchUrl}/search/requestSessionToken`, + { + headers: { + "user-agent": "property-assessor/1.0" + } + } + ); + const sessionTokenPayload = await sessionTokenResponse.json(); + const searchSessionToken = sessionTokenPayload?.searchSessionToken; + const resultUrl = `${normalizedPropertySearchUrl}/search/result?keywords=${encodeURIComponent(searchKeywords)}&searchSessionToken=${encodeURIComponent(String(searchSessionToken || ""))}`; + const resultResponse = await fetch(resultUrl, { + headers: { + "user-agent": "property-assessor/1.0" + } + }); + const cookieHeader = (resultResponse.headers.getSetCookie?.() || []) + .map((item) => item.split(";", 1)[0]) + .join("; "); + const resultPageHtml = await resultResponse.text(); + const liveSearchToken = extractSearchToken(resultPageHtml) || searchToken; + const jsonResponse = await fetch(searchResultsUrl, { + body: JSON.stringify({ + page, + pageSize: 25, + isArb: false, + recaptchaToken: "", + searchToken: liveSearchToken + }), + headers: { + "content-type": "application/json", + cookie: cookieHeader, + referer: resultUrl, + "user-agent": "property-assessor/1.0" + }, + method: "POST" + }); + return await jsonResponse.json(); + } + + const searchResultsRaw = await fetchText(searchResultsUrl, { + body: JSON.stringify({ + page, + pageSize: 25, + isArb: false, + recaptchaToken: "", + searchToken + }), + headers: { + "content-type": "application/json" + }, + method: "POST" + }); + return JSON.parse(searchResultsRaw); + }; + + const firstPage = await fetchSearchPage(1); + const totalPages = Math.min(Number(firstPage?.totalPages || 1), 8); + const collectedResults: Array> = Array.isArray(firstPage?.resultsList) + ? [...firstPage.resultsList] + : []; + let bestResult = pickBestCadResult(address, collectedResults); + + if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) { + bestResult = null; + } + + for (let page = 2; !bestResult && page <= totalPages; page += 1) { + const nextPage = await fetchSearchPage(page); + if (Array.isArray(nextPage?.resultsList)) { + collectedResults.push(...nextPage.resultsList); + bestResult = pickBestCadResult(address, collectedResults); + if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) { + bestResult = null; + } + } + } + + if (!bestResult) return null; + + const detailPath = collapseWhitespace(String(bestResult.detailUrl || "")); + const canUseDetailPath = Boolean(detailPath) && !/[?&]Id=/i.test(detailPath); + const detailUrl = canUseDetailPath + ? new URL(detailPath, `${normalizedPropertySearchUrl}/`).toString() + : new URL( + `/property/view/${encodeURIComponent(String(bestResult.propertyId || ""))}?year=${encodeURIComponent(String(bestResult.year || searchYear))}&ownerId=${encodeURIComponent(String(bestResult.ownerId || ""))}`, + `${normalizedPropertySearchUrl}/` + ).toString(); + + const detailHtml = await fetchText(detailUrl); + return { + source: "nueces-esearch", + sourceUrl: detailUrl, + propertyId: collapseWhitespace(String(bestResult.propertyId || "")) || null, + ownerName: + extractDetailField(detailHtml, "Owner Name") || + collapseWhitespace(String(bestResult.ownerName || "")) || + null, + situsAddress: + extractDetailField(detailHtml, "Situs Address") || + extractDetailField(detailHtml, "Address") || + collapseWhitespace(String(bestResult.address || "")) || + null, + legalDescription: + extractDetailField(detailHtml, "Legal Description") || + collapseWhitespace(String(bestResult.legalDescription || "")) || + null, + landValue: parseCurrencyValue(extractDetailField(detailHtml, "Land Value")), + improvementValue: parseCurrencyValue(extractDetailField(detailHtml, "Improvement Value")), + marketValue: + parseCurrencyValue(extractDetailField(detailHtml, "Market Value")) || + (Number.isFinite(Number(bestResult.appraisedValue)) + ? Number(bestResult.appraisedValue) + : parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))), + assessedTotalValue: + parseCurrencyValue(extractDetailField(detailHtml, "Assessed Value")) || + parseCurrencyValue(extractDetailField(detailHtml, "Appraised Value")) || + (Number.isFinite(Number(bestResult.appraisedValue)) + ? Number(bestResult.appraisedValue) + : parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))), + exemptions: extractExemptions(detailHtml), + notes: [ + "Official CAD property detail page exposed owner, value, and exemption data." + ] + }; +} + +async function tryEnrichPropertyDetails( + address: string, + appraisalDistrictWebsite: string | null, + fetchText: FetchLike +): Promise { + const website = collapseWhitespace(appraisalDistrictWebsite); + if (!website) return null; + + const normalizedWebsite = normalizeUrl(website).toLowerCase(); + try { + if (normalizedWebsite.includes("nuecescad.net") || normalizedWebsite.includes("ncadistrict.com")) { + return await enrichNuecesCadPropertyDetails(address, website, fetchText); + } + } catch { + return null; + } + + return null; +} + async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{ match: any; censusGeocoderUrl: string; @@ -321,6 +708,7 @@ export async function resolvePublicRecords( let texasPropertyTaxPortal: string | null = null; let appraisalDistrict: Record | null = null; let taxAssessorCollector: Record | null = null; + let propertyDetails: PropertyDetailsResolution | null = null; const lookupRecommendations = [ "Start from the official public-record jurisdiction instead of a listing-site geo ID.", @@ -339,10 +727,23 @@ export async function resolvePublicRecords( texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL; appraisalDistrict = offices.appraisalDistrict; taxAssessorCollector = offices.taxAssessorCollector; + propertyDetails = await tryEnrichPropertyDetails( + address, + typeof offices.appraisalDistrict?.Website === "string" + ? offices.appraisalDistrict.Website + : null, + fetchText + ); 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." ); + if (propertyDetails) { + lookupRecommendations.push( + ...propertyDetails.notes, + "Use the official CAD property-detail values in the final assessment instead of relying only on listing-site value hints." + ); + } } const sourceIdentifierHints: Record = {}; @@ -381,6 +782,7 @@ export async function resolvePublicRecords( appraisalDistrict, taxAssessorCollector, lookupRecommendations, - sourceIdentifierHints + sourceIdentifierHints, + propertyDetails }; } diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts index 5fc169c..e005cbe 100644 --- a/skills/property-assessor/tests/assessment.test.ts +++ b/skills/property-assessor/tests/assessment.test.ts @@ -44,6 +44,21 @@ const samplePublicRecords: PublicRecordsResolution = { ], sourceIdentifierHints: { parcelId: "14069438" + }, + propertyDetails: { + source: "nueces-esearch", + sourceUrl: "https://esearch.nuecescad.net/property/view/14069438?year=2026", + propertyId: "14069438", + ownerName: "Fiorini Family Trust", + situsAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418", + legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION", + landValue: 42000, + improvementValue: 99000, + assessedTotalValue: 141000, + exemptions: ["Homestead"], + notes: [ + "Official CAD property detail page exposed owner, value, and exemption data." + ] } }; @@ -104,6 +119,19 @@ test("assessProperty auto-discovers listing sources, runs Zillow photos first, a assert.equal(result.reportPayload?.subjectProperty?.address, samplePublicRecords.matchedAddress); assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District"); assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438"); + assert.equal(result.reportPayload?.publicRecords?.ownerName, "Fiorini Family Trust"); + assert.equal(result.reportPayload?.publicRecords?.landValue, 42000); + assert.equal(result.reportPayload?.publicRecords?.improvementValue, 99000); + assert.equal(result.reportPayload?.publicRecords?.assessedTotalValue, 141000); + assert.deepEqual(result.reportPayload?.publicRecords?.exemptions, ["Homestead"]); + assert.match( + String(result.reportPayload?.snapshot?.join(" ")), + /141,000|141000|assessed/i + ); + assert.match( + String(result.reportPayload?.risksAndDiligence?.join(" ")), + /official cad property detail page exposed owner, value, and exemption data/i + ); assert.equal(result.reportPayload?.photoReview?.status, "completed"); assert.equal(result.reportPayload?.photoReview?.source, "zillow"); assert.equal(result.reportPayload?.photoReview?.photoCount, 29); diff --git a/skills/property-assessor/tests/public-records.test.ts b/skills/property-assessor/tests/public-records.test.ts index 8945fd1..506c1ed 100644 --- a/skills/property-assessor/tests/public-records.test.ts +++ b/skills/property-assessor/tests/public-records.test.ts @@ -196,3 +196,98 @@ test("resolvePublicRecords retries fallback geocoding without the unit suffix", assert.equal(payload.county.name, "Nueces County"); assert.equal(payload.state.code, "TX"); }); + +test("resolvePublicRecords enriches official CAD property facts when a supported CAD detail source is available", async () => { + const fetchedUrls: string[] = []; + const enrichedFetchText = async (url: string): Promise => { + fetchedUrls.push(url); + + if (url.includes("geocoding.geo.census.gov")) { + return JSON.stringify(geocoderPayload); + } + if (url.endsWith("/county-directory/")) { + return countyIndexHtml; + } + if (url.endsWith("/county-directory/nueces.php")) { + return countyPageHtml.replace( + "http://www.ncadistrict.com/", + "https://nuecescad.net/" + ); + } + if (url === "https://nuecescad.net/") { + return ` + + + Property Search + + + `; + } + if (url === "https://esearch.nuecescad.net/") { + return ` + + + + + + Property Search + + + `; + } + if (url.includes("/search/SearchResults?")) { + return JSON.stringify({ + success: true, + resultsList: [ + { + propertyId: "14069438", + ownerName: "Fiorini Family Trust", + ownerId: "998877", + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION", + appraisedValueDisplay: "$141,000", + detailUrl: "/property/view/14069438?year=2026" + } + ] + }); + } + if (url === "https://esearch.nuecescad.net/property/view/14069438?year=2026") { + return ` + + +
+
Owner Name
Fiorini Family Trust
+
Account Number
14069438
+
Situs Address
4141 Whiteley Dr, Corpus Christi, TX 78418
+
Legal Description
LOT 4 BLOCK 3 EXAMPLE SUBDIVISION
+
Land Value
$42,000
+
Improvement Value
$99,000
+
Market Value
$141,000
+
Assessed Value
$141,000
+
Exemptions
Homestead
+
+ + + `; + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", { + fetchText: enrichedFetchText + }); + + assert.equal(payload.propertyDetails?.propertyId, "14069438"); + assert.equal(payload.propertyDetails?.ownerName, "Fiorini Family Trust"); + assert.equal(payload.propertyDetails?.landValue, 42000); + assert.equal(payload.propertyDetails?.improvementValue, 99000); + assert.equal(payload.propertyDetails?.assessedTotalValue, 141000); + assert.deepEqual(payload.propertyDetails?.exemptions, ["Homestead"]); + assert.match( + payload.lookupRecommendations.join(" "), + /official cad property detail/i + ); + assert.ok( + fetchedUrls.some((url) => url.includes("esearch.nuecescad.net/property/view/14069438")) + ); +});