# Design — Feature #2a : import keywords multi-source (Mangools/Semrush, unifié)

Date : 2026-06-05
Branche : `feature/keywords-import`

> Deuxième vague keywords. #1 (reservoir lit la DB) est mergée. Cette spec couvre
> **#2a : l'import multi-source auto-détecté + le modèle unifié**. Différé → **#2b** :
> labels/listes, ajout manuel d'un keyword.

## Contexte & objectif

On importe des **exports réels** de deux outils connus (échantillons dans
`~/Downloads/`) :
- **Mangools (KWFinder)** : `Keyword`, `Avg. Search Volume (Last Known Values)`,
  vol 12/6/3 mois, `Location`, `CPC/USD`, `PPC`, `Keyword Difficulty`,
  `Keyword interest growth (%)`, `Content Type`, `Search Intent` (multi :
  "commercial, informational"), `Keyword Opportunities`, `Serp Features`, **+ une
  colonne par mois** `Search Volume MM/YYYY` (série de tendance réelle).
- **Semrush** : `Keyword, Intent, Volume, Trend, Keyword Difficulty, CPC (USD),
  Competitive Density, SERP Features, Number of Results`. `Trend` = 12 valeurs
  relatives (0-1) séparées par virgule, dans une cellule entre guillemets.

But : **unifier** les deux dans un schéma canonique unique pour une lecture
simple, auto-détecter la source à l'import et la **faire valider** par
l'utilisateur, persister **par site actif**. Champs qu'une source n'a pas →
stockés `null` → affichés **« N/A » gris clair**. Une **colonne `source`** indique
l'origine.

## Schéma unifié (mapping)

| Champ canonique | Mangools | Semrush |
|---|---|---|
| `term` | Keyword | Keyword |
| `intent[]` | Search Intent (split `,`) | Intent (1 valeur) |
| `metrics.volume` | Avg. Search Volume (Last Known Values) | Volume |
| `trend[]` | colonnes `Search Volume MM/YYYY` (mois non vides) | `Trend` (12 valeurs → 12 derniers mois) |
| `metrics.kd` | Keyword Difficulty | Keyword Difficulty |
| `metrics.cpc` | CPC/USD | CPC (USD) |
| `metrics.competitiveDensity` | — → `null` (N/A) | Competitive Density |
| `serpFeatures[]` | Serp Features (split `,`) | SERP Features (split `,`) |
| `metrics.numberOfResults` | — → `null` (N/A) | Number of Results |
| `metrics.growthPct` | Keyword interest growth (%) | — → `null` (N/A) |
| `primarySource` (= `source`) | `mangools` | `semrush` |
| `locale` | Location → country (US) ; lang `en` | (pas de Location) → country `US`, lang `en` |
| `theme` | `null` (absent des sources) | `null` |

Intent normalisé en minuscules + validé contre `SearchIntent`
(`informational | commercial | transactional | navigational`) ; valeurs hors enum
ignorées. Nombres : non parsables ou vides → `null`.

## Changements de modèle

- **`src/lib/keywords/types.ts`** :
  - `SourceMetrics` : ajouter `competitiveDensity?: number | null` et
    `numberOfResults?: number | null` (existants inchangés ; `growthPct` reste).
  - `KeywordSource` : ajouter `"semrush"` →
    `["mangools", "ahrefs", "gsc", "semrush"]` (+ `KEYWORD_SOURCES`).
  - `Keyword.theme` : `KeywordTheme | null` (nullable).
- **`src/lib/db/domain-schema.ts`** : `keywords.theme` → **drop `notNull`**
  (nullable). Migration drizzle (`bun run db:generate` + `db:migrate`, et la base
  test). Le `metrics` est jsonb → aucune migration pour les 2 nouveaux champs.
- **`src/lib/keywords/queries.ts`** (`toKeyword`) : `theme: r.theme ?? null`
  (déjà 1:1 sinon). Pas d'autre changement (metrics/trend jsonb passent tels
  quels).

## Rust core — extension du crate `import-core` (module `keyword`)

Réutilise l'infra existante (`csv`, `slugify`). Pur, testé `cargo test`.

- `detect_keyword_source(csv: &str) -> &'static str` → `"mangools" | "semrush" |
  "unknown"`, depuis la **signature de l'en-tête** :
  - mangools si l'en-tête contient `Avg. Search Volume (Last Known Values)` (et/ou
    des colonnes `Search Volume \d+/\d+`),
  - semrush si l'en-tête == la liste Semrush (présence de `Competitive Density` +
    `Number of Results` + `Trend`),
  - sinon `unknown`.
- `parse_keywords_csv(csv: &str, source: &str) -> Vec<KeywordRowResult>` —
  applique le mapping de la source → `CanonicalKeyword`. `RowResult` :
  `{ ok, error?, keyword? }`.
- `CanonicalKeyword` (serde camelCase) : `term`, `slug` (slug du terme, pour
  construire l'id côté action), `lang`, `country`, `intent: string[]`,
  `primarySource`, `metrics: { volume?, kd?, cpc?, growthPct?, competitiveDensity?,
  numberOfResults? }` (nombres nullables), `trend: [{month, volume}]`,
  `serpFeatures: string[]`. (Pas de `theme`, pas d'`id` : posés par l'action.)
- **WASM** : `detect_keyword_source(csv)`, `parse_keywords_csv(csv, source)`.
  Rebuild `bun run build:wasm && bun install`.
- Tendance : Mangools = `{month:"YYYY-MM", volume}` depuis les colonnes mensuelles
  non vides ; Semrush = les 12 valeurs mappées aux **12 derniers mois calendaires**
  (la sparkline montre la forme, même relative). Les 12 mois sont fournis par
  l'action (le core ne connaît pas « aujourd'hui ») : `parse_keywords_csv` reçoit
  aussi un paramètre `recent_months: string[]` (12 labels `YYYY-MM`) que l'action
  calcule. (Mangools ignore ce param.)
- **Pas de gabarit keyword** : on importe les exports réels.

## Server actions (`src/lib/keywords/`) — site-scopées

Le site actif est un **état client** → passé en argument.

- `src/lib/keywords/import.ts` (`server-only`) : `upsertKeywords(siteId, rows:
  CanonicalKeyword[])` — `db.insert(keywords).values(... id: `${siteId}-${slug}`,
  siteId, theme: null, status: "idea", sources: [primarySource], ...)
  .onConflictDoUpdate({ target: keywords.id, set: {...} })`. Le `set` met à jour
  `term, lang, country, intent, primarySource, sources, metrics, trend,
  serpFeatures` mais **préserve `theme` et `status`** (workflow utilisateur ;
  `excluded.*` exclut theme/status). Nouvelles lignes : `status: "idea"`,
  `theme: null`.
- `src/lib/keywords/import-types.ts` : `CanonicalKeyword`, `KwPreviewRow`,
  `KwPreviewResult` (types partagés client+serveur).
- `src/lib/keywords/import-actions.ts` (`"use server"`) :
  - `previewKeywordsImportAction(siteId, csv)` : `detect_keyword_source` → si
    `unknown` renvoie `{ source: "unknown", rows: [], counts }` ; sinon
    `parse_keywords_csv(csv, source, recentMonths)` → classe `new`/`update` (id =
    `siteId-slug`, query des ids existants du site) → `{ source, rows, counts }`.
  - `commitKeywordsImportAction(siteId, csv, source)` : **re-parse serveur** avec
    la source confirmée → `upsertKeywords(siteId, ok)` → counts. (Pas de
    `revalidatePath` : le reservoir est client-driven → refetch côté client.)
  - `recentMonths` calculé serveur (12 derniers `YYYY-MM`).

## UI (`src/components/keywords/`)

- **Toolbar** : bouton **« Importer »** inline dans la `FiltersBar` keywords
  (après le toggle Colonnes), même pattern que le backlink (slot `actions`).
- **`import-keywords-dialog.tsx`** (client) : `Dialog` + **dropzone**
  (drag-and-drop + parcourir) → lit `file.text()` → `previewKeywordsImportAction(
  activeSiteId, text)` →
  - **bandeau « Source détectée : Semrush »** avec un `Select` (position popper)
    pour **corriger** la source si mal détectée (re-preview au changement) ;
  - si `unknown` → message « Format non reconnu (Mangools / Semrush) » ;
  - **table preview** (terme, volume, KD, intent, statut `nouvelle`/`mise à jour`/
    `erreur`) + compteurs → **« Importer (N) »** → `commitKeywordsImportAction(
    activeSiteId, text, source)` → ferme + **appelle le refresh du reservoir**.
- **Refresh** : `Reservoir` expose une fonction `refresh()` (re-run
  `fetchKeywordsForSiteAction(activeSiteId)` + reset selection), passée au
  toolbar/dialog ; appelée après un import réussi.
- **Colonnes** (`columns.tsx`) : ajouter **Competitive Density**, **Number of
  Results**, **Source** (= `primarySource`). Toutes lisent
  `k.metrics[k.primarySource]?.<field>` (sauf Source). Les nouvelles colonnes sont
  **cachées par défaut** (rejoignent les colonnes optionnelles) pour ne pas
  surcharger ; activables via « Colonnes ».
- **Cellule « N/A »** : un petit composant partagé `MetricCell` (ou helper)
  affiche la valeur, ou **« N/A » en `text-muted-foreground/60` (gris clair)** si
  `null`/`undefined`. Appliqué à volume/KD/CPC/competitiveDensity/numberOfResults/
  growthPct pour les keywords où la source ne fournit pas le champ.

## Tests

- **Rust** (`cargo test -p import-core`) : `detect_keyword_source` (en-têtes
  Mangools vs Semrush vs inconnu) ; `parse_keywords_csv` sur des **échantillons
  réels** des 2 fichiers (1-2 lignes copiées) → vérifie term, intent, volume, kd,
  cpc, competitiveDensity (N/A pour Mangools), numberOfResults (N/A Mangools),
  growthPct (N/A Semrush), serpFeatures, trend, primarySource.
- **Next** (bun, DB-gated `seo_insight_test` + `assertTestDb`) : `upsertKeywords`
  insère (id = `siteId-slug`, theme null, status idea) ; sur ré-import met à jour
  les métriques **mais préserve theme + status** ; org/site-scopé.
- **Live d3k** : sur `/keywords/reservoir`, drop d'un CSV Semrush → bandeau
  « Semrush » → import → les lignes apparaissent (refetch) ; colonnes N/A en gris
  pour les champs manquants ; drop d'un CSV Mangools → « Mangools » → import OK.

## Hors scope (→ #2b ou plus tard)

- **labels/listes** (tags `labels: string[]`), filtrage par label.
- **ajout manuel** d'un keyword.
- tendance Semrush en volumes absolus (on garde les valeurs relatives).
- autres sources (Ahrefs, GSC), mapping de colonnes interactif.
- migration de la page **Research** vers la DB (reste fixtures).
