← back to Lawyer Directory Builder
src/enrich/geocode_nominatim.ts
182 lines
/**
* Nominatim geocoder — fills lat/lng for firms that have an address but no coordinates.
*
* Free, no key. OpenStreetMap's nominatim.openstreetmap.org per their usage policy:
* - 1 request/second max
* - Meaningful User-Agent required
* - Cache results (we already do — geocoded_at gates re-runs)
*
* https://operations.osmfoundation.org/policies/nominatim/
*
* CLI:
* npx tsx src/enrich/geocode_nominatim.ts # full pass
* npx tsx src/enrich/geocode_nominatim.ts --smoke # first 5
* npx tsx src/enrich/geocode_nominatim.ts --limit 100 # cap at N
*/
import 'dotenv/config';
import { pool, query } from '../db/pool.ts';
const SOURCE_NAME = 'OSM Nominatim Geocoder';
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
const RATE_DELAY_MS = Number(process.env.NOMINATIM_DELAY_MS || 1100);
const USER_AGENT = process.env.USER_AGENT
|| 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
type Firm = {
id: number;
name: string;
address: string;
city: string | null;
state: string | null;
zip: string | null;
};
type NomHit = {
lat: string;
lon: string;
display_name: string;
importance?: number;
};
const argv = process.argv.slice(2);
const SMOKE = argv.includes('--smoke');
const LIMIT_IDX = argv.indexOf('--limit');
const LIMIT = LIMIT_IDX >= 0 ? Number(argv[LIMIT_IDX + 1]) : (SMOKE ? 5 : 0);
async function ensureSource(): Promise<number> {
await query(`
INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps)
VALUES ($1, 'api', $2,
'OSM Nominatim public instance. ODbL. Polite-use ≤1 rps with meaningful UA. Cache results.',
'api', 1.0)
ON CONFLICT (source_name) DO NOTHING
`, [SOURCE_NAME, NOMINATIM_URL]);
const r = await query<{ id: number }>(`SELECT id FROM sources WHERE source_name = $1`, [SOURCE_NAME]);
return r.rows[0].id;
}
async function startJob(sourceId: number, label: string): Promise<number> {
const r = await query<{ id: number }>(`
INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
VALUES ($1, $2, 'running', NOW()) RETURNING id
`, [sourceId, label]);
return r.rows[0].id;
}
async function finishJob(jobId: number, fields: Record<string, unknown>) {
const sets: string[] = []; const params: unknown[] = []; let i = 1;
for (const [k, v] of Object.entries(fields)) { sets.push(`${k} = $${i++}`); params.push(v); }
sets.push(`finished_at = NOW()`); params.push(jobId);
await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
}
async function loadFirms(): Promise<Firm[]> {
const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
const r = await query<Firm>(`
SELECT id, name, address, city, state, zip
FROM organizations
WHERE type = 'law_firm'
AND address IS NOT NULL
AND lat IS NULL
ORDER BY id
${lim}
`);
return r.rows;
}
function buildQuery(firm: Firm): string {
// Nominatim does best with a single comma-separated string. Don't double-include
// city/state if they're already in the address blob.
const haystack = firm.address.toLowerCase();
const parts = [firm.address];
if (firm.city && !haystack.includes(firm.city.toLowerCase())) parts.push(firm.city);
if (firm.state && !haystack.includes(`, ${firm.state.toLowerCase()}`)) parts.push(firm.state);
if (firm.zip && !haystack.includes(firm.zip)) parts.push(firm.zip);
return parts.join(', ');
}
async function geocode(firm: Firm): Promise<NomHit | null> {
const { fetch } = await import('undici');
const params = new URLSearchParams({
q: buildQuery(firm),
format: 'json',
limit: '1',
countrycodes: 'us',
addressdetails: '0',
});
const r = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(20000),
});
if (!r.ok) {
if (r.status === 429) throw new Error('NOMINATIM_429');
return null;
}
const j = await r.json() as NomHit[];
return j && j.length > 0 ? j[0] : null;
}
async function applyHit(firmId: number, hit: NomHit): Promise<void> {
const lat = parseFloat(hit.lat);
const lng = parseFloat(hit.lon);
if (!isFinite(lat) || !isFinite(lng)) return;
// Guardrail: discard hits outside the LA County bounding box.
// ~33.7° to 34.85° N, -119.0° to -117.6° W (loose)
if (lat < 33.5 || lat > 35.0 || lng < -119.5 || lng > -117.5) return;
await query(
`UPDATE organizations SET lat = $2, lng = $3, geocoded_at = NOW(), updated_at = NOW() WHERE id = $1 AND lat IS NULL`,
[firmId, lat, lng]);
}
async function main() {
const sourceId = await ensureSource();
const label = SMOKE ? 'nominatim:smoke' : (LIMIT > 0 ? `nominatim:limit-${LIMIT}` : 'nominatim:la-firms');
const jobId = await startJob(sourceId, label);
const firms = await loadFirms();
console.log(`[nom] geocoding ${firms.length} firms…`);
let seen = 0, hit = 0, miss = 0, errs = 0, outside = 0;
try {
for (const firm of firms) {
seen++;
try {
const h = await geocode(firm);
if (!h) { miss++; }
else {
const lat = parseFloat(h.lat); const lng = parseFloat(h.lon);
if (lat < 33.5 || lat > 35.0 || lng < -119.5 || lng > -117.5) outside++;
else { await applyHit(firm.id, h); hit++; }
}
} catch (e) {
errs++;
const msg = (e as Error).message;
if (msg === 'NOMINATIM_429') {
console.error('[nom] 429 — backing off 60s');
await new Promise(r => setTimeout(r, 60000));
} else {
console.error(`[nom] firm=${firm.id} err: ${msg}`);
}
}
if (seen % 50 === 0) console.log(`[nom] ${seen}/${firms.length} hit=${hit} miss=${miss} outside=${outside} errs=${errs}`);
await new Promise(r => setTimeout(r, RATE_DELAY_MS));
}
} finally {
await finishJob(jobId, {
status: errs > firms.length / 2 ? 'failed' : 'completed',
records_found: seen,
records_updated: hit,
records_skipped: miss + outside,
error_message: errs > 0 ? `${errs} per-firm errors, ${outside} outside-LA discarded` : null,
});
}
console.log(`[nom] done. seen=${seen} hit=${hit} miss=${miss} outside=${outside} errs=${errs}`);
await pool.end();
}
main().catch(async (err) => {
console.error('[nom] fatal:', err);
try { await pool.end(); } catch {}
process.exit(1);
});