SPB Git forge

spb/trawls

Public
3commits 1branches 0releases
456.0 KBsize
maindefault branch
18 days agolast push
Python 76.2% JavaScript 11.3% CSS 6.3% HTML 5.9%
22.3 KB

# 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-ngrokhttps://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

text
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)

text
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 <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 MD tests/golden/<site>/{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.jsonM1M32:~/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.