# CLAUDE.md — Trawls (www.trawls.dev) > Moteur de crawling, d'extraction et de navigation web, construit from scratch. > Objectif : dépasser crawl4ai et Firecrawl en robustesse, qualité de sortie et autonomie. > Nom : **Trawls** — domaine **www.trawls.dev**. Un chalut (trawl) ratisse le fond et remonte tout : on cartographie et on extrait n'importe quel site. Ce fichier est la source de vérité pour Claude Code. Lis-le intégralement avant toute modification. En cas de doute entre deux approches, choisis celle qui respecte la section « Principes ». **État au 2026-09-05 (v0.1.0)** : phases 1 à 6 livrées et déployées sur MacLustr via `mld` (PM2 `trawls-api` + `trawls-ngrok` → https://www.trawls.dev). Écarts assumés par rapport à la cible ci-dessous, à résorber dans l'ordre : (a) persistance **SQLite + worker embarqué asyncio** au lieu de PostgreSQL + Redis/arq (principe « self-host first », zéro dépendance) — `TRAWLS_DATABASE_URL`/`TRAWLS_REDIS_URL` réservés ; (b) UI en **HTML/JS vanilla** servie par FastAPI (`trawls/web/static/`) au lieu de Next.js (`web/`) ; (c) blobs (screenshots) sur disque local `data/blobs/` au lieu de S3/MinIO ; (d) agent (phase 7) et SDK/benchmarks (phase 8) non commencés. Ne pas casser le contrat API existant en résorbant ces écarts. --- ## 0. Principes (ordre de priorité) 1. **Ne jamais crasher sur une page.** Une page qui échoue produit un `PageResult` avec `status=failed` et une erreur typée. Le crawl continue. 2. **Sortie LLM-ready par défaut.** Markdown propre, sans boilerplate, métadonnées complètes, chunks optionnels. La qualité du MD est le critère n°1 du produit. 3. **Escalade automatique.** HTTP pur d'abord (rapide, 90 % des cas), navigateur seulement si nécessaire, stealth seulement si bloqué. L'utilisateur ne choisit pas le mode sauf s'il le force. 4. **Déterministe avant probabiliste.** Extraction CSS/JSON-LD sans LLM quand c'est possible. Le LLM est un outil, pas une béquille. 5. **Self-host first.** Tout fonctionne sans clé API externe (Ollama pour le LLM local). Le SaaS est une couche au-dessus, jamais une dépendance. 6. **Observable.** Chaque job, chaque page, chaque retry est tracé. Pas de « ça a échoué on ne sait pas pourquoi ». 7. **Async partout, backpressure partout.** Aucun appel bloquant dans le chemin chaud. --- ## 1. Stack technique | Couche | Choix | Raison / contraintes | |---|---|---| | Langage | Python 3.12+, `uv` pour les deps | Typage strict, `mypy --strict` | | HTTP rapide | `curl_cffi` (impersonation TLS Chrome/Safari) | Passe la plupart des fingerprints JA3 | | HTTP fallback | `httpx[http2]` async | Quand curl_cffi est indisponible | | Navigateur | Playwright (Chromium par défaut, Firefox/WebKit optionnels) | Pool de contexts persistants, pas un browser par page | | Stealth | patches maison (webdriver, plugins, canvas noise, WebGL, permissions) | Voir `core/antibot/stealth.py` | | Parsing HTML | `selectolax` (Lexbor) | 10-20x plus rapide que BS4. `lxml` uniquement pour le fallback recover/XPath | | Détection encodage | `charset-normalizer` | Fallback latin-1, jamais d'exception non gérée | | PDF | `pymupdf` (texte, layout, tables) ; OCR : `rapidocr-onnxruntime` (extra `ocr`) | Détection scan = ratio texte/page < 50 | | Markdown | Pipeline maison (`processors/html_to_md/`), pas de `markdownify` | Voir §4 | | Tokenisation | `tiktoken` (cl100k) pour compter les tokens des chunks | | | LLM | Adapters : Anthropic, OpenAI-compatible (couvre Ollama, vLLM, OpenRouter, llm.maclustr.io) | Interface unique `LLMClient` | | Queue | **actuel** : worker asyncio embarqué (`worker/crawl.py`) · **cible** : Redis 7 + `arq` | Simple, retries natifs, cron | | DB | **actuel** : SQLite (aiosqlite, WAL) · **cible** : PostgreSQL 16 (SQLModel/SQLAlchemy 2 async) | Jobs, pages, clés API, usage | | Blobs | **actuel** : disque `data/blobs/` · **cible** : S3-compatible (MinIO en local) | HTML brut, screenshots, PDF sources | | API | FastAPI + Pydantic v2 | OpenAPI générée = contrat du SDK | | Realtime | SSE (progression jobs) ; WebSocket réservé à l'agent console | | | UI | **actuel** : HTML/JS vanilla dense (`trawls/web/static/`) · **cible** : Next.js 15, TypeScript, Tailwind, shadcn/ui, TanStack Query (`web/`) | | | SDK | Python (`trawls`) et TypeScript (`@trawls/sdk`) générés depuis OpenAPI puis polis à la main | phase 8 | | Conteneurs | Docker Compose (api, worker, ollama) | `make up` doit tout lancer | --- ## 2. Arborescence ``` trawls/ ├── trawls/ # package Python │ ├── config.py # Settings pydantic-settings, un seul point d'entrée (préfixe TRAWLS_) │ ├── models/ # Pydantic: ScrapeOptions, PageResult, Job, ErrorInfo… │ ├── core/ │ │ ├── fetcher/ # base (Protocol), http_fast (curl_cffi), browser (pool Playwright), strategy (escalade) │ │ ├── antibot/ # detect, stealth, identity, blocklist.txt (proxy : pool à venir) │ │ ├── scheduler/ # robots, politeness, dedup (normalisation URL + simhash) ; frontier dans worker/crawl.py │ │ ├── resilience/ # retry, breaker, budget │ │ ├── security.py # garde SSRF │ │ └── scrape.py # pipeline d'une page — ne lève jamais │ ├── processors/ │ │ ├── html_to_md/ # clean, readability, convert, tables, citations │ │ ├── pdf/ # extract + tables + OCR + to_md (un module) │ │ ├── structured/ # metadata (head + OpenGraph + JSON-LD + microdata) │ │ ├── chunker/ # by_heading, by_tokens │ │ ├── encoding.py │ │ └── links.py │ ├── extract/ # css, llm, merge │ ├── agent/ # phase 7 — à créer (loop, observe, tools, planner, memory, guards) │ ├── map/ # sitemap + crawl_links + rank (BM25) dans un module │ ├── llm/client.py # interface unique (anthropic + openai_compat) │ ├── api/ # main (routes /v1, SSE, UI, docs), auth │ ├── worker/ # store (SQLite), crawl (moteur de jobs : crawl/batch/extract, EventBus) │ ├── web/static/ # UI : index.html, app.js, style.css │ └── cli.py # trawls scrape|crawl|map|serve|worker|keys ├── tests/unit/ ├── deploy/trawls.manifest.json # manifeste mld (MacLustr) ├── Dockerfile · docker-compose.yml · Makefile · CLAUDE.md ``` --- ## 3. Modèles de données (Pydantic v2) ```python class ScrapeOptions(BaseModel): formats: list[ Literal["markdown", "html", "raw_html", "json", "links", "screenshot", "chunks", "metadata"] ] = ["markdown"] only_main_content: bool = True include_tags: list[str] = [] # CSS, forcer l'inclusion exclude_tags: list[str] = [] wait_for: str | int | None = None # sélecteur CSS ou ms timeout_ms: int = 30_000 mode: Literal["auto", "http", "browser", "stealth"] = "auto" headers: dict[str, str] = {} cookies: list[Cookie] = [] proxy: str | None = None actions: list[BrowserAction] = [] # click/scroll/type/wait/press/evaluate avant extraction location: Location | None = None # pays, langues → headers + timezone remove_base64_images: bool = True chunk: ChunkOptions | None = None extract: ExtractOptions | None = None # css: {champ: CssField} | llm: schema JSON + prompt cache: Literal["use", "bypass", "refresh"] = "use" max_age_s: int = 86_400 citations: bool = False verify_ssl: bool = True respect_robots: bool = True class PageResult(BaseModel): url: str final_url: str status: Literal["ok", "failed", "skipped"] http_status: int | None fetch_mode_used: Literal["http", "browser", "stealth"] | None markdown: str | None html: str | None raw_html: str | None json_data: dict | None # {"data", "errors"} pour extract ; {"jsonld"} pour format json links: list[Link] metadata: PageMetadata chunks: list[Chunk] | None screenshot_url: str | None error: ErrorInfo | None timings: Timings # ttfb, fetch, render, process, total (ms) fetched_at: datetime depth: int from_cache: bool class ErrorInfo(BaseModel): code: ErrorCode message: str retryable: bool attempts: int details: dict | None ``` Règle : aucun `dict` non typé ne sort de l'API. Tout passe par un modèle. --- ## 4. Pipeline HTML → Markdown (le cœur du produit) Étapes, dans l'ordre, chacune testable isolément : 1. **Parse** avec selectolax. Si le HTML est irrécupérable, fallback `lxml.html.fromstring` avec `recover=True`. 2. **Clean brut** : supprimer `script`, `style`, `noscript`, `iframe` (sauf embed vidéo → lien), `svg` décoratifs, éléments `hidden`/`display:none`/`aria-hidden`, commentaires, contrôles de formulaire. 3. **Suppression boilerplate** par heuristiques cumulées : tags sémantiques (`nav`, `footer`, `aside`, `header` sans `h1`, rôles ARIA), regex classes/ids (`cookie|consent|gdpr|banner|popup|modal|newsletter|share|social|sidebar|related|recommend|advert|promo|breadcrumb|comment…`) sauf si le bloc a l'air d'être le contenu, densité de liens (`liens/mots > 0.6` avec ≥ 4 liens). 4. **Scoring contenu principal** (`readability.py`) : score = log(1+n)·n·(1 − densité liens)²·bonus(paragraphes, ponctuation, `article|main|role=main`, classes contenu, titres). Remontée vers le parent tant qu'il n'ajoute pas trop de bruit. Sauté si `only_main_content=false`. 5. **Conversion DOM → MD** (`convert.py`) : titres normalisés (un seul `h1`), listes imbriquées, `pre/code` avec `language-*`, liens absolutisés, images (`data:` ignorées si `remove_base64_images`), tables (`tables.py` : thead absent, rowspan/colspan dupliqués, tables de mise en page aplaties), `dl`, `details/summary`, `figure/figcaption`, vidéo/audio → lien. 6. **Post-traitement** : lignes vides (>2 → 2), trim, NFC, zero-width, espaces multiples hors code. 7. **Citations** (option) : `[texte](url)` → `[texte][n]` + références en fin. Critères de qualité mesurés (tests golden, à constituer) : sur un corpus de 200 pages annotées, le MD doit contenir ≥ 95 % du texte principal attendu et ≤ 5 % de boilerplate. Toute PR touchant `html_to_md/` lance ce benchmark. --- ## 5. Stratégie de fetch et escalade (`core/fetcher/strategy.py`) ``` auto: 1. GET http_fast (curl_cffi, impersonate=chrome, redirections manuelles + re-check SSRF) → si PDF → processors/pdf → si HTML et heuristique "page vide" (texte visible < 200 chars ET scripts / racine SPA vide) → 2 → si antibot.detect() positif (Cloudflare, DataDome, Akamai, PerimeterX, Kasada, Imperva, captchas) → 3 → si 403/429/503 ou erreur TLS → 2 → sinon OK 2. browser (Playwright, images/fonts/media/pubs bloqués, domcontentloaded puis networkidle ≤ 15 s ou `wait_for`, actions) → si antibot.detect() positif → 3 3. stealth (browser + patches JS + identité cohérente + proxy résidentiel si configuré) → si toujours bloqué → ErrorInfo(code=BLOCKED, retryable=False, details.trace=[…]) ``` - Le mode résolu est mémorisé **par host** (cache 1 h, ne descend jamais). `actions`, `wait_for`, `screenshot` démarrent directement en navigateur. - Pool navigateur : N contexts (défaut = CPU, 2..8), recyclés toutes les 50 pages ou 10 min. Un context = une identité cohérente (UA, viewport, timezone, locale, Accept-Language). - Interception réseau : `image`, `font`, `media`, `stylesheet` + domaines de `antibot/blocklist.txt`, sauf si `screenshot` demandé. - Timeouts : connect 10 s, lecture `timeout_ms`, render `timeout_ms`, téléchargement coupé à `max_size_mb` (slowloris → TIMEOUT_TTFB). --- ## 6. Modules fonctionnels ### 6.1 Crawl (`worker/crawl.py`) - Frontier priorisée : BFS par défaut ; `strategy: bfs|dfs|best_first` (score BM25 URL/`search`). Amorçage par sitemaps sauf `ignore_sitemap`. - Options : `max_depth`, `max_pages`, `include_paths[]`, `exclude_paths[]` (globs sur path/URL), `allow_subdomains`, `allow_external_links`, `ignore_sitemap`, `respect_robots` (défaut true), `delay_ms`, `concurrency`, `max_duration_s`. - Dédup : normalisation (host minuscule, fragment, tri des params, retrait `utm_*|fbclid|gclid|ref|mc_*…`), puis simhash du MD (distance ≤ 3 → `skipped`). - Chaque page est persistée dès qu'elle est prête ; SSE `page`/`progress`/`done`. - Reprise : frontier sauvegardée en base toutes les 5 s ; jobs `running` au démarrage → `queued` et repris. ### 6.2 Map (`map/`) - Sources en parallèle : `robots.txt` → sitemaps (récursif, gzip, index) + crawl shallow http depth 2 (concurrence 16). Common Crawl : à venir. - Sortie : URLs dédupliquées avec `sources[]`, `lastmod`, `depth`, `title` si `include_titles`, `score` si `search` (BM25 URL+title, plafond de collecte ×10 avant tri). ### 6.3 PDF (`processors/pdf/`) - Détection : content-type, magic `%PDF`. Extraction `pymupdf` `sort=True`, titres inférés (taille > médiane×1.2 → `##`, ×1.5 → `#`, gras court → `**`), `find_tables()` → MD, scan (< 50 car./page) → OCR rapidocr si installé, lignes coupées rejointes. Limites `max_pages_pdf` (500), `max_size_mb` (50). ### 6.4 Extraction structurée (`extract/`) - **CSS** : `{ champ: { selector, attr: text|href|src|html|…, type: str|int|float|date|url|list|bool, multiple } }`. Coercition typée, erreurs par champ. - **LLM** : JSON Schema + `prompt` → MD tronqué par chunks pertinents (BM25-light sur le prompt) → sortie structurée native (response_format / tool use) → validation JSON Schema légère → ≤ 2 corrections. - **Multi-pages** : `urls[]` ou `pattern` (map + glob) → `merge.py` (dédup entités par `merge_key`). Sync ≤ 5 URLs, sinon job. ### 6.5 Chunker (`processors/chunker/`) - `by_heading` : sections h1-h6, fusion des petites (< `min_tokens`), split des grandes (> `max_tokens`) sur frontières de phrases avec overlap. `by_tokens` : `size_tokens` (512) + `overlap_tokens` (64). Chaque chunk : `index`, `heading_path`, `token_count`, `char_range`, `url`. ### 6.6 Agent — phase 7, non commencé Entrée `{ url, objective, max_steps=30, schema?, allowed_domains?, budget_tokens? }`. Boucle observe (accessibility tree + Set-of-Marks) → décide (LLM → action JSON) → agit (Playwright, vérification post-action) → vérifie (planner, `done` validé contre `schema`). Tools `goto, click, type, scroll, wait, extract, back, done, ask_user`. Garde-fous : pas de soumission de formulaires `password|card|cvv|iban` sans whitelist, domaines autorisés, `max_steps`/budget, journal rejouable. --- ## 7. API (`/v1`, OpenAPI sur `/docs` via Scalar) Auth `Authorization: Bearer ` (ou `X-API-Key`, ou `?api_key=` pour les SSE). Sans `TRAWLS_REQUIRE_AUTH`, l'auth est désactivée ; `TRAWLS_ADMIN_KEY` donne accès à `/keys` et `/requests`. | Méthode | Route | Corps | Réponse | |---|---|---|---| | POST | `/scrape` | `{ url, ...ScrapeOptions }` | `PageResult` (sync) | | POST | `/crawl` | `{ url, crawl: CrawlOptions, scrape: ScrapeOptions, webhook? }` | 202 `{ job_id, status, url }` | | GET | `/crawl/{job_id}` | `?cursor&limit&status` | `JobSummary + pages[] + next_cursor` | | GET | `/crawl/{job_id}/stream` | | SSE : `status`, `page`, `progress`, `done`, `error`, `ping` | | DELETE | `/crawl/{job_id}` | | annulation | | POST | `/map` | `{ url, search?, include_subdomains?, limit?, include_titles?, ignore_sitemap? }` | `{ url, count, urls[], took_ms }` | | POST | `/extract` | `{ urls[] \| pattern, mode: css\|llm, css? \| schema?, prompt?, merge_key?, limit? }` | sync si ≤ 5 URLs `{ job_id, data, per_url[] }`, sinon 202 | | POST | `/batch/scrape` | `{ urls[], concurrency?, ...ScrapeOptions }` | 202 `{ job_id }` | | GET | `/jobs`, `/jobs/{id}`, `/jobs/{id}/stream`, DELETE `/jobs/{id}` | | statut générique | | GET | `/jobs/{id}/export?format=jsonl\|zip\|md` | | export | | GET | `/usage` | | requêtes/crédits par jour | | GET/POST/DELETE | `/keys` | admin | clés argon2, valeur affichée une fois | | GET | `/system`, `/blobs/{name}` | | diagnostic, screenshots | Conventions : erreurs `{ error: { code, message, retryable, details? } }` ; pagination par cursor ; idempotence via `Idempotency-Key` ; webhooks signés HMAC-SHA256 `X-Trawls-Signature` (3 essais, pas de redirection). Santé : `/healthz`, `/readyz` (db, browser, worker), `/metrics` Prometheus. Cycle de vie d'un job : `queued → running → completed | failed | cancelled` (`paused` réservé à l'agent). Résultats conservés `RESULT_TTL_DAYS` (7). --- ## 8. Taxonomie des erreurs (`models.ErrorCode`) | Code | Récupérable | Déclencheur | |---|---|---| | `TIMEOUT_DNS` / `TIMEOUT_CONNECT` / `TIMEOUT_TTFB` / `TIMEOUT_RENDER` | oui | par étape | | `HTTP_4XX` | non (408 → HTTP_5XX, 429 → RATE_LIMITED) | | | `HTTP_5XX` | oui | | | `RATE_LIMITED` | oui (respecter `Retry-After`) | 429 | | `BLOCKED` | non | antibot après escalade complète (`details.trace`) | | `ROBOTS_DISALLOWED` | non | | | `TOO_LARGE` | non | > `max_size_mb` | | `UNSUPPORTED_CONTENT` | non | binaire inconnu | | `PARSE_FAILED` | non | HTML/PDF irrécupérable | | `SSL_ERROR` | non (option `verify_ssl=false`) | | | `SSRF_REFUSED` / `INVALID_URL` | non | garde sécurité | | `CIRCUIT_OPEN` | oui (plus tard) | breaker host ouvert | | `BUDGET_EXCEEDED` | non | job | | `LLM_INVALID_OUTPUT` | oui (≤ 2) | extraction | | `NETWORK` / `INTERNAL` / `CANCELLED` | NETWORK oui | filets | Retry : max 3, backoff `1s × 2^n + jitter(0-500ms)`, uniquement si `retryable`. Breaker : 5 échecs consécutifs sur un host → ouvert 60 s, half-open ensuite. --- ## 9. Tests et qualité - `tests/unit/` : html_to_md (contenu principal, boilerplate, tables rowspan, listes, code, citations, HTML cassé), dedup/simhash, SSRF, détection anti-bot, chunker, extraction CSS, métadonnées. `pytest -q`. - À constituer : `tests/fixtures_server/` (aiohttp : boucles de redirection, HTML tronqué, encodage faux, SPA vide, slowloris, faux challenge Cloudflare, PDF scanné/corrompu, doublons), golden MD `tests/golden//{input.html, expected.md}` (`make golden-update`), benchmarks vs crawl4ai/Firecrawl → `BENCHMARKS.md`. - `ruff check`, `ruff format --check` bloquants ; `mypy --strict` objectif. --- ## 10. Interface web (`trawls/web/static/`) Dark par défaut, fond neutre profond, accent ambre unique, `Inter` + `JetBrains Mono`, densité élevée. Routes servies par FastAPI : `/playground` (URL, options, Run ⌘↵, onglets Markdown/brut/JSON/HTML/Links/Screenshot/Chunks/Metadata, snippets cURL/Python/TS, historique local 20), `/crawls` (formulaire ou JSON brut, table des jobs, détail live SSE, arborescence des URLs, filtre, export ZIP/JSONL/MD), `/map` (liste par tranches, filtre instantané, groupement par path, CSV/JSON, « Crawler la sélection » → batch), `/extract` (builder de schéma ↔ JSON, CSS/LLM, résultat par URL + fusion), `/keys` (système, usage, clés, journal des requêtes), `/docs` (Scalar). Clé API obfusquée en localStorage uniquement. --- ## 11. Déploiement et config - **MacLustr** : `deploy/trawls.manifest.json` → `M1M32:~/dispatch/apps/trawls.json` ; `mld stage ~/Desktop/Projets/apps-web/trawls trawls && mld deploy trawls`. PM2 `trawls-api` (uvicorn, port 8180) + `trawls-ngrok` (`www.trawls.dev`). Hook post_sync : venv uv 3.12 + `pip install -e .` + `playwright install chromium`. - Docker : `docker-compose.yml` (api, worker optionnel, ollama sous profil `llm`). `make up`. - Config env `TRAWLS_*` : `PORT`, `PUBLIC_URL`, `DATA_DIR`, `REQUIRE_AUTH`, `ADMIN_KEY`, `BROWSER_POOL_SIZE`, `MAX_CONCURRENCY_PER_HOST`, `MAX_CONCURRENCY_GLOBAL`, `MAX_SIZE_MB`, `PROXY_URLS`, `LLM_PROVIDER|LLM_BASE_URL|LLM_API_KEY|LLM_MODEL`, `RESULT_TTL_DAYS`, `RATE_LIMIT_PER_MINUTE`, `WORKER_CONCURRENCY`, `ALLOW_PRIVATE_TARGETS` (jamais en prod), `EMBEDDED_WORKER`. - Healthchecks `/healthz`, `/readyz` ; métriques `/metrics`. --- ## 12. Sécurité - SSRF (`core/security.py`) : refus IP privées/loopback/link-local/multicast, schémas non http(s), `localhost`/`.internal`/metadata ; DNS résolu **avant** la requête et re-vérifié à chaque redirection (http et navigateur). - Limites strictes de taille et de temps ; pas d'`eval` serveur — `actions.evaluate` s'exécute uniquement dans le sandbox Playwright. - Clés API hashées argon2, préfixe indexé, affichées une seule fois. Rate limit token-bucket par clé/IP. Quota journalier par clé. - Webhooks : signature HMAC, 3 essais, pas de redirection suivie. --- ## 13. Conventions de développement - Commits conventionnels (`feat`, `fix`, `perf`, `refactor`, `test`, `docs`). Remote `origin` = spbgit (`gitsrv:~/srv/git/trawls.git`). - Une PR = un module ou une fonctionnalité ; description avec « Comment tester ». - Chaque module a une docstring de tête expliquant son rôle et ses invariants. - Pas de `print` hors CLI : `structlog` JSON, `job_id`/`url` dans le contexte. - Pas de `except Exception: pass` dans le chemin chaud. Toute exception attrapée est convertie en `ErrorInfo` typé. - Les options ont des valeurs par défaut sûres ; `verify_ssl=false`, `respect_robots=false`, `allow_private_targets` sont loguées explicitement. --- ## 14. Feuille de route et définition de « terminé » | Phase | Contenu | Terminé quand | État | |---|---|---|---| | 1 | `fetcher` (http + browser + auto), `resilience` | 100 % des fixtures produisent un `PageResult` sans exception | livré (fixtures server à écrire) | | 2 | `html_to_md` complet + golden tests | ≥ 95 % de rappel texte, ≤ 5 % boilerplate sur le corpus golden | livré (corpus golden à constituer) | | 3 | API `/scrape`, `/crawl`, jobs, SSE, CLI | Crawl de 1 000 pages sans fuite mémoire, reprise après kill du worker | livré (reprise via frontier SQLite) | | 4 | `map`, `pdf` (texte + tables + OCR) | Map 5 000 URLs < 10 s ; 20 PDF de référence convertis | livré | | 5 | `extract` CSS + LLM + merge, `chunker` | 10 schémas de référence, 0 JSON invalide en sortie | livré | | 6 | UI web : playground, crawls, map, extract, keys | Parité fonctionnelle Firecrawl | livré (vanilla, Next.js cible) | | 7 | `agent` + console | 10 missions de référence réussies | à faire | | 8 | SDK Python/TS, docs, benchmarks publiés | `pip install trawls`, `BENCHMARKS.md` | à faire | Ne pas commencer une phase tant que la précédente n'est pas « terminée » selon ce tableau.