/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/firecrawl/client.ts * Description: Firecrawl REST adapter — search, scrape, crawl, extract with retries and structured errors. */ import { env } from "@/lib/env"; const BASE = "https://api.firecrawl.dev"; const MAX_RETRIES = 3; export class FirecrawlError extends Error { constructor( message: string, public readonly status: number | null, public readonly retryable: boolean, public readonly endpoint: string, ) { super(message); this.name = "FirecrawlError"; } } export type SearchResult = { url: string; title: string; description: string }; export type ScrapeResult = { markdown: string; title: string | null; description: string | null; statusCode: number | null; sourceUrl: string; }; export type CrawlPage = ScrapeResult; async function fcFetch(path: string, init: RequestInit, timeoutMs = 90_000): Promise { let lastError: FirecrawlError | null = null; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (attempt > 0) { await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1) + Math.random() * 500)); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`${BASE}${path}`, { ...init, headers: { Authorization: `Bearer ${env().FIRECRAWL_API_KEY}`, "Content-Type": "application/json", ...init.headers, }, signal: controller.signal, }); if (res.ok) { return (await res.json()) as T; } const body = await res.text().catch(() => ""); const retryable = res.status === 429 || res.status >= 500; lastError = new FirecrawlError( `Firecrawl ${path} failed (${res.status}): ${body.slice(0, 300)}`, res.status, retryable, path, ); if (!retryable) throw lastError; } catch (err) { if (err instanceof FirecrawlError) { if (!err.retryable) throw err; lastError = err; } else { // Network error / timeout — retryable lastError = new FirecrawlError( `Firecrawl ${path} network error: ${err instanceof Error ? err.message : String(err)}`, null, true, path, ); } } finally { clearTimeout(timer); } } throw lastError ?? new FirecrawlError(`Firecrawl ${path} failed`, null, false, path); } /** Web search. Returns organic web results (no scraping). */ export async function searchWeb(query: string, limit = 8): Promise { type Resp = { success: boolean; data?: { web?: Array<{ url: string; title?: string; description?: string }> } | Array<{ url: string; title?: string; description?: string; }>; }; const resp = await fcFetch("/v2/search", { method: "POST", body: JSON.stringify({ query, limit, sources: [{ type: "web" }] }), }); const raw = Array.isArray(resp.data) ? resp.data : (resp.data?.web ?? []); return raw .filter((r) => typeof r.url === "string" && r.url.startsWith("http")) .map((r) => ({ url: r.url, title: r.title ?? "", description: r.description ?? "" })); } /** Scrape a single page to markdown (main content only). */ export async function scrapePage(url: string): Promise { type Resp = { success: boolean; data?: { markdown?: string; metadata?: { title?: string; description?: string; statusCode?: number; sourceURL?: string }; }; }; const resp = await fcFetch("/v2/scrape", { method: "POST", body: JSON.stringify({ url, formats: ["markdown"], onlyMainContent: true, timeout: 60_000 }), }); const d = resp.data; if (!d?.markdown) { throw new FirecrawlError(`Scrape returned no content for ${url}`, null, false, "/v2/scrape"); } return { markdown: d.markdown, title: d.metadata?.title ?? null, description: d.metadata?.description ?? null, statusCode: d.metadata?.statusCode ?? null, sourceUrl: d.metadata?.sourceURL ?? url, }; } /** Selective crawl: start a bounded crawl and poll until done (or timeout). Never whole domains. */ export async function crawlSite(url: string, limit = 8, maxWaitMs = 180_000): Promise { type StartResp = { success: boolean; id?: string }; const start = await fcFetch("/v2/crawl", { method: "POST", body: JSON.stringify({ url, limit: Math.min(limit, 10), scrapeOptions: { formats: ["markdown"], onlyMainContent: true }, }), }); if (!start.id) throw new FirecrawlError("Crawl did not return a job id", null, false, "/v2/crawl"); type StatusResp = { status: "scraping" | "completed" | "failed" | "cancelled"; data?: Array<{ markdown?: string; metadata?: { title?: string; description?: string; statusCode?: number; sourceURL?: string }; }>; }; const deadline = Date.now() + maxWaitMs; for (;;) { await new Promise((r) => setTimeout(r, 5000)); const status = await fcFetch(`/v2/crawl/${start.id}`, { method: "GET" }); if (status.status === "completed") { return (status.data ?? []) .filter((p) => p.markdown) .map((p) => ({ markdown: p.markdown as string, title: p.metadata?.title ?? null, description: p.metadata?.description ?? null, statusCode: p.metadata?.statusCode ?? null, sourceUrl: p.metadata?.sourceURL ?? url, })); } if (status.status === "failed" || status.status === "cancelled") { throw new FirecrawlError(`Crawl ${status.status} for ${url}`, null, false, "/v2/crawl"); } if (Date.now() > deadline) { throw new FirecrawlError(`Crawl timed out after ${maxWaitMs}ms for ${url}`, null, false, "/v2/crawl"); } } } /** Structured extraction from one or more URLs against a JSON schema. */ export async function extractStructured( urls: string[], prompt: string, schema: Record, maxWaitMs = 120_000, ): Promise { type StartResp = { success: boolean; id?: string; data?: unknown }; const start = await fcFetch("/v2/extract", { method: "POST", body: JSON.stringify({ urls: urls.slice(0, 5), prompt, schema }), }); if (start.data && !start.id) return start.data; if (!start.id) throw new FirecrawlError("Extract did not return a job id", null, false, "/v2/extract"); type StatusResp = { status: "processing" | "completed" | "failed" | "cancelled"; data?: unknown }; const deadline = Date.now() + maxWaitMs; for (;;) { await new Promise((r) => setTimeout(r, 4000)); const status = await fcFetch(`/v2/extract/${start.id}`, { method: "GET" }); if (status.status === "completed") return status.data; if (status.status === "failed" || status.status === "cancelled") { throw new FirecrawlError(`Extract ${status.status}`, null, false, "/v2/extract"); } if (Date.now() > deadline) { throw new FirecrawlError(`Extract timed out after ${maxWaitMs}ms`, null, false, "/v2/extract"); } } }