SPB Git forge

spb/trawls

Public
3commits 1branches 0releases
456.0 KBsize
maindefault branch
19 days agolast push
Python 76.2% JavaScript 11.3% CSS 6.3% HTML 5.9%
22.3 KB · 323 lines markdown
Rendered Raw Blame History
1# CLAUDE.md — Trawls (www.trawls.dev)23> Moteur de crawling, d'extraction et de navigation web, construit from scratch.4> Objectif : dépasser crawl4ai et Firecrawl en robustesse, qualité de sortie et autonomie.5> Nom : **Trawls** — domaine **www.trawls.dev**. Un chalut (trawl) ratisse le fond et remonte tout : on cartographie et on extrait n'importe quel site.67Ce 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 ».89**É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.1011---1213## 0. Principes (ordre de priorité)14151. **Ne jamais crasher sur une page.** Une page qui échoue produit un `PageResult` avec `status=failed` et une erreur typée. Le crawl continue.162. **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.173. **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.184. **Déterministe avant probabiliste.** Extraction CSS/JSON-LD sans LLM quand c'est possible. Le LLM est un outil, pas une béquille.195. **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.206. **Observable.** Chaque job, chaque page, chaque retry est tracé. Pas de « ça a échoué on ne sait pas pourquoi ».217. **Async partout, backpressure partout.** Aucun appel bloquant dans le chemin chaud.2223---2425## 1. Stack technique2627| Couche | Choix | Raison / contraintes |28|---|---|---|29| Langage | Python 3.12+, `uv` pour les deps | Typage strict, `mypy --strict` |30| HTTP rapide | `curl_cffi` (impersonation TLS Chrome/Safari) | Passe la plupart des fingerprints JA3 |31| HTTP fallback | `httpx[http2]` async | Quand curl_cffi est indisponible |32| Navigateur | Playwright (Chromium par défaut, Firefox/WebKit optionnels) | Pool de contexts persistants, pas un browser par page |33| Stealth | patches maison (webdriver, plugins, canvas noise, WebGL, permissions) | Voir `core/antibot/stealth.py` |34| Parsing HTML | `selectolax` (Lexbor) | 10-20x plus rapide que BS4. `lxml` uniquement pour le fallback recover/XPath |35| Détection encodage | `charset-normalizer` | Fallback latin-1, jamais d'exception non gérée |36| PDF | `pymupdf` (texte, layout, tables) ; OCR : `rapidocr-onnxruntime` (extra `ocr`) | Détection scan = ratio texte/page < 50 |37| Markdown | Pipeline maison (`processors/html_to_md/`), pas de `markdownify` | Voir §4 |38| Tokenisation | `tiktoken` (cl100k) pour compter les tokens des chunks | |39| LLM | Adapters : Anthropic, OpenAI-compatible (couvre Ollama, vLLM, OpenRouter, llm.maclustr.io) | Interface unique `LLMClient` |40| Queue | **actuel** : worker asyncio embarqué (`worker/crawl.py`) · **cible** : Redis 7 + `arq` | Simple, retries natifs, cron |41| DB | **actuel** : SQLite (aiosqlite, WAL) · **cible** : PostgreSQL 16 (SQLModel/SQLAlchemy 2 async) | Jobs, pages, clés API, usage |42| Blobs | **actuel** : disque `data/blobs/` · **cible** : S3-compatible (MinIO en local) | HTML brut, screenshots, PDF sources |43| API | FastAPI + Pydantic v2 | OpenAPI générée = contrat du SDK |44| Realtime | SSE (progression jobs) ; WebSocket réservé à l'agent console | |45| UI | **actuel** : HTML/JS vanilla dense (`trawls/web/static/`) · **cible** : Next.js 15, TypeScript, Tailwind, shadcn/ui, TanStack Query (`web/`) | |46| SDK | Python (`trawls`) et TypeScript (`@trawls/sdk`) générés depuis OpenAPI puis polis à la main | phase 8 |47| Conteneurs | Docker Compose (api, worker, ollama) | `make up` doit tout lancer |4849---5051## 2. Arborescence5253```54trawls/55├── trawls/                     # package Python56│   ├── config.py                # Settings pydantic-settings, un seul point d'entrée (préfixe TRAWLS_)57│   ├── models/                  # Pydantic: ScrapeOptions, PageResult, Job, ErrorInfo…58│   ├── core/59│   │   ├── fetcher/             # base (Protocol), http_fast (curl_cffi), browser (pool Playwright), strategy (escalade)60│   │   ├── antibot/             # detect, stealth, identity, blocklist.txt (proxy : pool à venir)61│   │   ├── scheduler/           # robots, politeness, dedup (normalisation URL + simhash) ; frontier dans worker/crawl.py62│   │   ├── resilience/          # retry, breaker, budget63│   │   ├── security.py          # garde SSRF64│   │   └── scrape.py            # pipeline d'une page — ne lève jamais65│   ├── processors/66│   │   ├── html_to_md/          # clean, readability, convert, tables, citations67│   │   ├── pdf/                 # extract + tables + OCR + to_md (un module)68│   │   ├── structured/          # metadata (head + OpenGraph + JSON-LD + microdata)69│   │   ├── chunker/             # by_heading, by_tokens70│   │   ├── encoding.py71│   │   └── links.py72│   ├── extract/                 # css, llm, merge73│   ├── agent/                   # phase 7 — à créer (loop, observe, tools, planner, memory, guards)74│   ├── map/                     # sitemap + crawl_links + rank (BM25) dans un module75│   ├── llm/client.py            # interface unique (anthropic + openai_compat)76│   ├── api/                     # main (routes /v1, SSE, UI, docs), auth77│   ├── worker/                  # store (SQLite), crawl (moteur de jobs : crawl/batch/extract, EventBus)78│   ├── web/static/              # UI : index.html, app.js, style.css79│   └── cli.py                   # trawls scrape|crawl|map|serve|worker|keys80├── tests/unit/81├── deploy/trawls.manifest.json  # manifeste mld (MacLustr)82├── Dockerfile · docker-compose.yml · Makefile · CLAUDE.md83```8485---8687## 3. Modèles de données (Pydantic v2)8889```python90class ScrapeOptions(BaseModel):91    formats: list[92        Literal["markdown", "html", "raw_html", "json", "links", "screenshot", "chunks", "metadata"]93    ] = ["markdown"]94    only_main_content: bool = True95    include_tags: list[str] = []  # CSS, forcer l'inclusion96    exclude_tags: list[str] = []97    wait_for: str | int | None = None  # sélecteur CSS ou ms98    timeout_ms: int = 30_00099    mode: Literal["auto", "http", "browser", "stealth"] = "auto"100    headers: dict[str, str] = {}101    cookies: list[Cookie] = []102    proxy: str | None = None103    actions: list[BrowserAction] = []  # click/scroll/type/wait/press/evaluate avant extraction104    location: Location | None = None  # pays, langues → headers + timezone105    remove_base64_images: bool = True106    chunk: ChunkOptions | None = None107    extract: ExtractOptions | None = None  # css: {champ: CssField} | llm: schema JSON + prompt108    cache: Literal["use", "bypass", "refresh"] = "use"109    max_age_s: int = 86_400110    citations: bool = False111    verify_ssl: bool = True112    respect_robots: bool = True113114115class PageResult(BaseModel):116    url: str117    final_url: str118    status: Literal["ok", "failed", "skipped"]119    http_status: int | None120    fetch_mode_used: Literal["http", "browser", "stealth"] | None121    markdown: str | None122    html: str | None123    raw_html: str | None124    json_data: dict | None  # {"data", "errors"} pour extract ; {"jsonld"} pour format json125    links: list[Link]126    metadata: PageMetadata127    chunks: list[Chunk] | None128    screenshot_url: str | None129    error: ErrorInfo | None130    timings: Timings  # ttfb, fetch, render, process, total (ms)131    fetched_at: datetime132    depth: int133    from_cache: bool134135136class ErrorInfo(BaseModel):137    code: ErrorCode138    message: str139    retryable: bool140    attempts: int141    details: dict | None142```143144Règle : aucun `dict` non typé ne sort de l'API. Tout passe par un modèle.145146---147148## 4. Pipeline HTML → Markdown (le cœur du produit)149150Étapes, dans l'ordre, chacune testable isolément :1511521. **Parse** avec selectolax. Si le HTML est irrécupérable, fallback `lxml.html.fromstring` avec `recover=True`.1532. **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.1543. **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).1554. **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`.1565. **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.1576. **Post-traitement** : lignes vides (>2 → 2), trim, NFC, zero-width, espaces multiples hors code.1587. **Citations** (option) : `[texte](url)` → `[texte][n]` + références en fin.159160Critè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.161162---163164## 5. Stratégie de fetch et escalade (`core/fetcher/strategy.py`)165166```167auto:168  1. GET http_fast (curl_cffi, impersonate=chrome, redirections manuelles + re-check SSRF)169     → si PDF → processors/pdf170     → si HTML et heuristique "page vide" (texte visible < 200 chars ET scripts / racine SPA vide) → 2171     → si antibot.detect() positif (Cloudflare, DataDome, Akamai, PerimeterX, Kasada, Imperva, captchas) → 3172     → si 403/429/503 ou erreur TLS → 2173     → sinon OK174  2. browser (Playwright, images/fonts/media/pubs bloqués, domcontentloaded puis networkidle ≤ 15 s ou `wait_for`, actions)175     → si antibot.detect() positif → 3176  3. stealth (browser + patches JS + identité cohérente + proxy résidentiel si configuré)177     → si toujours bloqué → ErrorInfo(code=BLOCKED, retryable=False, details.trace=[…])178```179180- Le mode résolu est mémorisé **par host** (cache 1 h, ne descend jamais). `actions`, `wait_for`, `screenshot` démarrent directement en navigateur.181- 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).182- Interception réseau : `image`, `font`, `media`, `stylesheet` + domaines de `antibot/blocklist.txt`, sauf si `screenshot` demandé.183- Timeouts : connect 10 s, lecture `timeout_ms`, render `timeout_ms`, téléchargement coupé à `max_size_mb` (slowloris → TIMEOUT_TTFB).184185---186187## 6. Modules fonctionnels188189### 6.1 Crawl (`worker/crawl.py`)190- Frontier priorisée : BFS par défaut ; `strategy: bfs|dfs|best_first` (score BM25 URL/`search`). Amorçage par sitemaps sauf `ignore_sitemap`.191- 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`.192- Dédup : normalisation (host minuscule, fragment, tri des params, retrait `utm_*|fbclid|gclid|ref|mc_*…`), puis simhash du MD (distance ≤ 3 → `skipped`).193- Chaque page est persistée dès qu'elle est prête ; SSE `page`/`progress`/`done`.194- Reprise : frontier sauvegardée en base toutes les 5 s ; jobs `running` au démarrage → `queued` et repris.195196### 6.2 Map (`map/`)197- Sources en parallèle : `robots.txt` → sitemaps (récursif, gzip, index) + crawl shallow http depth 2 (concurrence 16). Common Crawl : à venir.198- 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).199200### 6.3 PDF (`processors/pdf/`)201- 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).202203### 6.4 Extraction structurée (`extract/`)204- **CSS** : `{ champ: { selector, attr: text|href|src|html|…, type: str|int|float|date|url|list|bool, multiple } }`. Coercition typée, erreurs par champ.205- **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.206- **Multi-pages** : `urls[]` ou `pattern` (map + glob) → `merge.py` (dédup entités par `merge_key`). Sync ≤ 5 URLs, sinon job.207208### 6.5 Chunker (`processors/chunker/`)209- `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`.210211### 6.6 Agent — phase 7, non commencé212Entré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.213214---215216## 7. API (`/v1`, OpenAPI sur `/docs` via Scalar)217218Auth `Authorization: Bearer <clé>` (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`.219220| Méthode | Route | Corps | Réponse |221|---|---|---|---|222| POST | `/scrape` | `{ url, ...ScrapeOptions }` | `PageResult` (sync) |223| POST | `/crawl` | `{ url, crawl: CrawlOptions, scrape: ScrapeOptions, webhook? }` | 202 `{ job_id, status, url }` |224| GET | `/crawl/{job_id}` | `?cursor&limit&status` | `JobSummary + pages[] + next_cursor` |225| GET | `/crawl/{job_id}/stream` | | SSE : `status`, `page`, `progress`, `done`, `error`, `ping` |226| DELETE | `/crawl/{job_id}` | | annulation |227| POST | `/map` | `{ url, search?, include_subdomains?, limit?, include_titles?, ignore_sitemap? }` | `{ url, count, urls[], took_ms }` |228| POST | `/extract` | `{ urls[] \| pattern, mode: css\|llm, css? \| schema?, prompt?, merge_key?, limit? }` | sync si ≤ 5 URLs `{ job_id, data, per_url[] }`, sinon 202 |229| POST | `/batch/scrape` | `{ urls[], concurrency?, ...ScrapeOptions }` | 202 `{ job_id }` |230| GET | `/jobs`, `/jobs/{id}`, `/jobs/{id}/stream`, DELETE `/jobs/{id}` | | statut générique |231| GET | `/jobs/{id}/export?format=jsonl\|zip\|md` | | export |232| GET | `/usage` | | requêtes/crédits par jour |233| GET/POST/DELETE | `/keys` | admin | clés argon2, valeur affichée une fois |234| GET | `/system`, `/blobs/{name}` | | diagnostic, screenshots |235236Conventions : 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.237238Cycle de vie d'un job : `queued → running → completed | failed | cancelled` (`paused` réservé à l'agent). Résultats conservés `RESULT_TTL_DAYS` (7).239240---241242## 8. Taxonomie des erreurs (`models.ErrorCode`)243244| Code | Récupérable | Déclencheur |245|---|---|---|246| `TIMEOUT_DNS` / `TIMEOUT_CONNECT` / `TIMEOUT_TTFB` / `TIMEOUT_RENDER` | oui | par étape |247| `HTTP_4XX` | non (408 → HTTP_5XX, 429 → RATE_LIMITED) | |248| `HTTP_5XX` | oui | |249| `RATE_LIMITED` | oui (respecter `Retry-After`) | 429 |250| `BLOCKED` | non | antibot après escalade complète (`details.trace`) |251| `ROBOTS_DISALLOWED` | non | |252| `TOO_LARGE` | non | > `max_size_mb` |253| `UNSUPPORTED_CONTENT` | non | binaire inconnu |254| `PARSE_FAILED` | non | HTML/PDF irrécupérable |255| `SSL_ERROR` | non (option `verify_ssl=false`) | |256| `SSRF_REFUSED` / `INVALID_URL` | non | garde sécurité |257| `CIRCUIT_OPEN` | oui (plus tard) | breaker host ouvert |258| `BUDGET_EXCEEDED` | non | job |259| `LLM_INVALID_OUTPUT` | oui (≤ 2) | extraction |260| `NETWORK` / `INTERNAL` / `CANCELLED` | NETWORK oui | filets |261262Retry : 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.263264---265266## 9. Tests et qualité267268- `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`.269- À 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/<site>/{input.html, expected.md}` (`make golden-update`), benchmarks vs crawl4ai/Firecrawl → `BENCHMARKS.md`.270- `ruff check`, `ruff format --check` bloquants ; `mypy --strict` objectif.271272---273274## 10. Interface web (`trawls/web/static/`)275276Dark 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.277278---279280## 11. Déploiement et config281282- **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`.283- Docker : `docker-compose.yml` (api, worker optionnel, ollama sous profil `llm`). `make up`.284- 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`.285- Healthchecks `/healthz`, `/readyz` ; métriques `/metrics`.286287---288289## 12. Sécurité290291- 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).292- Limites strictes de taille et de temps ; pas d'`eval` serveur — `actions.evaluate` s'exécute uniquement dans le sandbox Playwright.293- 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é.294- Webhooks : signature HMAC, 3 essais, pas de redirection suivie.295296---297298## 13. Conventions de développement299300- Commits conventionnels (`feat`, `fix`, `perf`, `refactor`, `test`, `docs`). Remote `origin` = spbgit (`gitsrv:~/srv/git/trawls.git`).301- Une PR = un module ou une fonctionnalité ; description avec « Comment tester ».302- Chaque module a une docstring de tête expliquant son rôle et ses invariants.303- Pas de `print` hors CLI : `structlog` JSON, `job_id`/`url` dans le contexte.304- Pas de `except Exception: pass` dans le chemin chaud. Toute exception attrapée est convertie en `ErrorInfo` typé.305- Les options ont des valeurs par défaut sûres ; `verify_ssl=false`, `respect_robots=false`, `allow_private_targets` sont loguées explicitement.306307---308309## 14. Feuille de route et définition de « terminé »310311| Phase | Contenu | Terminé quand | État |312|---|---|---|---|313| 1 | `fetcher` (http + browser + auto), `resilience` | 100 % des fixtures produisent un `PageResult` sans exception | livré (fixtures server à écrire) |314| 2 | `html_to_md` complet + golden tests | ≥ 95 % de rappel texte, ≤ 5 % boilerplate sur le corpus golden | livré (corpus golden à constituer) |315| 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) |316| 4 | `map`, `pdf` (texte + tables + OCR) | Map 5 000 URLs < 10 s ; 20 PDF de référence convertis | livré |317| 5 | `extract` CSS + LLM + merge, `chunker` | 10 schémas de référence, 0 JSON invalide en sortie | livré |318| 6 | UI web : playground, crawls, map, extract, keys | Parité fonctionnelle Firecrawl | livré (vanilla, Next.js cible) |319| 7 | `agent` + console | 10 missions de référence réussies | à faire |320| 8 | SDK Python/TS, docs, benchmarks publiés | `pip install trawls`, `BENCHMARKS.md` | à faire |321322Ne pas commencer une phase tant que la précédente n'est pas « terminée » selon ce tableau.323