← back to Small Business Builder
scripts/geocode-via-nominatim.js
142 lines
#!/usr/bin/env node
// Forward-geocode every shop with an address but no lat/lng using OSM
// Nominatim. Free, 1 req/sec policy enforced. Runs idempotently — already-
// geocoded rows are skipped. Built to run overnight; safe to kill + restart.
//
// On hit: UPDATE businesses SET latitude/longitude + source_data_json.
// On miss: record the attempt in source_data_json.geocode_attempt='miss'
// so we don't re-hit Nominatim every night for an unresolvable address.
//
// node scripts/geocode-via-nominatim.js # full run
// LIMIT=500 node scripts/geocode-via-nominatim.js # cap for testing
// DRY=1 node scripts/geocode-via-nominatim.js # show queries only
//
// Nominatim's usage policy:
// - max 1 req/sec
// - require valid User-Agent + email
// - identify yourself in the UA string
// - cache results (we do — DB write is the cache)
// https://operations.osmfoundation.org/policies/nominatim/
import 'dotenv/config';
import { query, one } from '../src/lib/db.js';
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const UA = 'la-salon-directory/1.0 (steveabramsdesigns@gmail.com)';
const SLEEP_MS = 1100; // 1.1s — slight buffer above 1 req/sec
const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
const DRY = process.env.DRY === '1';
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
function log(m) { console.log(`[${ts()}] ${m}`); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Build the canonical Nominatim query string. Cleans BBCB ALL-CAPS to
// title-case to match OSM's data shape, drops obvious unit/suite suffixes
// that confuse the geocoder.
function buildQuery(b) {
const addr = String(b.address || '').trim()
.replace(/\s*(STE|SUITE|UNIT|APT|#)\s*[\w-]+/gi, '') // drop "STE 100", "#5", etc.
.replace(/\s+/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase()); // title-case
const city = (b.city || '').trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
const zip = (b.zip || '').trim().slice(0, 5);
return [addr, city, 'CA', zip].filter(Boolean).join(', ');
}
async function geocode(b) {
const q = buildQuery(b);
if (!q || q.length < 5) return { hit: false, reason: 'no-address' };
const url = `${NOMINATIM}?format=jsonv2&limit=1&addressdetails=0&countrycodes=us&q=${encodeURIComponent(q)}`;
if (DRY) {
return { hit: true, dry: true, q, url };
}
let res;
try {
res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'application/json' } });
} catch (e) {
return { hit: false, reason: 'fetch-error: ' + (e.message || '').slice(0, 80) };
}
if (!res.ok) {
if (res.status === 429) return { hit: false, reason: '429-throttled' };
return { hit: false, reason: 'http-' + res.status };
}
const json = await res.json().catch(() => null);
if (!Array.isArray(json) || json.length === 0) return { hit: false, reason: 'no-result' };
const top = json[0];
const lat = parseFloat(top.lat), lon = parseFloat(top.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { hit: false, reason: 'bad-coords' };
// Sanity-check: result should be in CA-ish lat/lng box (roughly 32.5-42 lat,
// -124.5 to -114 lng). Drops out-of-state false positives that Nominatim
// sometimes returns when the in-state address has no exact match.
if (lat < 32.0 || lat > 42.5 || lon < -125 || lon > -113) {
return { hit: false, reason: 'out-of-CA: ' + lat.toFixed(2) + ',' + lon.toFixed(2) };
}
return { hit: true, lat, lon, importance: top.importance, display: top.display_name };
}
async function main() {
log(`mode=${DRY ? 'DRY' : 'WRITE'}${LIMIT ? ` limit=${LIMIT}` : ''}`);
const rows = await query(
`SELECT id, slug, name, address, city, state, zip
FROM businesses
WHERE latitude IS NULL
AND address IS NOT NULL AND address <> ''
AND city IS NOT NULL AND city <> ''
AND (source_data_json->>'geocode_attempt') IS NULL
ORDER BY id
${LIMIT ? `LIMIT ${LIMIT}` : ''}`
);
log(`fetched ${rows.rows.length} candidate rows · estimated runtime: ${(rows.rows.length * SLEEP_MS / 1000 / 60).toFixed(0)} min`);
let hits = 0, misses = 0, errored = 0;
let i = 0;
for (const r of rows.rows) {
i++;
const result = await geocode(r);
if (result.hit && !result.dry) {
await query(
`UPDATE businesses
SET latitude = $1, longitude = $2,
source_data_json = jsonb_set(jsonb_set(COALESCE(source_data_json,'{}'::jsonb),
'{geocode_source}', '"nominatim"'),
'{geocode_attempt}', '"hit"'),
updated_at = now()
WHERE id = $3`,
[result.lat, result.lon, r.id]
);
hits++;
} else if (result.dry) {
hits++;
} else if (!DRY) {
// Mark the miss so we don't retry forever.
await query(
`UPDATE businesses
SET source_data_json = jsonb_set(COALESCE(source_data_json,'{}'::jsonb),
'{geocode_attempt}', to_jsonb($1::text)),
updated_at = now()
WHERE id = $2`,
[`miss:${result.reason || '?'}`, r.id]
);
misses++;
} else {
misses++;
}
if (i % 50 === 0) log(` …${i}/${rows.rows.length} hits=${hits} misses=${misses}`);
if (DRY && i >= 5) {
log(`DRY mode — sample queries shown, exiting after first 5`);
break;
}
if (!DRY) await sleep(SLEEP_MS);
}
log(`DONE: scanned=${i} hits=${hits} misses=${misses} errored=${errored}`);
if (!DRY) {
const tally = await one(`SELECT COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded,
COUNT(*) AS total FROM businesses`);
log(`POST: ${tally.geocoded}/${tally.total} businesses geocoded (${(tally.geocoded/tally.total*100).toFixed(1)}%)`);
}
}
main().catch(e => { console.error(e); process.exit(2); });