Enrich property assessor with CAD detail data
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ scripts/property-assessor render-report --input "<report-payload-json>" --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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
lookupRecommendations: string[];
|
||||
sourceIdentifierHints: Record<string, string>;
|
||||
propertyDetails: PropertyDetailsResolution | null;
|
||||
}
|
||||
|
||||
interface FetchTextInit {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
interface FetchLike {
|
||||
(url: string): Promise<string>;
|
||||
(url: string, init?: FetchTextInit): Promise<string>;
|
||||
}
|
||||
|
||||
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*</i);
|
||||
return generic ? generic[1] : null;
|
||||
}
|
||||
|
||||
function extractDetailField(detailHtml: string, label: string): string | null {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const patterns = [
|
||||
new RegExp(`<div[^>]*>\\s*${escaped}\\s*<\\/div>\\s*<div[^>]*>(.*?)<\\/div>`, "is"),
|
||||
new RegExp(`<strong>\\s*${escaped}\\s*:?\\s*<\\/strong>\\s*(.*?)(?:<br\\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<string, unknown>>
|
||||
): Record<string, unknown> | 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<PropertyDetailsResolution | null> {
|
||||
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<any> => {
|
||||
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<Record<string, unknown>> = 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<PropertyDetailsResolution | null> {
|
||||
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<string, unknown> | null = null;
|
||||
let taxAssessorCollector: Record<string, unknown> | 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<string, string> = {};
|
||||
@@ -381,6 +782,7 @@ export async function resolvePublicRecords(
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector,
|
||||
lookupRecommendations,
|
||||
sourceIdentifierHints
|
||||
sourceIdentifierHints,
|
||||
propertyDetails
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> => {
|
||||
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 `
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://esearch.nuecescad.net/">Property Search</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
if (url === "https://esearch.nuecescad.net/") {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="search-token" content="token-value|2026-03-28T00:00:00Z" />
|
||||
</head>
|
||||
<body>
|
||||
Property Search
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
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 `
|
||||
<html>
|
||||
<body>
|
||||
<div class="property-summary">
|
||||
<div>Owner Name</div><div>Fiorini Family Trust</div>
|
||||
<div>Account Number</div><div>14069438</div>
|
||||
<div>Situs Address</div><div>4141 Whiteley Dr, Corpus Christi, TX 78418</div>
|
||||
<div>Legal Description</div><div>LOT 4 BLOCK 3 EXAMPLE SUBDIVISION</div>
|
||||
<div>Land Value</div><div>$42,000</div>
|
||||
<div>Improvement Value</div><div>$99,000</div>
|
||||
<div>Market Value</div><div>$141,000</div>
|
||||
<div>Assessed Value</div><div>$141,000</div>
|
||||
<div>Exemptions</div><div>Homestead</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
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"))
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user