# Module « Pages » — 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:** Inventaire de pages par site (sitemap → crawl métas via job analytics) + mapping keywords↔pages (GSC > content > textuel > manuel), section sidebar « Pages » + colonne « Page ciblée » au réservoir.

**Architecture:** Job `site-crawl` dans le service analytics existant (colonne `type` ajoutée à `analytics_jobs` + switch dans `processJob`) ; logique pure (sitemap, scoring, issues) dans `src/lib/pages/` (testée par test:all, importée par le service via l'alias `@`) ; parsing métas en Rust wasm (`parse_page_meta`, léger) ; nouvelle table `site_pages` + 3 colonnes mapping sur `keywords`.

**Tech Stack:** Rust wasm (tl), service analytics (bun/Hono), Drizzle/Postgres, Next 16 RSC.

**Spec (normative, à lire d'abord)** : `docs/superpowers/specs/2026-07-15-pages-crawl-design.md`.

## Global Constraints

- Branche `feature/pages-crawl` (active). Conventional Commits. JAMAIS main/prod.
- Cap **5 000 URLs**/crawl · pool fetch **6** · seuil matching textuel **50** (URL slug +40, H1 +30, title +30) · égalité → URL la plus courte · 1 job site-crawl actif par site · une page cible par kw · assignation `manual` JAMAIS écrasée par l'auto-mapping.
- Normalisation URL : réutiliser **`normalizeUrl` de `src/lib/content/stats-logic.ts:18`** (garde path, strip trailing slash) — PAS celle de backlink/normalize.
- Une page en échec = ligne `status` erreur, jamais un job failed. Pas de sitemap → job done `{pages: 0}` + message.
- Wasm : après modif du crate → `bun run build:wasm && bun install` + restart dev ; sortie gitignorée, jamais commitée.
- i18n : 4 catalogues `messages/{en,fr,es,de}.json`. Tests DI (pas de mock.module hors stub next/cache). Gates par task : tests ciblés + `bunx tsc --noEmit` + biome. Gate finale : `bun run test:all` + `cargo test -p import-core`.
- Modèles subagents : **Opus 4.8 par défaut**, Fable 5 si exigé (review finale).
- Vagues parallèles : T1/T2/T3 simultanées (fichiers disjoints), puis T4/T5, puis T6/T7. Chaque agent : `git add` scoped à ses fichiers uniquement.

---

### Task 1: Migration — site_pages + colonnes mapping + type de job  [VAGUE 1]

**Files:** `src/lib/db/domain-schema.ts` (site_pages + analytics_jobs.type + keywords cols), `drizzle/0064_*.sql` (db:generate), les deux DB (dev+test).

**Interfaces (produit):**
- Table `sitePages` : cf. spec §Modèle de données — `id` PK, `siteId` FK cascade + index, `url`, UNIQUE(siteId,url), `status` int, `title`, `metaDescription`, `h1`, `h1Count` int default 0, `canonical`, `robots`, `lang`, `wordCount` int default 0, `issues text[] default '{}'`, `crawledAt` timestamptz.
- `keywords` : `targetPageId` text NULL FK site_pages ON DELETE SET NULL, `targetPageSource` text NULL, `targetPageScore` integer NULL.
- `analyticsJobs` : `type` text NOT NULL DEFAULT `'csv-import'` ; `source` et `objectKey` passent NULLABLE (un job site-crawl n'en a pas — vérifier l'impact sur queue.ts alias et worker.ts qui les lisent : le chemin csv-import continue de les exiger à l'exécution).

- [ ] Schéma Drizzle (styles/commentaires des tables voisines) → `bun run db:generate` → inspecter le SQL (1 CREATE + ALTERs attendus seulement) → appliquer dev (`bun run db:migrate`) + test (`bun run db:migrate:test`).
- [ ] `bunx tsc --noEmit` (⚠️ si le passage nullable de source/objectKey casse worker.ts/queue.ts du service, ajoute les gardes minimales là-bas — throw explicite si null sur le chemin csv-import) ; `bun run test src/lib/db` sans régression.
- [ ] Commit `feat(pages): schéma — site_pages, mapping keywords, type de job analytics (migration 0064)`.

---

### Task 2: Rust — parse_page_meta (léger) + canonical/robots  [VAGUE 1]

**Files:** `crates/import-core/src/onpage.rs`, `crates/import-core/src/lib.rs`.

**Interfaces (produit):** wasm `parse_page_meta(html: string)` → (serde camelCase)
```ts
{ title: string|null; metaDescription: string|null; h1: string|null; h1Count: number;
  canonical: string|null; robots: string|null; lang: string|null; wordCount: number }
```
`wordCount` = mots du texte visible (réutilise le walker markdown existant en interne, sans retourner le markdown). `h1` = première H1 (texte trimé), `h1Count` = total. `canonical` = href du premier `<link rel="canonical">`. `robots` = content du premier `<meta name="robots">`.

- [ ] TDD cargo : happy path complet ; canonical/robots absents → null ; 2 H1 → h1Count 2 ; html vide → tout null/0 ; lang `fr-FR` → `fr` (réutilise extract_language).
- [ ] Impl : nouveaux `extract_canonical`/`extract_robots` (pattern des extract_* existants, tl), struct `PageMeta` Serialize camelCase, fonction pure + export wasm dans lib.rs (pattern parse_onpage_document). Ne PAS toucher OnpageDocument existant.
- [ ] `cargo test -p import-core` vert → `bun run build:wasm && bun install` → grep `parse_page_meta` dans `src/lib/backlink/import-core/import_core.d.ts`.
- [ ] Commit `feat(pages): parse_page_meta — métas légères (canonical, robots, H1, wordCount) en Rust`.

---

### Task 3: Libs pures src/lib/pages/ — sitemap, issues, mapping  [VAGUE 1]

**Files (create):** `src/lib/pages/types.ts`, `sitemap.ts`, `issues.ts`, `mapping.ts` + leurs `.test.ts`.

**Interfaces (produit):**
```ts
// types.ts
export type PageMeta = { title: string|null; metaDescription: string|null; h1: string|null;
  h1Count: number; canonical: string|null; robots: string|null; lang: string|null; wordCount: number };
export type CrawledPage = PageMeta & { url: string; status: number };
// sitemap.ts — TOUT en DI fetch (fetchText: (url) => Promise<string|null>)
export const SITEMAP_URL_CAP = 5_000;
export function parseRobotsSitemaps(robotsTxt: string): string[];
export function parseSitemapXml(xml: string): { urls: string[]; children: string[] }; // urlset vs sitemapindex
export async function discoverSitemapUrls(siteUrl: string, fetchText: FetchText):
  Promise<{ urls: string[]; truncated: boolean }>; // robots→Sitemap: puis /sitemap.xml, récursion ≤3, même-host (www toléré), dédup, cap
// issues.ts
export function computeIssues(p: CrawledPage): string[]; // règles exactes de la spec §Issues (bornes 60/15, 160/50)
// mapping.ts
export type PageRef = { id: string; url: string; title: string|null; h1: string|null };
export function textualMatch(term: string, pages: PageRef[]): { pageId: string; score: number } | null;
  // slug(term) dans path +40, terme (casse/accents-insensible) dans h1 +30, dans title +30 ; seuil 50 ; égalité → url la plus courte
export function normalizePageUrl(u: string): string; // wrapper de normalizeUrl (content/stats-logic) — UN SEUL point de vérité
```

- [ ] TDD complet : robots.txt multi-Sitemap ; sitemapindex récursif (profondeur 3 max, pas de boucle infinie sur self-référence) ; cap 5 000 + truncated ; cross-domain filtré, www toléré ; XML pourri → [] sans throw. Issues : chaque règle + page saine → []. textualMatch : slug dans path seul (40) < seuil → null ; path+h1 (70) → match ; accents (« bagué or » vs "bague-or") ; égalité de score → URL courte. Slugification : réutilise `wasm.slugify`? NON — mapping.ts doit rester pur sans wasm : implémente un slug local minimal (lowercase, accents strippés via normalize("NFD"), non-alphanum → '-') et documente l'écart éventuel avec wasm.slugify.
- [ ] `bun test src/lib/pages/` vert (pas de DB), tsc, biome.
- [ ] Commit `feat(pages): libs pures — découverte sitemap, issues on-page, matching textuel`.

---

### Task 4: Service analytics — job site-crawl  [VAGUE 2, après T1+T2+T3]

**Files:** `services/analytics/src/{index.ts,worker.ts}` (modif), `services/analytics/src/site-crawl.ts` (create), test dans `src/lib/pages/crawl-job.test.ts` si la logique orchestrée est extraite pure (préférer extraire le cœur orchestrable en `src/lib/pages/crawl-runner.ts` avec DI db/fetch/parse pour qu'il soit testé par test:all — le fichier service reste un adaptateur mince).

**Interfaces:**
- Enqueue étendu : le schéma Zod de POST /jobs accepte `{ type: "site-crawl", siteId }` (union discriminée avec l'existant csv implicite `type: "csv-import"` défaut). Refus 409 si un job site-crawl `queued|running` existe déjà pour ce site.
- `processJob` (worker.ts) : switch sur `job.type` — `csv-import` = chemin actuel inchangé ; `site-crawl` → runner.
- Runner : lit site (url requise — absente → failJob message clair) → `discoverSitemapUrls` (fetchText dérivé de fetchHtmlWith assoupli : accepte aussi content-type xml/text) → `mapPool(urls, 6, fetch+parse)` (réutilise `mapPool` — copie locale ou export depuis serp-actions, choisis l'import propre) → wasm `parse_page_meta` → `computeIssues` → upsert `site_pages` par lots de 200 (ON CONFLICT (siteId,url) DO UPDATE métas+issues+crawledAt) → **passe mapping** : charge keywords du site (id, term, gsc, targetPageSource) + content_items.url liés (content_keywords) + pages fraîches ; pour chaque kw avec `targetPageSource != 'manual'` : gsc (normalizePageUrl égal) > content > textualMatch ; UPDATE ciblés par lots. Progression : `totals` jsonb réutilisé en `{ phase, done, total, pages, errors, mapped, unmapped }` (updateJob périodique toutes les ~25 pages).
- ⚠️ `gsc` jsonb des keywords : lire `gsc.page` (et c'est tout — pas gscByCountry, slice 1).

- [ ] TDD sur le runner extrait (DI : fetchText/fetchHtml/parse/db-exécuteur ou vraie DB test — vraie DB préférée, pattern sync.test.ts) : crawl 3 pages fake → 3 lignes site_pages avec issues ; 1 URL en échec → status 0, job continue ; mapping gsc prioritaire sur text ; manual intact ; re-run idempotent.
- [ ] Vérifier à la main en dev : `curl -X POST localhost:8787/jobs` type site-crawl sur un site avec url → job done, lignes en DB. (Le service tourne déjà via bun dev.)
- [ ] Gates : `bun run test src/lib/pages/` + tsc + biome (y compris services/).
- [ ] Commit `feat(pages): job site-crawl — découverte, pool 6, parse wasm, upsert, mapping en 4 signaux`.

---

### Task 5: Next — actions + exposition réservoir  [VAGUE 2, après T1, parallèle T4]

**Files (create):** `src/lib/pages/actions.ts` (+ test) ; **modify:** `src/lib/analytics/enqueue.ts` (type site-crawl), `src/lib/keywords/list-query.ts` + `src/lib/keywords/queries.ts` (exposer targetPage au réservoir), `src/lib/keywords/types.ts` (champ Keyword.targetPage).

**Interfaces (produit pour T6/T7):**
```ts
// src/lib/pages/actions.ts ("use server")
export type SitePageRow = { id: string; url: string; status: number; title: string|null;
  titleLength: number; metaDescription: string|null; descriptionLength: number; h1: string|null;
  issues: string[]; keywordCount: number; crawledAt: string };
export async function startSiteCrawlAction(siteId: string): Promise<{ ok: true; jobId: string } | { ok: false; error: string }>;
  // gates : assertSiteInActiveOrg, site.url présente, pas de job actif → réutilise enqueueAnalyticsJob étendu
export async function fetchSitePagesAction(siteId: string, opts?: { q?: string; issue?: string; limit?: number }): Promise<SitePageRow[]>;
export async function fetchPageDetailAction(siteId: string, pageId: string): Promise<{ page: SitePageRow & { canonical: string|null; robots: string|null; lang: string|null; wordCount: number }; keywords: { id: string; term: string; source: string|null }[] } | null>;
export async function setKeywordTargetPageAction(siteId: string, keywordId: string, pageId: string | null): Promise<{ ok: boolean }>;
  // pageId null → targetPageId NULL + source NULL (le kw redevient auto-mappable) ; pageId → source 'manual', score NULL
export async function searchSitePagesAction(siteId: string, q: string): Promise<{ id: string; url: string; title: string|null }[]>; // picker, limit 20
// Keyword (types.ts) gagne : targetPage?: { url: string; source: "gsc"|"content"|"text"|"manual"; score: number|null } | null
```
- Réservoir : `keywordSelect`/`toKeyword` (queries.ts) + `searchKeywords` (list-query.ts) exposent targetPage via LEFT JOIN site_pages sur targetPageId (url seule). Filtre facette « sans page » : ajouter `noTargetPage?: boolean` à KeywordListFilter (`targetPageId IS NULL`).
- Le polling job réutilise `getAnalyticsJob` existant tel quel (totals shape libre).

- [ ] TDD (DB test) : gates de chaque action ; startSiteCrawl refuse sans url et avec job actif (DI enqueue) ; setKeywordTargetPage manual + clear ; LEFT JOIN réservoir renvoie targetPage ; filtre noTargetPage.
- [ ] Gates : `bun run test src/lib/pages/ src/lib/keywords/` sans régression, tsc, biome.
- [ ] Commit `feat(pages): actions serveur — crawl, inventaire, assignation manuelle, exposition réservoir`.

---

### Task 6: UI — section « Pages »  [VAGUE 3, après T4+T5]

**Files (create):** `src/app/(app)/pages/page.tsx`, `src/components/pages/pages-view.tsx` (+ sheet détail) ; **modify:** `src/components/app-sidebar.tsx` (entrée navMainConfig), `messages/{en,fr,es,de}.json` (namespace `Pages` — ancre-toi HORS des blocs Keywords/Billing).

- [ ] Page RSC : squelette exact de `src/app/(app)/backlink/overview/page.tsx` (org → sites → siteId actif → fetch initial → `<PagesView key={siteId} …>` + fallback sans site).
- [ ] PagesView (client) : table (URL path, statut badge, title+len, desc+len, H1, badges issues, kw count), recherche + filtre issue, bouton « Crawler le site » → `startSiteCrawlAction` → poll `getAnalyticsJob` (pattern import-keywords-dialog : setInterval + cleanup) avec barre `{phase, done/total}` → toast récap `pages · erreurs · mappés · sans page` → refetch. Sheet détail au clic (métas complètes + kw liés). Empty state avec prérequis site.url.
- [ ] Sidebar : entrée « Pages » (icône lucide type FileSearch) entre Keywords et Contenu, un seul sous-item « Inventaire » → `/pages`.
- [ ] Gates : tsc, biome, `bun run test src/components` sans régression. Pas de QA navigateur (orchestrateur).
- [ ] Commit `feat(pages): section Pages — inventaire, crawl avec progression, sheet détail`.

---

### Task 7: UI — réservoir « Page ciblée »  [VAGUE 3, parallèle T6]

**Files:** `src/components/keywords/columns.tsx`, `src/components/keywords/reservoir.tsx` + `reservoir-toolbar.tsx` (facette « Sans page »), `src/lib/keywords/search-params.ts` (param URL `nopage` boolean), nouveau `src/components/keywords/target-page-cell.tsx` (cell + picker), `messages/*` (clés dans le bloc Keywords — PAS le namespace Pages de l'agent T6).

- [ ] Colonne TOGGLEABLE `targetPage` (masquée par défaut ? NON — visible par défaut, c'est le livrable) : cell = path court + badge source (gsc vert / content bleu / texte ambre + score / manuel gris), tooltip URL complète ; clic → popover picker (`searchSitePagesAction`, debounce) → `setKeywordTargetPageAction` → refresh ; action « retirer ».
- [ ] Facette « Sans page » : toggle simple dans la toolbar (pas un MultiFacet — un booléen), param URL `nopage`, branché sur `KeywordListFilter.noTargetPage` (T5) via filterFromSearchParams.
- [ ] Gates : tsc, biome, tests components sans régression.
- [ ] Commit `feat(pages): colonne Page ciblée au réservoir — badge source, picker manuel, facette Sans page`.

---

### Task 8: Gate finale + roadmap + review + merge + QA  [orchestrateur]

- [ ] `bun run test:all` + `cargo test -p import-core` + tsc + biome.
- [ ] Roadmap : entrée ✅ module Pages (+ différés spec).
- [ ] Review finale de branche (**Fable**) → fixes → merge ff-only dev + push + delete branche.
- [ ] QA live avec l'utilisateur : crawl réel d'un site (advmfr a-t-il une url ? sinon site perso), progression, inventaire, issues, mapping (kw GSC → badge gsc), picker manuel, facette Sans page.

## Vérification finale

1. Suites vertes ; aucun fichier wasm build commité.
2. Un re-crawl ne duplique rien et préserve les assignations manuelles.
3. `manual` jamais écrasé ; retrait manuel ré-éligible à l'auto-mapping.
4. Le job survit aux pages mortes (status 0) et à l'absence de sitemap.
