# Design — Crawl on-page via Firecrawl self-hosted (job `crawl-onpage`)

- **Date** : 2026-07-22
- **Statut** : validé (brainstorming avec Bastien, approche A retenue)
- **Périmètre** : crawl on-page des concurrents SERP + corpus de l'analyse sémantique. Le crawl du site propre (`site-crawl`) reste sur son chemin actuel — hors périmètre.
- **Related** : `docs/project/imports-follow-ups.md`, `docs/superpowers/specs/2026-07-21-import-routing-adr.md`, `~/Documents/Firecrawl/Nouvelle-conf` (config de l'instance `crawl.elevaseo.app`).

## Problème

`crawlOnpage` (`src/lib/keywords/onpage.ts`) fetch les pages concurrentes en direct depuis le serveur Next : IP unique de datacenter, pas de rendu JS. Conséquences observées : pages JS exclues du profil sémantique (« Rendu JS suspecté », 3/6 pages sur *jewelry casting*), domaines protégés bloqués (gemstones.com / Incapsula), et risque de blacklistage progressif de l'IP. L'utilisateur exige des crawls **toujours frais, on demand** — le cache n'est donc pas la parade ; la parade est de crawler proprement (rendu navigateur, headers réels, politeness) via l'instance Firecrawl self-hosted, avec une sortie de secours proxy.

## Décisions de cadrage (actées)

1. **Périmètre** : on-page concurrents + corpus sémantique SERP (même corpus — un seul pipeline à brancher).
2. **Fraîcheur** : toujours frais, on demand (`maxAge: 0`). Pas de cache applicatif.
3. **IP** : direct d'abord ; l'architecture doit permettre de brancher un proxy résidentiel rotatif plus tard **sans changement applicatif** (`PROXY_SERVER` sur l'instance Firecrawl).
4. **UX** : job en arrière-plan (pattern queue + poll existant des imports CSV), jamais de Server Action bloquée 30-60 s.
5. **Pas de fallback fetch direct** : si Firecrawl est indisponible, le job échoue proprement. Un fallback réintroduirait exactement le risque éliminé.

## Architecture (approche A retenue)

Firecrawl = **exécuteur d'extraction** (rendu JS, headers, politeness par domaine). L'orchestration, la persistance et l'observabilité restent dans la queue `analytics_jobs` existante.

```
Panneau / SerpPanel ── enqueue ──> analytics_jobs (type: crawl-onpage)
                                        │
                            worker analytics (Bun — sériel à ce jour, pool de claim = follow-up)
                                        │
                POST {FIRECRAWL_API_URL}/v2/batch-scrape  (rawHtml, maxAge:0)
                                        │              ← la queue BullMQ de
                        poll GET /v2/batch-scrape/{id}     Firecrawl absorbe la
                                        │                  charge multi-clients
                        wasm.parse_onpage_document (inchangé)
                                        │
              UPDATE keywords SET serp[locale].competitors (merge jsonb SQL-side)
                                        │
        Panneau poll getAnalyticsJob ──> fin → router.refresh() + toast
```

### Composants

| Unité | Rôle | Dépendances |
|---|---|---|
| `services/analytics/src/firecrawl.ts` (nouveau) | Client HTTP minimal Firecrawl v2 : `startBatchScrape(urls, opts)` → `{ jobId }`, `getBatchScrape(jobId)` → statut + résultats par URL (html ou erreur typée `blocked` / `timeout` / `error` + httpStatus). 1 retry sur timeout réseau uniquement. | `FIRECRAWL_API_URL`, `FIRECRAWL_API_KEY` (Zod, `services/analytics/src/env.ts` — optionnels : leur absence fait échouer le job `crawl-onpage` avec un message clair, pas le service). |
| Job type `crawl-onpage` (worker.ts `HANDLERS`) | Payload `{ keywordId }`. | queue existante (`claimNextJob`, transitions, totals). |
| `services/analytics/src/crawl-onpage.ts` (nouveau) | Handler : lit `keywords.serp[country].competitors`, soumet le batch à Firecrawl, poll jusqu'à complétion (timeout global 5 min), parse WASM chaque page rendue, merge jsonb par locale (même pattern SQL que `serp-store.ts`), écrit `totals { crawled, blocked, failed }`. | `firecrawl.ts`, `import-core` (WASM), Drizzle. |
| `crawlSerpForKeywordAction` (modifiée) | Devient un *enqueue* : tenancy check (inchangé) → `enqueueAnalyticsJob({ type: "crawl-onpage", keywordId })` → `{ ok, jobId }`. Skip + réutilisation si un job identique est déjà `queued/running` (même garde que les imports). | `enqueueAnalyticsJob` existant. |
| Panneau (`keyword-overview-modal`) + `SerpPanel` | Poll `getAnalyticsJob(jobId)` (action existante, ~2 s) ; pastille « En cours » pendant le job ; à la fin : toast `Crawl : X ok, Y bloqués, Z échecs` + `retry()`/`router.refresh()`. Le worker (hors Next) ne revalide pas — le client s'en charge, comme pour l'import CSV. | `getAnalyticsJob`. |

### Contrat Firecrawl — asynchrone via la queue BullMQ de l'instance

**Décision multi-tenant (Bastien, 2026-07-22)** : ne PAS envoyer de scrapes synchrones par URL — avec ~50 clients simultanés, ce serait des centaines de requêtes concurrentes sur l'instance. On soumet chaque crawl comme **job asynchrone** ; c'est la **queue BullMQ interne de Firecrawl** (visible via `/admin/{BULL_AUTH_KEY}/queues`) qui absorbe la charge, sérialise et applique la politeness — sa capacité se règle côté conf (`NUM_WORKERS_PER_QUEUE`, `Nouvelle-conf`), pas côté app.

- `POST /v2/batch-scrape` avec les ~10 URLs du snapshot, `formats: ["rawHtml"]` — **notre parseur WASM reste l'unique autorité** (conforme ADR routage imports) ; on n'utilise pas le markdown Firecrawl.
- `onlyMainContent: false` (le parseur veut la page entière : meta, JSON-LD, alt).
- `maxAge: 0`, `timeout: 30000` par page, pas de screenshot, pas de features IA.
- Le worker analytics **poll le statut du job Firecrawl** (`GET /v2/batch-scrape/{id}`, intervalle ~3 s, timeout global 5 min) puis parse/écrit. Le poll est purement I/O ; la vraie concurrence de crawl vit dans BullMQ côté Firecrawl. **État réel (revue finale 2026-07-22)** : le worker claim-and-process est SÉRIEL — un crawl de 5 min bloque les autres types de jobs en tête de file. Un petit pool de claim (~4) est un follow-up requis avant l'usage multi-clients réel (suivi : docs/project/imports-follow-ups.md).
- Auth : header d'API key vérifié par Traefik (le self-hosted n'a pas d'auth applicative).
- Un hit par domaine par analyse (top SERP ≈ 10 domaines distincts) ; un seul job crawl par mot-clé à la fois.

### Taxonomie d'échec par concurrent

Nouveau champ dans chaque competitor du snapshot (`keywords.serp[locale].competitors[i]`) :

```ts
crawlStatus?: "ok" | "blocked" | "timeout" | "empty" | "fetch_error";
crawlHttpStatus?: number;
```

- `blocked` : HTTP 401/403/429 ou challenge détecté (Cloudflare/Incapsula) dans la réponse.
- `empty` : rendu OK mais corpus vide/inexploitable (l'actuel « rendu JS suspecté » devient marginal grâce à playwright).
- Le profil sémantique et ses chips lisent `crawlStatus` : « page protégée — à retenter via proxy » ≠ « corpus vide » ≠ « timeout ». Le **taux de `blocked`** devient l'indicateur objectif du moment où brancher le proxy.
- Compatibilité : champ optionnel — les snapshots existants sans `crawlStatus` gardent le comportement d'affichage actuel.

## Gestion d'erreurs

- **Firecrawl down / env absente** : job → `failed` avec message explicite ; toast côté client ; pas de fallback direct (décision n°5).
- **Page individuelle en échec** : n'échoue pas le job — le competitor est marqué (`crawlStatus`), le job se termine `done` avec ses totals.
- **Instance chargée** : la queue sérialise ; garde « un job par mot-clé » ; pas de retry agressif côté app (Firecrawl gère sa propre politeness).
- **Proxy plus tard** : `PROXY_SERVER` (+ credentials) dans la conf de l'instance (`Nouvelle-conf`) — zéro changement applicatif. À documenter dans le README de la conf le moment venu.

## Tests

- **Worker** : `processCrawlOnpage` avec client Firecrawl mocké — cas ok / blocked / timeout / mixte : assertions sur le merge jsonb (competitors enrichis, statuts posés), les totals, et l'absence d'écrasement des autres locales du snapshot.
- **Client** : mapping réponse/erreurs Firecrawl → erreurs typées (fixtures de réponses 200/403/timeout).
- **Parseur** : non-régression `parse_onpage_document` sur un HTML rendu par playwright (fixture réelle).
- **E2E (manuel, d3k)** : sur *jewelry casting* — attendu : les 3 pages « rendu JS suspecté » passent `ok`, gemstones.com passe `blocked`, complétude et profil sémantique mis à jour après le poll.

## Migration

- Suppression du fetch direct dans `crawlOnpage` (`src/lib/keywords/onpage.ts` garde `onpageFieldsFromDocument` consommé par le worker ; la partie fetch meurt avec son dernier appelant).
- `docs/project/imports-follow-ups.md` : ajouter l'item « proxy résidentiel — à brancher quand le taux de blocked le justifie ».
- Aucune migration de données : `crawlStatus` est additif.
