← back to Professional Directory
agents/ingest-agent/importers/cms-care-compare.js
225 lines
#!/usr/bin/env node
/**
* CMS Care Compare — "Doctors and Clinicians National Downloadable File"
* Stage 3 enrichment.
*
* Source: https://data.cms.gov/provider-data/dataset/mj5m-pzi6
* CSV : https://data.cms.gov/provider-data/sites/default/files/resources/<id>/DAC_NationalDownloadableFile.csv
*
* Joins to professionals by NPI. Adds:
* primary_specialty (CMS specialty) → professionals.primary_specialty
* hospital_affiliation_ccn / name → organizations + affiliation row
* group_practice_pac_id / name → organizations (medical_group)
* gender → professionals.gender
* credentials → professionals.title
*
* No PII exposed beyond what NPPES already publishes.
*/
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { parse } = require('csv-parse');
const { fetch } = require('undici');
const { pool, query, withTx } = require('../../shared/db');
const { laZipSet } = require('../../shared/la-zips');
const SOURCE_NAME = 'CMS Care Compare (Doctors and Clinicians)';
const SOURCE_URL = 'https://data.cms.gov/provider-data/dataset/mj5m-pzi6';
const CSV_URL = process.env.CMS_DAC_URL
|| 'https://data.cms.gov/provider-data/sites/default/files/resources/52c3f098d7e56028a298fd297cb0b38d_1774905035/DAC_NationalDownloadableFile.csv';
const CACHE = path.resolve(__dirname, '../../../data/cms/DAC_NationalDownloadableFile.csv');
function clean(s) {
if (s === undefined || s === null) return null;
const t = String(s).trim();
return t.length === 0 ? null : t;
}
function pick(row, ...keys) {
for (const k of keys) {
const v = row[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return clean(v);
}
return null;
}
function zip5(z) {
if (!z) return null;
const m = String(z).match(/\d{5}/);
return m ? m[0] : null;
}
function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
async function downloadIfNeeded() {
if (fs.existsSync(CACHE)) {
const ageDays = (Date.now() - fs.statSync(CACHE).mtimeMs) / 86400000;
if (ageDays < 30) {
console.log(`[cms-dac] using cache (${ageDays.toFixed(1)}d old)`);
return;
}
}
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
console.log(`[cms-dac] downloading ${CSV_URL}`);
const res = await fetch(CSV_URL, { signal: AbortSignal.timeout(180000) });
if (!res.ok) throw new Error(`CMS DAC download failed: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(CACHE, buf);
console.log(`[cms-dac] cached → ${CACHE} (${(buf.length/1e6).toFixed(1)} MB)`);
}
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) {
const r = await query(
`INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
VALUES ($1,'cms-dac:bulk','running',NOW()) RETURNING id`, [sourceId]);
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 findProfessional(client, npi) {
if (!npi) return null;
const r = await client.query(`SELECT id FROM professionals WHERE npi_number = $1 LIMIT 1`, [npi]);
return r.rowCount ? r.rows[0].id : null;
}
async function ensureGroup(client, name, address, city, state, zip, ccn) {
if (!name) return null;
// Try CCN first, then name+zip.
let r;
if (ccn) {
r = await client.query(`SELECT id FROM organizations WHERE cdph_license = $1 OR hcai_id = $1 LIMIT 1`, [ccn]);
if (r.rowCount) return r.rows[0].id;
}
if (name && zip) {
r = await client.query(`SELECT id FROM organizations WHERE LOWER(name)=LOWER($1) AND zip=$2 LIMIT 1`, [name, zip]);
if (r.rowCount) return r.rows[0].id;
}
r = await client.query(`
INSERT INTO organizations (name, type, address, city, state, zip, county)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id
`, [name, 'medical_group', address, city, state || 'CA', zip, 'Los Angeles']);
return r.rows[0].id;
}
async function applyDac(client, professionalId, row, sourceId) {
const cmsSpecialty = pick(row, 'pri_spec', 'Primary specialty', 'Primary Specialty');
const credentials = pick(row, 'Cred', 'Credential', 'cred');
const gender = pick(row, 'gndr', 'Gender');
await client.query(`
UPDATE professionals SET
primary_specialty = COALESCE($2, primary_specialty),
title = COALESCE($3, title),
gender = COALESCE($4, gender),
source_confidence_score = GREATEST(COALESCE(source_confidence_score, 0), 0.75),
updated_at = NOW()
WHERE id = $1
`, [professionalId, cmsSpecialty, credentials, gender ? gender.charAt(0).toUpperCase() : null]);
// Group practice as an organization + affiliation.
// NB: the CMS DAC CSV uses 'Facility Name' for the group name (not 'org_nm'); 'org_nm'
// appears in the older DAC schema. Keep both for forward-compat.
const groupName = pick(row, 'Facility Name', 'org_nm', 'Organization legal name', 'Group name');
const groupZip = zip5(pick(row, 'ZIP Code', 'adr_zip', 'org_zip'));
const groupCity = pick(row, 'City/Town', 'cty', 'City', 'org_cty');
const groupAddr = pick(row, 'adr_ln_1', 'Address Line 1', 'org_adr_ln_1');
if (groupName) {
const orgId = await ensureGroup(client, groupName, groupAddr, groupCity, 'CA', groupZip,
pick(row, 'org_pac_id', 'Group PAC ID'));
await client.query(`
INSERT INTO professional_locations
(professional_id, organization_id, role, address, source_url, last_verified_at)
VALUES ($1,$2,'employed',$3,$4,NOW())
ON CONFLICT DO NOTHING
`, [professionalId, orgId, groupAddr, SOURCE_URL]);
}
// Hospital affiliation. NB: `IN (..., NULL)` does not match NULLs in SQL —
// use OR ... IS NULL to also promote rows whose type was never set.
const hospName = pick(row, 'hosp_afl_1', 'Hospital affiliation name 1');
const hospCcn = pick(row, 'hosp_afl_lbn_1', 'Hospital affiliation CCN 1');
if (hospName) {
const hospId = await ensureGroup(client, hospName, null, null, 'CA', null, hospCcn);
await client.query(`
UPDATE organizations SET type = 'hospital', updated_at = NOW()
WHERE id = $1 AND (type IN ('medical_group','clinic') OR type IS NULL)
`, [hospId]);
await client.query(`
INSERT INTO professional_locations
(professional_id, organization_id, role, source_url, last_verified_at)
VALUES ($1,$2,'privileges',$3,NOW())
ON CONFLICT DO NOTHING
`, [professionalId, hospId, SOURCE_URL]);
}
// Provenance.
const json = JSON.stringify(row);
const hash = sha256(json + '|professional|' + professionalId);
await client.query(`
INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, hash)
VALUES ($1,$2,'professional',$3,$4::jsonb,$5)
ON CONFLICT (hash) DO NOTHING
`, [sourceId, SOURCE_URL, professionalId, json, hash]);
}
async function main() {
await downloadIfNeeded();
const sourceId = await getSourceId();
const jobId = await startJob(sourceId);
const laZips = await laZipSet();
let scanned = 0, matched = 0, skipped = 0;
const parser = fs.createReadStream(CACHE).pipe(parse({
columns: true, skip_empty_lines: true, bom: true, relax_column_count: true,
}));
for await (const row of parser) {
scanned++;
const npi = pick(row, 'NPI', 'npi');
const state = pick(row, 'st', 'State');
const z = zip5(pick(row, 'adr_zip', 'ZIP Code'));
if (state && state !== 'CA') { skipped++; continue; }
if (z && !laZips.has(z)) { skipped++; continue; }
if (!npi) { skipped++; continue; }
try {
await withTx(async (client) => {
const id = await findProfessional(client, npi);
if (!id) { skipped++; return; }
await applyDac(client, id, row, sourceId);
matched++;
});
} catch (e) {
console.error(`[cms-dac] row error npi=${npi}: ${e.message}`);
}
if (scanned % 10000 === 0) console.log(`[cms-dac] scanned=${scanned} matched=${matched}`);
}
await finishJob(jobId, {
status: 'done',
records_found: scanned, records_updated: matched, records_skipped: skipped,
});
console.log(`[cms-dac] done. scanned=${scanned} matched=${matched} skipped=${skipped}`);
await pool.end();
}
main().catch(async (err) => {
console.error('[cms-dac] fatal:', err);
try { await pool.end(); } catch (_) {}
process.exit(1);
});