← back to Stayclaim

scripts/ingest-santa-monica-permits.ts

91 lines

/**
 * ingest-santa-monica-permits.ts
 *
 * Santa Monica Active Building & Safety Permits via CKAN datastore_search.
 * Source: data.santamonica.gov resource d6867c7d-89bc-4975-be35-4d2673a4764b
 * Verified 2026-04-30: 1,997 rows. ODC-BY 1.0 license.
 */
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.PGHOST ?? '/tmp',
  database: process.env.PGDATABASE ?? 'stayclaim',
  user: process.env.PGUSER ?? process.env.USER,
  password: process.env.PGPASSWORD,
  port: parseInt(process.env.PGPORT ?? '5432', 10),
  max: 4,
});

const RESOURCE = 'd6867c7d-89bc-4975-be35-4d2673a4764b';
const URL = `https://data.santamonica.gov/api/3/action/datastore_search`;
const PAGE = 1000;

function canonicalize(addr: string): string {
  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
}

async function fetchPage(offset: number) {
  const u = `${URL}?resource_id=${RESOURCE}&limit=${PAGE}&offset=${offset}`;
  const res = await fetch(u);
  const j = await res.json() as { result: { records: any[]; total: number } };
  return j.result;
}

async function upsertOne(r: any) {
  const addr = (r.address ?? '').toString().trim();
  if (!addr) return;
  const permitNbr = (r.permit_number ?? r._id ?? '').toString().trim();
  if (!permitNbr) return;
  const canonical = canonicalize(addr);
  if (!canonical) return;
  const lat = r.latitude ? parseFloat(r.latitude) : null;
  const lon = r.longitude ? parseFloat(r.longitude) : null;

  const lid = (await pool.query<{ id: string }>(
    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
     VALUES ($1,'santa_monica_permit',$2,$3,$4,'Santa Monica','CA','US',$5,$6,true,'free')
     ON CONFLICT (slug) DO UPDATE SET
       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
       updated_at = now()
     RETURNING id`,
    [`${canonical}-sm`, `santamonica:${canonical}`, addr, addr, lat, lon]
  )).rows[0]!.id;

  const issueDate = r.date_issued ? r.date_issued.slice(0, 10) : null;
  await pool.query(
    `INSERT INTO permit (source, source_dataset, permit_number, listing_id, primary_address, apn,
       permit_type, permit_sub_type, status_desc, issue_date, valuation, work_desc,
       latitude, longitude, source_url, source_label)
     VALUES ('santa_monica','sm-active-permits',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'City of Santa Monica')
     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
       retrieved_at = now()`,
    [
      permitNbr, lid, addr, r.parcel_no ?? null,
      r.permit_type_desc ?? r.permit_type ?? null, r.permit_sub_type ?? null,
      r.status ?? null, issueDate, parseFloat(r.valuation ?? '0') || null,
      r.description ?? null, lat, lon,
      `https://data.santamonica.gov/dataset/active-building-and-safety-permits/resource/${RESOURCE}`,
    ]
  );
}

async function main() {
  let offset = 0;
  let total = 0;
  while (true) {
    const { records, total: tot } = await fetchPage(offset);
    if (!records.length) break;
    for (const r of records) await upsertOne(r);
    total += records.length;
    console.log(`  santa_monica: ${total}/${tot}`);
    if (records.length < PAGE) break;
    offset += PAGE;
  }
  console.log(`✓ Santa Monica permits: ${total} total`);
  await pool.end();
}

main().catch(e => { console.error('FATAL', e); process.exit(1); });