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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- Texas Comptroller county directory page
|
||||||
- appraisal district contact/site details
|
- appraisal district contact/site details
|
||||||
- tax assessor/collector 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:
|
Important rules:
|
||||||
|
|
||||||
@@ -174,12 +177,14 @@ For Texas addresses, the helper resolves:
|
|||||||
- the official Texas Comptroller county directory page
|
- the official Texas Comptroller county directory page
|
||||||
- the appraisal district website
|
- the appraisal district website
|
||||||
- the tax assessor/collector 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:
|
That output should be used by the skill to:
|
||||||
|
|
||||||
- identify the correct CAD
|
- identify the correct CAD
|
||||||
- attempt address / parcel / account lookup on the CAD site
|
- attempt address / parcel / account lookup on the discovered CAD site for that county
|
||||||
- capture official assessed values and exemptions when a public detail page is available
|
- 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:
|
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
|
- 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
|
- 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
|
- 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
|
- 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
|
- run the approval-safe Zillow/HAR photo extractor chain automatically
|
||||||
- build a purpose-aware report payload
|
- build a purpose-aware report payload
|
||||||
@@ -180,11 +181,13 @@ This command currently:
|
|||||||
- returns county/state/FIPS/GEOID context
|
- returns county/state/FIPS/GEOID context
|
||||||
- for Texas, resolves the official Texas Comptroller county directory page
|
- for Texas, resolves the official Texas Comptroller county directory page
|
||||||
- returns the county appraisal district and tax assessor/collector links when available
|
- 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:
|
Important rules:
|
||||||
- listing-site geo IDs are hints only; do **not** treat them as assessor record keys
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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",
|
"Tax Assessor / Collector Directory Page",
|
||||||
publicRecords.taxAssessorCollector?.directoryPage
|
publicRecords.taxAssessorCollector?.directoryPage
|
||||||
);
|
);
|
||||||
|
pushLink(links, "CAD Property Detail", publicRecords.propertyDetails?.sourceUrl);
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +319,13 @@ export function buildAssessmentReportPayload(
|
|||||||
publicRecords.county.name && publicRecords.appraisalDistrict
|
publicRecords.county.name && publicRecords.appraisalDistrict
|
||||||
? `${publicRecords.county.name} Appraisal District`
|
? `${publicRecords.county.name} Appraisal District`
|
||||||
: publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction";
|
: 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 {
|
return {
|
||||||
recipientEmails,
|
recipientEmails,
|
||||||
@@ -339,6 +347,9 @@ export function buildAssessmentReportPayload(
|
|||||||
`Matched address: ${matchedAddress}`,
|
`Matched address: ${matchedAddress}`,
|
||||||
`Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`,
|
`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.",
|
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
|
purposeGuidance.snapshot
|
||||||
],
|
],
|
||||||
whatILike: [
|
whatILike: [
|
||||||
@@ -356,17 +367,21 @@ export function buildAssessmentReportPayload(
|
|||||||
carryView: [purposeGuidance.carry],
|
carryView: [purposeGuidance.carry],
|
||||||
risksAndDiligence: [
|
risksAndDiligence: [
|
||||||
...publicRecords.lookupRecommendations,
|
...publicRecords.lookupRecommendations,
|
||||||
|
...(publicRecords.propertyDetails?.notes || []),
|
||||||
purposeGuidance.diligence
|
purposeGuidance.diligence
|
||||||
],
|
],
|
||||||
photoReview,
|
photoReview,
|
||||||
publicRecords: {
|
publicRecords: {
|
||||||
jurisdiction,
|
jurisdiction,
|
||||||
accountNumber: options.parcelId || publicRecords.sourceIdentifierHints.parcelId,
|
accountNumber:
|
||||||
ownerName: undefined,
|
publicRecords.propertyDetails?.propertyId ||
|
||||||
landValue: undefined,
|
options.parcelId ||
|
||||||
improvementValue: undefined,
|
publicRecords.sourceIdentifierHints.parcelId,
|
||||||
assessedTotalValue: undefined,
|
ownerName,
|
||||||
exemptions: undefined,
|
landValue,
|
||||||
|
improvementValue,
|
||||||
|
assessedTotalValue,
|
||||||
|
exemptions,
|
||||||
links: publicRecordLinks
|
links: publicRecordLinks
|
||||||
},
|
},
|
||||||
sourceLinks
|
sourceLinks
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
|
|||||||
|
|
||||||
export class PublicRecordsLookupError extends Error {}
|
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 {
|
export interface PublicRecordsResolution {
|
||||||
requestedAddress: string;
|
requestedAddress: string;
|
||||||
matchedAddress: string;
|
matchedAddress: string;
|
||||||
@@ -34,17 +49,27 @@ export interface PublicRecordsResolution {
|
|||||||
taxAssessorCollector: Record<string, unknown> | null;
|
taxAssessorCollector: Record<string, unknown> | null;
|
||||||
lookupRecommendations: string[];
|
lookupRecommendations: string[];
|
||||||
sourceIdentifierHints: Record<string, string>;
|
sourceIdentifierHints: Record<string, string>;
|
||||||
|
propertyDetails: PropertyDetailsResolution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchTextInit {
|
||||||
|
body?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
method?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchLike {
|
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, {
|
const response = await fetch(url, {
|
||||||
|
body: init.body,
|
||||||
headers: {
|
headers: {
|
||||||
"user-agent": "property-assessor/1.0"
|
"user-agent": "property-assessor/1.0",
|
||||||
}
|
...(init.headers || {})
|
||||||
|
},
|
||||||
|
method: init.method || "GET"
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
|
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
|
||||||
@@ -72,6 +97,8 @@ function stripHtml(value: string): string {
|
|||||||
.replace(/&/gi, "&")
|
.replace(/&/gi, "&")
|
||||||
.replace(/"/gi, '"')
|
.replace(/"/gi, '"')
|
||||||
.replace(/'/g, "'")
|
.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, "<")
|
||||||
.replace(/>/gi, ">");
|
.replace(/>/gi, ">");
|
||||||
output = collapseWhitespace(output.replace(/\n/g, ", "));
|
output = collapseWhitespace(output.replace(/\n/g, ", "));
|
||||||
@@ -109,6 +136,366 @@ function extractAnchorHref(fragment: string): string | null {
|
|||||||
return href;
|
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<{
|
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
|
||||||
match: any;
|
match: any;
|
||||||
censusGeocoderUrl: string;
|
censusGeocoderUrl: string;
|
||||||
@@ -321,6 +708,7 @@ export async function resolvePublicRecords(
|
|||||||
let texasPropertyTaxPortal: string | null = null;
|
let texasPropertyTaxPortal: string | null = null;
|
||||||
let appraisalDistrict: Record<string, unknown> | null = null;
|
let appraisalDistrict: Record<string, unknown> | null = null;
|
||||||
let taxAssessorCollector: Record<string, unknown> | null = null;
|
let taxAssessorCollector: Record<string, unknown> | null = null;
|
||||||
|
let propertyDetails: PropertyDetailsResolution | null = null;
|
||||||
|
|
||||||
const lookupRecommendations = [
|
const lookupRecommendations = [
|
||||||
"Start from the official public-record jurisdiction instead of a listing-site geo ID.",
|
"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;
|
texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL;
|
||||||
appraisalDistrict = offices.appraisalDistrict;
|
appraisalDistrict = offices.appraisalDistrict;
|
||||||
taxAssessorCollector = offices.taxAssessorCollector;
|
taxAssessorCollector = offices.taxAssessorCollector;
|
||||||
|
propertyDetails = await tryEnrichPropertyDetails(
|
||||||
|
address,
|
||||||
|
typeof offices.appraisalDistrict?.Website === "string"
|
||||||
|
? offices.appraisalDistrict.Website
|
||||||
|
: null,
|
||||||
|
fetchText
|
||||||
|
);
|
||||||
lookupRecommendations.push(
|
lookupRecommendations.push(
|
||||||
"Use the Texas Comptroller county directory page as the official jurisdiction link in the final report.",
|
"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."
|
"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> = {};
|
const sourceIdentifierHints: Record<string, string> = {};
|
||||||
@@ -381,6 +782,7 @@ export async function resolvePublicRecords(
|
|||||||
appraisalDistrict,
|
appraisalDistrict,
|
||||||
taxAssessorCollector,
|
taxAssessorCollector,
|
||||||
lookupRecommendations,
|
lookupRecommendations,
|
||||||
sourceIdentifierHints
|
sourceIdentifierHints,
|
||||||
|
propertyDetails
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ const samplePublicRecords: PublicRecordsResolution = {
|
|||||||
],
|
],
|
||||||
sourceIdentifierHints: {
|
sourceIdentifierHints: {
|
||||||
parcelId: "14069438"
|
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?.subjectProperty?.address, samplePublicRecords.matchedAddress);
|
||||||
assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District");
|
assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District");
|
||||||
assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438");
|
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?.status, "completed");
|
||||||
assert.equal(result.reportPayload?.photoReview?.source, "zillow");
|
assert.equal(result.reportPayload?.photoReview?.source, "zillow");
|
||||||
assert.equal(result.reportPayload?.photoReview?.photoCount, 29);
|
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.county.name, "Nueces County");
|
||||||
assert.equal(payload.state.code, "TX");
|
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