← back to Professional Directory
agents/crawler-agent/crawlers/front-page.js
237 lines
/**
* Front-page HTML capture for organizations with a `website`.
*
* Drives ad-social-tracker Pass 1 (regex pixel detection) downstream via
* src/enrich/ad_signals.js. Internal sales-targeting only — admin-gated
* output. We pull only the public marketing front page (one URL per org),
* never deeper paths, never anything resembling patient/PHI surface.
*
* Behavior:
* - Selects orgs with website IS NOT NULL/empty AND no front_page_audits row
* in last 30 days.
* - Playwright headless chromium, 5s nav timeout, single browser, single
* context, max 5 parallel pages.
* - Saves page.content() to data/raw/org-<id>-<ts>.html.
* - INSERTs front_page_audits row per attempt (success OR failure — failures
* get raw_html_path=NULL, http_status=null/code).
* - Skips robots.txt-disallowed paths.
* - User-agent: DoctorDirectoryBot/1.0
* - HIPAA: only the website URL declared by the org. Never widen scope.
*/
const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const { URL } = require('node:url');
const robotsParser = require('robots-parser');
const { pool, query } = require('../../shared/db');
const UA = 'DoctorDirectoryBot/1.0 (+https://drvouch.local/bot)';
const NAV_TIMEOUT_MS = 5000;
const CONCURRENCY = parseInt(process.env.CRAWL_CONCURRENCY || '5', 10);
const LIMIT = parseInt(process.env.CRAWL_LIMIT || '500', 10);
const STALE_DAYS = 30;
const RAW_DIR = path.resolve(__dirname, '../../../data/raw');
const REPO_ROOT = path.resolve(__dirname, '../../..');
fs.mkdirSync(RAW_DIR, { recursive: true });
// In-memory robots.txt cache per origin
const robotsCache = new Map();
function normalizeUrl(raw) {
if (!raw) return null;
let s = String(raw).trim();
if (!s) return null;
if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
try {
const u = new URL(s);
return u.toString();
} catch {
return null;
}
}
async function getRobots(origin) {
if (robotsCache.has(origin)) return robotsCache.get(origin);
const robotsUrl = origin + '/robots.txt';
let parser;
try {
const res = await fetch(robotsUrl, {
method: 'GET',
headers: { 'user-agent': UA },
signal: AbortSignal.timeout(4000),
});
const body = res.ok ? await res.text() : '';
parser = robotsParser(robotsUrl, body);
} catch {
// No robots → allowed by default (common for small sites)
parser = robotsParser(robotsUrl, '');
}
robotsCache.set(origin, parser);
return parser;
}
async function fetchOrgs(limit) {
const sql = `
SELECT o.id, o.name, o.website
FROM organizations o
WHERE o.website IS NOT NULL
AND o.website <> ''
AND o.opted_out = false
AND NOT EXISTS (
SELECT 1 FROM front_page_audits a
WHERE a.organization_id = o.id
AND a.audited_at > now() - INTERVAL '${STALE_DAYS} days'
)
ORDER BY o.id
LIMIT $1
`;
const r = await query(sql, [limit]);
return r.rows;
}
async function crawlOne(context, org) {
const url = normalizeUrl(org.website);
if (!url) {
await query(
`INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
VALUES ($1, $2, NULL, NULL, NULL)`,
[org.id, org.website || '']
);
return { id: org.id, ok: false, reason: 'bad_url' };
}
let origin;
try {
origin = new URL(url).origin;
} catch {
return { id: org.id, ok: false, reason: 'bad_url' };
}
// robots.txt check
try {
const robots = await getRobots(origin);
if (!robots.isAllowed(url, 'DoctorDirectoryBot')) {
await query(
`INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
VALUES ($1, $2, NULL, $3, NULL)`,
[org.id, url, 999] // 999 = robots-disallowed sentinel
);
return { id: org.id, ok: false, reason: 'robots_disallow' };
}
} catch {
// continue — robots fetch failure is not fatal
}
const t0 = Date.now();
const page = await context.newPage();
let httpStatus = null;
let rawPath = null;
let ok = false;
let reason = null;
try {
page.setDefaultNavigationTimeout(NAV_TIMEOUT_MS);
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
httpStatus = resp ? resp.status() : null;
if (resp && resp.ok()) {
const html = await page.content();
const ts = Date.now();
const rel = path.join('data', 'raw', `org-${org.id}-${ts}.html`);
const abs = path.join(REPO_ROOT, rel);
fs.writeFileSync(abs, html, 'utf8');
rawPath = rel;
ok = true;
} else {
reason = `http_${httpStatus}`;
}
} catch (e) {
reason = (e && e.message) ? e.message.slice(0, 80) : 'nav_error';
} finally {
try { await page.close(); } catch {}
}
const fetchMs = Date.now() - t0;
await query(
`INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
VALUES ($1, $2, $3, $4, $5)`,
[org.id, url, rawPath, httpStatus, fetchMs]
);
return { id: org.id, ok, reason, httpStatus, fetchMs };
}
async function runPool(items, worker, concurrency) {
let i = 0;
let active = 0;
let done = 0;
const results = [];
return new Promise((resolve) => {
const next = () => {
while (active < concurrency && i < items.length) {
const idx = i++;
active++;
Promise.resolve(worker(items[idx]))
.then((r) => { results[idx] = r; })
.catch((e) => { results[idx] = { ok: false, reason: e?.message || 'err' }; })
.finally(() => {
active--;
done++;
if (done % 25 === 0) {
console.log(`[front-page] progress ${done}/${items.length}`);
}
if (done === items.length) resolve(results);
else next();
});
}
};
if (items.length === 0) resolve(results);
else next();
});
}
async function main() {
const limit = LIMIT;
console.log(`[front-page] fetching up to ${limit} orgs (stale > ${STALE_DAYS}d)…`);
const orgs = await fetchOrgs(limit);
console.log(`[front-page] ${orgs.length} orgs to crawl, concurrency=${CONCURRENCY}`);
if (orgs.length === 0) {
await pool.end();
return;
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: UA,
javaScriptEnabled: true,
bypassCSP: true,
ignoreHTTPSErrors: true,
viewport: { width: 1280, height: 900 },
});
// Block heavy media — we only need the HTML/script tags for ad-pixel detection
await context.route('**/*', (route) => {
const t = route.request().resourceType();
if (t === 'image' || t === 'media' || t === 'font') return route.abort();
return route.continue();
});
const results = await runPool(orgs, (o) => crawlOne(context, o), CONCURRENCY);
await context.close();
await browser.close();
const ok = results.filter((r) => r && r.ok).length;
const robotsBlocked = results.filter((r) => r && r.reason === 'robots_disallow').length;
const errs = results.length - ok - robotsBlocked;
console.log(`[front-page] done · ok=${ok} robots_blocked=${robotsBlocked} errors=${errs}`);
await pool.end();
}
if (require.main === module) {
main().catch((e) => {
console.error('[front-page] fatal', e);
process.exit(1);
});
}
module.exports = { crawlOne, normalizeUrl };