← back to Lawyer Directory Builder
src/ingest/osm_name_search.ts
228 lines
/**
* OSM Overpass — broader pass: any node/way in LA County with a name suggesting
* legal services, even if NOT tagged office=lawyer. Catches firms mappers tagged
* generically (office, shop, building) plus mis-tagged ones.
*
* Filters out obvious false positives by requiring at least one of:
* • a "law"/"attorney"/"legal"/"counsel" word in the name,
* • AND not tagged as a clearly non-firm thing (school, restaurant, etc.).
*/
import 'dotenv/config';
import crypto from 'node:crypto';
import { fetch } from 'undici';
import { pool, query, withTx } from '../db/pool.ts';
const SOURCE_NAME = 'OSM Overpass — name match';
const OVERPASS = 'https://overpass-api.de/api/interpreter';
const USER_AGENT = process.env.USER_AGENT || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
// Match firm-name vocabulary; exclude schools/restaurants/courts/etc.
const QL = `
[out:json][timeout:120];
area["wikidata"="Q104994"]->.la;
(
nwr["name"~"\\\\b(law offices?|law firm|law group|attorneys?( at law)?|counsel(o?r)?|legal( services| aid)?|esquire|, esq)\\\\b",i](area.la);
);
(._; - nwr["amenity"~"^(school|restaurant|cafe|bar|pub|fast_food|college|university|library|courthouse|police|hospital|clinic)$"];);
out center tags 600;
`.trim();
const EXCLUDE_AMENITY = new Set([
'school', 'restaurant', 'cafe', 'bar', 'pub', 'fast_food', 'college', 'university',
'library', 'courthouse', 'police', 'hospital', 'clinic', 'pharmacy', 'fuel', 'bank'
]);
interface Element {
type: 'node' | 'way' | 'relation';
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
const clean = (s: string | undefined | null) => s ? String(s).trim() || null : null;
const normAddress = (s: string | null) => s
? s.toLowerCase().replace(/[.,]/g, ' ').replace(/\b(suite|ste|unit|apt|#)\b/g, '').replace(/\s+/g, ' ').trim()
: null;
function buildAddress(t: Record<string, string>) {
const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ');
const unit = t['addr:unit'] ? ` Suite ${t['addr:unit']}` : '';
const line1 = clean(street + unit);
const city = clean(t['addr:city']);
const state = clean(t['addr:state']) || 'CA';
const zip = clean(t['addr:postcode']);
const full = (line1 || city) ? [line1, city, state, zip].filter(Boolean).join(', ') : null;
return { line1, city, state, zip, full };
}
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, $3, 'api', 0.10)
ON CONFLICT (source_name) DO NOTHING
`, [SOURCE_NAME, OVERPASS, 'OSM ODbL. Broader name-based query for legal-services entities.']);
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) {
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 fetchOverpass(): Promise<Element[]> {
const r = await fetch(OVERPASS, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: `data=${encodeURIComponent(QL)}`,
signal: AbortSignal.timeout(180000),
});
if (!r.ok) throw new Error(`Overpass ${r.status}: ${(await r.text()).slice(0, 300)}`);
const j = await r.json() as { elements: Element[] };
return j.elements;
}
async function upsert(el: Element, sourceId: number) {
const t = el.tags || {};
const name = clean(t.name);
if (!name) return null;
if (t.amenity && EXCLUDE_AMENITY.has(t.amenity)) return null;
const lat = el.lat ?? el.center?.lat ?? null;
const lng = el.lon ?? el.center?.lon ?? null;
const addr = buildAddress(t);
const phone = clean(t.phone) || clean(t['contact:phone']);
const website = clean(t.website) || clean(t['contact:website']);
const sourceUrl = `https://www.openstreetmap.org/${el.type}/${el.id}`;
return await withTx(async (client) => {
const addressNorm = normAddress(addr.full);
let orgId: number | null = null;
if (website) {
const r = await client.query<{ id: number }>(
`SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
if (r.rowCount) orgId = r.rows[0].id;
}
if (!orgId && addressNorm) {
const r = await client.query<{ id: number }>(
`SELECT id FROM organizations WHERE address_norm = $1 AND LOWER(name) = LOWER($2) LIMIT 1`,
[addressNorm, name]);
if (r.rowCount) orgId = r.rows[0].id;
}
if (!orgId && addr.city) {
const r = await client.query<{ id: number }>(
`SELECT id FROM organizations WHERE LOWER(name) = LOWER($1) AND LOWER(city) = LOWER($2) LIMIT 1`,
[name, addr.city]);
if (r.rowCount) orgId = r.rows[0].id;
}
if (orgId) {
// Enrich missing fields. OSM has phone/website that LA City lacks.
await client.query(`
UPDATE organizations
SET address = COALESCE(address, $2),
address_norm = COALESCE(address_norm, $3),
city = COALESCE(city, $4),
neighborhood = COALESCE(neighborhood, $4),
state = COALESCE(state, $5),
zip = COALESCE(zip, $6),
lat = COALESCE(lat, $7::double precision),
lng = COALESCE(lng, $8::double precision),
geocoded_at = COALESCE(geocoded_at, CASE WHEN $7::double precision IS NOT NULL THEN NOW() END),
phone = COALESCE(phone, $9),
website = COALESCE(website, $10),
source_url = COALESCE(source_url, $11),
updated_at = NOW()
WHERE id = $1
`, [orgId, addr.full, addressNorm, addr.city, addr.state, addr.zip, lat, lng, phone, website, sourceUrl]);
} else {
const r = await client.query<{ id: number }>(`
INSERT INTO organizations (
name, type, address, address_norm, city, neighborhood, state, county, zip,
lat, lng, geocoded_at, phone, website, source_url
) VALUES (
$1,'law_firm',$2,$3,$4,$4,$5,'Los Angeles',$6,
$7::double precision, $8::double precision,
CASE WHEN $7::double precision IS NOT NULL THEN NOW() END,
$9, $10, $11
) RETURNING id
`, [name, addr.full, addressNorm, addr.city, addr.state, addr.zip, lat, lng, phone, website, sourceUrl]);
orgId = r.rows[0].id;
}
// Phones / emails as separate records (idempotent check)
if (phone) {
const exists = await client.query(`SELECT 1 FROM phones WHERE organization_id=$1 AND phone=$2 LIMIT 1`, [orgId, phone]);
if (exists.rowCount === 0) {
await client.query(`INSERT INTO phones (organization_id, phone, phone_type, source_url, last_verified_at)
VALUES ($1,$2,'office',$3,NOW())`, [orgId, phone, sourceUrl]);
}
}
const rawJson = JSON.stringify({ ...el, tags: t });
const hash = crypto.createHash('sha256').update(rawJson + '|osm-name|' + el.id + '|' + orgId).digest('hex');
await client.query(`
INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, fetched_at, hash)
VALUES ($1,$2,'organization',$3,$4::jsonb,NOW(),$5)
ON CONFLICT (source_id, hash) DO NOTHING
`, [sourceId, sourceUrl, orgId, rawJson, hash]);
return orgId;
});
}
async function main() {
console.log('[osm-name] querying broader name-based law-firm OSM…');
const sourceId = await ensureSource();
const jobId = await startJob(sourceId, 'osm:name-match:la-county');
let elements: Element[] = [];
try {
elements = await fetchOverpass();
} catch (e) {
await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
throw e;
}
console.log(`[osm-name] received ${elements.length} elements`);
let inserted = 0, skipped = 0;
for (const el of elements) {
try {
const id = await upsert(el, sourceId);
if (id) inserted++; else skipped++;
} catch (e) {
console.error(`[osm-name] err ${el.type}/${el.id}: ${(e as Error).message}`);
skipped++;
}
}
await finishJob(jobId, { status: 'completed', records_found: elements.length, records_inserted: inserted, records_skipped: skipped });
console.log(`[osm-name] done. seen=${elements.length} kept=${inserted} skipped=${skipped}`);
await pool.end();
}
main().catch(async (err) => {
console.error('[osm-name] fatal:', err);
try { await pool.end(); } catch {}
process.exit(1);
});