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']; 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, html: d?.html ?? d?.rawHtml ?? null, markdown: d?.markdown ?? null, json: d?.json ?? null, qualityScore: 0, requiresReview: false, error: ok ? null : (r.error ?? d?.metadata?.error ?? `status ${status}`), costCredits: r.creditsUsed ?? d?.metadata?.creditsUsed ?? 1, durationMs: Date.now() - started, fetchedAt: new Date(), }; } catch (err) { return { success: false, engine: 'firecrawl', url, finalUrl: null, httpStatus: null, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error: err instanceof Error ? err.message : String(err), costCredits: 0, durationMs: Date.now() - started, fetchedAt: new Date() }; } } async function search(query: string, o: { limit?: number; scrape?: boolean; country?: string; tbs?: string } = {}) { const body: Record = { query, limit: o.limit ?? 10, ...(o.country ? { country: o.country } : {}), ...(o.tbs ? { tbs: o.tbs } : {}) }; if (o.scrape) body.scrapeOptions = { formats: ['markdown'] }; const r = await post<{ success: boolean; data?: { web?: Array<{ url: string; title?: string; description?: string; markdown?: string }> } | Array<{ url: string; title?: string; description?: string; markdown?: string }>; creditsUsed?: number }>('/v2/search', body, 60_000); const data = r.data; const items = Array.isArray(data) ? data : (data?.web ?? []); return { items, credits: r.creditsUsed ?? items.length }; } async function map(url: string, o: { search?: string; limit?: number } = {}) { const r = await post<{ success: boolean; links?: Array; creditsUsed?: number }>('/v2/map', { url, limit: o.limit ?? 5000, ...(o.search ? { search: o.search } : {}) }, 60_000); const links = (r.links ?? []).map((l) => (typeof l === 'string' ? l : l.url)); return { links, credits: r.creditsUsed ?? 1 }; } return { scrape, search, map }; } export type FirecrawlEngine = ReturnType;