# Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Client REST Firecrawl : search, map, scrape. Le respect du robots.txt et le débit sont gérés en amont (voir src/robots.py et src/scraper.py). Ce client reste une couche transport minimale. """ from __future__ import annotations import time from typing import Any, Optional import requests from .util import RateLimiter class FirecrawlClient: # ka4 : fail-fast pour ne pas bloquer le crawl concurrent def __init__(self, api_key: str, base_url: str = "https://api.firecrawl.dev", delay: float = 1.5, timeout: int = 45, retries: int = 2): self.base = base_url.rstrip("/") self.session = requests.Session() self.session.headers.update( {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} ) self.limiter = RateLimiter(delay) self.timeout = timeout self.retries = retries def _post(self, path: str, payload: dict[str, Any], retries: Optional[int] = None) -> dict[str, Any]: url = f"{self.base}{path}" last_err: Optional[Exception] = None for attempt in range(retries or self.retries): self.limiter.wait() try: r = self.session.post(url, json=payload, timeout=self.timeout) if r.status_code == 429: time.sleep(1.5 * (attempt + 1)) continue r.raise_for_status() return r.json() except Exception as e: # noqa: BLE001 last_err = e time.sleep(1.0 * (attempt + 1)) raise RuntimeError(f"Firecrawl {path} a échoué: {last_err}") def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]: data = self._post("/v1/search", {"query": query, "limit": limit}) return data.get("data", []) or [] def map(self, url: str, search: Optional[str] = None, limit: int = 200) -> list[str]: payload: dict[str, Any] = {"url": url, "limit": limit} if search: payload["search"] = search data = self._post("/v1/map", payload) links = data.get("links", []) or [] return [l["url"] if isinstance(l, dict) else l for l in links] def scrape(self, url: str, formats: Optional[list[str]] = None) -> dict[str, Any]: # demande aussi les liens -> alimente la frontière agressive data = self._post("/v1/scrape", {"url": url, "formats": formats or ["markdown", "links"]}) d = data.get("data", {}) or {} return { "markdown": d.get("markdown", "") or "", "metadata": d.get("metadata", {}) or {}, "links": d.get("links", []) or [], "backend": "firecrawl", }