← back to Small Business Builder
scripts/data-hound-census.js
101 lines
#!/usr/bin/env node
// Data Hound #1 — Census Geocoder fallback for the 2,686 Nominatim misses
// and 7 transient fetch errors. Free US-only geocoder, no key, no rate
// limit (within reason — we still throttle to 5 req/sec to be polite).
// On hit: clear the prior 'miss:no-result' flag, write lat/lng,
// mark source_data_json.geocode_attempt='census-hit'.
// On miss: bump source_data_json.geocode_attempt='miss:census-also-no-result'.
//
// node scripts/data-hound-census.js
// LIMIT=200 node scripts/data-hound-census.js
// DRY=1 node scripts/data-hound-census.js
import 'dotenv/config';
import { query, one } from '../src/lib/db.js';
const CENSUS = 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress';
const SLEEP_MS = 220; // ~5/sec
const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
const DRY = process.env.DRY === '1';
const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
const log = m => console.log(`[${ts()}] ${m}`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
function buildOneLine(b) {
const addr = String(b.address || '').trim()
.replace(/\s*(STE|SUITE|UNIT|APT|BLDG|FL|FLR|RM|#)\s*[\w-]+/gi, '')
.replace(/\s+/g, ' ').trim();
if (!addr) return null;
const city = String(b.city || '').trim();
const zip = String(b.zip || '').trim();
return [addr, city, 'CA', zip].filter(Boolean).join(', ');
}
async function censusLookup(oneline) {
const u = `${CENSUS}?address=${encodeURIComponent(oneline)}&benchmark=Public_AR_Current&format=json`;
const r = await fetch(u, { signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
const m = j?.result?.addressMatches?.[0];
if (!m?.coordinates) return null;
return { lat: m.coordinates.y, lon: m.coordinates.x, matchedAddress: m.matchedAddress };
}
async function run() {
// Eligible: missing geocode AND prior attempt was nominatim miss/fetch-error/null
const rows = await query(
`SELECT id, slug, name, address, city, zip, source_data_json
FROM businesses
WHERE (latitude IS NULL OR longitude IS NULL)
AND address IS NOT NULL AND address <> ''
ORDER BY id
${LIMIT ? `LIMIT ${LIMIT}` : ''}`
);
log(`Census geocoder hound: ${rows.rows.length} candidates`);
let hits = 0, misses = 0, errs = 0;
for (const b of rows.rows) {
const oneline = buildOneLine(b);
if (!oneline) { misses++; continue; }
if (DRY) { log(`DRY: ${oneline}`); await sleep(SLEEP_MS); continue; }
try {
const r = await censusLookup(oneline);
if (r) {
await query(
`UPDATE businesses
SET latitude = $1,
longitude = $2,
source_data_json = jsonb_set(
COALESCE(source_data_json,'{}'::jsonb),
'{geocode_attempt}', '"census-hit"'::jsonb)
WHERE id = $3`,
[r.lat, r.lon, b.id]
);
hits++;
if (hits % 25 === 0) log(` ${hits} hits / ${misses} misses / ${errs} errors`);
} else {
await query(
`UPDATE businesses
SET source_data_json = jsonb_set(
COALESCE(source_data_json,'{}'::jsonb),
'{geocode_attempt}', '"miss:census-also-no-result"'::jsonb)
WHERE id = $1`,
[b.id]
);
misses++;
}
} catch (e) {
errs++;
if (errs % 10 === 0) log(` ${errs} errors so far (last: ${e.message})`);
}
await sleep(SLEEP_MS);
}
log(`DONE — hits=${hits} misses=${misses} errors=${errs}`);
process.exit(0);
}
run().catch(e => { console.error(e); process.exit(1); });