# Rust on-page crawler — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Remplacer Firecrawl par un crawler maison : `fetch()` Node + extraction Rust wasm (`parse_onpage_document` dans `crates/import-core`), suppression complète de Firecrawl, et fix « crawledAt seulement si ≥ 1 succès ».

**Architecture:** Voir la spec `docs/superpowers/specs/2026-07-02-rust-onpage-crawler-design.md`. Un seul parse `tl` produit signaux on-page + title/description/lang + markdown (walker maison — parité wordCount/outline, pas fidélité CommonMark). Le seam `crawlOnpage(url, now, injectable)` est conservé, l'injectable devient `fetchHtml`.

**Tech Stack:** Rust (crate `tl` 0.7 déjà présent, wasm-bindgen, AUCUNE dépendance nouvelle), wasm-pack (`bun run build:wasm`), Node `fetch`, bun test, cargo test.

## Global Constraints

- Branche : `feature/rust-onpage-crawler` (existante, spec committée). JAMAIS main.
- Conventional Commits. Gate par tâche : `bunx tsc --noEmit` 0 erreur, `bun run lint` 0 erreur, tests de la tâche verts.
- AUCUNE dépendance Cargo nouvelle (le walker markdown se fait sur le DOM `tl` existant).
- Après tout changement du crate : `bun run build:wasm` PUIS `bun install` (module externe, non HMR'd) ; le build output est COMMITTÉ (`src/lib/backlink/import-core/`).
- Tests TS via `bun run test <path>` (env DB_TESTS/--conditions=react-server géré par le script). Tests Rust via `cargo test -p import-core`.
- Le contrat de sortie wasm est camelCase (`serde(rename_all = "camelCase")`) — comme `OnpageHtml` existant.

---

### Task 1: Rust — `parse_onpage_document` dans import-core

**Files:**
- Modify: `crates/import-core/src/onpage.rs` (nouvelle struct + fonction + walker markdown + tests)
- Modify: `crates/import-core/src/lib.rs` (export wasm_bindgen `parse_onpage_document`; SUPPRIMER l'export `parse_onpage_html`)
- Regenerate (committé): `src/lib/backlink/import-core/*` via `bun run build:wasm` + `bun install`

**Interfaces:**
- Consumes: `parse_onpage_html` existant (sa logique images/alt/JSON-LD est RÉUTILISÉE telle quelle, déplacée/absorbée).
- Produces (contrat exact pour Task 2, JSON camelCase via serde) :
  ```rust
  #[derive(Debug, Clone, PartialEq, Serialize)]
  #[serde(rename_all = "camelCase")]
  pub struct OnpageDocument {
      pub title: Option<String>,            // <title>, trim, None si vide/absent
      pub description: Option<String>,      // meta[name=description] content, trim
      pub language: Option<String>,         // html[lang] → minuscules, sous-tag principal ("fr-FR"→"fr")
      pub markdown: Option<String>,         // walker tl ; None si document sans contenu texte
      pub images: i64,
      pub images_without_alt: i64,
      pub image_alts: Vec<ImageAlt>,
      pub missing_alt_srcs: Vec<String>,
      pub has_json_ld: bool,
      pub json_ld_types: Vec<String>,
  }
  pub fn parse_onpage_document(html: &str) -> OnpageDocument
  ```
  Côté TS (`import * as wasm from "import-core"`) : `wasm.parse_onpage_document(html)` rend l'objet camelCase `{title, description, language, markdown, images, imagesWithoutAlt, imageAlts, missingAltSrcs, hasJsonLd, jsonLdTypes}`.

**Règles du walker markdown (parcours du body en ordre de document) :**
| HTML | Markdown émis |
|---|---|
| `h1`..`h6` | `#`×niveau + espace + texte, ligne isolée |
| `p`, `div` feuille avec texte | paragraphe (ligne + ligne vide) |
| `li` | `- texte` |
| `a href` | `[texte](href)` (dans le flux du texte) |
| `br` | saut de ligne |
| `script`, `style`, `noscript`, `svg`, `template`, `head` (hors title/meta), commentaires | EXCLUS |
| tout le reste (span, strong…) | texte brut aplati |
Espaces : séquences de blancs → un espace ; entités HTML décodées par `tl` ; lignes vides multiples compressées à une.

- [ ] **Step 1: Écrire les tests Rust (failing)** — dans `onpage.rs` `#[cfg(test)]`, en PLUS des tests existants (qui doivent être portés sur la nouvelle fonction s'ils visaient `parse_onpage_html`) :

```rust
#[test]
fn document_extracts_metadata() {
    let html = r#"<html lang="fr-FR"><head>
      <title> Certificat SSL — guide </title>
      <meta name="description" content=" Tout savoir sur TLS. ">
      </head><body><p>x</p></body></html>"#;
    let d = parse_onpage_document(html);
    assert_eq!(d.title.as_deref(), Some("Certificat SSL — guide"));
    assert_eq!(d.description.as_deref(), Some("Tout savoir sur TLS."));
    assert_eq!(d.language.as_deref(), Some("fr"));
}

#[test]
fn document_metadata_absent_is_none() {
    let d = parse_onpage_document("<html><body><p>x</p></body></html>");
    assert_eq!(d.title, None);
    assert_eq!(d.description, None);
    assert_eq!(d.language, None);
}

#[test]
fn markdown_preserves_heading_levels_in_order() {
    let html = r#"<body><h1>Un</h1><p>intro</p><h2>Deux</h2>
      <ul><li>a</li><li>b</li></ul><h3>Trois</h3></body>"#;
    let md = parse_onpage_document(html).markdown.unwrap();
    let lines: Vec<&str> = md.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(lines[0], "# Un");
    assert_eq!(lines[1], "intro");
    assert_eq!(lines[2], "## Deux");
    assert_eq!(lines[3], "- a");
    assert_eq!(lines[4], "- b");
    assert_eq!(lines[5], "### Trois");
}

#[test]
fn markdown_links_and_excluded_tags() {
    let html = r#"<body><script>var x=1;</script><style>.a{}</style>
      <p>Voir <a href="https://ex.dev/tls">le guide TLS</a> ici.</p></body>"#;
    let md = parse_onpage_document(html).markdown.unwrap();
    assert!(md.contains("[le guide TLS](https://ex.dev/tls)"));
    assert!(!md.contains("var x"));
    assert!(!md.contains(".a{}"));
}

#[test]
fn document_keeps_existing_onpage_signals() {
    // même fixture que les tests images/JSON-LD existants — mêmes attendus,
    // portés sur parse_onpage_document (images, image_alts, has_json_ld, …).
    let html = r#"<body>
      <img src="/a.png" alt="logo alpha">
      <img src="/b.png" alt="">
      <script type="application/ld+json">{"@type":"Article"}</script>
    </body>"#;
    let d = parse_onpage_document(html);
    assert_eq!(d.images, 2);
    assert_eq!(d.images_without_alt, 1);
    assert!(d.has_json_ld);
    assert_eq!(d.json_ld_types, vec!["Article".to_string()]);
}

#[test]
fn pathological_html_never_panics() {
    for h in ["", "<div><p>never closed", "\u{0}\u{1}binary", "<html>"] {
        let _ = parse_onpage_document(h);
    }
}
```

- [ ] **Step 2: Run** `cargo test -p import-core` → les nouveaux tests FAIL (fonction absente).

- [ ] **Step 3: Implémenter** `parse_onpage_document` : parse `tl` UNE fois ; réutiliser la logique existante images/alt/JSON-LD (déplacer le corps de `parse_onpage_html` dedans, ou factoriser) ; extraire title/meta-description/html-lang via `query_selector` ; walker markdown récursif sur le body selon la table ci-dessus. Supprimer `parse_onpage_html` (plus aucun call-site après Task 2 — l'export lib.rs est retiré dans CE commit, la compile TS ne casse pas car le .d.ts régénéré et onpage.ts sont mis à jour en Task 2 ; d'ici là seul le crate est committé… NON : le build wasm régénéré casserait `onpage.ts`. ORDRE IMPOSÉ : dans CE task, garder `parse_onpage_html` exporté (deprecated) ; sa suppression se fait en Task 2 avec le call-site, suivie d'un re-build wasm).

- [ ] **Step 4: Run** `cargo test -p import-core` → PASS (nouveaux + anciens).

- [ ] **Step 5: Rebuild wasm + vérifier le contrat TS**

```bash
bun run build:wasm && bun install
grep -n "parse_onpage_document" src/lib/backlink/import-core/import_core.d.ts
```

Expected : la fonction apparaît dans le .d.ts. `bunx tsc --noEmit` → 0 erreur.

- [ ] **Step 6: Gate + commit**

```bash
bunx tsc --noEmit && bun run lint && cargo test -p import-core
git add crates/import-core/ src/lib/backlink/import-core/ bun.lock
git commit -m "feat(import-core): parse_onpage_document — metadata + markdown walker (tl, zero new deps)"
```

---

### Task 2: TS — fetch maison, câblage, suppression Firecrawl, fix crawledAt

**Files:**
- Create: `src/lib/keywords/onpage-fetch.ts` + `src/lib/keywords/onpage-fetch.test.ts`
- Modify: `src/lib/keywords/onpage.ts` + `src/lib/keywords/onpage.test.ts`
- Delete: `src/lib/keywords/firecrawl.ts`
- Modify: `src/env.ts` (retirer FIRECRAWL_API_URL/KEY + firecrawlEnabled)
- Modify: `src/lib/keywords/serp-actions.ts` (+ `serp-actions.test.ts`)
- Modify: `crates/import-core/src/lib.rs` (retirer l'export `parse_onpage_html`) + rebuild wasm committé

**Interfaces:**
- Consumes (Task 1): `wasm.parse_onpage_document(html)` → objet camelCase (contrat en Task 1).
- Produces :
  ```ts
  // onpage-fetch.ts
  export type FetchHtml = (url: string) => Promise<string | null>;
  export const fetchHtml: FetchHtml; // fetch natif, voir règles ci-dessous
  // onpage.ts (signature quasi inchangée)
  export function onpageFieldsFromDocument(doc: OnpageDoc, crawledAt: string): OnpageFields;
  export async function crawlOnpage(url: string, nowIso: string, fetcher?: FetchHtml): Promise<OnpageFields | null>;
  ```

**Règles de `fetchHtml`** : `fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow", headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "fr,en;q=0.8" } })` ; `!res.ok` → null ; content-type sans `text/html` ni `xhtml` → null ; lecture par reader avec cap 3 * 1024 * 1024 octets (au-delà : `reader.cancel()` et on garde ce qui est lu) ; toute exception → null.

- [ ] **Step 1: Test de `onpage-fetch` (failing)**

```ts
// src/lib/keywords/onpage-fetch.test.ts
import { describe, expect, it } from "bun:test";
import { fetchHtmlWith } from "./onpage-fetch";

function fakeResponse(body: string, init: { status?: number; type?: string } = {}) {
  return new Response(body, {
    status: init.status ?? 200,
    headers: { "content-type": init.type ?? "text/html; charset=utf-8" },
  });
}

describe("fetchHtml", () => {
  it("200 HTML → texte", async () => {
    const f = fetchHtmlWith(async () => fakeResponse("<html>ok</html>"));
    expect(await f("https://ex.dev/")).toBe("<html>ok</html>");
  });
  it("non-200 → null", async () => {
    const f = fetchHtmlWith(async () => fakeResponse("nope", { status: 403 }));
    expect(await f("https://ex.dev/")).toBeNull();
  });
  it("content-type non HTML → null", async () => {
    const f = fetchHtmlWith(async () => fakeResponse("{}", { type: "application/json" }));
    expect(await f("https://ex.dev/")).toBeNull();
  });
  it("exception fetch → null", async () => {
    const f = fetchHtmlWith(async () => { throw new Error("boom"); });
    expect(await f("https://ex.dev/")).toBeNull();
  });
  it("corps > cap → tronqué sans throw", async () => {
    const big = "x".repeat(4 * 1024 * 1024);
    const f = fetchHtmlWith(async () => fakeResponse(big));
    const out = await f("https://ex.dev/");
    expect(out).not.toBeNull();
    expect((out as string).length).toBeLessThanOrEqual(3 * 1024 * 1024);
  });
});
```

Structure d'implémentation induite : `export function fetchHtmlWith(f: typeof fetch): FetchHtml` (testable) + `export const fetchHtml = fetchHtmlWith(fetch)`.

- [ ] **Step 2: Run** `bun run test src/lib/keywords/onpage-fetch.test.ts` → FAIL. **Step 3:** implémenter selon les règles. **Step 4:** PASS (5 tests).

- [ ] **Step 5: Rework `onpage.ts`** — `onpageFieldsFromScrape` devient `onpageFieldsFromDocument(doc, crawledAt)` : mapping direct du retour wasm (title/description→metaDescription/language→lang + signaux + markdown → `capMarkdown`/`wordCountFromMarkdown`/`outlineFromMarkdown` inchangés, caps 50 conservés). `crawlOnpage(url, nowIso, fetcher = fetchHtml)` : `const html = await fetcher(url); if (!html) return null; return onpageFieldsFromDocument(wasm.parse_onpage_document(html), nowIso);`. Adapter `onpage.test.ts` (fixtures HTML directes au lieu d'OnpageScrape ; garder les mêmes assertions de champs).

- [ ] **Step 6: Suppression Firecrawl** — `git rm src/lib/keywords/firecrawl.ts` ; dans `env.ts` retirer les 2 vars + `firecrawlEnabled` ; dans `serp-actions.ts` retirer le garde `if (!env.firecrawlEnabled)` et l'import env devenu inutile.

- [ ] **Step 7: Fix crawledAt-si-succès** — dans `crawlSerpForKeywordAction`, ne poser `crawledAt: now` sur le snapshot QUE si `competitors.length - failed > 0` (sinon conserver l'ancien `snap.crawledAt`), et retourner `ok: failed < competitors.length` (0 succès → `{ok:false, error:"Crawl impossible (0 page récupérée)"}` sans écrire en DB). Ajouter le cas dans `serp-actions.test.ts` : fetcher/crawl mocké tout-échec → snapshot inchangé en DB + ok:false. (Regarder comment le test existant mocke ; le crawl est injectable via `crawlOnpage` importé — si le test mocke au niveau module, suivre son pattern existant.)

- [ ] **Step 8: Retirer `parse_onpage_html`** — supprimer l'export dans `crates/import-core/src/lib.rs` (et la fonction si plus utilisée en interne), `bun run build:wasm && bun install`, vérifier `grep -rn "parse_onpage_html" src/` → seul le .d.ts régénéré n'en parle plus (0 hit).

- [ ] **Step 9: Gate + commit**

```bash
cargo test -p import-core && bunx tsc --noEmit && bun run lint && bun run test src/lib/keywords/
git add -A
git commit -m "feat(keywords): in-house on-page crawler — Node fetch + wasm extraction, drop Firecrawl"
```

---

### Task 3: Gate final + QA live (contrôleur)

- [ ] `bun run test:all` 0 fail ; tsc ; lint.
- [ ] Restart du dev server d3k (wasm externe non HMR'd).
- [ ] QA slice-3 (clôture) : « Crawler on-page » sur `qa-slice3-ssl` → 3 concurrents avec wordCount/hnOutline en DB → prompt Overview modal affiche CONTENU CONCURRENTS. Screenshot.
- [ ] ff-merge dev, push, suppression branche ; mémoire (todo-competitor-crawl-qa soldé, note remplacement Firecrawl) ; suppression du keyword de test.
