# Keywords — GSC exclusion + cross-source metric (médiane)

**Date:** 2026-06-09
**Domaine:** mots-clés / page Recherche (`/keywords/recherche`).

Deux features indépendantes, même domaine → une spec, un plan, build groupé.

---

## A. Exclure des mots-clés GSC indésirables

**Problème:** le sync GSC crée des mots-clés positionnés (≤20) dans Recherche ; certains sont indésirables (au-delà du filtre opérateurs déjà en place). Les supprimer ne suffit pas — un re-sync les recrée (ils rankent toujours). Il faut une **exclusion persistante**.

**Table** `keyword_exclusions` (Drizzle, `src/lib/db/domain-schema.ts`) :
- `siteId` (text, FK → sites, cascade)
- `slug` (text) — le slug du terme (= la partie après `${siteId}-` dans `keywords.id`)
- `createdAt` (timestamp, defaultNow)
- **PK composite** `(siteId, slug)`.

**Server action** `excludeKeywordsAction(siteId, keywordIds: string[])` (`src/lib/keywords/…`) :
- charge les keywords ciblés (scopés au site, via l'org active — réutiliser le pattern owner/org existant) ;
- dans une **transaction** : insère `(siteId, slug)` dans `keyword_exclusions` (`onConflictDoNothing`) pour chacun, puis supprime les rows `keywords` ;
- retourne `{ excluded: number }`.

**UI:** un bouton **« Exclure »** dans la barre de sélection de Recherche (la zone qui agit sur les lignes cochées, à côté du ciblage), avec confirmation (AlertDialog) « Exclure N mot(s)-clé(s) ? Ils ne reviendront pas au prochain sync GSC. » → toast « N exclu(s) », refetch de la table.

**Sync:** `syncGscForSite` charge le `Set<slug>` exclus du site (une requête) et **skip** toute requête GSC dont le slug est exclu — appliqué à la fois au chemin overlay (ne pas re-créer/promouvoir) et au `toCreate`. → exclusion respectée à chaque sync.

**Réversibilité:** pas d'UI de dé-exclusion en v1 (noté backlog). Suppression directe en base si besoin.

**Tests:** l'action + le skip-sync sont testés au niveau DB (suite `gsc`/keywords existante). Le slug dérive du terme via `import-core` `slugify` (déjà utilisé par `createGscKeywordsForSite`).

---

## B. Métrique cross-source (médiane)

**Problème:** un mot-clé peut avoir des métriques de plusieurs sources (Semrush/Mangools/Ahrefs). On veut une valeur de **consensus** par métrique.

**Util pur** `consensusMetrics(metrics)` (`src/lib/keywords/consensus.ts`) :
- entrée = `keyword.metrics` (`Partial<Record<KeywordSource, SourceMetrics>>`) ;
- pour chaque champ `volume` / `kd` / `cpc` : collecte les valeurs **non-nulles** des sources SEO (semrush, mangools, ahrefs — pas gsc), calcule la **médiane** (moyenne des 2 centraux si pair) ;
- 0 source avec la valeur → `null` ; 1 → cette valeur ; ≥2 → médiane ;
- retourne `{ volume: number|null; kd: number|null; cpc: number|null }`.
- **TDD** (cas : 0/1/2/3 sources, pair/impair, champs partiels).

**Colonnes** (`src/components/keywords/columns.tsx`) : 3 nouvelles colonnes `volumeConsensus` / `kdConsensus` / `cpcConsensus` (libellés `Vol. ✦` / `KD ✦` / `CPC ✦`) :
- accessor = la médiane via `consensusMetrics` (number pour le tri/filtre, `?? 0`) ;
- cellule = `naCell` (N/A si null), réutilise `KdBadge` / `CpcBadge` ;
- ajoutées à `TOGGLEABLE_COLUMNS` ;
- **masquées par défaut** (`DEFAULT_COLUMN_VISIBILITY`) — opt-in via le menu Colonnes, pour ne pas noyer la table de N/A (la majorité des mots-clés Recherche sont GSC-only).

**Portée:** colonnes partagées → visibles aussi au Réservoir (même `columns.tsx`), cohérent. Pas de changement de comportement par défaut (masquées).

---

## Découpage technique
- `src/lib/db/domain-schema.ts` — table `keywordExclusions`.
- migration Drizzle (`db:generate` + `db:migrate`, JAMAIS `db:push`).
- `src/lib/keywords/exclusions.ts` — `excludeKeywordsAction` (+ helper `getExcludedSlugs(siteId)` pour le sync).
- `src/lib/gsc/sync.ts` — charger + appliquer le `Set` d'exclusions.
- `src/lib/keywords/consensus.ts` + `.test.ts` — médiane pure (TDD).
- `src/components/keywords/columns.tsx` — 3 colonnes consensus.
- `src/components/keywords/research/research-container.tsx` (+ la barre de sélection / `TargetingBar`) — bouton « Exclure » + AlertDialog + refetch.

## Tests
- **Pur (TDD):** `consensus.test.ts` (médiane).
- **DB:** `exclusions` action (delete + insert) + `syncGscForSite` skip des slugs exclus (étendre `sync.test.ts`).
- **UI:** bouton Exclure + colonnes consensus → vérif live d3k.

## Out of scope (backlog)
- UI de dé-exclusion (lister / réintégrer un mot-clé exclu).
- Filtre/sélection de l'export enrichissement (déjà au backlog).
- Import direct dans Recherche (déjà au backlog).
