# Module « Pages » — inventaire crawl (sitemap) + mapping keywords

**Date** : 2026-07-15 · **Branche** : `feature/pages-crawl`

## Contexte & objectif

Façon Screaming Frog, scopé SEO Insight : récupérer le **sitemap** du site actif,
crawler chaque page pour en extraire les **métas**, constituer un **inventaire
de pages** par site, et **assigner les pages aux keywords existants** du
réservoir (enrichissement de couverture). L'audit on-page (métas
manquantes/dupliquées) vient par-dessus l'inventaire.

Briques déjà en place réutilisées : fetch HTML maison (`fetchHtmlWith`,
timeout 15 s, cap 3 Mo, UA desktop), parseur Rust wasm `parse_onpage_document`
(title/description/lang/Hn/markdown), overlay GSC `gsc.page` (« la page qui
ranke »), service analytics (queue de jobs + polling UI), `content_keywords`.

**Décisions verrouillées (brainstorm 2026-07-15)** :
1. Finalité : **inventaire + mapping kw** (l'un sert l'autre).
2. Signaux de mapping (cumulés, par priorité) : **GSC > content_items >
   textuel scoré > manuel**.
3. Exécution : **job du service analytics** (queue + polling, pattern import CSV).
4. UI : **nouvelle section sidebar « Pages »** + colonne « Page ciblée » au
   réservoir.
5. Modèles subagents : Opus 4.8 par défaut, Fable 5 si la tâche l'exige.

## Modèle de données (1 migration)

### Table `site_pages`
- `id` text PK — `${siteId}-${hash(urlNormalisée)}` (hash court stable, ex.
  sha256 tronqué 16 hex).
- `siteId` FK sites ON DELETE CASCADE + index.
- `url` text NOT NULL — URL canonique telle que crawlée (après redirects).
  UNIQUE (siteId, url).
- `status` integer — HTTP final (200, 301→ status de la cible, 404, 0 = fetch
  raté/timeout).
- `title` text NULL · `metaDescription` text NULL · `h1` text NULL (première
  H1) · `h1Count` integer NOT NULL DEFAULT 0.
- `canonical` text NULL — href du `<link rel="canonical">`.
- `robots` text NULL — contenu de `<meta name="robots">` (ex. "noindex,follow").
- `lang` text NULL · `wordCount` integer NOT NULL DEFAULT 0.
- `issues` text[] NOT NULL DEFAULT '{}' — calculées à l'upsert (voir §Issues).
- `crawledAt` timestamptz NOT NULL.
- Index GIN sur `issues` (filtres) si trivial, sinon différé.

### Colonnes `keywords` (mapping)
- `targetPageId` text NULL — FK `site_pages` ON DELETE SET NULL.
- `targetPageSource` text NULL — `gsc | content | text | manual`.
- `targetPageScore` integer NULL — 0-100, renseigné pour `text` seulement.
**Une page cible par keyword** (slice 1 — les pages par pays existent déjà via
`gscByCountry[].page`).

## Crawl — job analytics `site-crawl`

1. **Découverte** : `GET {siteUrl}/robots.txt` → lignes `Sitemap:` ; fallback
   `{siteUrl}/sitemap.xml`. Sitemap-index → récursion (profondeur max 3).
   Filtre même-domaine (host du site, www toléré). Dédup. **Cap 5 000 URLs**
   (au-delà : tronqué + compté dans le récap).
2. **Fetch** : pool de **6** requêtes concurrentes, réutilise le pattern
   `fetchHtmlWith` (timeout 15 s/page, cap 3 Mo, mêmes headers). Une page en
   échec = ligne `status` d'erreur (0 ou code HTTP), JAMAIS un job échoué.
3. **Parse** : extension Rust de `parse_onpage_document` — nouvelle fonction
   wasm **`parse_page_meta(html)`** (légère : title, metaDescription, h1 +
   h1Count, canonical, meta robots, lang, wordCount — SANS markdown complet ni
   images). Gotcha build : `build:wasm` + `bun install` + restart.
4. **Upsert** : par lots dans `site_pages` (ON CONFLICT (siteId,url) → update
   métas + issues + crawledAt). Les pages disparues du sitemap ne sont PAS
   supprimées automatiquement (slice 1) — marquées par `crawledAt` ancien.
5. **Progression** : le job expose `{phase: discover|crawl|map, done, total}`,
   pollée par l'UI (pattern getAnalyticsJob).
6. **Garde** : 1 crawl simultané par site (refus si un job site-crawl actif).

## Mapping keywords ← pages (passe finale du job)

Pour chaque keyword du site, premier signal qui matche gagne — **jamais
d'écrasement d'une assignation `manual`** :
1. **gsc** : `gsc.page` normalisée == `site_pages.url` normalisée.
2. **content** : `content_keywords` → content_item dont l'URL publiée matche
   une page crawlée.
3. **text** : score = +40 si le slug du terme apparaît dans le path de l'URL,
   +30 si le terme (insensible casse/accents) est dans le H1, +30 dans le
   title. **Seuil 50**. Plusieurs pages au-dessus du seuil → la mieux scorée
   (égalité → URL la plus courte). Source `text` + score stocké.
4. Aucun match → `targetPageId` NULL = **gap de couverture**.

Normalisation URL partagée avec l'existante du badge « autre page ranke »
(GSC) — un seul util, réutilisé.

## Issues on-page (calcul à l'upsert, pur)

- `title-missing` · `title-long` (> 60 chars) · `title-short` (< 15).
- `description-missing` · `description-long` (> 160) · `description-short` (< 50).
- `h1-missing` · `h1-multiple` (h1Count > 1).
- `noindex` (robots contient noindex) · `canonical-mismatch` (canonical ≠ url
  normalisée, non vide).
- `error` (status ≥ 400 ou 0).

## UI

### Section sidebar « Pages » (`/pages`)
- Table : URL (path), statut (badge), title + longueur, description + longueur,
  H1, badges issues, nb de kw assignés. Recherche texte, filtres (issue,
  statut). Tri par défaut : issues d'abord.
- Bouton « **Crawler le site** » → enqueue → barre de progression (poll) →
  toast récap : `N pages crawlées · X en erreur · Y kw mappés · Z kw sans page`.
- Clic ligne → sheet détail : métas complètes + liste des kw assignés (lien
  réservoir).
- Empty state : « Aucune page — lance un crawl » (+ prérequis : site.url
  renseignée).

### Réservoir
- Colonne optionnelle « **Page ciblée** » (TOGGLEABLE) : path court + badge
  source (`gsc`/`content`/`texte`/`manuel`) ; tooltip URL complète + score.
- Édition : picker (recherche dans l'inventaire) → assignation `manual` ;
  « retirer » → NULL manual (le prochain crawl ne ré-assigne pas par-dessus un
  manual, y compris un manual-NULL ? → NON : un retrait manuel repasse le kw en
  éligible à l'auto-mapping au prochain crawl ; seul un CHOIX manuel est
  protégé).
- Facette « Sans page » (filtre `targetPageId IS NULL`).

## Gates & limites

- Tenancy : `assertSiteInActiveOrg` sur toutes les actions ; le job vérifie le
  couple (siteId, orgId) comme les jobs d'import.
- Aucun quota/billing (slice 1 — CPU maison). Cap 5 000 URLs, 1 job/site.
- `site.url` absente → action refuse avec message clair.

## Cas limites

| Cas | Comportement |
|---|---|
| Pas de sitemap (robots vide + /sitemap.xml 404) | Job termine `{pages: 0}` + message « Aucun sitemap trouvé » (le spider de liens est hors périmètre). |
| Sitemap énorme | Cap 5 000, récap l'indique. |
| Page en redirect | Suivie (redirect: follow) ; l'URL stockée = URL du sitemap, le status = final. |
| Page non-HTML (PDF du sitemap) | status conservé, métas vides, issue `error` si ≥400 sinon ignorée silencieusement (wordCount 0). |
| Re-crawl | Upsert idempotent ; mapping re-calculé (manual préservé). |
| Kw multi-pages candidates | Meilleur score, égalité → URL la plus courte. |
| gsc.page pointe hors inventaire (page pas dans le sitemap) | Pas d'assignation gsc (la page n'existe pas en base) ; le kw retombe sur content/text. Noté comme amélioration future (créer la page orpheline). |

## Tests

- **Rust** : `parse_page_meta` (canonical, robots, h1Count, wordCount, absents).
- **TS purs** : découverte sitemap (robots.txt, index récursif, cap, dédup,
  même-domaine), scoring textuel (slug/H1/title, seuil, égalités),
  normalisation URL (partagée GSC), calcul issues.
- **DB** : upsert idempotent re-crawl ; mapping priorités (gsc > content >
  text) ; manual jamais écrasé ; SET NULL au delete d'une page ; UNIQUE
  (siteId,url).
- **Job** : DI fetch (sitemap + pages fake) de bout en bout dans le service.
- Gate : `bun run test:all` + `cargo test -p import-core`.

## Hors périmètre (slices futures)

Spider de liens internes (au-delà du sitemap), liens cassés/orphelins, cron de
re-crawl, export CSV, quotas/billing, historique de crawls (une seule photo),
création de pages orphelines depuis gsc.page, PageSpeed/perf.
