// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Client Firecrawl v2 (scrape / search / map) : timeouts, réessais, rythme par * domaine, hachage du contenu. Clé côté serveur uniquement (FIRECRAWL_API_KEY). * Aucun appel n'est fait depuis le chemin utilisateur. */ import { createHash } from "crypto"; const API = "https://api.firecrawl.dev/v2"; export interface ScrapeResult { markdown: string; metadata: Record; statusCode: number | null; links: string[]; } export interface SearchHit { url: string; title: string; description: string; } export class FirecrawlError extends Error { constructor(message: string, public readonly status: number | null, public readonly url: string) { super(message); this.name = "FirecrawlError"; } } /* --------------------------------------------------------- rythme par domaine */ const lastCall = new Map(); const inflight = new Map(); function domainOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return "unknown"; } } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** Attend le créneau libre pour un domaine (rythme minimal + concurrence). */ export async function throttle(domain: string, minGapMs: number, concurrency: number): Promise<() => void> { for (;;) { const now = Date.now(); const last = lastCall.get(domain) ?? 0; const busy = inflight.get(domain) ?? 0; if (busy < concurrency && now - last >= minGapMs) { lastCall.set(domain, now); inflight.set(domain, busy + 1); return () => inflight.set(domain, Math.max(0, (inflight.get(domain) ?? 1) - 1)); } await sleep(Math.max(50, minGapMs - (now - last))); } } /* ------------------------------------------------------------------ client */ export function firecrawlKey(): string { const k = process.env.FIRECRAWL_API_KEY; if (!k) throw new FirecrawlError("FIRECRAWL_API_KEY manquante (serveur)", null, API); return k; } async function post(path: string, body: unknown, timeoutMs: number, retries: number): Promise { let lastErr: unknown = null; for (let attempt = 0; attempt <= retries; attempt++) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(`${API}${path}`, { method: "POST", headers: { Authorization: `Bearer ${firecrawlKey()}`, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal }); const json = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string; data?: T }; if (res.status === 429 || res.status >= 500) throw new FirecrawlError(`HTTP ${res.status} ${json.error ?? ""}`.trim(), res.status, path); if (!res.ok || json.success === false) throw new FirecrawlError(json.error ?? `HTTP ${res.status}`, res.status, path); return json.data as T; } catch (e) { lastErr = e; const status = e instanceof FirecrawlError ? e.status : null; const retriable = status == null || status === 429 || status >= 500; if (!retriable || attempt === retries) break; await sleep(1500 * 2 ** attempt); } finally { clearTimeout(t); } } if (lastErr instanceof Error && lastErr.name === "AbortError") throw new FirecrawlError("délai dépassé", null, path); throw lastErr instanceof Error ? lastErr : new FirecrawlError(String(lastErr), null, path); } export interface ScrapeOptions { timeoutMs?: number; retries?: number; rateLimitMs?: number; concurrency?: number; links?: boolean } export async function scrape(url: string, o: ScrapeOptions = {}): Promise { const release = await throttle(domainOf(url), o.rateLimitMs ?? 1000, o.concurrency ?? 2); try { const formats = o.links ? ["markdown", "links"] : ["markdown"]; const data = await post<{ markdown?: string; metadata?: Record; links?: string[] }>("/scrape", { url, formats, onlyMainContent: true }, o.timeoutMs ?? 90_000, o.retries ?? 2); const md = data?.metadata ?? {}; const sc = typeof md.statusCode === "number" ? md.statusCode : null; return { markdown: data?.markdown ?? "", metadata: md, statusCode: sc, links: data?.links ?? [] }; } finally { release(); } } export async function search(query: string, limit = 5, o: ScrapeOptions = {}): Promise { const release = await throttle("api.firecrawl.dev/search", o.rateLimitMs ?? 1000, o.concurrency ?? 2); try { const data = await post<{ web?: { url: string; title?: string; description?: string }[] }>("/search", { query, limit }, o.timeoutMs ?? 60_000, o.retries ?? 2); return (data?.web ?? []).map((h) => ({ url: h.url, title: h.title ?? "", description: h.description ?? "" })); } finally { release(); } } export async function mapSite(url: string, searchTerm?: string, limit = 50, o: ScrapeOptions = {}): Promise { const release = await throttle(domainOf(url), o.rateLimitMs ?? 1000, o.concurrency ?? 1); try { const data = await post<{ links?: { url: string }[] | string[] }>("/map", { url, search: searchTerm, limit }, o.timeoutMs ?? 60_000, o.retries ?? 1); return (data?.links ?? []).map((l) => (typeof l === "string" ? l : l.url)); } finally { release(); } } /** Hachage stable du contenu utile (markdown normalisé) pour la détection de changement. */ export function contentHash(markdown: string, extra: Record = {}): string { const norm = markdown.replace(/\s+/g, " ").trim(); return createHash("sha256").update(norm).update(JSON.stringify(extra)).digest("hex"); } /** Parse « 5,98 $ », « $6.98 », « 1 234,50 » → nombre ; null si impossible. */ export function parseMoney(s: unknown): number | null { if (typeof s === "number") return Number.isFinite(s) ? s : null; if (typeof s !== "string") return null; const m = s.replace(/ /g, " ").match(/-?\d[\d\s]*(?:[.,]\d+)?/); if (!m) return null; const v = Number(m[0].replace(/\s/g, "").replace(",", ".")); return Number.isFinite(v) ? v : null; }