← back to Professional Directory
agents/enrich-agent/geocode.js
117 lines
#!/usr/bin/env node
/**
* Free geocoder — Stage 6.
*
* 1. US Census Geocoding API — free, no key, no auth, no rate limit
* https://geocoding.geo.census.gov/geocoder/locations/onelineaddress
*
* 2. OpenStreetMap Nominatim — free, polite-use 1 req/s, identifying UA
* https://nominatim.openstreetmap.org/search
*
* No Google APIs (compliance rule #3 — zero-dollar build).
*
* Run:
* node agents/enrich-agent/geocode.js # all rows where lat IS NULL
* LIMIT=100 node agents/enrich-agent/geocode.js
*/
const { fetch } = require('undici');
const { pool, query } = require('../shared/db');
const { setHostRateLimit } = require('../shared/compliance');
const CENSUS_URL = 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress';
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = process.env.USER_AGENT
|| 'ProfessionalDirectoryBot/0.1 (research; contact: steveabramsdesigns@gmail.com)';
setHostRateLimit('nominatim.openstreetmap.org', 1.0); // 1 rps per their ToS
setHostRateLimit('geocoding.geo.census.gov', 5.0); // generous; no published limit
async function geocodeCensus(addr, city, state, zip) {
const oneLine = [addr, city, state, zip].filter(Boolean).join(', ');
const url = `${CENSUS_URL}?address=${encodeURIComponent(oneLine)}&benchmark=Public_AR_Current&format=json`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return null;
const j = await res.json();
const m = j?.result?.addressMatches?.[0];
if (!m) return null;
return {
lat: Number(m.coordinates?.y),
lng: Number(m.coordinates?.x),
source: 'us_census',
matched: m.matchedAddress,
};
}
async function geocodeNominatim(addr, city, state, zip) {
const oneLine = [addr, city, state, zip].filter(Boolean).join(', ');
const url = `${NOMINATIM_URL}?q=${encodeURIComponent(oneLine)}&format=json&limit=1&countrycodes=us`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept-Language': 'en-US' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return null;
const arr = await res.json();
if (!Array.isArray(arr) || arr.length === 0) return null;
return {
lat: Number(arr[0].lat),
lng: Number(arr[0].lon),
source: 'nominatim',
matched: arr[0].display_name,
};
}
async function main() {
const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
const sql = `
SELECT id, address, city, state, zip
FROM organizations
WHERE lat IS NULL
AND address IS NOT NULL
AND zip IS NOT NULL
ORDER BY id
${limit ? `LIMIT ${limit}` : ''}
`;
const rows = (await query(sql, [])).rows;
console.log(`[geo] candidates=${rows.length}`);
let ok = 0, fail = 0;
for (const r of rows) {
let result;
try {
result = await geocodeCensus(r.address, r.city, r.state, r.zip);
if (!result) {
// Census misses → polite Nominatim fallback (1 rps).
await new Promise(res => setTimeout(res, 1100));
result = await geocodeNominatim(r.address, r.city, r.state, r.zip);
}
} catch (e) {
console.error(`[geo] error org=${r.id}: ${e.message}`);
}
if (result && Number.isFinite(result.lat) && Number.isFinite(result.lng)) {
await query(`
UPDATE organizations
SET lat = $2, lng = $3, geocoded_at = NOW(), updated_at = NOW()
WHERE id = $1
`, [r.id, result.lat, result.lng]);
ok++;
} else {
fail++;
}
if ((ok + fail) % 50 === 0) console.log(`[geo] ok=${ok} fail=${fail}`);
}
console.log(`[geo] done. ok=${ok} fail=${fail}`);
await pool.end();
}
main().catch(async (err) => {
console.error('[geo] fatal:', err);
try { await pool.end(); } catch (_) {}
process.exit(1);
});