← back to Restaurant Directory
scripts/ingest-violations.ts
127 lines
#!/usr/bin/env tsx
/**
* Pull LA County DPH Violations dataset (rolling 3-yr window).
* ArcGIS item: 5eaea9f89b7549ee841da7617d3a9cba
* ~416K rows, 41 MB CSV, latin-1.
*
* Joined to inspection by SERIAL NUMBER. Each violation = one inspector note
* with code, status, description, point deduction.
*/
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 = '5eaea9f89b7549ee841da7617d3a9cba';
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-violations.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 main() {
const pulledAt = new Date();
let counts = { inserted: 0, orphaned: 0, failed: 0 };
try {
if (!existsSync(CSV_PATH) || (Date.now() - statSync(CSV_PATH).mtimeMs) > 86400_000) {
await mkdir(DATA_DIR, { recursive: true });
console.log(`[violations] downloading…`);
const res = await fetch(SOURCE_URL, { redirect: 'follow' });
if (!res.ok || !res.body) throw new Error(`download ${res.status}`);
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(CSV_PATH));
console.log(`[violations] downloaded ${(statSync(CSV_PATH).size / 1024 / 1024).toFixed(1)} MB`);
}
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(`[violations] parsed ${rows.length.toLocaleString()} rows`);
// Pre-fetch known serial numbers (FK target)
const { rows: serials } = await pool.query<{ serial_number: string }>(`SELECT serial_number FROM inspection`);
const known = new Set(serials.map(s => s.serial_number));
console.log(`[violations] ${known.size.toLocaleString()} known inspections`);
// Wipe + bulk insert (cleaner than ON CONFLICT for a child table that
// doesn't have a stable natural key — same violation can appear across
// re-pulls with new IDs).
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('TRUNCATE violation');
// Batch insert via multi-VALUES — much faster than per-row.
const BATCH = 500;
for (let i = 0; i < rows.length; i += BATCH) {
const slice = rows.slice(i, i + BATCH).filter(r => {
const sn = r['SERIAL NUMBER']?.trim();
if (!sn || !known.has(sn)) { counts.orphaned++; return false; }
return true;
});
if (slice.length === 0) continue;
const params: any[] = [];
const placeholders: string[] = [];
slice.forEach((r, j) => {
const base = j * 5;
placeholders.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5})`);
params.push(
r['SERIAL NUMBER'].trim(),
r['VIOLATION STATUS']?.trim() || null,
r['VIOLATION CODE']?.trim() || null,
r['VIOLATION DESCRIPTION']?.trim() || null,
r['POINTS'] ? parseInt(r['POINTS'], 10) : null,
);
});
try {
await client.query(
`INSERT INTO violation (serial_number, violation_status, violation_code, violation_description, points)
VALUES ${placeholders.join(',')}`,
params
);
counts.inserted += slice.length;
} catch (e) {
counts.failed += slice.length;
if (counts.failed < 5) console.error(`[violations] batch failed at ${i}:`, (e as Error).message);
}
if ((i + BATCH) % 50000 === 0 || i + BATCH >= rows.length) {
console.log(`[violations] progress ${Math.min(i + BATCH, rows.length).toLocaleString()}/${rows.length.toLocaleString()}`);
}
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
console.log(`[violations] DONE — inserted=${counts.inserted} 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_failed)
VALUES ('violations', $1, $2, now(), $3, $4, $5)`,
[SOURCE_URL, pulledAt, rows.length, counts.inserted, counts.failed]
);
} finally {
await pool.end();
}
}
main();