Fix property assessor geocode fallback
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user