← back to Nationalrealestate
src/ingest/commercial/la_assessor.ts
105 lines
/**
* LA commercial layer ETL: read the CRCP assessor sqlite (read-only, 2.4M rows),
* keep the Commercial + Industrial parcels (~154k), classify each into a canonical
* commercial type, parse city/zip out of property_location, and materialize into the
* pg `commercial_parcel` table so the feed / explorer / rankings query fast indexes.
*
* LA carries NO sale price and NO owner (assessor roll limits) — only assessed values
* + last recording_date. We never fabricate those; prices come from the listings crawl.
*
* Run: npm run ingest:commercial -- la
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { pool, query } from '../../../db/pool.ts';
import { openRun, closeRun } from '../run.ts';
import { classifyCommercial } from '../../lib/commercial_types.ts';
const require = createRequire(import.meta.url);
const FIPS = '06037';
const SQLITE = process.env.ASSESSOR_SQLITE || join(homedir(), 'Projects', 'commercialrealestate', 'data', 'assessor.sqlite');
interface Row {
ain: string; property_location: string | null; year_built: number | null;
sqft_main: number | null; units: number | null; use_desc1: string | null; use_desc2: string | null;
roll_total_value: number | null; roll_land_value: number | null; roll_imp_value: number | null;
roll_year: string | null; recording_date: string | null;
}
/** "8407 FALLBROOK AVE WEST HILLS CA 91304" -> {street, city, zip}; nulls if unparseable. */
function parseLoc(loc: string | null): { street: string | null; city: string | null; zip: string | null } {
if (!loc) return { street: null, city: null, zip: null };
const m = loc.match(/^(.*?)\s{2,}(.+?)\s+CA\s+(\d{5})/);
if (m) return { street: m[1].trim() || null, city: m[2].trim() || null, zip: m[3] };
return { street: loc.trim() || null, city: null, zip: null };
}
const isoDate = (d: string | null) => (d && /^\d{4}-\d{2}-\d{2}$/.test(d.slice(0, 10)) ? d.slice(0, 10) : null);
const COLS = ['county_fips', 'ain', 'address', 'city', 'zip', 'ctype', 'use_desc', 'use_class',
'assessed_total', 'assessed_land', 'assessed_imp', 'roll_year', 'recording_date', 'sqft', 'year_built', 'units'];
export async function ingestLaCommercial(): Promise<{ upserted: number }> {
const runId = await openRun('commercial_la_assessor', SQLITE);
try {
if (!existsSync(SQLITE)) throw new Error(`LA assessor sqlite not found at ${SQLITE}`);
const Database = require('better-sqlite3');
const db = new Database(SQLITE, { readonly: true, fileMustExist: true });
const rows = db.prepare(
`SELECT ain, property_location, year_built, sqft_main, units, use_desc1, use_desc2,
roll_total_value, roll_land_value, roll_imp_value, roll_year, recording_date
FROM assessor_parcel WHERE use_desc1 IN ('Commercial','Industrial')`,
).all() as Row[];
console.log(`[la-commercial] read ${rows.length} commercial+industrial parcels from sqlite`);
// fresh authoritative load for this county
await query(`DELETE FROM commercial_parcel WHERE county_fips = $1`, [FIPS]);
let upserted = 0, skipped = 0;
const batch: unknown[][] = [];
const flush = async () => {
if (!batch.length) return;
const params: unknown[] = [];
const values = batch.map((r, j) => {
const b = j * COLS.length;
params.push(...r);
return '(' + COLS.map((_, k) => `$${b + k + 1}`).join(',') + ')';
});
await query(
`INSERT INTO commercial_parcel (${COLS.join(',')}) VALUES ${values.join(',')}
ON CONFLICT (county_fips, ain) DO UPDATE SET
${COLS.filter(c => c !== 'county_fips' && c !== 'ain').map(c => `${c}=EXCLUDED.${c}`).join(', ')},
updated_at = now()`,
params,
);
upserted += batch.length;
batch.length = 0;
};
for (const r of rows) {
const ctype = classifyCommercial(r.use_desc1, r.use_desc2);
if (!ctype) { skipped++; continue; }
const { street, city, zip } = parseLoc(r.property_location);
batch.push([FIPS, r.ain, street, city, zip, ctype, (r.use_desc2 || r.use_desc1), r.use_desc1,
r.roll_total_value, r.roll_land_value, r.roll_imp_value, r.roll_year, isoDate(r.recording_date),
r.sqft_main, r.year_built, r.units]);
if (batch.length >= 1000) await flush();
}
await flush();
if (upserted < 100000) throw new Error(`only ${upserted} commercial parcels loaded — expected ~154k, sqlite drift?`);
await closeRun(runId, 'ok', { upserted, skipped, notes: `LA commercial: ${upserted} parcels materialized (${skipped} unclassified)` });
console.log(`[la-commercial] ok: ${upserted} parcels into commercial_parcel (${skipped} skipped)`);
return { upserted };
} catch (e: any) {
await closeRun(runId, 'failed', { notes: String(e.message || e) });
throw e;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
ingestLaCommercial().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
}