import type { Engine, ExtractionResult, Logger } from '@rareindex/shared'; import { createHttpEngine, type HttpEngine } from './engines/http.js'; import { createFirecrawlEngine, type FirecrawlEngine } from './engines/firecrawl.js'; import { createScrapflyEngine, type ScrapflyEngine } from './engines/scrapfly.js'; import { documentQuality, qualityScore, MIN_QUALITY } from './quality.js'; import type { EngineStats, FetchOptions } from './types.js'; export interface RouterOptions { firecrawlApiKey?: string; scrapflyApiKey?: string; log?: Logger; /** called for every engine attempt (cost ledger, metrics) */ onAttempt?: (info: { engine: Engine; url: string; success: boolean; credits: number; ms: number; quality: number; error: string | null }) => void; } export interface Router { fetch(url: string, opts?: FetchOptions, defaults?: { enginePriority: Engine[]; stats?: Record }): Promise; engines: { http: HttpEngine; firecrawl: FirecrawlEngine | null; scrapfly: ScrapflyEngine | null }; available(): Engine[]; } /** * Firecrawl → Scrapfly router (§198). Tries engines in priority order, scores each result with the * connector's parser (or a document heuristic), and falls through until quality ≥ minQuality. * A result that fails everywhere is returned with requiresReview=true — never silently dropped. */ export function createRouter(opts: RouterOptions = {}): Router { const http = createHttpEngine(); const firecrawl = opts.firecrawlApiKey ? createFirecrawlEngine({ apiKey: opts.firecrawlApiKey }) : null; const scrapfly = opts.scrapflyApiKey ? createScrapflyEngine({ apiKey: opts.scrapflyApiKey }) : null; function available(): Engine[] { const out: Engine[] = ['api', 'feed']; if (firecrawl) out.push('firecrawl'); if (scrapfly) out.push('scrapfly'); return out; } async function runEngine(engine: Engine, url: string, o: FetchOptions): Promise { switch (engine) { case 'api': case 'feed': return http.fetch(url, o); case 'firecrawl': return firecrawl ? firecrawl.scrape(url, o) : null; case 'scrapfly': return scrapfly ? scrapfly.scrape(url, o) : null; default: return null; } } async function routedFetch(url: string, o: FetchOptions = {}, defaults?: { enginePriority: Engine[]; stats?: Record }): Promise { const engines = (o.engines ?? defaults?.enginePriority ?? ['api', 'firecrawl', 'scrapfly']).filter((e) => available().includes(e)); const minQuality = o.minQuality ?? MIN_QUALITY; let last: ExtractionResult | null = null; const errors: string[] = []; for (const engine of engines) { const res = await runEngine(engine, url, o); if (!res) continue; let quality = 0; if (res.success) { if (o.parse) { try { const found = o.parse(res); quality = qualityScore(found, o.expect); } catch (err) { quality = 0; res.error = `parse: ${err instanceof Error ? err.message : String(err)}`; } } else { quality = documentQuality(res); } } res.qualityScore = quality; const stats = defaults?.stats; if (stats) { const s = (stats[engine] ??= { attempts: 0, success: 0, credits: 0, ms: 0 }); s.attempts++; s.credits += res.costCredits ?? 0; s.ms += res.durationMs ?? 0; if (res.success && quality >= minQuality) s.success++; } opts.onAttempt?.({ engine, url, success: res.success && quality >= minQuality, credits: res.costCredits ?? 0, ms: res.durationMs ?? 0, quality, error: res.error }); if (res.success && quality >= minQuality) return res; errors.push(`${engine}: ${res.error ?? `quality ${quality}`}`); last = res; opts.log?.debug({ url, engine, quality, error: res.error }, 'engine fallback'); // Do not escalate on hard "not found"/"gone" statuses: the page does not exist. if (res.httpStatus === 404 || res.httpStatus === 410) break; } return { ...(last ?? { engine: engines[0] ?? 'api', url, finalUrl: null, httpStatus: null, html: null, markdown: null, json: null, costCredits: 0, durationMs: 0, fetchedAt: new Date(), qualityScore: 0 }), success: false, requiresReview: true, error: errors.join(' | ') || 'no engine available', }; } return { fetch: routedFetch, engines: { http, firecrawl, scrapfly }, available }; }