# Crawl on-page via Firecrawl self-hosted — 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 le fetch direct des pages concurrentes (crawl on-page + corpus sémantique) par l'instance Firecrawl self-hosted (`crawl.elevaseo.app`), en asynchrone via sa queue BullMQ, avec taxonomie d'échec par page.

**Architecture:** Nouveau job `crawl-onpage` dans la queue `analytics_jobs` (pattern identique à `serp-semantics`) ; le worker soumet un `/v2/batch-scrape` à Firecrawl et poll son statut ; le parseur WASM `parse_onpage_document` reste l'unique autorité ; le runner sémantique bascule son `fetchHtml` injecté sur Firecrawl (le rendu Chrome local devient inutile sur ce chemin quand Firecrawl est configuré). Spec : `docs/superpowers/specs/2026-07-22-firecrawl-onpage-crawl-design.md`.

**Tech Stack:** Bun + Hono (services/analytics), Drizzle/Postgres (queue), WASM `import-core`, Firecrawl v2 API (self-hosted, auth header via Traefik), vitest/bun test.

## Global Constraints

- **Jamais de scrape synchrone en rafale** : un crawl = un `POST /v2/batch-scrape` (la queue BullMQ de Firecrawl absorbe la charge multi-clients). Le fetch sémantique par URL passe par `/v2/scrape` (chaque requête est elle-même queueée par Firecrawl) et reste borné par le pool du runner (4).
- `formats: ["rawHtml"]`, `onlyMainContent: false`, `maxAge: 0` (toujours frais), timeout 30 s/page.
- **Aucun fallback fetch direct** quand Firecrawl est configuré. Env absentes (`FIRECRAWL_API_URL`/`FIRECRAWL_API_KEY`) : le job `crawl-onpage` échoue avec un message clair ; le chemin sémantique retombe sur le fetch legacy (dev local sans instance).
- `crawlStatus` est **additif** : `"ok" | "blocked" | "timeout" | "empty" | "fetch_error"` (+ `crawlHttpStatus?: number`) — les snapshots existants sans ce champ gardent leur comportement.
- Libellés UI en français. Conventional Commits. Chaque tâche : tsc + biome + tests verts avant commit.
- Git flow : tout sur la branche `feature/firecrawl-onpage` créée depuis `dev` ; merge ff-only en fin de plan.

---

### Task 1: Client Firecrawl du service analytics

**Files:**
- Create: `services/analytics/src/firecrawl.ts`
- Create: `services/analytics/test/firecrawl.test.ts`
- Modify: `services/analytics/src/env.ts` (ajout de 2 clés optionnelles dans le schéma zod existant)

**Interfaces:**
- Consumes: `env` du service (pattern des clés optionnelles existantes, cf. `R2_ACCOUNT_ID`).
- Produces (consommé par Tasks 3 et 4) :

```ts
export type FirecrawlPageStatus = "ok" | "blocked" | "timeout" | "empty" | "fetch_error";
export type FirecrawlPage = {
  url: string;
  html: string | null;
  status: FirecrawlPageStatus;
  httpStatus?: number;
};
export type BatchState =
  | { status: "scraping"; completed: number; total: number }
  | { status: "completed"; pages: FirecrawlPage[] }
  | { status: "failed"; error: string };

export function firecrawlConfigured(): boolean;
export async function startBatchScrape(urls: string[]): Promise<{ jobId: string }>;
export async function getBatchScrape(jobId: string): Promise<BatchState>;
export async function scrapeOne(url: string): Promise<FirecrawlPage>; // pour le fetch sémantique (Task 4)
```

- [ ] **Step 1: env** — dans `services/analytics/src/env.ts`, ajouter au schéma zod (à côté de `CHROME_PATH`) :

```ts
FIRECRAWL_API_URL: z.string().optional(),
FIRECRAWL_API_KEY: z.string().optional(),
```

- [ ] **Step 2: test qui échoue** — `services/analytics/test/firecrawl.test.ts` (bun:test, DI du `fetch` comme `src/lib/semantic/fetch.ts`) :

```ts
import { describe, expect, test } from "bun:test";
import { classifyScrapeResult, mapBatchResponse } from "../src/firecrawl";

describe("classifyScrapeResult", () => {
  test("200 + html → ok", () => {
    expect(classifyScrapeResult({ statusCode: 200, rawHtml: "<html>x</html>" }))
      .toEqual({ status: "ok", httpStatus: 200 });
  });
  test("403 → blocked", () => {
    expect(classifyScrapeResult({ statusCode: 403, rawHtml: null }).status).toBe("blocked");
  });
  test("429 → blocked, 500 → fetch_error", () => {
    expect(classifyScrapeResult({ statusCode: 429, rawHtml: null }).status).toBe("blocked");
    expect(classifyScrapeResult({ statusCode: 500, rawHtml: null }).status).toBe("fetch_error");
  });
  test("200 + html vide/challenge → empty/blocked", () => {
    expect(classifyScrapeResult({ statusCode: 200, rawHtml: "" }).status).toBe("empty");
    expect(classifyScrapeResult({ statusCode: 200, rawHtml: "<title>Just a moment...</title>" }).status).toBe("blocked");
  });
});

describe("mapBatchResponse", () => {
  test("job scraping → progression", () => {
    expect(mapBatchResponse({ status: "scraping", completed: 3, total: 10, data: [] }))
      .toEqual({ status: "scraping", completed: 3, total: 10 });
  });
  test("job completed → pages classifiées", () => {
    const r = mapBatchResponse({
      status: "completed", completed: 2, total: 2,
      data: [
        { metadata: { sourceURL: "https://a.com", statusCode: 200 }, rawHtml: "<html>a</html>" },
        { metadata: { sourceURL: "https://b.com", statusCode: 403 }, rawHtml: null },
      ],
    });
    expect(r.status).toBe("completed");
    if (r.status === "completed") {
      expect(r.pages[0]).toMatchObject({ url: "https://a.com", status: "ok" });
      expect(r.pages[1]).toMatchObject({ url: "https://b.com", status: "blocked", httpStatus: 403 });
    }
  });
});
```

- [ ] **Step 3: run** — `cd services/analytics && bun test test/firecrawl.test.ts` → FAIL (module inexistant).
- [ ] **Step 4: implémentation** — `services/analytics/src/firecrawl.ts` : helpers purs `classifyScrapeResult` (401/403/429 → `blocked` ; marqueurs challenge « Just a moment » / « Attention Required » / « Incapsula » dans les 2 premiers Ko → `blocked` ; 2xx html non vide → `ok` ; 2xx vide → `empty` ; timeout → `timeout` ; reste → `fetch_error`) et `mapBatchResponse` ; puis les 4 fonctions publiques : `POST {url}/v2/batch-scrape` body `{ urls, formats:["rawHtml"], onlyMainContent:false, maxAge:0, timeout:30000 }`, header `Authorization: Bearer {FIRECRAWL_API_KEY}` ; `GET {url}/v2/batch-scrape/{id}` ; `scrapeOne` = `POST /v2/scrape` même options, 1 retry sur erreur réseau uniquement. Fetch injectable (paramètre optionnel) pour les tests.
- [ ] **Step 5: run** — `bun test test/firecrawl.test.ts` → PASS ; `bun run check` (biome + tsc du service) → clean.
- [ ] **Step 6: commit** — `git add services/analytics/src/firecrawl.ts services/analytics/src/env.ts services/analytics/test/firecrawl.test.ts && git commit -m "feat(analytics): client Firecrawl v2 (batch-scrape + scrape, classification des échecs)"`

---

### Task 2: Type de job `crawl-onpage` — schéma, endpoint, enqueue app

**Files:**
- Modify: `src/lib/db/domain-schema.ts:311-346` (union `type`, union `payload`, totals)
- Modify: `services/analytics/src/index.ts` (schéma zod du POST /jobs + garde anti-doublon)
- Modify: `src/lib/analytics/enqueue.ts` (nouveau membre d'union `EnqueueInput`)
- Modify: `src/lib/keywords/serp-types.ts` (champs `crawlStatus`/`crawlHttpStatus` sur `SerpCompetitor`)

**Interfaces:**
- Produces: `type: "crawl-onpage"`, `payload: { keywordId: string }` ; `totals` étendu `{ crawled?, blocked?, failed? }` ; `CrawlOnpagePayload` exporté depuis `src/lib/analytics/enqueue.ts`.
- Garde anti-doublon : 409 si un job `crawl-onpage` `queued|running` existe pour le même `payload->>'keywordId'` (mirror exact du bloc `serp-semantics` de `index.ts:115+`).

- [ ] **Step 1: schéma** — `domain-schema.ts` : `type` devient `$type<"csv-import" | "site-crawl" | "serp-semantics" | "crawl-onpage">()` ; `payload` devient `$type<SerpSemanticsPayload | CrawlOnpagePayload | null>()` avec `type CrawlOnpagePayload = { keywordId: string }` (défini et exporté à côté de l'import `SerpSemanticsPayload`) ; `totals` : ajouter `crawled?: number; blocked?: number; failed?: number;` à l'objet typé. Aucune migration SQL (colonnes text/jsonb inchangées).
- [ ] **Step 2: serp-types** — dans `src/lib/keywords/serp-types.ts`, sur le type `SerpCompetitor` :

```ts
/** Résultat du dernier crawl Firecrawl de cette page (additif — absent sur
 *  les snapshots antérieurs). */
crawlStatus?: "ok" | "blocked" | "timeout" | "empty" | "fetch_error";
crawlHttpStatus?: number;
```

- [ ] **Step 3: endpoint** — `services/analytics/src/index.ts` : ajouter le schéma zod `{ type: z.literal("crawl-onpage"), siteId: z.string(), payload: z.object({ keywordId: z.string() }) }` à l'union, et le bloc de garde anti-doublon calqué ligne à ligne sur celui de `serp-semantics` (409 si `queued|running` pour le même keywordId), insertion du row avec `type: "crawl-onpage"`.
- [ ] **Step 4: enqueue app** — `src/lib/analytics/enqueue.ts` : membre d'union `type CrawlOnpageInput = { type: "crawl-onpage"; siteId: string; payload: { keywordId: string } }`, branche du body correspondante.
- [ ] **Step 5: vérif** — `bunx tsc --noEmit -p tsconfig.json` (racine) ET `cd services/analytics && bun run check` → clean.
- [ ] **Step 6: commit** — `git commit -m "feat(analytics): type de job crawl-onpage (schéma, endpoint, enqueue, crawlStatus additif)"`

---

### Task 3: Handler worker `processCrawlOnpage`

**Files:**
- Create: `services/analytics/src/crawl-onpage.ts`
- Create: `services/analytics/test/crawl-onpage.test.ts`
- Modify: `services/analytics/src/worker.ts:79-83` (entrée `"crawl-onpage": processCrawlOnpage` dans `HANDLERS`)

**Interfaces:**
- Consumes: `startBatchScrape`/`getBatchScrape`/`firecrawlConfigured` (Task 1), `Job`/`completeJob`/`failJob`/`updateJobTotals` (queue.ts), `wasm.parse_onpage_document` + `onpageFieldsFromDocument` (`src/lib/keywords/onpage.ts` — déjà importable, le worker consomme `import-core` comme `site-crawl.ts`).
- Produces: `processCrawlOnpage(job: Job, deps?)` — deps injectables `{ firecrawl, db, now }` (DI, pas de mock.module — convention mémoire projet).

- [ ] **Step 1: tests qui échouent** — `crawl-onpage.test.ts` : client Firecrawl fake (DI) ; cas :
  1. **mixte** : 3 URLs → ok(html réel minimal avec title/meta), blocked(403), timeout → assertions : competitors mergés avec `crawlStatus` posés, champs onpage remplis pour la page ok, `totals {crawled:1, blocked:1, failed:1}`, job `done` ;
  2. **payload manquant** → `failJob` ;
  3. **env non configurée** (`firecrawlConfigured()` false) → `failJob` avec message contenant « FIRECRAWL » ;
  4. **merge non destructif** : un snapshot avec 2 locales → seule la locale du keyword est modifiée ;
  5. **timeout global** : `getBatchScrape` répond `scraping` au-delà de la deadline (injecter `now`) → `failJob`.
  Fixtures DB : mêmes helpers que `test/` existants du service (DB test `seo_insight_test`, `DB_TESTS=1`).
- [ ] **Step 2: run** → FAIL.
- [ ] **Step 3: implémentation** — `crawl-onpage.ts` :

```
lire keywords.serp[country] (country = keywords.country, mirror serp-store) ;
urls = competitors[].url ; si vide → failJob("Aucune SERP à crawler") ;
{jobId} = startBatchScrape(urls) ;
boucle poll (3 s, deadline now+5 min) : scraping → updateJobTotals({crawled: completed}) ;
completed → pour chaque page : classifier ; si ok → parse WASM → onpageFieldsFromDocument → merge champs dans le competitor correspondant (match par url) + crawlStatus/"crawlHttpStatus" ; sinon → poser uniquement crawlStatus/httpStatus ;
UPDATE keywords SET serp = jsonb_set(serp, '{<locale>}', <snapshot mis à jour>) WHERE id = keywordId  (une seule écriture, même esprit SQL-side que serp-store.ts) ;
completeJob(job.id, { crawled, blocked, failed }).
```

  Concurrence worker : rien à faire ici — le poll est I/O ; la concurrence des jobs est celle du worker existant.
- [ ] **Step 4: run** — `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun test test/crawl-onpage.test.ts` → PASS ; `bun run check` → clean.
- [ ] **Step 5: commit** — `git commit -m "feat(analytics): handler crawl-onpage (batch-scrape Firecrawl, parse WASM, crawlStatus, totals)"`

---

### Task 4: Fetch sémantique via Firecrawl

**Files:**
- Create: `services/analytics/src/firecrawl-fetch.ts`
- Create: `services/analytics/test/firecrawl-fetch.test.ts`
- Modify: `services/analytics/src/serp-semantics.ts` (choix du `fetchHtml` + neutralisation du rendu Chrome local quand Firecrawl est configuré)

**Interfaces:**
- Consumes: `scrapeOne` (Task 1) ; signature attendue par le runner : `fetchHtml: (url: string) => Promise<{ html: string; finalUrl: string } | null>` (cf. `analysis-runner.ts:75` / `fetchSemanticHtml`).
- Produces: `fetchViaFirecrawl(url)` conforme à cette signature ; `blocked`/`timeout`/`empty`/`fetch_error` → `null` (contrat du runner : jamais de throw), le détail est loggé.

- [ ] **Step 1: test qui échoue** — `firecrawl-fetch.test.ts` : `scrapeOne` fake → ok → `{ html, finalUrl: url }` ; blocked/timeout → `null` (pas de throw).
- [ ] **Step 2: run** → FAIL.
- [ ] **Step 3: implémentation** — `firecrawl-fetch.ts` : wrapper trivial `scrapeOne` → shape runner. Dans `serp-semantics.ts` :

```ts
import { firecrawlConfigured } from "./firecrawl";
import { fetchViaFirecrawl } from "./firecrawl-fetch";
// Firecrawl configuré → il fetch ET rend (playwright côté instance) : le
// rendu Chrome local devient inutile sur ce chemin. Non configuré (dev sans
// instance) → chemin legacy inchangé (fetchSemanticHtml + Chrome local).
const useFirecrawl = firecrawlConfigured();
// dans l'appel runner :
fetchHtml: useFirecrawl ? fetchViaFirecrawl : fetchSemanticHtml,
render: useFirecrawl ? null : renderProvider,
```

- [ ] **Step 4: run** — tests du service complets (`bun test` avec DB) → tous verts, y compris les tests serp-semantics existants (chemin legacy inchangé quand env absente — c'est le cas en CI/test).
- [ ] **Step 5: commit** — `git commit -m "feat(analytics): corpus sémantique via Firecrawl (fetch rendu, Chrome local court-circuité)"`

---

### Task 5: Bascule app — action enqueue + poll UI

**Files:**
- Modify: `src/lib/keywords/serp-actions.ts:42-85` (`crawlSerpForKeywordAction` → enqueue)
- Modify: `src/lib/keywords/serp-actions.test.ts` (adapter les tests du crawl)
- Modify: `src/components/keywords/planning/keyword-overview-modal.tsx` (crawlOnpage → enqueue + poll)
- Modify: `src/components/content/serp-panel.tsx` (idem pour le bouton « Crawler on-page »)

**Interfaces:**
- Consumes: `enqueueAnalyticsJob` (+ `AnalyticsJobError` 409), `getAnalyticsJob` (`src/lib/analytics/actions.ts`, poll 2 s — pattern `import-keywords-dialog.tsx:122-142`).
- Produces: `crawlSerpForKeywordAction(keywordId): Promise<{ ok: boolean; jobId?: string; error?: string }>` — **signature modifiée** (plus de `crawled`/`failed` synchrone ; ces totaux arrivent par le poll).

- [ ] **Step 1: adapter les tests** — `serp-actions.test.ts` : le test du crawl mocke `enqueueAnalyticsJob` (DI ou mock du module app — suivre le pattern des mocks déjà présents dans ce fichier) ; cas : succès → `{ok:true, jobId}` ; 409 → `{ok:true, jobId:null}` (job déjà en cours, l'UI poll le job existant via la liste) ; tenancy inchangée.
- [ ] **Step 2: run** → FAIL (signature actuelle synchrone).
- [ ] **Step 3: action** — `crawlSerpForKeywordAction` : garde `assertKeywordInActiveOrg` inchangée ; lit `siteId` du keyword ; `enqueueAnalyticsJob({ type:"crawl-onpage", siteId, payload:{ keywordId } })` ; catch `AnalyticsJobError` 409 → `{ ok:true, jobId: null }`. Supprimer `mapPool`/`crawlOnpage` de l'action (le fetch direct meurt ici). Conserver les 4 `revalidatePath` (posés au moment où le CLIENT rafraîchit ; inoffensifs).
- [ ] **Step 4: UI** — dans `keyword-overview-modal.tsx` (fonction `crawlOnpage`) et `serp-panel.tsx` (fonction `crawl`) : après `{ok, jobId}`, si `jobId` → poll `getAnalyticsJob(jobId)` toutes les 2 s (max 6 min) ; `running` → état `crawling` maintenu (pastille « En cours » existante) ; `done` → toast `Crawl : {totals.crawled} ok{totals.blocked ? `, ${totals.blocked} bloqué(s)` : ""}{totals.failed ? `, ${totals.failed} échec(s)` : ""}` + `retry()`/`onDataChanged?.()` (modal) ou `onImported()` (panel) ; `failed` → toast.error(job.error). `jobId null` (409) → toast « Crawl déjà en cours » et poll du job pending du keyword si trivial, sinon simple message.
- [ ] **Step 5: run** — `bunx tsc --noEmit`, `bunx biome check` sur les 4 fichiers, `bun --env-file=.env.local --conditions=react-server test src/lib/keywords/serp-actions.test.ts` (avec `DB_TESTS=1` + DATABASE_URL test) → verts.
- [ ] **Step 6: commit** — `git commit -m "feat(keywords): le crawl on-page passe par le job analytics (enqueue + poll, plus de fetch direct)"`

---

### Task 6: Nettoyage, taxonomie visible, suivi

**Files:**
- Modify: `src/lib/keywords/onpage.ts:74-84` (supprimer `crawlOnpage` et l'import de son fetcher direct ; garder `onpageFieldsFromDocument`)
- Modify: `src/lib/keywords/onpage-fetch.ts` → supprimer si plus aucun appelant (`grep` d'abord)
- Modify: `src/components/keywords/planning/semantic-analysis-section.tsx` (libellé de chip : les exclusions `blocked` affichent « page protégée — à retenter via proxy » si le flag runner correspondant existe ; sinon ne rien changer — vérifier ce que le runner émet réellement comme flags après Task 4 et n'ajouter QUE le mapping de libellé)
- Modify: `docs/project/imports-follow-ups.md` (item « Proxy résidentiel : à brancher quand le taux de blocked le justifie — PROXY_SERVER, Nouvelle-conf » + cocher le chantier crawl)

**Interfaces:** aucune nouvelle.

- [ ] **Step 1: grep des appelants** — `grep -rn "crawlOnpage\|onpage-fetch" src/ services/` : il ne doit rester que `onpageFieldsFromDocument` (worker) après Task 5. Supprimer le code mort révélé.
- [ ] **Step 2: vérif complète** — racine : `bunx tsc --noEmit` + `bunx biome check` sur tous les fichiers touchés + tests keywords ; service : `bun run check` + `bun test` (DB). Tout vert.
- [ ] **Step 3: E2E manuel (d3k)** — sur *jewelry casting* : cliquer « Crawler » → pastille « En cours » → toast avec totaux → attendu : les 3 pages « rendu JS suspecté » deviennent `ok` (playwright), gemstones.com `blocked` ; profil sémantique relancé sur corpus complet. Nécessite l'instance `crawl.elevaseo.app` joignable + env posées sur le service local.
- [ ] **Step 4: commit + merge** — `git commit -m "chore(keywords): supprime le fetch direct on-page, libellés blocked, suivi proxy"` puis flow : push branche, `git checkout dev && git merge feature/firecrawl-onpage --ff-only && git push`, suppression de branche.

---

## Self-Review (fait à l'écriture)

- **Couverture spec** : composants (T1-T3), contrat batch/BullMQ (T1/T3), taxonomie (T1/T2/T6), sémantique (T4), UX poll (T5), pas de fallback (T3 cas 3 ; T4 fallback legacy = décision spec « env absentes »), migration (T6), proxy documenté (T6). ✔
- **Types cohérents** : `FirecrawlPage`/`BatchState` (T1) consommés tels quels en T3/T4 ; `CrawlOnpagePayload` (T2) consommé en T3/T5 ; signature `crawlSerpForKeywordAction` modifiée documentée en T5. ✔
- **Placeholders** : aucun TBD ; le seul point conditionnel (flags runner en T6) est borné à « mapping de libellé uniquement, après vérification ». ✔
