← back to Professional Directory

scripts/crawl-hospitals.js

172 lines

#!/usr/bin/env node
/**
 * Hospital directory crawl dispatcher — Stage 5.
 * Runs each hospital-system crawler module under crawlers/ and persists
 * results back into professionals + professional_locations.
 *
 * Usage:
 *   node scripts/crawl-hospitals.js                      # all 10
 *   node scripts/crawl-hospitals.js ucla-health,cedars-sinai
 *   MAX_PAGES=5 node scripts/crawl-hospitals.js ucla-health   # quick smoke
 */
const path = require('path');
const fs = require('fs');
const crypto = require('node:crypto');
const { pool, query, withTx } = require('../agents/shared/db');

const CRAWLER_DIR = path.resolve(__dirname, '../agents/crawler-agent/crawlers');
const MAX_PAGES = process.env.MAX_PAGES ? Number(process.env.MAX_PAGES) : Infinity;

function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); }

async function getSourceId(name) {
  const r = await query('SELECT id FROM sources WHERE source_name = $1', [name]);
  return r.rowCount ? r.rows[0].id : null;
}

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 persistProvider(provider, sourceId) {
  if (!provider.name) return;

  await withTx(async (client) => {
    // Try to match an existing professional by NPI, then by name+specialty.
    let professionalId;
    if (provider.npi) {
      const r = await client.query(`SELECT id FROM professionals WHERE npi_number = $1`, [provider.npi]);
      if (r.rowCount) professionalId = r.rows[0].id;
    }
    if (!professionalId) {
      const r = await client.query(`
        SELECT id FROM professionals
         WHERE LOWER(full_name) = LOWER($1)
            OR (LOWER(last_name) = LOWER(SPLIT_PART($1, ' ', -1))
                AND LOWER(first_name) = LOWER(SPLIT_PART($1, ' ', 1)))
         LIMIT 2
      `, [provider.name]);
      if (r.rowCount === 1) professionalId = r.rows[0].id;
    }

    if (!professionalId) {
      // New professional — name only, low confidence until license joins.
      const r = await client.query(`
        INSERT INTO professionals (full_name, primary_specialty, profile_image_url, bio, source_confidence_score)
        VALUES ($1,$2,$3,$4,0.30)
        RETURNING id
      `, [provider.name, provider.specialty, provider.image_url, provider.bio]);
      professionalId = r.rows[0].id;
    } else {
      await client.query(`
        UPDATE professionals SET
          primary_specialty   = COALESCE(primary_specialty, $2),
          profile_image_url   = COALESCE(profile_image_url, $3),
          bio                 = COALESCE(bio, $4),
          medical_school      = COALESCE(medical_school, $5),
          source_confidence_score = GREATEST(COALESCE(source_confidence_score,0), 0.85),
          updated_at = NOW()
        WHERE id = $1
      `, [professionalId, provider.specialty, provider.image_url, provider.bio, provider.medical_school]);
    }

    // Hospital affiliation.
    const orgRes = await client.query(`SELECT id FROM organizations WHERE name = $1 LIMIT 1`, [provider.hospital_system]);
    let hospId;
    if (orgRes.rowCount) hospId = orgRes.rows[0].id;
    else {
      const ins = await client.query(`
        INSERT INTO organizations (name, type, state, county) VALUES ($1,'hospital','CA','Los Angeles') RETURNING id
      `, [provider.hospital_system]);
      hospId = ins.rows[0].id;
    }

    await client.query(`
      INSERT INTO professional_locations
        (professional_id, organization_id, role, address, phone, source_url, last_verified_at)
      VALUES ($1,$2,'privileges',$3,$4,$5,NOW())
      ON CONFLICT DO NOTHING
    `, [professionalId, hospId, provider.address, provider.phone, provider.source_url]);

    if (provider.email) {
      await client.query(`
        INSERT INTO emails (professional_id, email, email_type, source_url, discovered_at, verification_status)
        VALUES ($1, $2, 'office', $3, NOW(), 'unverified')
        ON CONFLICT DO NOTHING
      `, [professionalId, provider.email, provider.source_url]);
    }
    if (provider.phone) {
      await client.query(`
        INSERT INTO phones (professional_id, phone, phone_type, source_url, last_verified_at)
        VALUES ($1, $2, 'office', $3, NOW())
        ON CONFLICT DO NOTHING
      `, [professionalId, provider.phone, provider.source_url]);
    }

    // Provenance.
    const json = JSON.stringify(provider);
    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, provider.source_url, professionalId, json, hash]);
  });
}

async function runCrawler(modName) {
  const file = path.join(CRAWLER_DIR, `${modName}.js`);
  if (!fs.existsSync(file)) {
    console.warn(`[crawl] crawler not found: ${modName}`);
    return 0;
  }
  const mod = require(file);
  const sourceId = await getSourceId(mod.SOURCE_NAME);
  if (!sourceId) {
    console.warn(`[crawl] source not seeded for ${modName} — skipping`);
    return 0;
  }
  const jobId = await startJob(sourceId, `crawl:${modName}`);
  let inserted = 0, errors = 0;

  try {
    await mod.crawl({
      onProvider: async (p) => {
        try { await persistProvider(p, sourceId); inserted++; }
        catch (e) { errors++; console.error(`[crawl] persist error: ${e.message}`); }
      },
      maxPages: MAX_PAGES === Infinity ? Infinity : MAX_PAGES,
    });
    await finishJob(jobId, { status: 'done', records_inserted: inserted, records_skipped: errors });
  } catch (e) {
    await finishJob(jobId, { status: 'failed', error_message: e.message, records_inserted: inserted });
    throw e;
  }
  console.log(`[crawl:${modName}] inserted=${inserted} errors=${errors}`);
  return inserted;
}

async function main() {
  const targets = process.argv[2]
    ? process.argv[2].split(',')
    : fs.readdirSync(CRAWLER_DIR).filter(f => f.endsWith('.js')).map(f => f.replace(/\.js$/, ''));

  for (const t of targets) {
    try { await runCrawler(t); }
    catch (e) { console.error(`[crawl:${t}] fatal: ${e.message}`); }
  }
  await pool.end();
}

main().catch((e) => { console.error(e); process.exit(1); });