← back to Lawyer Directory Builder
src/lib/compliance.ts
112 lines
import 'dotenv/config';
import { fetch } from 'undici';
// @ts-ignore — robots-parser has no types ship
import robotsParser from 'robots-parser';
const USER_AGENT = process.env.USER_AGENT
|| 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
const DEFAULT_RPS = parseFloat(process.env.STATE_BAR_RPS || '0.5');
const robotsCache = new Map<string, { robots: any; fetchedAt: number }>();
const ONE_DAY = 24 * 60 * 60 * 1000;
async function getRobotsFor(urlStr: string) {
const u = new URL(urlStr);
const cached = robotsCache.get(u.host);
if (cached && Date.now() - cached.fetchedAt < ONE_DAY) return cached.robots;
const robotsUrl = `${u.protocol}//${u.host}/robots.txt`;
let body = '';
try {
const res = await fetch(robotsUrl, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(10000),
});
if (res.ok) body = await res.text();
} catch { /* permissive on transport error */ }
const robots = robotsParser(robotsUrl, body);
robotsCache.set(u.host, { robots, fetchedAt: Date.now() });
return robots;
}
export async function isAllowed(urlStr: string): Promise<boolean> {
const robots = await getRobotsFor(urlStr);
const verdict = robots.isAllowed(urlStr, USER_AGENT);
return verdict !== false;
}
const lastFetchByHost = new Map<string, number>();
const rpsByHost = new Map<string, number>();
export function setHostRateLimit(host: string, rps: number) {
rpsByHost.set(host, rps);
}
async function gateHost(host: string) {
const rps = rpsByHost.get(host) ?? DEFAULT_RPS;
const minInterval = 1000 / rps;
const last = lastFetchByHost.get(host) || 0;
const wait = Math.max(0, last + minInterval - Date.now());
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastFetchByHost.set(host, Date.now());
}
export interface FetchOpts {
respectRobots?: boolean;
accept?: string;
headers?: Record<string, string>;
timeoutMs?: number;
}
export async function fetchCompliant(urlStr: string, opts: FetchOpts = {}) {
const u = new URL(urlStr);
if (opts.respectRobots !== false) {
const allowed = await isAllowed(urlStr);
if (!allowed) {
const err = new Error(`robots.txt disallows ${urlStr}`) as Error & { code?: string };
err.code = 'ROBOTS_DISALLOWED';
throw err;
}
}
await gateHost(u.host);
const headers: Record<string, string> = {
'User-Agent': USER_AGENT,
Accept: opts.accept || 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
...(opts.headers || {}),
};
let attempt = 0;
while (true) {
attempt++;
let res: Response | undefined;
try {
res = await fetch(urlStr, { headers, signal: AbortSignal.timeout(opts.timeoutMs || 30000) }) as unknown as Response;
} catch (e) {
if (attempt >= 4) throw e;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
if (res.status === 403 || res.status === 401) {
const text = await res.text();
if (/cf-challenge|recaptcha|hcaptcha|captcha/i.test(text)) {
const err = new Error(`CAPTCHA detected at ${urlStr} — aborting per policy`) as Error & { code?: string };
err.code = 'CAPTCHA_DETECTED';
throw err;
}
// re-wrap response with body since we consumed it
return new Response(text, { status: res.status, headers: res.headers });
}
if (res.status >= 500 && attempt < 4) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
continue;
}
return res;
}
}