/** * Domain-aware rate limiter — ensures the crawler respects per-domain rate limits. * * Uses a token bucket algorithm per domain. When the bucket is empty, requests * wait until a token is available. Tokens refill at a configurable rate. * * Default: 2 requests/second per domain, burst of 5. * Can be configured per-domain via the rateMap (domain → requests/second). */ export interface RateLimiterOpts { defaultRps?: number; // requests per second (default 2) burstSize?: number; // max burst (default 5) rateMap?: Record; // domain-specific overrides } interface Bucket { tokens: number; lastRefill: number; // Date.now() } function extractDomain(url: string): string { try { return new URL(url).hostname; } catch { return url; } } export class DomainRateLimiter { private buckets = new Map(); private readonly defaultRps: number; private readonly burstSize: number; private readonly rateMap: Record; constructor(opts: RateLimiterOpts = {}) { this.defaultRps = opts.defaultRps ?? (parseFloat(process.env.CRAWLER_RATE_LIMIT_DEFAULT_RPS ?? '') || 2); this.burstSize = opts.burstSize ?? (parseInt(process.env.CRAWLER_RATE_LIMIT_BURST_SIZE ?? '') || 5); this.rateMap = opts.rateMap ?? {}; } private getBucket(domain: string): Bucket { if (!this.buckets.has(domain)) { this.buckets.set(domain, { tokens: this.burstSize, lastRefill: Date.now() }); } return this.buckets.get(domain)!; } private refill(domain: string, bucket: Bucket): void { const rps = this.rateMap[domain] ?? this.defaultRps; const now = Date.now(); const elapsed = (now - bucket.lastRefill) / 1000; bucket.tokens = Math.min(this.burstSize, bucket.tokens + elapsed * rps); bucket.lastRefill = now; } /** Wait until a token is available for the given URL's domain. */ async throttle(url: string): Promise { const domain = extractDomain(url); const bucket = this.getBucket(domain); this.refill(domain, bucket); if (bucket.tokens >= 1) { bucket.tokens -= 1; return; } // Calculate wait time until next token const rps = this.rateMap[domain] ?? this.defaultRps; const waitMs = Math.ceil((1 - bucket.tokens) / rps * 1000); await new Promise(r => setTimeout(r, waitMs)); bucket.tokens = 0; bucket.lastRefill = Date.now(); } /** Override rate for a specific domain at runtime (e.g. after seeing 429). */ setRate(domain: string, rps: number): void { this.rateMap[domain] = rps; const bucket = this.getBucket(domain); bucket.tokens = 0; // drain immediately on rate change } stats(): Record { const result: Record = {}; for (const [domain, bucket] of this.buckets) { this.refill(domain, bucket); result[domain] = { tokens: Math.round(bucket.tokens * 100) / 100, rps: this.rateMap[domain] ?? this.defaultRps }; } return result; } }