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é)
- Ne jamais crasher sur une page. Une page qui échoue produit un
PageResultavecstatus=failedet une erreur typée. Le crawl continue. - 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.
- 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.
- Déterministe avant probabiliste. Extraction CSS/JSON-LD sans LLM quand c'est possible. Le LLM est un outil, pas une béquille.
- 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.
- Observable. Chaque job, chaque page, chaque retry est tracé. Pas de « ça a échoué on ne sait pas pourquoi ».
- 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 |
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.md3. Modèles de données (Pydantic v2)
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 | NoneRè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 :
- Parse avec selectolax. Si le HTML est irrécupérable, fallback
lxml.html.fromstringavecrecover=True. - Clean brut : supprimer
script,style,noscript,iframe(sauf embed vidéo → lien),svgdécoratifs, élémentshidden/display:none/aria-hidden, commentaires, contrôles de formulaire. - Suppression boilerplate par heuristiques cumulées : tags sémantiques (
nav,footer,aside,headersansh1, 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.6avec ≥ 4 liens). - 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é sionly_main_content=false. - Conversion DOM → MD (
convert.py) : titres normalisés (un seulh1), listes imbriquées,pre/codeaveclanguage-*, liens absolutisés, images (data:ignorées siremove_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. - Post-traitement : lignes vides (>2 → 2), trim, NFC, zero-width, espaces multiples hors code.
- 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,screenshotdé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 deantibot/blocklist.txt, sauf siscreenshotdemandé. - Timeouts : connect 10 s, lecture
timeout_ms, rendertimeout_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 saufignore_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
runningau démarrage →queuedet 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,titlesiinclude_titles,scoresisearch(BM25 URL+title, plafond de collecte ×10 avant tri).
6.3 PDF (processors/pdf/)
- Détection : content-type, magic
%PDF. Extractionpymupdfsort=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. Limitesmax_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[]oupattern(map + glob) →merge.py(dédup entités parmerge_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 <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.
| 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 MDtests/golden/<site>/{input.html, expected.md}(make golden-update), benchmarks vs crawl4ai/Firecrawl →BENCHMARKS.md. ruff check,ruff format --checkbloquants ;mypy --strictobjectif.
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. PM2trawls-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 profilllm).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'
evalserveur —actions.evaluates'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). Remoteorigin= 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
printhors CLI :structlogJSON,job_id/urldans le contexte. - Pas de
except Exception: passdans le chemin chaud. Toute exception attrapée est convertie enErrorInfotypé. - Les options ont des valeurs par défaut sûres ;
verify_ssl=false,respect_robots=false,allow_private_targetssont 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.