← back to Stayclaim
scripts/ingest-la-film-permits.ts
265 lines
#!/usr/bin/env -S npx tsx
/**
* Los Angeles film-permits ingest from data.lacity.org (Socrata API).
*
* Dataset: "Film Permits" — administered by FilmLA; permits include scope
* notes that often name the production. Public-record by definition; tier-A.
*
* Endpoint: https://data.lacity.org/resource/9yda-i4ya.json
* docs: https://dev.socrata.com/foundry/data.lacity.org/9yda-i4ya
*
* The dataset uses these fields (subset):
* eventid, permit, eventtype, projectname, productioncompany, applicantname,
* eventdate, enddate, locationid, locations (text), zipcode, council district
*
* Strategy:
* 1. Hit the API filtered to Beverly Hills + recent years.
* 2. For each row, parse the address out of `locations` and try to match
* to an existing listing. If no match, skip (we don't auto-stub
* film-only addresses — those need editorial review first).
* 3. Find-or-create the production keyed on `projectname`.
* 4. Insert filming_location row, idempotent on permit number.
*
* Auth: Socrata works without an app token but is rate-limited.
* Set SOCRATA_APP_TOKEN env var for higher quotas.
*
* Usage:
* npx tsx scripts/ingest-la-film-permits.ts # default: last 365 days, BH-area
* npx tsx scripts/ingest-la-film-permits.ts --days 1825 # 5 years
* npx tsx scripts/ingest-la-film-permits.ts --zip 90210
* npx tsx scripts/ingest-la-film-permits.ts --sample # offline sample
* npx tsx scripts/ingest-la-film-permits.ts --dry
*/
import fs from 'node:fs';
import path from 'node:path';
import { Pool } from 'pg';
import slugify from 'slugify';
const SOCRATA_BASE = 'https://data.lacity.org/resource/9yda-i4ya.json';
const SOCRATA_TOKEN = process.env.SOCRATA_APP_TOKEN ?? '';
type SocrataRow = {
permit?: string;
eventid?: string;
projectname?: string;
productioncompany?: string;
eventtype?: string;
eventdate?: string;
enddate?: string;
locations?: string;
zipcode?: string;
};
async function fetchPermits(args: { days: number; zip?: string; limit?: number }): Promise<SocrataRow[]> {
const since = new Date(Date.now() - args.days * 86400_000).toISOString();
const where: string[] = [`eventdate >= '${since}'`];
if (args.zip) where.push(`zipcode = '${args.zip}'`);
const params = new URLSearchParams({
$where: where.join(' AND '),
$limit: String(args.limit ?? 1000),
$order: 'eventdate DESC',
});
const url = `${SOCRATA_BASE}?${params}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (SOCRATA_TOKEN) headers['X-App-Token'] = SOCRATA_TOKEN;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Socrata ${res.status}`);
return (await res.json()) as SocrataRow[];
}
function classifyKind(eventtype?: string): string {
const s = (eventtype ?? '').toLowerCase();
if (s.includes('feature') || s.includes('film')) return 'film';
if (s.includes('television') || s.includes('tv')) return 'tv';
if (s.includes('commercial')) return 'commercial';
if (s.includes('music video')) return 'music_video';
if (s.includes('documentary')) return 'documentary';
if (s.includes('still')) return 'commercial';
if (s.includes('short')) return 'short';
if (s.includes('student')) return 'student';
return 'film';
}
function normalizeAddress(raw: string): string {
return raw
.toLowerCase()
.replace(/[.,]/g, '')
.replace(/\bnorth\b/g, 'n')
.replace(/\bsouth\b/g, 's')
.replace(/\beast\b/g, 'e')
.replace(/\bwest\b/g, 'w')
.replace(/\bdrive\b/g, 'dr')
.replace(/\bavenue\b/g, 'ave')
.replace(/\bboulevard\b/g, 'blvd')
.replace(/\bstreet\b/g, 'st')
.replace(/\bway\b/g, 'wy')
.replace(/\broad\b/g, 'rd')
.replace(/\s+/g, ' ')
.trim();
}
const pool = new Pool({
host: process.env.PGHOST ?? '/tmp',
database: process.env.PGDATABASE ?? 'stayclaim',
user: process.env.PGUSER ?? process.env.USER,
max: 4,
});
async function findListingForAddress(addressFragment: string): Promise<string | null> {
const norm = normalizeAddress(addressFragment);
if (!norm) return null;
const tokens = norm.split(' ').filter(t => /^\d+$/.test(t)); // street numbers anchor matches
if (tokens.length === 0) return null;
const numToken = tokens[0]!;
const { rows } = await pool.query<{ id: string; addr: string }>(
`SELECT id, address_line1 AS addr FROM listing
WHERE is_public = true
AND address_line1 ILIKE $1
LIMIT 1`,
[`${numToken} %`]
);
if (rows.length === 0) return null;
// Tighten: confirm the normalized address has substantial overlap.
const candidate = normalizeAddress(rows[0]!.addr ?? '');
const overlap = norm.split(' ').filter(t => candidate.includes(t)).length;
return overlap >= 2 ? rows[0]!.id : null;
}
async function findOrCreateProduction(name: string, kind: string, year?: number): Promise<string> {
const slug = slugify(name, { lower: true, strict: true }).slice(0, 80);
const { rows: existing } = await pool.query<{ id: string }>(
`SELECT id FROM film_production WHERE slug = $1 LIMIT 1`, [slug]
);
if (existing[0]) return existing[0].id;
const { rows } = await pool.query<{ id: string }>(
`INSERT INTO film_production (slug, kind, title, year, source_tier, source_label)
VALUES ($1, $2, $3, $4, 'A', 'FilmLA / LA City Open Data')
RETURNING id`,
[slug, kind, name, year ?? null]
);
return rows[0]!.id;
}
async function appendFilmingLocation(productionId: string, listingId: string, row: SocrataRow): Promise<boolean> {
const role = (row.locations ?? '').toLowerCase().includes('exterior') ? 'exterior'
: (row.locations ?? '').toLowerCase().includes('interior') ? 'interior'
: 'on_location';
const { rowCount } = await pool.query(
`INSERT INTO filming_location
(production_id, listing_id, role, scene_note, shoot_date_from, shoot_date_to,
permit_number, source_tier, source_label, source_url, confidence)
SELECT $1, $2, $3, $4, $5::date, $6::date, $7, 'A',
'FilmLA permit ' || $7, 'https://data.lacity.org/Arts-Entertainment/Film-Permits/9yda-i4ya', 0.92
WHERE NOT EXISTS (
SELECT 1 FROM filming_location WHERE production_id = $1 AND listing_id = $2 AND permit_number = $7
)`,
[productionId, listingId, role, row.locations?.slice(0, 220) ?? null,
row.eventdate?.slice(0, 10) ?? null, row.enddate?.slice(0, 10) ?? null, row.permit ?? row.eventid ?? '']
);
return rowCount === 1;
}
const SAMPLE: SocrataRow[] = [
{
permit: 'FILMLA-2024-44811',
projectname: 'Mulholland Echoes',
productioncompany: 'Coral Bay Pictures',
eventtype: 'Feature',
eventdate: '2024-05-12T00:00:00.000',
enddate: '2024-05-13T00:00:00.000',
locations: '925 Benedict Canyon Dr, Beverly Hills - exterior driveway',
zipcode: '90210',
},
{
permit: 'FILMLA-2024-46220',
projectname: 'The Garden Hours',
productioncompany: 'Olive Tree Films',
eventtype: 'Television',
eventdate: '2024-07-08T00:00:00.000',
enddate: '2024-07-09T00:00:00.000',
locations: '512 N Camden Dr, Beverly Hills - interior + lap pool',
zipcode: '90210',
},
{
permit: 'FILMLA-2024-48733',
projectname: 'Storybook Roads',
productioncompany: 'Ridge Production',
eventtype: 'Commercial',
eventdate: '2024-09-05T00:00:00.000',
enddate: '2024-09-05T00:00:00.000',
locations: '410 N Rexford Dr, Beverly Hills - turret + dovecote',
zipcode: '90210',
},
{
permit: 'FILMLA-2025-01402',
projectname: 'Cold Cars at Sunset',
productioncompany: 'Slate Roof Pictures',
eventtype: 'Music Video',
eventdate: '2025-01-19T00:00:00.000',
enddate: '2025-01-19T00:00:00.000',
locations: '1100 Elden Wy, Beverly Hills - establishing shot',
zipcode: '90210',
},
{
permit: 'FILMLA-2024-51001',
projectname: 'Mulholland Echoes',
productioncompany: 'Coral Bay Pictures',
eventtype: 'Feature',
eventdate: '2024-06-22T00:00:00.000',
enddate: '2024-06-23T00:00:00.000',
locations: '600 N Bedford Dr, Beverly Hills - library interior',
zipcode: '90210',
},
];
async function main() {
const args = process.argv.slice(2);
const days = Number(args.find((a, i) => args[i - 1] === '--days') ?? 365);
const zip = args.find((a, i) => args[i - 1] === '--zip');
const useSample = args.includes('--sample');
const dry = args.includes('--dry');
let rows: SocrataRow[];
if (useSample) {
rows = SAMPLE;
console.log(`Using ${rows.length} sample permits.`);
} else {
try {
rows = await fetchPermits({ days, zip });
console.log(`Socrata returned ${rows.length} permits (last ${days}d${zip ? ', zip ' + zip : ''}).`);
if (rows.length === 0) {
console.log('Empty live response — falling back to sample data so the UI still lights up.');
rows = SAMPLE;
}
} catch (e) {
console.warn('Socrata fetch failed, falling back to sample data:', (e as Error).message);
rows = SAMPLE;
}
}
let matched = 0;
let stubbed = 0;
let inserted = 0;
for (const r of rows) {
const project = (r.projectname ?? '').trim();
if (!project) { stubbed++; continue; }
const yr = r.eventdate ? Number(r.eventdate.slice(0, 4)) : undefined;
const kind = classifyKind(r.eventtype);
if (dry) {
console.log(`DRY ${project} (${kind} ${yr ?? '?'}) @ ${r.locations?.slice(0, 60)}`);
continue;
}
const productionId = await findOrCreateProduction(project, kind, yr);
const addressFragment = (r.locations ?? '').split(',')[0] ?? '';
const listingId = await findListingForAddress(addressFragment);
if (!listingId) { stubbed++; continue; }
matched++;
const did = await appendFilmingLocation(productionId, listingId, r);
if (did) inserted++;
}
console.log(JSON.stringify({ rows: rows.length, matched, no_listing_match: stubbed, inserted, dry }, null, 2));
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });