/** * Search-box.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/firecrawl/src/index.ts * Description: Firecrawl v2 REST adapter (search + scrape) with retries and SSRF guard. */ import { sanitizeText } from "@search-box/shared"; import { assertSafeUrl } from "./url-guard.js"; const BASE = "https://api.firecrawl.dev/v2"; export interface SearchResultItem { url: string; title: string | null; description: string | null; } export interface ScrapeResult { url: string; title: string | null; markdown: string; statusCode: number | null; } export class FirecrawlError extends Error { constructor( message: string, public readonly status: number | null = null, public readonly retryable: boolean = false ) { super(message); this.name = "FirecrawlError"; } } function apiKey(): string { const key = process.env.FIRECRAWL_API_KEY; if (!key) throw new Error("FIRECRAWL_API_KEY is not set"); return key; } async function post(path: string, body: unknown, timeoutMs: number): Promise { const maxAttempts = 3; let lastErr: unknown = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`${BASE}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey()}` }, body: JSON.stringify(body), signal: controller.signal }); if (res.status === 429 || res.status >= 500) { lastErr = new FirecrawlError(`firecrawl ${path} → ${res.status}`, res.status, true); await res.text().catch(() => ""); await sleep(500 * attempt * attempt); continue; } if (!res.ok) { const text = await res.text().catch(() => ""); throw new FirecrawlError(`firecrawl ${path} → ${res.status}: ${text.slice(0, 300)}`, res.status); } return (await res.json()) as T; } catch (err) { if (err instanceof FirecrawlError && !err.retryable) throw err; lastErr = err; if (attempt < maxAttempts) await sleep(500 * attempt * attempt); } finally { clearTimeout(timer); } } throw lastErr instanceof Error ? lastErr : new FirecrawlError(String(lastErr)); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** Web search. `tbs` filters by recency (e.g. "qdr:w" past week, "qdr:y" past year). */ export async function search( query: string, opts: { limit?: number; tbs?: string } = {} ): Promise { interface Resp { success: boolean; data?: { web?: Array<{ url: string; title?: string; description?: string }> }; } const resp = await post( "/search", { query: query.slice(0, 500), limit: Math.min(opts.limit ?? 8, 20), sources: [{ type: "web" }], ...(opts.tbs ? { tbs: opts.tbs } : {}) }, 45_000 ); const web = resp.data?.web ?? []; return web .filter((r) => typeof r.url === "string" && r.url.length > 0) .map((r) => ({ url: r.url, title: r.title ? sanitizeText(r.title) : null, description: r.description ? sanitizeText(r.description) : null })); } /** Scrape a single URL to markdown. */ export async function scrape(rawUrl: string): Promise { const url = assertSafeUrl(rawUrl); interface Resp { success: boolean; data?: { markdown?: string; metadata?: { title?: string; sourceURL?: string; statusCode?: number }; }; } const resp = await post( "/scrape", { url, formats: ["markdown"], onlyMainContent: true, timeout: 60_000, maxAge: 172_800_000 }, 90_000 ); const data = resp.data; if (!resp.success || !data?.markdown) { throw new FirecrawlError(`scrape returned no content for ${url}`); } return { url: data.metadata?.sourceURL ?? url, title: data.metadata?.title ? sanitizeText(data.metadata.title) : null, markdown: sanitizeText(data.markdown), statusCode: data.metadata?.statusCode ?? null }; } export { assertSafeUrl };