← back to Professional Directory
agents/ingest-agent/importers/dca.js
184 lines
#!/usr/bin/env node
/**
* DCA License Search enricher — Stage 2.
*
* Source page: https://search.dca.ca.gov/
* Public, no key, robots-aware via lib compliance.
*
* The public DCA "results" endpoint accepts a license number + board ID
* and returns HTML; we parse the structured fields. We rate-limit to
* 0.5 rps per host and back off on any non-200.
*
* Run:
* node agents/ingest-agent/importers/dca.js # all CA-licensed pros
* LIMIT=50 node agents/ingest-agent/importers/dca.js # smoke
*/
const cheerio = require('cheerio');
const { pool, query, withTx } = require('../../shared/db');
const { fetchCompliant, setHostRateLimit } = require('../../shared/compliance');
const SOURCE_NAME = 'DCA License Search';
const BOARD_MD = '800'; // Medical Board of California — physicians & surgeons (M.D.)
const BOARD_DO = '870'; // Osteopathic Medical Board of California (D.O.)
const SEARCH_URL = 'https://search.dca.ca.gov/results';
setHostRateLimit('search.dca.ca.gov', 0.5);
function buildResultsUrl(licenseType, licenseNumber) {
const board = licenseType === 'DO' ? BOARD_DO : BOARD_MD;
// The site accepts a GET form post style URL.
const qs = new URLSearchParams({
boardCode: board,
licenseType: 'A', // any
licenseNumber: licenseNumber,
});
return `${SEARCH_URL}?${qs.toString()}`;
}
function parseDcaPage(html) {
const $ = cheerio.load(html);
// Each license card has labels in <strong> with values following.
const out = {};
$('div.search_result, table tr').each((_, el) => {
const $el = $(el);
const text = $el.text().replace(/\s+/g, ' ').trim();
const m = text.match(/License (?:Status|Type|Number|Issue Date|Expiration Date|Original Issue Date)[:\s]+([^\n]+?)(?=\s+License|\s+Address|\s+School|$)/gi);
if (!m) return;
for (const piece of m) {
const [, key, val] = piece.match(/(License (?:Status|Type|Number|Issue Date|Expiration Date|Original Issue Date))[:\s]+(.+)/i) || [];
if (key) out[key.toLowerCase().replace(/\s+/g, '_')] = val.trim();
}
});
// Robust fallback: scan label/value pairs.
$('td,div,span,li').each((_, el) => {
const t = $(el).text().replace(/\s+/g,' ').trim();
let m;
if ((m = t.match(/^License Status:?\s*(.+)$/i))) out.status = m[1].trim();
if ((m = t.match(/^License Type:?\s*(.+)$/i))) out.license_type_full = m[1].trim();
if ((m = t.match(/^Original Issue Date:?\s*(.+)$/i))) out.issue_date = m[1].trim();
if ((m = t.match(/^Expiration Date:?\s*(.+)$/i))) out.expiration_date = m[1].trim();
if ((m = t.match(/^School Name:?\s*(.+)$/i))) out.school = m[1].trim();
if ((m = t.match(/^Graduation Year:?\s*(.+)$/i))) out.graduation_year = m[1].trim();
if ((m = t.match(/^Address of Record:?\s*(.+)$/i))) out.address = m[1].trim();
});
return out;
}
function toIso(d) {
if (!d) return null;
const us = d.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (us) return `${us[3]}-${us[1].padStart(2,'0')}-${us[2].padStart(2,'0')}`;
const iso = d.match(/(\d{4})-(\d{2})-(\d{2})/);
return iso ? `${iso[1]}-${iso[2]}-${iso[3]}` : 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 loadCandidates(limit) {
const sql = `
SELECT id, license_number, title
FROM professionals
WHERE license_number IS NOT NULL
AND title IN ('MD','DO','M.D.','D.O.')
AND (license_status IS NULL OR license_status NOT IN ('Active','Current'))
ORDER BY id
${limit ? `LIMIT ${Number(limit)}` : ''}
`;
const r = await query(sql, []);
return r.rows;
}
async function startJob(sourceId) {
const r = await query(
`INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
VALUES ($1,'dca:enrich','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 applyDca(professionalId, dca, sourceUrl, sourceId) {
await withTx(async (client) => {
await client.query(`
UPDATE professionals SET
license_status = COALESCE($2, license_status),
license_issue_date = COALESCE($3, license_issue_date),
license_expiration_date = COALESCE($4, license_expiration_date),
medical_school = COALESCE($5, medical_school),
graduation_year = COALESCE($6, graduation_year),
source_confidence_score = GREATEST(COALESCE(source_confidence_score,0), 0.80),
updated_at = NOW()
WHERE id = $1
`, [
professionalId,
dca.status || null,
toIso(dca.issue_date),
toIso(dca.expiration_date),
dca.school || null,
dca.graduation_year ? Number(String(dca.graduation_year).match(/\d{4}/)?.[0]) || null : null,
]);
const json = JSON.stringify(dca);
const crypto = require('node:crypto');
const hash = crypto.createHash('sha256').update(json + '|professional|' + professionalId).digest('hex');
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, sourceUrl, professionalId, json, hash]);
});
}
async function main() {
const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
const sourceId = await getSourceId();
const jobId = await startJob(sourceId);
const candidates = await loadCandidates(limit);
console.log(`[dca] candidates=${candidates.length}`);
let ok = 0, fail = 0;
for (const cand of candidates) {
const url = buildResultsUrl(cand.title, cand.license_number);
try {
const res = await fetchCompliant(url);
if (!res.ok) { fail++; continue; }
const html = await res.text();
const dca = parseDcaPage(html);
if (Object.keys(dca).length === 0) { fail++; continue; }
await applyDca(cand.id, dca, url, sourceId);
ok++;
} catch (e) {
if (e.code === 'ROBOTS_DISALLOWED' || e.code === 'CAPTCHA_DETECTED') {
console.error(`[dca] aborted by compliance: ${e.code}`);
break;
}
fail++;
}
if ((ok + fail) % 100 === 0) console.log(`[dca] ok=${ok} fail=${fail}`);
}
await finishJob(jobId, {
status: 'done',
records_found: candidates.length,
records_updated: ok,
records_skipped: fail,
});
console.log(`[dca] done. ok=${ok} fail=${fail}`);
await pool.end();
}
main().catch(async (err) => {
console.error('[dca] fatal:', err);
try { await pool.end(); } catch (_) {}
process.exit(1);
});