← back to Lawyer Directory Builder

src/ingest/osm_overpass.ts

276 lines

/**
 * OSM Overpass importer — pulls law-firm OFFICES in LA County.
 * Free, no key, no captcha. Tag filters: office=lawyer + amenity=lawyer (legacy).
 *
 * The data is the ground truth for "where the firms physically are":
 * each result has lat/lng, addr fields, and the firm name as tagged by mappers.
 */
import 'dotenv/config';
import crypto from 'node:crypto';
import { fetchCompliant } from '../lib/compliance.ts';
import { pool, query, withTx } from '../db/pool.ts';

const OVERPASS = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
const SOURCE_NAME = 'OpenStreetMap Overpass API';

type OverpassElement = {
  type: 'node' | 'way' | 'relation';
  id: number;
  lat?: number; lon?: number;
  center?: { lat: number; lon: number };
  tags?: Record<string, string>;
};

const QL = `
[out:json][timeout:90];
// LA County — wikidata Q104994
area["wikidata"="Q104994"]->.la;
(
  nwr["office"="lawyer"](area.la);
  nwr["amenity"="lawyer"](area.la);
);
out center tags;
`.trim();

function sha256(s: string) {
  return crypto.createHash('sha256').update(s).digest('hex');
}

function clean(s: string | undefined | null) {
  if (s === undefined || s === null) return null;
  const t = String(s).trim();
  return t.length === 0 ? null : t;
}

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']);
  // Only build a "full" address if we actually have a street line OR a city.
  // Otherwise the field would be a useless "CA, 90210" or just "CA".
  const full = (line1 || city)
    ? [line1, city, state, zip].filter(Boolean).join(', ')
    : null;
  return { line1, city, state, zip, full };
}

function normAddress(s: string | null) {
  if (!s) return null;
  return s.toLowerCase()
    .replace(/[.,]/g, ' ')
    .replace(/\b(suite|ste|unit|apt|#)\b/g, '')
    .replace(/\s+/g, ' ')
    .trim();
}

function neighborhoodOf(t: Record<string, string>) {
  // Use addr:suburb or addr:city for neighborhood. Beverly Hills, Century City, etc.
  return clean(t['addr:suburb']) || clean(t['addr:district']) || clean(t['addr:city']);
}

function firmSizeBand(name: string | null): string | null {
  if (!name) return null;
  const n = name.toLowerCase();
  // Heuristic — rough; refined later by attorney_count.
  if (/\b(llp|llc|p\.c\.|pc|pllc|chartered)\b/.test(n)) return 'medium';
  if (/&|and associates|group/.test(n)) return 'small';
  return 'solo';
}

async function getSourceId(): Promise<number> {
  const r = await query<{ id: number }>('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
  if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
  return r.rows[0].id;
}

async function startJob(sourceId: number, label: string) {
  // The lawyer DB schema uses scrape_jobs without job_label column? Let me match the actual schema.
  // It does have job_label per migration 001.
  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<OverpassElement[]> {
  // Overpass API is an explicitly programmatic endpoint per the OSM usage policy
  // (https://operations.osmfoundation.org/policies/api/). Their robots.txt blocks
  // generic crawlers but the API is intended for client consumption with a meaningful
  // User-Agent. We respect their RATE policy (max ~10k req/day, polite-use) instead.
  const { fetch } = await import('undici');
  const body = `data=${encodeURIComponent(QL)}`;
  const r = await fetch(OVERPASS, {
    method: 'POST',
    headers: {
      'User-Agent': process.env.USER_AGENT || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)',
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body,
    signal: AbortSignal.timeout(180000),
  });
  if (!r.ok) {
    const txt = await r.text();
    throw new Error(`Overpass ${r.status}: ${txt.slice(0, 400)}`);
  }
  const json = await r.json() as { elements: OverpassElement[] };
  return json.elements;
}

async function upsert(el: OverpassElement, sourceId: number) {
  const tags = el.tags || {};
  const name = clean(tags.name);
  if (!name) return null;

  const lat = el.lat ?? el.center?.lat ?? null;
  const lng = el.lon ?? el.center?.lon ?? null;
  const addr = buildAddress(tags);
  const neighborhood = neighborhoodOf(tags);
  const phone = clean(tags.phone) || clean(tags['contact:phone']);
  const website = clean(tags.website) || clean(tags['contact:website']);
  const email = clean(tags.email) || clean(tags['contact:email']);
  const sourceUrl = `https://www.openstreetmap.org/${el.type}/${el.id}`;

  return await withTx(async (client) => {
    // Upsert organization, dedup by (osm_type, osm_id) via raw_records or by name+address_norm.
    const addressNorm = normAddress(addr.full);

    // Try to find existing by website (most stable) or name+address_norm
    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 && r.rowCount > 0) 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 && r.rowCount > 0) orgId = r.rows[0].id;
    }

    if (orgId) {
      await client.query(`
        UPDATE organizations
        SET name = $2,
            address = COALESCE($3, address),
            city = COALESCE($4, city),
            state = COALESCE($5, state),
            zip = COALESCE($6, zip),
            county = COALESCE($7, county),
            neighborhood = COALESCE($8, neighborhood),
            lat = COALESCE($9, lat),
            lng = COALESCE($10, lng),
            geocoded_at = COALESCE(geocoded_at, NOW()),
            phone = COALESCE($11, phone),
            website = COALESCE($12, website),
            address_norm = COALESCE($13, address_norm),
            firm_size_band = COALESCE(firm_size_band, $14),
            source_url = COALESCE(source_url, $15),
            updated_at = NOW()
        WHERE id = $1
      `, [orgId, name, addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
          neighborhood, lat, lng, phone, website, addressNorm,
          firmSizeBand(name), sourceUrl]);
    } else {
      const r = await client.query<{ id: number }>(`
        INSERT INTO organizations (
          name, type, address, city, state, zip, county, neighborhood,
          lat, lng, geocoded_at, phone, website, address_norm,
          firm_size_band, source_url
        ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11,$12,$13,$14,$15)
        RETURNING id
      `, [name, 'law_firm', addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
          neighborhood, lat, lng, phone, website, addressNorm,
          firmSizeBand(name), sourceUrl]);
      orgId = r.rows[0].id;
    }

    if (email) {
      await client.query(`
        INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at)
        VALUES ($1,$2,'office',$3,NOW())
        `, [orgId, email, sourceUrl]);
    }
    if (phone) {
      await client.query(`
        INSERT INTO phones (organization_id, phone, phone_type, source_url, last_verified_at)
        VALUES ($1,$2,'office',$3,NOW())
        ON CONFLICT DO NOTHING
      `, [orgId, phone, sourceUrl]);
    }

    // Stash raw element for provenance.
    const rawJson = JSON.stringify({ ...el, tags });
    const hash = sha256(rawJson + '|organization|' + orgId);
    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] querying Overpass for office=lawyer in LA County (Q104994)…');
  const sourceId = await getSourceId();
  const jobId = await startJob(sourceId, 'osm:overpass:la-county');

  let elements: OverpassElement[] = [];
  try {
    elements = await fetchOverpass();
  } catch (e) {
    await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
    throw e;
  }

  console.log(`[osm] 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] upsert 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] done. seen=${elements.length} kept=${inserted} skipped=${skipped}`);
  await pool.end();
}

main().catch(async (err) => {
  console.error('[osm] fatal:', err);
  try { await pool.end(); } catch {}
  process.exit(1);
});