import type { ExtractionResult } from '@rareindex/shared'; import type { FetchOptions } from '../types.js'; export interface FirecrawlEngineOptions { apiKey: string; baseUrl?: string; defaultTimeoutMs?: number; } interface FirecrawlScrapeResponse { success: boolean; data?: { markdown?: string; html?: string; rawHtml?: string; json?: unknown; links?: string[]; metadata?: { statusCode?: number; sourceURL?: string; url?: string; title?: string; error?: string; creditsUsed?: number }; }; error?: string; creditsUsed?: number; } /** * Firecrawl v2 REST client (§103). Preferred engine for parseable public pages: returns markdown + * HTML (+ optional JSON extraction with a schema). Never used to bypass access controls (§179). */ export function createFirecrawlEngine(opts: FirecrawlEngineOptions) { const base = (opts.baseUrl ?? 'https://api.firecrawl.dev').replace(/\/$/, ''); const headers = { authorization: `Bearer ${opts.apiKey}`, 'content-type': 'application/json' }; async function post(path: string, body: unknown, timeoutMs: number): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs + 5_000); try { const res = await fetch(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal }); const text = await res.text(); let json: unknown = null; try { json = JSON.parse(text); } catch { throw new Error(`firecrawl ${path} HTTP ${res.status}: ${text.slice(0, 200)}`); } if (!res.ok) { const msg = (json as { error?: string })?.error ?? text.slice(0, 200); throw new Error(`firecrawl ${path} HTTP ${res.status}: ${msg}`); } return json as T; } finally { clearTimeout(timer); } } async function scrape(url: string, o: FetchOptions = {}): Promise { const started = Date.now(); const timeoutMs = o.timeoutMs ?? opts.defaultTimeoutMs ?? 60_000; const formats: unknown[] = ['markdown', 'html', 'rawHtml']; if (o.jsonSchema || o.jsonPrompt) formats.push({ type: 'json', ...(o.jsonSchema ? { schema: o.jsonSchema } : {}), ...(o.jsonPrompt ? { prompt: o.jsonPrompt } : {}) }); const body: Record = { url, formats, onlyMainContent: false, timeout: timeoutMs, ...(o.waitForMs ? { waitFor: o.waitForMs } : {}), ...(o.country ? { location: { country: o.country.toUpperCase() } } : {}), ...(o.headers ? { headers: o.headers } : {}), }; try { const r = await post('/v2/scrape', body, timeoutMs); const d = r.data; const status = d?.metadata?.statusCode ?? null; const ok = Boolean(r.success && d && (status === null || status < 400)); return { success: ok, engine: 'firecrawl', url, finalUrl: d?.metadata?.url ?? d?.metadata?.sourceURL ?? null, httpStatus: status, // rawHtml keeps