Fix property assessor geocode fallback

This commit is contained in:
2026-03-28 00:25:31 -05:00
parent 7570f748f0
commit c68523386d
6 changed files with 275 additions and 19 deletions

View File

@@ -127,16 +127,16 @@ function normalizePurpose(value: string): string {
function getPurposeGuidance(purpose: string): PurposeGuidance {
const normalized = purpose.toLowerCase();
if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) {
if (/(daughter|son|college|student|school|campus)/i.test(normalized)) {
return {
label: purpose,
snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`,
like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.",
caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.",
comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.",
carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.",
diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.",
verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.`
snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.",
like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.",
caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.",
comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.",
carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.",
diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.",
verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.`
};
}
@@ -153,16 +153,16 @@ function getPurposeGuidance(purpose: string): PurposeGuidance {
};
}
if (/(daughter|son|college|student|school|campus)/i.test(normalized)) {
if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) {
return {
label: purpose,
snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.",
like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.",
caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.",
comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.",
carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.",
diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.",
verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.`
snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`,
like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.",
caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.",
comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.",
carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.",
diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.",
verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.`
};
}

View File

@@ -1,5 +1,8 @@
export const CENSUS_GEOCODER_URL =
"https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress";
export const CENSUS_COORDINATES_URL =
"https://geocoding.geo.census.gov/geocoder/geographies/coordinates";
export const NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search";
export const TEXAS_COUNTY_DIRECTORY_URL =
"https://comptroller.texas.gov/taxes/property-tax/county-directory/";
export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
@@ -76,6 +79,28 @@ function stripHtml(value: string): string {
return output.replace(/^,\s*|\s*,\s*$/g, "");
}
function buildFallbackAddressCandidates(address: string): string[] {
const normalized = collapseWhitespace(address);
if (!normalized) return [];
const candidates = [normalized];
const [streetPartRaw, ...restParts] = normalized.split(",");
const streetPart = collapseWhitespace(streetPartRaw);
const locality = restParts.map((part) => collapseWhitespace(part)).filter(Boolean).join(", ");
const strippedStreet = collapseWhitespace(
streetPart.replace(
/\s+(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+$/i,
""
).replace(/\s+#\s*[a-z0-9-]+$/i, "")
);
if (strippedStreet && strippedStreet !== streetPart) {
candidates.push(locality ? `${strippedStreet}, ${locality}` : strippedStreet);
}
return candidates;
}
function extractAnchorHref(fragment: string): string | null {
const match = fragment.match(/<a[^>]+href="([^"]+)"/i);
if (!match) return null;
@@ -87,6 +112,7 @@ function extractAnchorHref(fragment: string): string | null {
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
match: any;
censusGeocoderUrl: string;
usedFallbackGeocoder: boolean;
}> {
const query = new URLSearchParams({
address,
@@ -97,10 +123,70 @@ async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`;
const payload = JSON.parse(await fetchText(url));
const matches = payload?.result?.addressMatches || [];
if (!matches.length) {
if (matches.length) {
return {
match: matches[0],
censusGeocoderUrl: url,
usedFallbackGeocoder: false
};
}
let fallbackMatch: any = null;
for (const candidateAddress of buildFallbackAddressCandidates(address)) {
const fallbackQuery = new URLSearchParams({
q: candidateAddress,
format: "jsonv2",
limit: "1",
countrycodes: "us",
addressdetails: "1"
});
const fallbackUrl = `${NOMINATIM_SEARCH_URL}?${fallbackQuery.toString()}`;
const fallbackPayload = JSON.parse(await fetchText(fallbackUrl));
fallbackMatch = Array.isArray(fallbackPayload) ? fallbackPayload[0] : null;
if (fallbackMatch) {
break;
}
}
if (!fallbackMatch) {
throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`);
}
return { match: matches[0], censusGeocoderUrl: url };
const latitude = Number(fallbackMatch.lat);
const longitude = Number(fallbackMatch.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new PublicRecordsLookupError(
`Fallback geocoder returned invalid coordinates for address: ${address}`
);
}
const coordinateQuery = new URLSearchParams({
x: String(longitude),
y: String(latitude),
benchmark: "Public_AR_Current",
vintage: "Current_Current",
format: "json"
});
const coordinateUrl = `${CENSUS_COORDINATES_URL}?${coordinateQuery.toString()}`;
const coordinatePayload = JSON.parse(await fetchText(coordinateUrl));
const geographies = coordinatePayload?.result?.geographies;
if (!geographies) {
throw new PublicRecordsLookupError(
`Census coordinate geographies lookup failed for address: ${address}`
);
}
return {
match: {
matchedAddress: collapseWhitespace(fallbackMatch.display_name || address),
coordinates: {
x: longitude,
y: latitude
},
geographies
},
censusGeocoderUrl: coordinateUrl,
usedFallbackGeocoder: true
};
}
async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise<string> {
@@ -221,7 +307,10 @@ export async function resolvePublicRecords(
} = {}
): Promise<PublicRecordsResolution> {
const fetchText = options.fetchText || defaultFetchText;
const { match, censusGeocoderUrl } = await geocodeAddress(address, fetchText);
const { match, censusGeocoderUrl, usedFallbackGeocoder } = await geocodeAddress(
address,
fetchText
);
const geographies = match.geographies || {};
const state = (geographies.States || [{}])[0];
const county = (geographies.Counties || [{}])[0];
@@ -238,6 +327,11 @@ export async function resolvePublicRecords(
"Try official address search first on the appraisal district site.",
"If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs."
];
if (usedFallbackGeocoder) {
lookupRecommendations.push(
"The Census address lookup missed this address, so a fallback geocoder was used to obtain coordinates before resolving official Census geographies."
);
}
if (state.STUSAB === "TX" && county.NAME) {
const offices = await fetchTexasCountyOffices(county.NAME, fetchText);