← back to Lawyer Directory Builder

src/ingest/la_city_business_licenses.ts

245 lines

/**
 * LA City Active Business Licenses importer.
 *
 * Source: https://data.lacity.org/resource/6rrh-rzua.json (Socrata API, free, no key).
 *         "Listing of Active Businesses" — every business currently licensed by LA City.
 * Filter: NAICS 541100 (Legal services), 541110 (Offices of Lawyers), 541199 (Other legal).
 *         Excludes 541120 notaries and 541191 title offices (not lawyers).
 *
 * Yields ~8K rows for LA City — vastly bigger than OSM (190) or Wikidata (29).
 * Includes solo practitioners working from home addresses, which is realistic
 * coverage of "where lawyers actually practice in LA."
 */
import 'dotenv/config';
import crypto from 'node:crypto';
import { fetch } from 'undici';
import { pool, query, withTx } from '../db/pool.ts';

const SOURCE_NAME = 'LA City Active Business Licenses';
const ENDPOINT = 'https://data.lacity.org/resource/6rrh-rzua.json';
const USER_AGENT = process.env.USER_AGENT
  || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';

// NAICS codes worth importing as law firms.
const KEEP_NAICS = new Set(['541100', '541110', '541199']);

const PAGE_SIZE = 1000;

interface Row {
  location_account?: string;
  business_name?: string;
  street_address?: string;
  city?: string;
  zip_code?: string;
  location_description?: string;
  naics?: string;
  primary_naics_description?: string;
  council_district?: string;
  location_start_date?: string;
  location_1?: { latitude?: string; longitude?: string };
}

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

function titleCase(s: string | null) {
  if (!s) return null;
  return s.toLowerCase().replace(/\b([a-z])([a-z]*)/g, (_, a, b) => a.toUpperCase() + b);
}

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 zip5(z: string | undefined | null) {
  if (!z) return null;
  const m = String(z).match(/\d{5}/);
  return m ? m[0] : null;
}

function neighborhoodOf(city: string | null) {
  return city ? titleCase(city) : null;
}

function firmSizeBand(name: string | null): string | null {
  if (!name) return null;
  const n = name.toLowerCase();
  if (/\b(llp|pllc)\b/.test(n)) return 'medium';     // LLPs lean medium-large
  if (/\b(pc|p\.c\.|aplc|apc)\b/.test(n)) return 'small'; // small CA prof corps
  if (/&|and associates|group|partners/.test(n)) return 'small';
  return 'solo';
}

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',
      'https://data.lacity.org/resource/6rrh-rzua.json',
      'Socrata Open Data — public domain. NAICS 5411 (legal services). Polite-use, no key required.',
      'api', 5.0)
    ON CONFLICT (source_name) DO NOTHING
  `, [SOURCE_NAME]);
  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 fetchPage(offset: number): Promise<Row[]> {
  const where = `naics in ('541100','541110','541199')`;
  const url = `${ENDPOINT}?$where=${encodeURIComponent(where)}&$limit=${PAGE_SIZE}&$offset=${offset}&$order=location_account`;
  const r = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, signal: AbortSignal.timeout(60000) });
  if (!r.ok) throw new Error(`Socrata ${r.status}: ${(await r.text()).slice(0, 300)}`);
  return await r.json() as Row[];
}

async function upsert(row: Row, sourceId: number) {
  if (!KEEP_NAICS.has(String(row.naics || ''))) return null;

  const rawName = clean(row.business_name);
  if (!rawName) return null;
  const name = titleCase(rawName);                                   // "LAW OFFICES OF X" → "Law Offices Of X"
  const street = titleCase(clean(row.street_address));
  const city = titleCase(clean(row.city));
  const zip = zip5(row.zip_code);
  const fullAddress = [street, city, 'CA', zip].filter(Boolean).join(', ');
  const lat = row.location_1?.latitude ? parseFloat(row.location_1.latitude) : null;
  const lng = row.location_1?.longitude ? parseFloat(row.location_1.longitude) : null;
  const sourceUrl = `https://data.lacity.org/resource/6rrh-rzua.json?$where=location_account='${row.location_account}'`;
  const addressNorm = normAddress(fullAddress);

  return await withTx(async (client) => {
    // Dedup: name + address_norm match an existing org (most likely from OSM with full address).
    let orgId: number | null = null;
    if (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;
    }
    // Fallback: name match in same city
    if (!orgId && 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!, city]);
      if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
    }

    if (orgId) {
      // Enrich with anything we have that's missing.
      await client.query(`
        UPDATE organizations
        SET address = COALESCE(address, $2),
            address_norm = COALESCE(address_norm, $3),
            city = COALESCE(city, $4),
            neighborhood = COALESCE(neighborhood, $4),
            zip = COALESCE(zip, $5),
            lat = COALESCE(lat, $6::double precision),
            lng = COALESCE(lng, $7::double precision),
            geocoded_at = COALESCE(geocoded_at, CASE WHEN $6::double precision IS NOT NULL THEN NOW() END),
            firm_size_band = COALESCE(firm_size_band, $8),
            source_url = COALESCE(source_url, $9),
            updated_at = NOW()
        WHERE id = $1
      `, [orgId, fullAddress, addressNorm, city, zip, lat, lng, firmSizeBand(name), 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, firm_size_band, source_url
        ) VALUES (
          $1, 'law_firm', $2, $3, $4, $4, 'CA', 'Los Angeles', $5,
          $6::double precision, $7::double precision,
          CASE WHEN $6::double precision IS NOT NULL THEN NOW() END,
          $8, $9
        )
        RETURNING id
      `, [name, fullAddress, addressNorm, city, zip, lat, lng, firmSizeBand(name), sourceUrl]);
      orgId = r.rows[0].id;
    }

    const rawJson = JSON.stringify(row);
    const hash = crypto.createHash('sha256').update(rawJson + '|la-city|' + (row.location_account || '') + '|' + 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('[la-city] importing legal-services business licenses (NAICS 5411)…');
  const sourceId = await ensureSource();
  const jobId = await startJob(sourceId, 'la-city:business-licenses');

  let offset = 0;
  let inserted = 0;
  let skipped = 0;
  let total = 0;

  try {
    while (true) {
      const page = await fetchPage(offset);
      if (page.length === 0) break;
      total += page.length;

      for (const row of page) {
        try {
          const id = await upsert(row, sourceId);
          if (id) inserted++; else skipped++;
        } catch (e) {
          console.error(`[la-city] upsert err ${row.location_account}: ${(e as Error).message}`);
          skipped++;
        }
      }

      console.log(`[la-city] page offset=${offset} (+${page.length}) → kept=${inserted} skipped=${skipped}`);
      if (page.length < PAGE_SIZE) break;
      offset += PAGE_SIZE;
    }
  } catch (e) {
    await finishJob(jobId, { status: 'failed', error_message: (e as Error).message,
                              records_found: total, records_inserted: inserted, records_skipped: skipped });
    throw e;
  }

  await finishJob(jobId, {
    status: 'completed',
    records_found: total,
    records_inserted: inserted,
    records_skipped: skipped,
  });

  console.log(`[la-city] done. seen=${total} kept=${inserted} skipped=${skipped}`);
  await pool.end();
}

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