← back to Stayclaim

scripts/ingest-weho-film-permits.ts

175 lines

/**
 * ingest-weho-film-permits.ts
 *
 * Pull every WeHo film permit (~1,821 since 2008) from Socrata and upsert into
 * pastdoor's listing + film_production + filming_location tables.
 *
 * Source: https://data.weho.org/resource/xd5y-dzvf.json
 *   - date, production, production_company, category, permit_no, address, location {lat,lng}
 *
 * Ingest pipeline:
 *   - Each WeHo address becomes (or matches) a listing (slug = slugified address + WH suffix)
 *   - Each unique production becomes a film_production row
 *   - Each permit becomes a filming_location row (linking production ↔ listing)
 *
 * Idempotent — safe to run repeatedly. Uses ON CONFLICT for listing.source/source_id and
 * filming_location.permit_number.
 *
 * Tier: A (gov data, City of West Hollywood official record).
 */
import { Pool } from 'pg';

const SOCRATA = 'https://data.weho.org/resource/xd5y-dzvf.json';

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,
});

type WehoRow = {
  date?: string;
  production?: string;
  production_company?: string;
  category?: string;
  permit_no?: string;
  address?: string;
  location?: { type: string; coordinates: [number, number] }; // [lng, lat]
};

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

function classifyKind(category?: string): string {
  const c = (category ?? '').toLowerCase();
  if (c.includes('feature')) return 'film';
  if (c.includes('tv') || c.includes('series')) return 'tv';
  if (c.includes('commercial')) return 'commercial';
  if (c.includes('web')) return 'web';
  if (c.includes('still') || c.includes('photo')) return 'photo';
  if (c.includes('music')) return 'music_video';
  if (c.includes('doc')) return 'documentary';
  if (c.includes('student')) return 'student';
  return 'other';
}

async function fetchAll(): Promise<WehoRow[]> {
  const all: WehoRow[] = [];
  const PAGE = 1000;
  let offset = 0;
  while (true) {
    const res = await fetch(`${SOCRATA}?$limit=${PAGE}&$offset=${offset}&$order=date%20DESC`);
    if (!res.ok) throw new Error(`Socrata ${res.status}`);
    const rows = (await res.json()) as WehoRow[];
    all.push(...rows);
    if (rows.length < PAGE) break;
    offset += PAGE;
  }
  return all;
}

async function upsertListing(row: WehoRow): Promise<string | null> {
  if (!row.address || !row.permit_no) return null;
  const lat = row.location?.coordinates?.[1] ?? null;
  const lng = row.location?.coordinates?.[0] ?? null;
  // Slug includes coords-derived suffix to avoid collisions on similar addresses
  const baseSlug = slugify(row.address) + '-wh';
  const sourceId = `weho-${row.address.replace(/\s+/g, '-').toLowerCase()}`;
  const result = 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,'weho_film_permit',$2,$3,$4,'West Hollywood','CA','US',$5,$6,true,'free')
     ON CONFLICT (source, source_id) DO UPDATE SET latitude=COALESCE(listing.latitude,EXCLUDED.latitude), longitude=COALESCE(listing.longitude,EXCLUDED.longitude), updated_at=now()
     RETURNING id`,
    [baseSlug, sourceId, row.address, row.address, lat, lng]
  );
  return result.rows[0]?.id ?? null;
}

async function upsertProduction(row: WehoRow): Promise<string | null> {
  if (!row.production) return null;
  const slug = slugify(row.production) || `production-${row.permit_no}`;
  const kind = classifyKind(row.category);
  const year = row.date ? new Date(row.date).getFullYear() : null;
  const blurb = row.production_company ? `Production company: ${row.production_company}` : null;
  const result = await pool.query<{ id: string }>(
    `INSERT INTO film_production (slug, title, kind, year, blurb, source_tier, source_label)
     VALUES ($1,$2,$3,$4,$5,'A','WeHo Film Permit')
     ON CONFLICT (slug) DO UPDATE SET title=EXCLUDED.title, kind=COALESCE(film_production.kind,EXCLUDED.kind)
     RETURNING id`,
    [slug, row.production, kind, year, blurb]
  );
  return result.rows[0]?.id ?? null;
}

async function upsertFilmingLocation(productionId: string, listingId: string, row: WehoRow): Promise<boolean> {
  if (!row.permit_no) return false;
  const dateStr = row.date ? new Date(row.date).toISOString().slice(0, 10) : null;
  const r = await pool.query(
    `INSERT INTO filming_location (production_id, listing_id, permit_number, role, shoot_date_from, source_tier, source_label, public_visible)
     VALUES ($1,$2,$3,$4,$5,'A','WeHo Film Permit',true)
     ON CONFLICT (production_id, listing_id, permit_number) DO NOTHING`,
    [productionId, listingId, row.permit_no, row.category ?? null, dateStr]
  );
  return (r.rowCount ?? 0) > 0;
}

async function main() {
  console.log(`Fetching all WeHo film permits from ${SOCRATA} ...`);
  const rows = await fetchAll();
  console.log(`  → ${rows.length} rows`);

  let listingsTouched = 0;
  let productionsTouched = 0;
  let filmingLocationsInserted = 0;
  let skipped = 0;

  for (const [i, row] of rows.entries()) {
    if (!row.address || !row.permit_no || !row.production) {
      skipped++;
      continue;
    }
    try {
      const listingId = await upsertListing(row);
      if (!listingId) { skipped++; continue; }
      listingsTouched++;
      const productionId = await upsertProduction(row);
      if (!productionId) { skipped++; continue; }
      productionsTouched++;
      if (await upsertFilmingLocation(productionId, listingId, row)) {
        filmingLocationsInserted++;
      }
    } catch (e) {
      console.error(`Row ${i} (${row.permit_no}): ${(e as Error).message}`);
      skipped++;
    }
    if ((i + 1) % 100 === 0) {
      console.log(`  ${i + 1}/${rows.length} processed`);
    }
  }

  console.log('');
  console.log({
    rows: rows.length,
    listings_touched: listingsTouched,
    productions_touched: productionsTouched,
    filming_locations_inserted: filmingLocationsInserted,
    skipped,
  });
  await pool.end();
}

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