← back to Professional Directory
agents/ingest-agent/importers/ccld.js
189 lines
#!/usr/bin/env node
/**
* CA DSS Community Care Licensing — facility importer.
*
* Pulls public CSVs from data.ca.gov for the three resident/elder-relevant
* datasets:
* - Residential Care Facilities for the Elderly (RCFEs + CCRCs) → 'rcfe' / 'ccrc'
* - Adult Residential Facilities → 'adult_residential' + variants
* - Home Care Organizations → 'home_care_agency'
*
* Filters to LA County. Upserts on `cdph_license` (= facility_number).
*
* Run:
* node agents/ingest-agent/importers/ccld.js # all three
* DATASET=rcfe node ccld.js # one only
*/
const fs = require('node:fs');
const path = require('node:path');
const { parse } = require('csv-parse');
const { pool, query, withTx } = require('../../shared/db');
const SOURCE_NAME = 'CA DSS Community Care Licensing';
const DATA_DIR = path.resolve(__dirname, '../../../data/ccld');
const FEEDS = {
rcfe: {
file: 'rcfe.csv',
url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/744d1583-f9eb-45b6-b0f8-b9a9dab936a6/download/tmpacjmwy9v.csv',
},
arf: {
file: 'arf.csv',
url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/9f5d1d00-6b24-4f44-a158-9cbe4b43f117/download/tmpx8kml5z4.csv',
},
hco: {
file: 'home_care.csv',
url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/b4d78b7f-12df-4b0c-a81a-ff40b949bc75/download/tmpbgsqj_4n.csv',
},
};
const FACILITY_TYPE_MAP = {
'RESIDENTIAL CARE ELDERLY': 'rcfe',
'RCFE-CONTINUING CARE RETIREMENT COMMUNITY': 'ccrc',
'ADULT RESIDENTIAL': 'adult_residential',
'ADULT DAY PROGRAM': 'adult_day_program',
'SOCIAL REHABILITATION FACILITY': 'social_rehab',
'ENHANCED BEHAVIORAL SUPPORTS HOME - ARF': 'arf_behavioral',
'ADULT RESIDENTIAL FACILITY FOR PERSONS WITH SPECIAL HEALTH CARE NEEDS': 'arf_special_needs',
'COMMUNITY CRISIS HOME - ARF': 'arf_crisis',
'RESIDENTIAL FACILITY CHRONICALLY ILL': 'arf_chronic_ill',
'HOME CARE': 'home_care_agency',
};
function clean(s) {
if (s === undefined || s === null) return null;
const t = String(s).trim();
return t.length === 0 ? null : t;
}
function zip5(z) {
if (!z) return null;
const m = String(z).match(/\d{5}/);
return m ? m[0] : null;
}
function digits(s) { return s ? String(s).replace(/[^0-9]/g, '') : null; }
async function getSourceId() {
const r = await query('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
return r.rows[0].id;
}
async function startJob(sourceId, label) {
const r = await query(`
INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
VALUES ($1,$2,'running',NOW()) RETURNING id`,
[sourceId, label]);
return r.rows[0].id;
}
async function finishJob(jobId, fields) {
const sets = []; const params = []; let i = 1;
for (const [k, v] of Object.entries(fields)) { sets.push(`${k} = $${i++}`); params.push(v); }
sets.push(`finished_at = NOW()`);
params.push(jobId);
await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
}
async function ensureFile(feed) {
const localPath = path.join(DATA_DIR, feed.file);
if (fs.existsSync(localPath) && fs.statSync(localPath).size > 0) return localPath;
fs.mkdirSync(DATA_DIR, { recursive: true });
console.log(`[ccld] downloading ${feed.url}`);
const { fetch } = require('undici');
const res = await fetch(feed.url);
if (!res.ok) throw new Error(`download failed ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(localPath, buf);
return localPath;
}
async function upsertOrg(client, row, sourceUrl) {
const facilityType = clean(row.facility_type);
const orgType = FACILITY_TYPE_MAP[facilityType?.toUpperCase()] || 'care_facility';
const name = clean(row.facility_name);
const license = clean(row.facility_number);
if (!name || !license) return null;
const phone = digits(row.facility_telephone_number);
const r = await client.query(`
INSERT INTO organizations (
name, type, address, city, state, zip, county, phone,
cdph_license
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (cdph_license) WHERE cdph_license IS NOT NULL DO UPDATE SET
name = EXCLUDED.name,
type = EXCLUDED.type,
address = COALESCE(EXCLUDED.address, organizations.address),
city = COALESCE(EXCLUDED.city, organizations.city),
zip = COALESCE(EXCLUDED.zip, organizations.zip),
phone = COALESCE(EXCLUDED.phone, organizations.phone),
updated_at = NOW()
RETURNING id, (xmax = 0) AS inserted
`, [
name, orgType,
clean(row.facility_address),
clean(row.facility_city),
clean(row.facility_state) || 'CA',
zip5(row.facility_zip),
'Los Angeles',
phone,
license,
]);
return { id: r.rows[0].id, inserted: r.rows[0].inserted };
}
async function importFeed(feedKey, feed, sourceId) {
const file = await ensureFile(feed);
console.log(`[ccld:${feedKey}] file=${file}`);
const jobId = await startJob(sourceId, `ccld:${feedKey}`);
let scanned = 0, kept = 0, inserted = 0, updated = 0, skipped = 0;
const parser = fs.createReadStream(file).pipe(parse({
columns: true, skip_empty_lines: true, relax_column_count: true, bom: true,
}));
for await (const row of parser) {
scanned++;
const county = (row.county_name || '').trim().toUpperCase();
const status = (row.facility_status || '').trim().toUpperCase();
if (county !== 'LOS ANGELES') { skipped++; continue; }
if (status && status !== 'LICENSED') { skipped++; continue; }
kept++;
try {
await withTx(async (client) => {
const r = await upsertOrg(client, row, feed.url);
if (!r) { skipped++; return; }
if (r.inserted) inserted++; else updated++;
});
} catch (e) {
console.error(`[ccld:${feedKey}] row error fac=${row.facility_number}: ${e.message}`);
skipped++;
}
if (kept % 500 === 0) console.log(`[ccld:${feedKey}] scanned=${scanned} kept=${kept} ins=${inserted} upd=${updated} skip=${skipped}`);
}
await finishJob(jobId, {
status: 'done',
records_found: scanned,
records_inserted: inserted,
records_updated: updated,
records_skipped: skipped,
});
console.log(`[ccld:${feedKey}] done. scanned=${scanned} kept=${kept} ins=${inserted} upd=${updated} skip=${skipped}`);
}
async function main() {
const sourceId = await getSourceId();
const which = process.env.DATASET ? [process.env.DATASET] : Object.keys(FEEDS);
for (const k of which) {
const feed = FEEDS[k];
if (!feed) { console.error(`[ccld] unknown DATASET=${k} (allowed: ${Object.keys(FEEDS).join(',')})`); continue; }
await importFeed(k, feed, sourceId);
}
await pool.end();
}
main().catch(async (e) => { console.error('[ccld] fatal:', e); try { await pool.end(); } catch (_) {} process.exit(1); });