# Crawler on-page maison — design (remplacement de Firecrawl)

Date : 2026-07-02 · Statut : validé (brainstorming) · Origine : QA slice-3 bloquée par
`crawl.elevaseo.app` en HTTP 526 ; décision : remplacer Firecrawl par du Rust maison,
plus léger (option A actée : fetch Node + extraction Rust wasm, zéro nouvelle infra).

## Contexte

Firecrawl n'était plus qu'un fetcher : `scrapeForOnpage(url)` → `{title, description,
language, rawHtml, markdown}` (`src/lib/keywords/firecrawl.ts`). Toute l'analyse est
déjà in-house et déjà en Rust : `wasm.parse_onpage_html` (crate `crates/import-core`,
parser `tl`) extrait images/alt/JSON-LD ; `markdown-analysis.ts` fait
wordCount/outline/cap. Le seam est propre : `crawlOnpage(url, now, scrape)` prend un
`scrape` injectable — on remplace UN symbole.

## Décisions

1. **Fetch côté Node** — nouveau `src/lib/keywords/onpage-fetch.ts` (remplace
   `firecrawl.ts`) : `fetch()` natif, UA réaliste + `Accept-Language`, timeout 15 s
   (`AbortSignal.timeout`), redirects suivis, refus des content-types non-HTML, cap de
   lecture 3 Mo (stream interrompu au-delà), non-200/erreur → `null` (fail-soft
   inchangé : concurrent compté `failed`, le crawl continue). `fetch` injectable pour
   les tests.
2. **Extraction côté Rust (un seul parse)** — `crates/import-core` gagne
   `parse_onpage_document(html) → {title, description, language, markdown, images,
   imagesWithoutAlt, imageAlts, missingAltSrcs, hasJsonLd, jsonLdTypes}` : le même DOM
   `tl` produit les signaux existants + les nouveaux champs. `title` = `<title>`,
   `description` = `meta[name=description]`, `language` = attribut `html[lang]`
   (normalisé en minuscules, sous-tag principal, ex. `fr-FR` → `fr`).
3. **Markdown : walker `tl` maison, PAS de dépendance nouvelle.** Consommateurs réels :
   `wordCountFromMarkdown`, `outlineFromMarkdown` (lignes `#…`), `capMarkdown` (extrait
   8 Ko pour le prompt). Donc le convertisseur émet : `#`-headings (h1-h6, niveaux
   préservés), paragraphes en texte, items de liste `- `, liens `[texte](href)`,
   `**strong**`/`*em*` optionnels, gras du reste ignoré ; `script/style/noscript/svg/
   template/nav/footer` exclus du texte. La fidélité CommonMark n'est PAS un objectif —
   la parité de comptage/outline avec l'existant l'est.
4. **`onpage.ts` simplifié** : `crawlOnpage(url, nowIso, fetchImpl?)` =
   `fetchHtml(url)` → `parse_onpage_document(html)` → mapping pur vers `OnpageFields`
   (`onpageFieldsFromDocument(doc, nowIso)`, unit-testable sans réseau). Le type
   `OnpageScrape` disparaît avec `firecrawl.ts` ; l'injectable passe de `scrape` à
   `fetchHtml`. `markdown-analysis.ts` (cap/wordCount/outline) reste en TS et
   s'applique au markdown produit par le wasm.
5. **`parse_onpage_html` supprimé** — call-sites vérifiés : uniquement `onpage.ts`
   (+ les .d.ts générés). `parse_onpage_document` le remplace intégralement.
6. **Firecrawl : suppression complète** — `firecrawl.ts`, `FIRECRAWL_API_URL/KEY`
   (env.ts), `firecrawlEnabled`, le message « Firecrawl non configuré » et le garde
   dans `crawlSerpForKeywordAction`. Git garde l'historique ; un futur besoin de
   rendering JS se branchera derrière la même interface `scrape`.
7. **Fix du finding QA embarqué** : `crawlSerpForKeywordAction` ne stampe `crawledAt`
   sur le snapshot que si **≥ 1 crawl a réussi** (aujourd'hui : 0 succès / 3 échecs →
   snapshot marqué crawlé, UI mensongère).

## Limites assumées

- Pas de rendu JavaScript : pages full-CSR → markdown maigre (identique au mode
  basique de la Firecrawl self-hosted remplacée).
- Pas de contournement anti-bot : Cloudflare strict → `failed` (inchangé).
- L'egress réseau part du process Next (serverless/VPS selon le déploiement).

## Tests

- **Rust** (`crates/import-core`, `cargo test`) : title/description/lang extraits
  (avec cas manquants → null) ; markdown avec les 6 niveaux de headings préservés ;
  parité des signaux existants (mêmes fixtures que les tests `parse_onpage_html`
  actuels, portées sur la nouvelle fonction) ; HTML pathologique (non fermé, vide,
  binaire déguisé) → pas de panic.
- **TS** : `onpage-fetch.test.ts` avec `fetch` injecté (200 HTML → ok ; non-200 →
  null ; content-type non HTML → null ; > 3 Mo → tronqué ; timeout → null).
  `onpage.test.ts` adapté au nouveau flux. `serp-actions.test.ts` : cas « 0 succès →
  crawledAt non stampé ».
- **Rebuild wasm obligatoire** : `bun run build:wasm` + `bun install` + restart dev
  server (le module est externe, pas HMR'd — gotcha mémoire connu).

## Validation finale (clôture de la QA slice-3)

Sur le keyword `qa-slice3-ssl` (org QA, 3 URLs publiques seedées, `crawledAt` remis à
null) : « Crawler on-page » → les 3 concurrents gagnent wordCount/hnOutline/markdown →
le prompt de l'Overview modal (/keywords/planning) affiche le bloc CONTENU CONCURRENTS.
Sans Firecrawl. Puis suppression du keyword de test.
