← back to Restaurant Directory
scripts/ingest-inspections.ts
132 lines
#!/usr/bin/env tsx
/**
* Pull LA County DPH Restaurant & Market Inspections (rolling 3-yr window).
*
* Source: ArcGIS Hub item 19b6607ac82c4512b10811870975dbdc
* https://www.arcgis.com/sharing/rest/content/items/19b6607ac82c4512b10811870975dbdc/data
*
* Encoding: Latin-1. Idempotent upsert keyed on SERIAL_NUMBER.
* Skips inspections whose facility_id is not in the facility table.
*/
import { createWriteStream, existsSync, statSync } from 'node:fs';
import { mkdir, readFile } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import path from 'node:path';
import iconv from 'iconv-lite';
import { parse } from 'csv-parse/sync';
import { Pool } from 'pg';
const DATASET_ID = '19b6607ac82c4512b10811870975dbdc';
const SOURCE_URL = `https://www.arcgis.com/sharing/rest/content/items/${DATASET_ID}/data`;
const DATA_DIR = path.resolve(__dirname, '..', 'data');
const CSV_PATH = path.join(DATA_DIR, 'la-eh-inspections.csv');
const pool = new Pool({
host: process.env.PGHOST ?? '/tmp',
database: process.env.PGDATABASE ?? 'restaurant_professional_directory',
user: process.env.PGUSER ?? process.env.USER,
max: 5,
});
async function downloadCSV(): Promise<void> {
await mkdir(DATA_DIR, { recursive: true });
console.log(`[inspections] downloading ${SOURCE_URL}`);
const res = await fetch(SOURCE_URL, { redirect: 'follow' });
if (!res.ok) throw new Error(`download failed: ${res.status}`);
if (!res.body) throw new Error('empty body');
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(CSV_PATH));
console.log(`[inspections] downloaded ${(statSync(CSV_PATH).size / 1024 / 1024).toFixed(1)} MB`);
}
function parseDate(s: string | undefined): Date | null {
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
async function main() {
const pulledAt = new Date();
let counts = { inserted: 0, updated: 0, unchanged: 0, failed: 0, orphaned: 0 };
try {
if (!existsSync(CSV_PATH) || (Date.now() - statSync(CSV_PATH).mtimeMs) > 86400_000) {
await downloadCSV();
}
const buf = await readFile(CSV_PATH);
const rows = parse(iconv.decode(buf, 'latin1'), {
columns: true, skip_empty_lines: true, trim: true,
relax_quotes: true, relax_column_count: true,
}) as Record<string, string>[];
console.log(`[inspections] parsed ${rows.length.toLocaleString()} rows`);
// Pre-fetch all known facility_ids to skip orphans cheaply
const { rows: facRows } = await pool.query<{ facility_id: string }>(`SELECT facility_id FROM facility`);
const known = new Set(facRows.map(r => r.facility_id));
console.log(`[inspections] ${known.size.toLocaleString()} known facilities`);
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const r of rows) {
const facility_id = r['FACILITY ID']?.trim();
const serial_number = r['SERIAL NUMBER']?.trim();
const activity_date = parseDate(r['ACTIVITY DATE']);
if (!facility_id || !serial_number || !activity_date) { counts.failed++; continue; }
if (!known.has(facility_id)) { counts.orphaned++; continue; }
const score = r['SCORE'] ? parseInt(r['SCORE'], 10) : null;
const grade = r['GRADE']?.trim().charAt(0).toUpperCase() || null;
const program_status = r['PROGRAM STATUS']?.trim() || null;
try {
const res = await client.query<{ inserted: boolean }>(
`INSERT INTO inspection (
facility_id, serial_number, activity_date, service_code, service_description,
score, grade, program_status, employee_id, source_pulled_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (serial_number) DO UPDATE SET
activity_date = EXCLUDED.activity_date,
score = EXCLUDED.score,
grade = EXCLUDED.grade,
program_status = EXCLUDED.program_status,
source_pulled_at = EXCLUDED.source_pulled_at
RETURNING (xmax = 0) AS inserted`,
[
facility_id, serial_number, activity_date,
r['SERVICE CODE']?.trim() || null,
r['SERVICE DESCRIPTION']?.trim() || null,
score, grade && /^[ABC]$/.test(grade) ? grade : null, program_status,
r['EMPLOYEE ID']?.trim() || null, pulledAt,
]
);
res.rows[0].inserted ? counts.inserted++ : counts.updated++;
} catch (e) {
counts.failed++;
if (counts.failed < 5) console.error(`[inspections] row failed (${serial_number}):`, (e as Error).message);
}
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
console.log(`[inspections] DONE — inserted=${counts.inserted} updated=${counts.updated} orphaned=${counts.orphaned} failed=${counts.failed}`);
await pool.query(
`INSERT INTO ingest_run (dataset, source_url, started_at, finished_at,
rows_pulled, rows_inserted, rows_updated, rows_failed)
VALUES ('inspections', $1, $2, now(), $3, $4, $5, $6)`,
[SOURCE_URL, pulledAt, rows.length, counts.inserted, counts.updated, counts.failed]
);
} finally {
await pool.end();
}
}
main();