← back to Ventura Corridor
src/ingest/la_btrc.ts
172 lines
/**
* LA City Business Tax Registrants → ventura-corridor
* https://data.lacity.org/resource/6rrh-rzua.json — Socrata SODA API, no key needed.
*
* Filters: street_address contains "VENTURA BLVD" or "VENTURA BOULEVARD".
* Source: la_btrc · source_id: location_account (UNIQUE).
* Category derived from NAICS (primary_naics_description); raw NAICS preserved.
*
* No LLM. Pure data pull. Run once to backfill (17K+ records expected).
*/
import { query, pool } from '../../db/pool.ts';
const BASE = 'https://data.lacity.org/resource/6rrh-rzua.json';
const WHERE =
"upper(street_address) like '%VENTURA BLVD%' OR upper(street_address) like '%VENTURA BOULEVARD%'";
const PAGE = 1000;
interface BTRCRecord {
location_account: string;
business_name?: string;
dba_name?: string;
street_address?: string;
city?: string;
zip_code?: string;
naics?: string;
primary_naics_description?: string;
council_district?: string;
location_start_date?: string;
location_end_date?: string;
location_1?: { latitude?: string; longitude?: string };
}
function parseAddr(addr: string | undefined): { number: string | null; street: string | null } {
if (!addr) return { number: null, street: null };
const m = addr.trim().match(/^(\d+[A-Z]?)\s+(.+)$/i);
return { number: m ? m[1] : null, street: m ? m[2] : addr };
}
// Map a NAICS description to our `office:*` / `shop:*` / `amenity:*` taxonomy
// (matching what the OSM Overpass ingest produces — keeps category filter consistent).
function naicsToCategory(naicsDesc: string | undefined): string {
if (!naicsDesc) return 'office: company';
const n = naicsDesc.toLowerCase();
if (/restaurant|food service|caterer|cafeteria/.test(n)) return 'amenity: restaurant';
if (/coffee|tea|snack|bakery|donut/.test(n)) return 'amenity: cafe';
if (/bar|drinking place|tavern/.test(n)) return 'amenity: bar';
if (/hair|salon|barber|nail|beauty|spa/.test(n)) return 'shop: hairdresser';
if (/grocery|supermarket|market/.test(n)) return 'shop: supermarket';
if (/clothing|apparel|shoe|jewelry|accessories/.test(n)) return 'shop: clothes';
if (/furniture|home furnish|interior|wallcover|paint|floor/.test(n)) return 'shop: furniture';
if (/pharmacy|drug store/.test(n)) return 'shop: pharmacy';
if (/dental|dentist/.test(n)) return 'amenity: dentist';
if (/medical|physician|chiropract|optomet|veterin/.test(n)) return 'amenity: clinic';
if (/law|legal|attorney/.test(n)) return 'office: lawyer';
if (/accounting|tax prep|bookkeep/.test(n)) return 'office: accountant';
if (/insurance/.test(n)) return 'office: insurance';
if (/real estate|property manage/.test(n)) return 'office: estate_agent';
if (/financial|invest|advisor|wealth/.test(n)) return 'office: financial advisor';
if (/independent artist|writer|performer|musician|production/.test(n)) return 'office: company';
if (/auto|car|tire|repair/.test(n)) return 'shop: car_repair';
if (/dry cleaning|laundry/.test(n)) return 'shop: laundry';
if (/fitness|gym|yoga|exercise/.test(n)) return 'leisure: fitness_centre';
return 'office: company';
}
async function fetchPage(offset: number): Promise<BTRCRecord[]> {
// $order is required for stable pagination on Socrata — without it, rows
// can drop or duplicate across pages.
const url = `${BASE}?$where=${encodeURIComponent(WHERE)}&$order=location_account&$limit=${PAGE}&$offset=${offset}`;
const r = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(60_000),
});
if (!r.ok) throw new Error(`BTRC ${r.status}: ${(await r.text()).slice(0, 200)}`);
return (await r.json()) as BTRCRecord[];
}
async function upsert(rec: BTRCRecord): Promise<'new' | 'updated' | 'skipped'> {
if (!rec.location_account) return 'skipped';
const name = (rec.dba_name || rec.business_name || '').trim();
if (!name) return 'skipped';
// Skip ended/closed registrations.
if (rec.location_end_date) return 'skipped';
const { number, street } = parseAddr(rec.street_address);
const lat = rec.location_1?.latitude ? parseFloat(rec.location_1.latitude) : null;
const lng = rec.location_1?.longitude ? parseFloat(rec.location_1.longitude) : null;
const category = naicsToCategory(rec.primary_naics_description);
const r = await query(
`
INSERT INTO businesses (
source, source_id, name, category, category_raw, category_naics,
address, street_number, street_name, city, zip,
lat, lng, raw
) VALUES (
'la_btrc', $1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13
)
ON CONFLICT (source, source_id) DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
category_raw = EXCLUDED.category_raw,
category_naics = EXCLUDED.category_naics,
address = EXCLUDED.address,
street_number = EXCLUDED.street_number,
street_name = EXCLUDED.street_name,
city = EXCLUDED.city,
zip = EXCLUDED.zip,
lat = COALESCE(EXCLUDED.lat, businesses.lat),
lng = COALESCE(EXCLUDED.lng, businesses.lng),
raw = EXCLUDED.raw,
last_seen_at = now()
RETURNING (xmax = 0) AS inserted
`,
[
rec.location_account,
name,
category,
rec.primary_naics_description || null,
rec.naics || null,
rec.street_address || null,
number,
street,
rec.city || null,
rec.zip_code ? rec.zip_code.split('-')[0] : null,
lat,
lng,
JSON.stringify(rec),
]
);
return r.rows[0].inserted ? 'new' : 'updated';
}
async function main() {
console.log('[la_btrc] starting ingest of LA City Business Tax Registrants on Ventura Blvd…');
let offset = 0;
let total = 0;
let stats = { new: 0, updated: 0, skipped: 0 };
while (true) {
const page = await fetchPage(offset);
if (!page.length) break;
for (const rec of page) {
const r = await upsert(rec);
stats[r]++;
total++;
}
console.log(
`[la_btrc] offset ${offset}+${page.length} (total ${total}) · new=${stats.new} updated=${stats.updated} skipped=${stats.skipped}`
);
if (page.length < PAGE) break;
offset += PAGE;
}
console.log(`[la_btrc] done · ${total} records processed · ${JSON.stringify(stats)}`);
// BTRC rows were already pre-filtered to street_address LIKE '%VENTURA BLVD%'
// at the SODA query — so every imported row IS on the corridor by definition.
console.log('[la_btrc] flagging all BTRC rows on_corridor (address-prefiltered)…');
const fence = await query(`
UPDATE businesses
SET on_corridor = TRUE
WHERE source = 'la_btrc' AND on_corridor = FALSE
`);
console.log(`[la_btrc] marked on_corridor for ${fence.rowCount} rows.`);
await pool.end();
}
main().catch((e) => {
console.error('[la_btrc]', e);
process.exit(1);
});