← back to Professional Directory
agents/crawler-agent/framework/extractor.js
144 lines
/**
* Generic provider-page extractor utilities used by hospital crawlers.
* Pure parsing helpers — no I/O, no DB.
*/
const cheerio = require('cheerio');
function textOf($el) {
return ($el.text() || '').replace(/\s+/g, ' ').trim() || null;
}
function attrFirst($el, ...names) {
for (const n of names) {
const v = $el.attr(n);
if (v) return v.trim();
}
return null;
}
/**
* Extract a flat list of doctor cards from a directory listing page.
* Tries common selectors first, then JSON-LD, then schema.org microdata.
* Returns an array of { name, specialty, location, phone, profile_url, image_url }.
*/
function extractProviderCards(html, baseUrl) {
const $ = cheerio.load(html);
const results = [];
// 1. JSON-LD blocks with @type Physician / Person.
$('script[type="application/ld+json"]').each((_, s) => {
try {
const j = JSON.parse($(s).text());
const arr = Array.isArray(j) ? j : [j];
for (const obj of arr) {
if (!obj) continue;
const t = (obj['@type'] || '').toLowerCase();
if (t === 'physician' || t === 'person' || t === 'medicalbusiness') {
results.push({
name: obj.name || obj.alternateName,
specialty: obj.medicalSpecialty || obj.knowsAbout,
phone: obj.telephone,
profile_url: obj.url ? new URL(obj.url, baseUrl).href : null,
image_url: obj.image,
address: obj.address && (obj.address.streetAddress || obj.address),
});
}
}
} catch (_) { /* ignore */ }
});
// 2. schema.org microdata.
$('[itemtype*="schema.org/Physician"], [itemtype*="schema.org/Person"]').each((_, el) => {
const $el = $(el);
const name = textOf($el.find('[itemprop="name"]').first()) || textOf($el.find('h2,h3,.name').first());
const specialty = textOf($el.find('[itemprop="medicalSpecialty"], .specialty').first());
const profile_url = $el.find('a[itemprop="url"], a').first().attr('href');
const image_url = $el.find('img[itemprop="image"], img').first().attr('src');
if (name) results.push({
name, specialty,
profile_url: profile_url ? new URL(profile_url, baseUrl).href : null,
image_url: image_url ? new URL(image_url, baseUrl).href : null,
});
});
// 3. Common card patterns.
const cardSelectors = [
'article.physician-card',
'article.provider-card',
'div.provider, div.physician, div.doctor-card, div.search-result',
'li.provider-card, li.doctor-card',
];
for (const sel of cardSelectors) {
$(sel).each((_, el) => {
const $el = $(el);
const name = textOf($el.find('h2, h3, .name, .provider-name').first());
if (!name) return;
const specialty = textOf($el.find('.specialty, .specialties, .provider-specialty').first());
const phone = textOf($el.find('.phone, [data-phone]').first());
const profile_url = $el.find('a').first().attr('href');
const image_url = $el.find('img').first().attr('src');
const location = textOf($el.find('.location, .address, .provider-location').first());
results.push({
name, specialty, phone, location,
profile_url: profile_url ? new URL(profile_url, baseUrl).href : null,
image_url: image_url ? new URL(image_url, baseUrl).href : null,
});
});
if (results.length > 0) break;
}
// De-dupe by (name, profile_url).
const seen = new Set();
return results.filter(r => {
const k = `${(r.name||'').toLowerCase()}|${r.profile_url||''}`;
if (seen.has(k)) return false;
seen.add(k);
return Boolean(r.name);
});
}
/**
* Parse an individual provider profile page for richer fields.
*/
function extractProviderProfile(html, url) {
const $ = cheerio.load(html);
const out = { profile_url: url, source_url: url };
// JSON-LD wins when present.
$('script[type="application/ld+json"]').each((_, s) => {
try {
const j = JSON.parse($(s).text());
const arr = Array.isArray(j) ? j : [j];
for (const obj of arr) {
const t = (obj?.['@type'] || '').toLowerCase();
if (t === 'physician' || t === 'person') {
out.name = out.name || obj.name;
out.specialty = out.specialty || obj.medicalSpecialty;
out.phone = out.phone || obj.telephone;
out.image_url = out.image_url || obj.image;
out.email = out.email || obj.email;
out.bio = out.bio || obj.description;
out.npi = out.npi || obj.identifier?.value;
if (obj.alumniOf) {
const a = Array.isArray(obj.alumniOf) ? obj.alumniOf[0] : obj.alumniOf;
out.medical_school = out.medical_school || (typeof a === 'string' ? a : a?.name);
}
}
}
} catch (_) { /* ignore */ }
});
// Heuristic fallbacks.
out.name ||= textOf($('h1').first());
out.specialty ||= textOf($('.specialty, [itemprop="medicalSpecialty"]').first());
out.phone ||= textOf($('a[href^="tel:"]').first()) || $('a[href^="tel:"]').first().attr('href')?.replace('tel:','');
out.email ||= ($('a[href^="mailto:"]').first().attr('href') || '').replace('mailto:','') || null;
out.bio ||= textOf($('.bio, .biography, .provider-bio, [itemprop="description"]').first());
out.image_url ||= $('.provider-photo img, .doctor-photo img, [itemprop="image"]').first().attr('src');
out.medical_school ||= textOf($('.education, .schooling').filter((_,e)=>/medical|md|school/i.test($(e).text())).first());
return out;
}
module.exports = { extractProviderCards, extractProviderProfile };