← back to Professional Directory
agents/shared/compliance.js
165 lines
/**
* Professional Directory — Compliance utilities.
*
* Every crawler MUST go through fetchCompliant() so we get:
* 1. robots.txt enforcement (per-host cache, 24 h TTL)
* 2. per-host rate limit (default 0.5 rps; configurable per source)
* 3. consistent User-Agent string with contact info
* 4. exponential backoff on 4xx/5xx
* 5. opt-out filter helpers for downstream API output
*/
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const { fetch } = require('undici');
const robotsParser = require('robots-parser');
const USER_AGENT = process.env.USER_AGENT
|| 'ProfessionalDirectoryBot/0.1 (research; contact: steveabramsdesigns@gmail.com)';
const DEFAULT_RPS = parseFloat(process.env.DEFAULT_RATE_LIMIT_RPS || '0.5');
// ─── Per-host robots.txt cache ───────────────────────────────────────────────
const robotsCache = new Map(); // protocol+host -> { robots, fetchedAt, denied }
const ONE_DAY = 24 * 60 * 60 * 1000;
async function getRobotsFor(urlStr) {
const u = new URL(urlStr);
// Cache key includes scheme — robots.txt is per-scheme per RFC 9309.
const cacheKey = `${u.protocol}//${u.host}`;
const cached = robotsCache.get(cacheKey);
if (cached && (Date.now() - cached.fetchedAt) < ONE_DAY) return cached;
const robotsUrl = `${cacheKey}/robots.txt`;
let body = '';
let denied = false;
try {
const res = await fetch(robotsUrl, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(10000),
});
if (res.ok) {
body = await res.text();
} else if (res.status >= 500) {
// RFC 9309: 5xx on robots.txt MUST be treated as full disallow.
denied = true;
}
// 4xx (incl. 404) is permissive per RFC.
} catch (_) { /* transport error → permissive */ }
const robots = robotsParser(robotsUrl, body);
const entry = { robots, fetchedAt: Date.now(), denied };
robotsCache.set(cacheKey, entry);
return entry;
}
async function isAllowed(urlStr) {
const { robots, denied } = await getRobotsFor(urlStr);
if (denied) return false;
return robots.isAllowed(urlStr, USER_AGENT) !== false;
}
// ─── Per-host rate limiter (token-bucket-ish, simple gating) ─────────────────
// Keys are normalized via hostKey() so callers may pass either u.host or
// u.hostname interchangeably without silently bypassing the limit.
const lastFetchByHost = new Map(); // host -> ms timestamp (next-allowed)
const rpsByHost = new Map(); // host -> rps override
function hostKey(hostOrUrl) {
if (typeof hostOrUrl !== 'string') return String(hostOrUrl);
// strip :port; lowercase
const lower = hostOrUrl.toLowerCase();
const colon = lower.indexOf(':');
return colon === -1 ? lower : lower.slice(0, colon);
}
function setHostRateLimit(host, rps) { rpsByHost.set(hostKey(host), rps); }
async function gateHost(host) {
const k = hostKey(host);
const rps = rpsByHost.get(k) ?? DEFAULT_RPS;
const minIntervalMs = 1000 / rps;
const now = Date.now();
const last = lastFetchByHost.get(k) || 0;
// Reserve the slot BEFORE awaiting so concurrent callers serialize against
// each other rather than all reading the same `last` and racing.
const reservedNext = Math.max(now, last) + minIntervalMs;
lastFetchByHost.set(k, reservedNext);
const wait = Math.max(0, last - now);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
}
// ─── Public fetch wrapper ────────────────────────────────────────────────────
async function fetchCompliant(urlStr, opts = {}) {
const u = new URL(urlStr);
if (opts.respectRobots !== false) {
const allowed = await isAllowed(urlStr);
if (!allowed) {
const err = new Error(`robots.txt disallows ${urlStr}`);
err.code = 'ROBOTS_DISALLOWED';
throw err;
}
}
await gateHost(u.hostname);
const headers = {
'User-Agent': USER_AGENT,
Accept: opts.accept || 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
...(opts.headers || {}),
};
let attempt = 0;
// Exponential backoff: 1s, 2s, 4s, then give up.
while (true) {
attempt++;
let res;
try {
res = await fetch(urlStr, {
headers,
method: opts.method || 'GET',
body: opts.body,
redirect: opts.redirect || 'follow',
signal: AbortSignal.timeout(opts.timeoutMs || 30000),
});
} catch (e) {
if (attempt >= 4) throw e;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
// CAPTCHA / Cloudflare detection — DO NOT bypass; abort the source.
if (res.status === 403 || res.status === 401) {
const text = await res.text().catch(() => '');
if (/cf-challenge|recaptcha|hcaptcha|captcha/i.test(text)) {
const err = new Error(`CAPTCHA / challenge detected at ${urlStr} — aborting per compliance policy`);
err.code = 'CAPTCHA_DETECTED';
err.body = text.slice(0, 4000);
throw err;
}
}
if (res.status >= 500 && attempt < 4) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
return res;
}
}
// ─── Opt-out helpers ─────────────────────────────────────────────────────────
const OPT_OUT_WHERE = 'opted_out = false';
module.exports = {
USER_AGENT,
DEFAULT_RPS,
fetchCompliant,
isAllowed,
setHostRateLimit,
OPT_OUT_WHERE,
};