/** * Configurable crawl traversal strategy — matches crawl4AI's BFS/DFS/ * Best-First deep-crawl strategies (ZeTa previously had a single hardcoded * FIFO queue, i.e. always BFS). * * Array-like on purpose (`length`, `shift()`, `push()`, `filter()`) so it * drops into crawler.ts's existing queue call sites with a rename, not a * rewrite — BFS's `shift()` behavior is byte-for-byte the same as the plain * array it replaces, so BFS (the default) has zero behavior change. */ export type TraversalStrategy = 'BFS' | 'DFS' | 'BEST_FIRST'; interface FrontierEntry { url: string; score: number; } /** * Best-First scoring heuristic: shallower paths score higher (breadth-like * bias toward top-level sections before deep sub-pages), boosted further by * any user-supplied keyword appearing in the URL. Simple and transparent by * design — crawl4AI's own default scorer is a similarly simple keyword- * relevance heuristic, not a learned model. */ function scoreUrl(url: string, keywords: string[]): number { try { const u = new URL(url); const segments = u.pathname.split('/').filter(Boolean); const depthPenaltyWeight = parseFloat(process.env.CRAWLER_FRONTIER_DEPTH_PENALTY ?? '') || 1; const keywordBoostWeight = parseFloat(process.env.CRAWLER_FRONTIER_KEYWORD_BOOST ?? '') || 10; let score = -segments.length * depthPenaltyWeight; if (keywords.length > 0) { const lower = url.toLowerCase(); for (const kw of keywords) { if (kw && lower.includes(kw)) score += keywordBoostWeight; } } return score; } catch { return 0; } } export class CrawlFrontier { private items: FrontierEntry[] = []; private readonly strategy: TraversalStrategy; private readonly keywords: string[]; private readonly urlFilter: RegExp | null; constructor(strategy: TraversalStrategy, seedUrls: string[], keywords: string[] = [], urlFilter?: string) { this.strategy = strategy; this.keywords = keywords.map((k) => k.toLowerCase()).filter(Boolean); this.urlFilter = urlFilter ? (() => { try { return new RegExp(urlFilter, 'i'); } catch { return null; } })() : null; for (const url of seedUrls) this.push(url); } get length(): number { return this.items.length; } /** * P3b: sparse-parent boost — if the parent page had fewer than 5 interactive * elements (thin/canvas page), its child URLs score +1 in BEST_FIRST mode so * the frontier revisits sparse branches before deep-linked content. */ push(url: string, pushOpts?: { parentElements?: number }): void { if (this.urlFilter && !this.urlFilter.test(url)) return; const sparseBoost = this.strategy === 'BEST_FIRST' && pushOpts?.parentElements !== undefined && pushOpts.parentElements < (parseInt(process.env.CRAWLER_FRONTIER_SPARSE_PARENT_THRESHOLD ?? '') || 5) ? 1 : 0; this.items.push({ url, score: this.strategy === 'BEST_FIRST' ? scoreUrl(url, this.keywords) + sparseBoost : 0 }); } /** Dequeues the next URL per this frontier's strategy. undefined if empty. */ shift(): string | undefined { if (this.items.length === 0) return undefined; switch (this.strategy) { case 'DFS': return this.items.pop()!.url; case 'BEST_FIRST': { let bestIdx = 0; for (let i = 1; i < this.items.length; i++) { if (this.items[i].score > this.items[bestIdx].score) bestIdx = i; } return this.items.splice(bestIdx, 1)[0].url; } case 'BFS': default: return this.items.shift()!.url; } } filter(predicate: (url: string) => boolean): string[] { return this.items.filter((e) => predicate(e.url)).map((e) => e.url); } /** Remove queued URLs not matching the regex pattern. Returns count removed. */ filterByPattern(pattern: string): number { try { const re = new RegExp(pattern, 'i'); const before = this.items.length; this.items = this.items.filter((e) => re.test(e.url)); return before - this.items.length; } catch { return 0; } } }