# seoinsight v2 — Module Imports : réservoir de keywords, pipeline CSV, attache keyword→page

Date : 2026-08-31
Amendé le 2026-08-31 suite à review (voir `reviews/2026-08-31-imports-specs-review.md`).
Statut : **validée par l'utilisateur le 2026-08-31** (« Go valide les deux specs ») — upload direct navigateur → FastAPI acté comme chemin principal (décision n°8).
Statut : proposé, en attente de validation avant plan d'exécution. Dépend de la mini-spec « socle jobs » (`2026-08-31-socle-jobs-design.md`), à valider en premier.
S'appuie sur : flow utilisateur (2026-08-28 §0, §5, §6), data (2026-08-28 §3, §7), architecture (2026-08-26 §7), module Sites (2026-08-31, patterns), synthèse du panel de design Imports (2026-08-31).

## 0. But et périmètre

Le module Imports remplit le **réservoir de mots-clés** (écran 3 du flow §6) : upload CSV des plateformes SEO (puis APIs en tranche finale), normalisation vers le modèle canonique `keywords` + `keyword_metrics` (data §7), organisation en listes et tags par cible, et **l'attache keyword→page** — le geste produit central. Aucune notation, aucun score : des listes, des métriques brutes sourcées, un planning.

**Verdict sur l'idée initiale** (« on importe le keyword, on l'attache à une page, puis on crawl ») : juste sur l'essentiel, fausse sur la séquence. L'app est keyword-first, le module est pleinement utilisable avant et sans crawl (pour un site neuf, le réservoir trié par volume/KD *est* le plan éditorial), et l'attache est bien un acte de **planification** qui peut précéder l'existence de la page. Mais la majorité des sources ne fournit aucune URL : la plupart des keywords sont structurellement inattachables à l'import. La réponse n'est pas d'inverser l'ordre mais de le **supprimer** :

- **L'attache est une convergence continue et idempotente, jamais une étape.** Elle se produit dès que l'information existe : à l'import quand l'URL est là, rétroactivement à chaque crawl, via GSC au module 3, à la main à tout moment — y compris vers une URL pas encore publiée (planification d'un site neuf, cas d'usage différenciant).
- **L'orphelin est un état de premier rang**, pas une erreur : c'est le backlog d'opportunités que la Matrice (module 5) consommera.

Périmètre : tables DATA du modèle canonique + `imports` + `keyword_page_map` + `org_settings`, pipeline worker DuckDB, adaptateurs v1, API FastAPI, écrans web (wizard, liste des imports, réservoir, attache). **Hors périmètre** (contrats §8) : table `pages` (Crawl), hydratation GSC (module 3), Matrice (module 5). Aucune modification de la base AUTH.

## 1. Flow utilisateur

1. **Nav du site → section « Keywords »**, sélecteur de cible en tête (défaut `is_default`), deux onglets : **Réservoir** et **Imports**. Empty state à deux CTA : « Importer des keywords » / « Connecter Search Console » (ce dernier grisé « bientôt » tant que le module GSC n'existe pas). Les écrans sont strictement identiques avec ou sans GSC.
2. **Onglet Imports** : data-table générique (search + filtres) des imports passés — source détectée, fichier, cible, date des données, statut, rapport lues/insérées/ignorées/erreurs — bouton « Importer », plus les lignes d'événement de rattachement rétroactif (« Crawl du X : N attaches créées/confirmées ») avec bouton « relancer le rattachement » : la convergence automatique est visible, jamais silencieuse.
3. **Wizard étape 1 — Fichier** : drag & drop CSV, **upload direct navigateur → FastAPI** (`POST /imports/files`, JWT Better Auth récupéré même-origine par le client, CORS strict limité à ce seul endpoint ; spool disque + sha256 au vol côté api). Si le sha256 existe déjà pour cette cible : avertissement « déjà importé le X » avec lien vers le rapport existant, force possible — l'idempotence à la ligne (§4) rend le rejeu inoffensif et permet le re-import après correction d'adaptateur.
4. **Wizard étape 2 — Format & aperçu** : source détectée par **signature de colonnes multilingue** (Semrush localise « Mot clé »/« Volume » ; « Organic Positions » ≠ « Keyword Magic » distingués par la présence des colonnes URL/Position, jamais par nom de fichier), override manuel du format, adaptateur **« CSV générique » de premier rang** (pas un filet v1.1) — **décision utilisateur 2026-09-01 : « le mapping, nous on le fait »** : pour un CSV inconnu, l'api PROPOSE le mapping par heuristique de synonymes (`suggested_mapping` dans le preview) et l'étape 2 est une confirmation pré-remplie, jamais des selects vides ; l'utilisateur n'ajuste qu'en désaccord — aperçu de 20 lignes canoniques, warnings (pays du fichier ≠ cibles du site, colonnes ignorées, ambiguïtés de proposition). On n'échoue jamais en silence.
5. **Wizard étape 3 — Cible & destination** : cible pré-sélectionnée par le pays détecté mais **la cible choisie fait autorité** (langue absente du fichier héritée de la cible — cas SE Ranking ; mismatch tracé dans `imports.detected_country` ; jamais de création implicite de cible, lien vers le module Sites si elle manque) ; liste de destination nouvelle (nom proposé « Semrush — 2026-08-31 ») ou existante ; tags optionnels ; **« date des données »** (`fetched_at`) éditable — défaut : aujourd'hui (aucune source fiable de date dans les exports — le nom de fichier Semrush n'en est pas une).
6. **Lancement** → job `import_csv`, suivi via le **composant partagé upload + polling de job** (construit une fois, réutilisé ensuite par Crawl et GSC) : progression lues/insérées, puis rapport final avec CSV des lignes en erreur téléchargeable et lien vers la liste créée.
7. **Onglet Réservoir** (par cible) : data-table générique — recherche plein texte (tsvector + pg_trgm), filtres liste/tag/source/intent/**statut d'attache** (orphelin, URL en attente de crawl, attaché, multi-pages) et **« last updated »** (fetched_at : préréglages 7/30/90 jours, avant date). **Deux modes de vue** (décision n°4) : la **vue consolidée** par défaut (vue `keyword_metrics_latest`, badge de provenance sur chaque valeur, popover historique par source avec fetched_at et sparkline volume) et un **switch/onglet par source individuelle** (les valeurs brutes de la seule source choisie, sa fraîcheur en colonne). Fonctionne à 100 % sans GSC et sans crawl.
8. **Actions ligne et masse** : « Attacher à une page » — picker des pages crawlées (si Crawl est passé) + champ « **déclarer une URL** » à la main qui crée un map manuel en attente (`page_id` NULL) : c'est la planification d'une page pas encore publiée, le geste central sauvé de l'idée initiale ; ajouter à une liste ; tagger. L'attache manuelle est **unique par (cible, keyword)** et n'est jamais écrasée par l'automatique.
9. **Après chaque crawl** : résolution automatique des maps en attente (jointure ensembliste sur `url_normalized`), matérialisée par l'événement de l'onglet Imports ; une URL déclarée vue en 404 **garde son attache** avec badge « pointe vers une page 404 » — information de planification (rediriger ou recréer), jamais une erreur à purger.
10. **Suppression d'une cible ou du site** : confirmation pédagogique obligatoire — « vos listes et rattachements seront supprimés ; vos keywords et leurs métriques restent dans le réservoir de l'organisation » — avec **décompte** avant confirmation (pattern DELETE conditionnel du module Sites).
11. **(Tranche finale T8) Écran Intégrations** au niveau org : clé DataForSEO chiffrée (BYOK), import API par cible via le même pipeline canonique, idempotence (source, fetched_at). **Mode de synchro choisi par l'utilisateur, par (source connectée, cible)** — décision utilisateur 2026-08-31 : `manuel` (bouton « Importer maintenant », défaut) ou `récurrent` (cadence hebdomadaire ou mensuelle) ; l'écran Intégrations affiche par cible le mode, la prochaine échéance et le dernier rapport. Les sources API restent grisées dans le wizard d'ici là.

En complément (décision n°3.d) : **/settings gagne un réglage d'organisation « source préférée »** — voir §3.10 et §2.

## 2. Décisions actées (utilisateur, 2026-08-31)

| # | Sujet | Décision |
|---|---|---|
| 1 | Ordre de construction | **Inversé** (déviation validée de flow §8) : socle jobs (T0) puis Imports (T1→T5) **avant** le module Crawl. Rendu possible par la suppression des deux coûts qui justifiaient l'ordre §8 : plus de table `pages` partagée (`keyword_page_map` avec `page_id` nullable) et un socle jobs neutre gelé par mini-spec. |
| 2 | `keywords.locale` | **Marché complet langue-pays** (`fr-FR` ≠ `fr-BE`), héritée de la cible (`language` + `country` de `site_targets` recombinés) ; `UNIQUE(org_id, locale, keyword)` ; mutualisation entre sites d'une même org sur le même marché (un fetch payé une fois sert tous les sites). Coût assumé : pas de vue cross-pays du même texte en v1. |
| 3 | Consolidation v1 (enrichie) | (a) consolidation **par champ** (repli quand la source préférée n'a pas de valeur, voir d) : valeur non nulle la plus récente, tie-break par **priorité de source par métrique**, matérialisée dans la fonction SQL `keyword_metrics_latest` (§3.3), badge de provenance partout ; (b) **switch de source** dans le réservoir : consolidée + une vue par source individuelle ; (c) **filtres « last updated »** (fetched_at) dans la table ; (d) **réglage d'org « source préférée »** dans /settings : **prioritaire dès qu'elle a une valeur, métrique par métrique — même si cette valeur est plus ancienne** qu'une autre source ; repli fraîcheur + priorité par métrique quand elle n'a pas de valeur ; badge de provenance et popover historique rendent le choix lisible. **Décision utilisateur actée le 2026-08-31, postérieure à la review — elle prime sur la recommandation contraire du reviewer (fraîcheur d'abord).** Le réglage fin par métrique reste différé. |
| 4 | Suppression | **Rétention org** : CASCADE du scopé (keyword_lists + items, keyword_page_map, imports), `keywords`/`keyword_metrics`/`tags` restent à l'org ; confirmation pédagogique avec décompte ; purge des keywords inertes = job différé, noté dans `docs/pre-prod-checklist.md`. |
| 5 | Socle jobs | Polling Postgres `FOR UPDATE SKIP LOCKED`, **sans broker** — mini-spec T0 dédiée, validée avant toute ligne. |
| 6 | Attache manuelle | **Unique par (cible, keyword)** : index partiel `UNIQUE(site_target_id, keyword_id) WHERE source='manual'` — la planification désigne UNE page cible ; les observations multiples (import_url, gsc) restent toutes représentées (cannibalisation). |
| 7 | Fichiers CSV | Conservés **30 jours** puis purge (re-parse possible après correction d'adaptateur), chemin `<org_id>/<sha256>.csv`, **exclus des backups** (données des clients de l'org). |
| 8 | Upload direct (décision utilisateur 2026-08-31, remplace le spike) | **L'upload direct navigateur → FastAPI est le chemin principal acté**, pas un plan B : il contourne à la fois la limite de 4,5 Mo des route handlers Vercel et la bande passante Vercel facturée (le fichier ne transite qu'une fois). Mécanisme : le client récupère son JWT Better Auth même-origine (`/api/auth/token`, session requise), puis POST multipart direct sur `POST /imports/files` ; CORS strict (origine = `BETTER_AUTH_URL`, ce seul endpoint) ; déviation ciblée et documentée de la règle « appels api côté serveur uniquement » — tout le reste passe toujours par les Server Actions. Le spike Vercel est supprimé ; jamais de chunking client. |

## 3. Modèle de données (DATA, SQLAlchemy — `api/app/keywords/models.py` et `api/app/imports/models.py`)

Toutes les tables portent `org_id TEXT NOT NULL` indexé, filtré dans chaque requête (architecture §8). PK `UUID server_default=uuidv7()`. La table `jobs` est spécifiée à part (socle T0).

### 3.1 `keywords` (canon data §7)

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, uuidv7 |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `locale` | `TEXT` | NOT NULL, `CHECK (locale ~ '^[a-z]{2}-[A-Z]{2}$')` — marché langue-pays hérité de la cible (`fr-FR`) |
| `keyword` | `TEXT` | NOT NULL — normalisé **NFC + trim + minuscules + espaces réduits** (fiabilise l'UNIQUE) |
| `keyword_tsv` | `TSVECTOR` | générée `to_tsvector('simple', keyword)`, index GIN |
| `embedding` | `HALFVEC(1024)` | nullable, **sans index** (data §3 — index HNSW hors périmètre) |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, défaut `now()` |

Contraintes : `UNIQUE (org_id, locale, keyword)` ; index GIN `pg_trgm` sur `keyword`. Pas de partitionnement (< 50 M lignes, data §4).

### 3.2 `keyword_metrics` (canon §7 intact, zéro colonne ajoutée)

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, uuidv7 |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `keyword_id` | `UUID` | NOT NULL, FK `keywords.id` ON DELETE CASCADE |
| `source` | `TEXT` | NOT NULL — nom d'adaptateur (`semrush_positions`, `generic_csv`, …) |
| `fetched_at` | `TIMESTAMPTZ` | NOT NULL — « date des données » déclarée au wizard (imports CSV) ou horodatage du fetch (API) |
| `volume` / `kd` / `cpc` | `INTEGER` / `NUMERIC` / `NUMERIC` | nullables — telles que fournies par la source |
| `intent` | `TEXT` | nullable |
| `serp_features` | `JSONB` | nullable |
| `trend` | `SMALLINT[]` | nullable (12 valeurs) |
| `raw` | `JSONB` | NOT NULL — la ligne source complète ; **position/URL d'origine de l'export y sont rangées** (l'URL vit aussi dans le map §3.7) |

Contraintes : `UNIQUE (keyword_id, source, fetched_at)` ; index `(keyword_id, source, fetched_at DESC)` — **amendement T1 (2026-08-31, review + migration-warden)** : cet index n'est pas créé, il serait un doublon strict du btree de l'unicité, qui porte exactement ces trois colonnes ; un btree se parcourt dans les deux sens, donc `WHERE keyword_id = ? AND source = ? ORDER BY fetched_at DESC LIMIT 1` est servi par un *backward index scan*. Sur la table la plus écrite du modèle, le doublon se paierait à chaque ligne d'import. Ne pas le « réparer » à une prochaine autogénération. **La provenance n'est jamais écrasée** : l'historique et le trend sortent gratuitement de l'empilement. Précision (review C1) : le canon interdit la fusion *inter-sources*, pas la correction d'une cellule identifiée par sa provenance — la clé `(keyword_id, source, fetched_at)` désigne **une même observation**, remplaçable par un re-import après correction d'adaptateur (§4.3).

### 3.3 Fonction `keyword_metrics_latest(preferred_source)` + vue par défaut (consolidation, décision n°3)

**Une seule implémentation SQL de la règle** (review, écart n°2 resserré) : une **fonction** `keyword_metrics_latest(preferred_source TEXT DEFAULT NULL)` (`LANGUAGE sql`, inlinable) ; la vue `keyword_metrics_latest` se réduit à `SELECT * FROM keyword_metrics_latest(NULL)` (confort pour la Matrice et les requêtes ad hoc) ; le service API appelle la fonction avec `org_settings.preferred_source`. Jamais deux logiques — aucune duplication côté web.

Règle par keyword et **par champ** (`volume`, `kd`, `cpc`, `intent`, `serp_features`, `trend`), décision n°3.d actée : **si `preferred_source` a une valeur non nulle pour le champ, elle gagne — même plus ancienne** ; sinon, repli : valeur **non nulle la plus récente**, tie-break par **priorité de source par métrique**. La fonction expose pour chaque champ la valeur, la source et le `fetched_at` (badges de provenance), plus `last_updated` = max(fetched_at) toutes sources (filtre 1.7). Pseudo-SQL du principe, décliné par champ :

```sql
SELECT DISTINCT ON (keyword_id)
       keyword_id, volume, source AS volume_source, fetched_at AS volume_fetched_at
FROM keyword_metrics WHERE volume IS NOT NULL
ORDER BY keyword_id, (source = preferred_source) DESC NULLS LAST,
         fetched_at DESC, metric_priority(source, 'volume');
```

Priorités v1 (défauts du repli, définis en `CASE` dans la seule définition de la fonction — pas de table de config) : `volume` → DataForSEO > Semrush > autres ; `kd` → Semrush > DataForSEO > autres ; `cpc` → DataForSEO > Semrush > autres ; `intent`/`serp_features`/`trend` → Semrush > SE Ranking > autres ; toute source absente de la liste vient après, `generic_csv` en dernier. La règle configurable **par org et par métrique** reste différée (conforme data §7, cas résiduel §10.2).

### 3.4 `keyword_lists`

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, uuidv7 |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `site_target_id` | `UUID` | **NOT NULL**, FK `site_targets.id` ON DELETE CASCADE, indexé — la liste est le lien cible↔réservoir (flow §5) |
| `name` | `TEXT` | NOT NULL |
| `source` | `TEXT` | nullable — adaptateur d'origine, NULL pour une liste manuelle |
| `created_from_import_id` | `UUID` | nullable, FK `imports.id` ON DELETE SET NULL |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, défaut `now()` |

Contrainte : `UNIQUE (site_target_id, name)`.

### 3.5 `keyword_list_items`

`(list_id UUID FK keyword_lists ON DELETE CASCADE, keyword_id UUID FK keywords ON DELETE CASCADE)` PK composite (= UNIQUE), `org_id`, `added_at TIMESTAMPTZ`. **Aucune URL sur les items** : la revendication d'URL vit dans le map, jamais couplée à l'appartenance de liste.

### 3.6 `tags` et `keyword_tags`

`tags` : `id`, `org_id`, `name`, `UNIQUE (org_id, name)`, `created_at`. `keyword_tags` : `(keyword_id FK CASCADE, tag_id FK CASCADE)` PK composite + `org_id`.

### 3.7 `keyword_page_map` — LA sortie du module, consommée par la Matrice

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, uuidv7 |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `site_target_id` | `UUID` | NOT NULL, FK `site_targets.id` ON DELETE CASCADE, indexé — rattaché à la **cible** (cohérent avec listes, locale, Matrice par cible) |
| `keyword_id` | `UUID` | NOT NULL, FK `keywords.id` ON DELETE CASCADE |
| `url_normalized` | `TEXT` | **NOT NULL** — la clé de résolution ; jamais de page fantôme |
| `page_id` | `UUID` | **nullable, sans FK** — la FK vers `pages` sera posée par la migration du module Crawl quand la table existera |
| `source` | `TEXT` | NOT NULL, `CHECK (source IN ('import_url','manual','gsc'))` — **`'gsc'` est réservée au module 3**, aucune hydratation GSC ici |
| `position` | `NUMERIC` | nullable — position observée par la source à `seen_at` |
| `seen_at` | `TIMESTAMPTZ` | NOT NULL — date de l'observation (import : `fetched_at` ; manuel : `now()`) |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, défaut `now()` |

Contraintes et index :
- `UNIQUE (site_target_id, keyword_id, url_normalized, source)` → observations multiples représentables nativement (cannibalisation GSC = zéro migration au module 3).
- Index partiel `UNIQUE (site_target_id, keyword_id) WHERE source = 'manual'` → **une** attache de planification par (cible, keyword), jamais écrasée par l'automatique (décision n°6).
- Index `(site_target_id, url_normalized) WHERE page_id IS NULL` → résolution ensembliste (§3.9).

Sémantique de `source` : `import_url` = observation issue d'un export avec URL (Semrush Positions, plus tard DataForSEO) ; `manual` = décision de planification de l'utilisateur (picker ou URL déclarée, page publiée ou non) ; `gsc` = réservé. **Une observation se rafraîchit, une décision jamais** : les maps `import_url`/`gsc` mettent à jour `position`/`seen_at` quand une observation plus récente arrive (`DO UPDATE` gardé par `seen_at`, §4.3) ; le map `manual` n'est jamais écrasé par l'automatique.

### 3.8 `normalize_url()` — contrat inter-modules (`api/app/core/urls.py`)

Fonction pure unique, figée par une **table de cas testée** ; le module Crawl l'adoptera telle quelle, sans copie. C'est, avec `jobs` et le **mini-contrat `pages`** ci-dessous, l'un des **trois contrats pré-figés** avec Crawl (review I4 : le nier était faux — §3.9 et `test_map_convergence` le gelaient déjà de fait).

**Mini-contrat `pages` (figé ici, implémenté par la migration Crawl)** : la table `pages` exposera au minimum `id UUID`, `url_normalized TEXT` **UNIQUE par cible** (produit de `normalize_url`), un rattachement à la cible, et un statut HTTP lisible (badge « pointe vers une page 404 », flow 1.9). Imports code dès T2 la **jointure de résolution encapsulée** (une fonction unique écrite contre ce contrat), non branchée tant que `pages` n'existe pas ; `test_map_convergence` (§11) fige le contrat par fixture. Règles : hôte en minuscules + punycode (idna, comme `normalize_domain`), schéma forcé `https` (http ≡ https), ports par défaut supprimés, fragment supprimé, path vide → `/`, **trailing slash conservé** (`/page/` ≠ `/page` — le crawl stocke ce qu'il voit), paramètres de tracking supprimés (`utm_*`, `gclid`, `fbclid`, `msclkid`), **query restante triée par clé** (stabilité). Entrée invalide (schéma non http(s), pas d'hôte) → erreur : ligne en erreur à l'import, 422 au geste manuel.

| Entrée | Sortie |
|---|---|
| `HTTPS://Exemple.FR/Page#x` | `https://exemple.fr/Page` |
| `http://exemple.fr:80/` | `https://exemple.fr/` |
| `exemple.fr/page/` | `https://exemple.fr/page/` (slash final conservé) |
| `https://éxemple.fr/a` | `https://xn--xemple-9ua.fr/a` |
| `https://exemple.fr/p?utm_source=x&b=2&a=1` | `https://exemple.fr/p?a=1&b=2` |

La table de cas complète (fixtures exhaustives : trailing slash, query, majuscules, punycode, ports, espaces et percent-encoding, **clés de query dupliquées** — `a=1&a=2` : tri stable par clé, ordre des valeurs préservé —, **query vide** — `?` seul, supprimé) vit dans les tests (§11) et fait foi. Coût assumé du slash strict : `/page` et `/page/` ne se rattachent pas entre eux (impact sur le taux de résolution) ; v2 envisagée : **suggestion non automatique** sur égalité slash-insensible, jamais de rattachement d'office.

### 3.9 Résolution de l'attache (invariant : converge quel que soit l'ordre d'arrivée)

- **À l'insertion** d'un map (import ou geste manuel) : lookup direct sur `pages.url_normalized` pour poser `page_id` — **branché au module Crawl (T6)**, quand `pages` existe ; d'ici là `page_id` reste NULL à l'insertion. La jointure est encapsulée dès T2 (§3.8), prête à être branchée — le reconcile de fin de crawl couvre tout le rattrapage.
- **Job `map_reconcile(site_target_id)`** : `UPDATE` ensembliste idempotent des maps `WHERE page_id IS NULL`, joints sur `pages.url_normalized` de la cible. Enfilé par le **hook fin-de-crawl** (une ligne côté module Crawl, §8) et par le bouton « relancer le rattachement » (`dedup_key = "map_reconcile:<target_id>"`, **`dedup_scope='queue'`** — clé relâchée au démarrage du run, socle §1 : un crawl qui finit *pendant* un run de reconcile ré-enfile sans lost-wakeup, la convergence tient quel que soit l'ordre). Sans Crawl : no-op propre, tout reste orphelin ou « en attente » — jamais bloqué. Cible supprimée entre l'enfilage et l'exécution (CASCADE) : le job finit **`done` no-op**, jamais `failed` (testé, §11).
- **Le manuel n'est jamais écrasé** : la résolution remplit `page_id`, elle ne touche jamais `url_normalized` ni ne supprime un map manuel (y compris vers une page vue en 404 — badge, flow 1.9).

### 3.10 `org_settings` (décision n°3.d — actée 2026-08-31)

`org_id TEXT` PK, `preferred_source TEXT` nullable, `created_at`/`updated_at`. Une ligne par org, créée à la première écriture. Portera d'autres réglages d'org plus tard ; en v1, seul `preferred_source` existe, édité dans `/settings` — **validé au PATCH contre le registre d'adaptateurs** (422 si inconnu). Table absente de la synthèse du panel, ajoutée au plus simple pour porter la décision 3.d (écart §12.1).

### 3.11 `imports` — persistance de domaine dédiée, jamais dans `jobs.payload`

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, uuidv7 |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `site_target_id` | `UUID` | NOT NULL, FK `site_targets.id` ON DELETE CASCADE, indexé |
| `source` | `TEXT` | NOT NULL — adaptateur retenu |
| `kind` | `TEXT` | NOT NULL, `CHECK (kind IN ('csv','api'))` |
| `filename` | `TEXT` | nullable (NULL pour `api`) |
| `file_sha256` | `TEXT` | nullable — **pas d'UNIQUE dur** : le re-import légitime après correction d'adaptateur reste possible ; le doublon est un warning soft au wizard |
| `detected_format` | `TEXT` | nullable — signature reconnue à l'étape 2 |
| `detected_country` | `TEXT` | nullable — audit des mismatchs pays fichier ≠ cible |
| `fetched_at` | `TIMESTAMPTZ` | NOT NULL — date des données déclarée |
| `keyword_list_id` | `UUID` | nullable, FK `keyword_lists.id` ON DELETE SET NULL |
| `job_id` | `UUID` | NOT NULL, FK `jobs.id` |
| `status` | `TEXT` | NOT NULL — recopie du statut du job (`queued`/`running`/`done`/`failed`), voir contrat ci-dessous |
| `report` | `JSONB` | nullable — rapport final (§4.5) |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, défaut `now()` |

**Contrat de recopie du statut (review I3)** — le worker neutre reste neutre, c'est le module Imports qui tient `imports.status` en cohérence sur **tous** les chemins : (a) le handler `import_csv` recopie `running` au démarrage, `done` + rapport à la fin ; (b) Imports enregistre auprès du registre du socle un **callback `on_fail(job)`** (socle §2.2), invoqué après tout passage du job à `failed` — exception du handler comme `failed` définitif posé par le sweep des zombies (`max_attempts`) — qui recopie `failed` + `error` dans la ligne `imports`. Plus aucun import `queued` à perpétuité, et le listing reste sans join sur la file.

### 3.12 Stockage fichiers et suppressions

- `IMPORT_FILES_DIR` (Dedibox, env) : `<org_id>/<sha256>.csv` (+ `<sha256>.errors.csv` généré par le job) — `org_id` vient du JWT (opaque Better Auth) mais est **sanitisé quand même** avant construction du chemin (défense en profondeur : rejet de tout séparateur de chemin ou `..`) ; **rétention ~30 jours** puis purge (cron simple du worker) ; **exclusion des backups** (décision n°7). DuckDB = librairie Python du worker, CSV uniquement (canon).
- **Suppressions** : cible → CASCADE (`keyword_lists`+items, `keyword_page_map`, `imports`) ; site → cascade via ses cibles ; org → tout. **Rétention org** : `keywords`, `keyword_metrics`, `tags` survivent (décision n°4). Purge des keywords inertes (plus référencés par aucune liste/map d'aucune cible) = job différé, **noté dans `docs/pre-prod-checklist.md`** dès T1.

### 3.13 Migration

Une seule révision nommée (`imports`) au-dessus de la révision `jobs` du socle : `keywords`, `keyword_metrics`, fonction + vue `keyword_metrics_latest`, `keyword_lists`, `keyword_list_items`, `tags`, `keyword_tags`, `keyword_page_map`, `org_settings`, `imports` — registre `app/db/models.py` complété (`_keywords`, `_imports`), rituel wip→squash, SQL relu (index partiels et GIN explicites, aucun `DROP`), `alembic check` couvert par `test_migrations.py`. **Ops manuelles dans la révision** (l'autogénération ne les produit pas — note pour le migration-warden, la review du SQL doit les attendre) : `CREATE EXTENSION IF NOT EXISTS pg_trgm` et `vector` (halfvec), la fonction et la vue `keyword_metrics_latest` en `op.execute` (downgrade symétrique) ; `data_test` et `scripts/db-init.sh` alignés sur les extensions.

## 4. Pipeline d'import (worker, job `import_csv`)

1. **Lecture** : ouverture de `<org_id>/<sha256>.csv` ; détection d'encodage par `charset-normalizer` (UTF-8, UTF-16 Semrush, latin-1) ; DuckDB `read_csv` avec sniff du séparateur (`,` `;` tab) et du BOM. Tout dans le worker, en thread — jamais dans une requête HTTP (data §3).
2. **Adaptateur** (§5) : le format retenu à l'étape 2 du wizard mappe chaque ligne vers `CanonicalKeywordRow` (`keyword`, `volume?`, `kd?`, `cpc?`, `intent?`, `serp_features?`, `trend?`, `url?`, `position?`, `raw`) ; `locale` héritée de la cible ; normalisation du keyword (NFC, trim, minuscules, espaces réduits) et `normalize_url()` si URL. Ligne inexploitable → collectée en erreur (avec numéro de ligne et raison), jamais d'échec silencieux ni d'abandon du job pour une ligne.
3. **Écriture par lots bornés** (~5 000 lignes, une transaction par lot) : upsert `keywords` `ON CONFLICT (org_id, locale, keyword) DO NOTHING` + relecture des ids ; upsert `keyword_metrics` **`ON CONFLICT (keyword_id, source, fetched_at) DO UPDATE`** (toutes les colonnes de valeurs + `raw`) — **même clé = même observation, corrigeable** : le re-import forcé après correction d'adaptateur remplace le parse erroné ; ce n'est jamais une fusion inter-sources, la clé porte la provenance (review C1) ; insert `keyword_list_items` `ON CONFLICT DO NOTHING` ; si URL : upsert `keyword_page_map` (`source='import_url'`) **`ON CONFLICT (site_target_id, keyword_id, url_normalized, source) DO UPDATE SET position=EXCLUDED.position, seen_at=EXCLUDED.seen_at WHERE EXCLUDED.seen_at > keyword_page_map.seen_at`** — observation rafraîchie, rejeu du même fichier = no-op strict (review I2) ; le map `manual` n'est jamais touché ; `page_id` reste NULL (résolution branchée au module Crawl, §3.9).
4. **Idempotence à deux niveaux** : (a) sha256 = warning + court-circuit **soft** au wizard (§1.3) ; (b) garantie métier = les upserts sur les unicités canoniques — **le rejeu forcé du même fichier laisse la base dans un état strictement identique** (updates sans effet), le rapport compte « ignorées » les lignes qui n'ont rien changé. **Reprise à mi-course** : le rejeu complet est sûr par construction ; `progress` (checkpoint par lot : `rows_read`, `updated_at`) sert au polling et au balayage des zombies (socle §2.5). Exigence **testée** (§11).
5. **Rapport** (`jobs.report` recopié dans `imports.report` avec le statut, §3.11) : `{rows_read, rows_inserted, rows_ignored, rows_error, errors: [{line, reason}, … plafonné à 1 000]}` ; le rapport **distingue les doublons intra-fichier attendus** (Semrush Positions émet une ligne par (keyword, URL) : un keyword multi-URLs produit N−1 métriques « ignorées » — normal, la cannibalisation vit dans les maps) des vraies lignes dupliquées, pour ne pas inquiéter ; les lignes en erreur sont écrites dans `<sha256>.errors.csv`, téléchargeable (même rétention que le fichier source) — **plafonné à 50 000 lignes** (précision T2, approuvée en review), le rapport expose `errors_complete: bool` pour signaler une éventuelle troncature.
6. **Garde-fou de mesure** (data §4) : import > 10 min → paralléliser par fichier dans le worker, pas de nouveau composant. Benchmark 500 k lignes obligatoire avant merge de T2 (§11) — fixture **synthétisée par concaténation d'exports réels** (les plafonds d'export Semrush sont à 10–50 k lignes), et mesure incluant la **requête réservoir consolidée** (filtre `volume_min` sur valeur calculée + curseur), pas seulement l'import.

## 5. Adaptateurs v1 (`api/app/keywords/sources/<nom>.py`)

Interface canonique (data §7, précisée T2) : `parse_csv(path) -> Iterable[CanonicalKeywordRow | RowError]` (CSV — les lignes en erreur sont des valeurs de première classe, pas des exceptions) / `fetch(...) -> Iterable[CanonicalKeywordRow]` (API, T8) + une **signature de colonnes multilingue** déclarée par adaptateur (ensembles d'en-têtes par langue d'export — Semrush localise « Mot clé », « Volume », « Position », « URL »). La détection à l'étape 2 confronte l'en-tête du fichier aux signatures ; ambiguïté ou aucune correspondance → `generic_csv` proposé. Semrush « Organic Positions » ≠ « Keyword Magic » : distingués par **présence des colonnes URL/Position**, jamais par nom de fichier. **Constats sur exports réels (2026-08-31, fixture `semrush_magic_us_full.csv`)** : colonne `Relevance` présente et non documentée (rangée en `raw`) ; l'en-tête porte la devise (`CPC (USD)`) ; en-têtes réels `Volume`/`Competitive Density` ; **pied de page commercial de 3 lignes** en fin d'export Semrush (à ignorer — piège générique à vérifier sur chaque source en T7).

**Règle dure** : un adaptateur par PR, **fixture d'export réel obligatoire avant d'écrire le parser**. Réalité des sources (Understand 2026-08-31) :

| Source | URL / position | Encodage locale / particularités | Statut |
|---|---|---|---|
| `semrush_positions` (Organic Research → Positions) | **oui / oui** | CSV `,` ou `;` (« CSV Semicolon »), UTF-8/UTF-16, en-têtes localisés, base = pays | **v1 (T2)** |
| `semrush_magic` (Keyword Overview / Magic Tool) | non / non | volume, KD %, intent, CPC, trend, SERP features | **v1 (T2)** |
| `generic_csv` | selon mapping | mapping **proposé automatiquement** par synonymes (décision 2026-09-01), confirmé/ajusté par l'utilisateur à l'étape 2, **premier rang** | **v1 (T2, proposition auto ajoutée post-T5)** |
| Mangools KWFinder | non / non | **CSV UI réel (fixture 2026-08-31 : `api/tests/fixtures/real/kwfinder_usa_top.csv`)** : en-têtes verbeux quotés (`"Keyword"`, `"Avg. Search Volume (Last 12 months)"`, `"Location"`, `"CPC/USD"`, `"PPC"`, `"Keyword Difficulty"`, intent, SERP features, volumes mensuels depuis 2015) — le format `kw, sv, lid…` vu en ligne est celui de l'**API**, pas du CSV. **API officielle** (apidocs.mangools.com/kwfinder, relevé 2026-08-31) : auth header `x-access-token` ; endpoints `GET/POST /kwfinder/competitor-keywords` (domaine→keywords), `related-keywords`, `suggested-keywords`, `POST /kwfinder/keyword-imports` (détails par lot), `GET /mangools/locations` (résolution des lid), `GET /kwfinder/limits` (quotas ; doublons <24h non décomptés) ; format JSON de réponse et pagination NON documentés sur la page → à relever par appel réel au moment de l'adaptateur `fetch()` | CSV : T7 (fixture disponible) ; API : T8 (BYOK, candidat prioritaire — source préférée de l'utilisateur) |
| SE Ranking | non / non | `source` = pays 2 lettres sans langue → langue héritée de la cible | T7, fixture requise |
| Keysearch | non / non | colonnes CSV non vérifiées | T7, fixture requise |
| LowFruits | non / non | pas de volume natif (SD/weak spots) ; colonnes non vérifiées | T7, fixture requise |
| Keywords Everywhere | non / non | Keyword, Monthly Volume, CPC, Competition, Trend | T7, fixture requise |
| DataForSEO `ranked_keywords` | **oui / oui** (API JSON) | `location_code`/`language_code` propriétaires → mapping vers `locale` porté par l'adaptateur | T8 (BYOK) |
| GSC | — | **réservé au module 3** (source `gsc` du map, liste système « Search Console ») | hors périmètre |

D'ici leurs fixtures, les sources non vérifiées passent par `generic_csv` + mapping manuel — la réponse est un processus, pas un contenu (cas résiduel §10.3).

## 6. API (`api/app/imports/` et `api/app/keywords/` : `models.py`, `schemas.py`, `service.py`, `router.py`)

Conventions du module Sites reprises à la lettre : `CurrentOrgDep` partout, 404 jamais 403, Problem Details RFC 9457 (`AppError`), aucun `SELECT *`, pagination par curseur UUIDv7 base64url (`encode/decode_cursor` extraits vers `app/db/cursor.py`, partagés avec Sites), schémas Pydantic `extra='forbid'` sur les entrées.

| Méthode & chemin | Corps / paramètres | Réponse | Erreurs |
|---|---|---|---|
| `POST /imports/files` | multipart **streamé, appelé directement depuis le navigateur** (décision n°8 : JWT Better Auth + CORS strict origine `BETTER_AUTH_URL` sur ce seul endpoint ; spool disque + sha256 au vol, plafond de taille constante côté FastAPI) | 201 `{sha256, filename, size, duplicate_of?}` (`duplicate_of` = import existant même sha même cible) | 401, 413, 422 |
| `POST /imports/preview` | `{sha256, format?}` — lecture bornée (en-tête + 20 lignes), synchrone | détection `{format, confidence, columns, sample: [20 lignes canoniques], warnings[]}` | 404 fichier, 422 |
| `POST /imports` | `{sha256, format, site_target_id, list: {id} \| {name}, tags?: [], fetched_at, force?}` | 202 `{import, job_id}` | 404 cible/fichier, 409 sha déjà importé pour la cible sans `force`, 422 |
| `GET /imports` | `?site_target_id=&cursor=&limit=` | `{items, next_cursor}` — imports **et** événements `map_reconcile` (lus dans `jobs`, entrelacés par date) | 422 curseur |
| `GET /imports/{id}` | — | import + rapport | 404 |
| `GET /imports/{id}/errors.csv` | — | CSV des lignes en erreur | 404, 410 après purge |
| `GET /keywords` | `?site_target_id=` (requis) `&q=&list_id=&tag_id=&source=&intent=&attach_status=&volume_min=&updated_since=&view=consolidated\|<source>&cursor=&limit=` | lignes réservoir (vue consolidée avec provenance par champ, ou brut par source) | 404 cible, 422 |
| `GET /keywords/{id}/metrics` | `?site_target_id=` | historique toutes sources (popover) | 404 |
| `PUT /keywords/{id}/map` | `{site_target_id, url}` — upsert de **l'attache manuelle** (unique par cible, remplaçable par l'utilisateur seulement) | 200 map (`page_id` posé si résoluble) | 404, 422 URL invalide |
| `DELETE /keywords/{id}/map` | `?site_target_id=` — supprime **le map `manual` uniquement** (les observations `import_url`/`gsc` ne se suppriment pas à la main) | 204 | 404 |
| `POST /keyword-lists` / `GET /keyword-lists` | `{site_target_id, name}` / `?site_target_id=&cursor=` | 201 / liste | 409 nom pris, 404, 422 |
| `POST /keyword-lists/{id}/items` | `{keyword_ids: []}` (masse, `ON CONFLICT DO NOTHING`) | 200 `{added}` | 404, 422 |
| `POST /tags` / `POST /keyword-tags` | `{name}` / `{keyword_ids: [], tag_id}` (masse) | 201 / 200 | 409, 404, 422 |
| `POST /site-targets/{id}/reconcile` | — | 202 `{job_id}` (dedup : renvoie le job en file s'il existe) | 404 |
| `GET /site-targets/{id}/usage` | — | décompte `{lists, list_items, maps, imports}` pour la confirmation de suppression (flow 1.10) | 404 |
| `GET /jobs/{id}` | — (socle T0) | état + progress + report | 404 |
| `GET /settings/org` / `PATCH /settings/org` | `{preferred_source?}` | réglages org | 422 (source absente du registre d'adaptateurs) |

**Prédicat d'appartenance au réservoir d'une cible** (review I6, gouverne `GET /keywords`, `attach_status`, le décompte `usage` et l'écran T5) : un keyword appartient au réservoir de la cible s'il est **membre d'au moins une `keyword_list` de la cible OU porte au moins un `keyword_page_map` sur la cible**. Un keyword attaché à la main puis retiré de toute liste reste donc visible (via son map) — jamais d'attache invisible. Les keywords de l'org sans lien avec la cible n'apparaissent pas ; **pas de vue « tout l'org » en v1** (tranché au plus simple : le besoin n'est pas établi, et rendre `site_target_id` optionnel plus tard suffirait à l'ajouter à coût nul).

**Curseur du listing fusionné `GET /imports`** (review, minor 1) : UNION des lignes `imports` de la cible et des jobs `map_reconcile` de la cible (événements), **triée par `id` DESC** (uuidv7 ≈ ordre temporel) ; keyset sur ce même `id` : `next_cursor` = dernier `id` rendu, `WHERE id < :cursor` appliqué dans chaque branche de l'UNION — même encodage base64url que Sites.

`attach_status` calculé côté SQL : `orphan` (aucun map), `pending` (map dont tous les `page_id` sont NULL), `attached`, `multi` (maps vers plusieurs `url_normalized`). La recherche `q` passe par `keyword_tsv` (+ repli `pg_trgm` pour le flou), jamais de `LIKE '%…%'` non indexé.

## 7. Web (`web/`)

### 7.1 Routes et navigation

```
web/app/(app)/sites/[siteId]/keywords/
├── page.tsx                 # Réservoir (onglet 1), sélecteur de cible, switch de vue source
├── actions.ts               # attache, listes, tags, réglages — Server Actions
├── loading.tsx  error.tsx  not-found.tsx
├── imports/
│   ├── page.tsx             # onglet Imports : data-table imports + événements reconcile
│   ├── loading.tsx  error.tsx
│   └── new/
│       ├── page.tsx         # coquille serveur, rend <ImportWizard/>
│       ├── actions.ts       # preview, création d'import, polling de job
│       └── loading.tsx  error.tsx
```

Entrée **« Keywords »** dans `nav-main.tsx` (surface de travail du site actif, href construit avec le site actif) ; `siteIdFromPathname` (`lib/active-site.ts`) étendu au préfixe `/sites/<id>/…`. La tuile « Listes de mots-clés » de `/settings/sites/[siteId]` se relie au vrai compteur. `/settings` gagne le champ « source préférée » (Select des sources connues + « aucune »).

### 7.2 Frontière typée et upload

`web/lib/keywords.ts`, `web/lib/imports.ts`, `web/lib/jobs.ts` : schémas Zod de toutes les réponses (pattern `lib/sites.ts`), types inférés. Tout passe par `apiFetch` server-only et des Server Actions — **une seule exception, actée (décision n°8, 2026-08-31)** : l'upload part **directement du navigateur vers FastAPI** (`POST /imports/files`). Le composant client récupère le JWT même-origine (`GET /api/auth/token`, plugin jwt de Better Auth, session requise) juste avant l'envoi (jeton 15 min, un upload ≪ 15 min), puis POST multipart avec `Authorization: Bearer`. Côté FastAPI : middleware CORS restreint à l'origine `BETTER_AUTH_URL` et à ce seul chemin, vérification JWT identique aux autres endpoints, plafond de taille explicite (413). Justification : contourne la limite de 4,5 Mo des route handlers Vercel ET la double traversée de bande passante (un export Positions 50 k lignes dépasse couramment 4,5 Mo). Aucun route handler proxy, aucun spike, jamais de chunking client — et rien d'autre ne déroge à la règle server-only.

### 7.3 Composant partagé upload + suivi de job

`web/components/jobs/` — construit **une fois**, réutilisé ensuite par Crawl et GSC (règle composants réutilisables) : zone de drop + envoi vers le route handler + barre de progression alimentée par polling de `GET /jobs/{id}` via Server Action (intervalle ~2 s, arrêt sur `done`/`failed`), rendu du rapport final (compteurs + lien erreurs.csv + lien liste créée).

### 7.4 Wizard d'import (3 étapes, pattern `SiteWizard`)

`components/keywords/import-wizard.tsx` (`'use client'`, état local `step`) : 1. Fichier (drop, upload, warning `duplicate_of` avec lien rapport + case « forcer ») ; 2. Format & aperçu (`preview` : format détecté, override Select, mapping manuel de colonnes pour `generic_csv`, 20 lignes, warnings) ; 3. Cible & destination (cible pré-sélectionnée par pays détecté, liste nouvelle/existante, tags, date des données) → `createImportAction` → suivi via §7.3 → redirect vers l'onglet Imports.

### 7.5 Réservoir et attache

`components/keywords/columns.tsx` + `keywords-table.tsx` sur le `DataTable` générique — jamais de table locale. La pagination des données est **serveur, par curseur** (lien « Suivant », pattern `/sites`) ; recherche et filtres passent par les paramètres de `GET /keywords` (volumes trop grands pour du client-side). Colonnes : keyword, métriques consolidées avec **badge de provenance** par valeur (source + fetched_at en tooltip), popover historique par source (fetched_at, sparkline volume), listes, tags, **statut d'attache** (badge orphelin / en attente de crawl / attaché / multi-pages / « pointe vers une page 404 » après Crawl), last updated. En tête : switch de vue (Consolidée | une entrée par source présente sur la cible) et filtres dont « last updated ». Actions ligne et masse : « Attacher à une page » (dialog : picker des pages crawlées — vide avant Crawl — + champ « déclarer une URL »), « Ajouter à une liste », « Tagger ». Suppression de cible : dialog de confirmation pédagogique alimenté par `GET /site-targets/{id}/usage`.

## 8. Contrats inter-modules

**Imports ne possède pas** : la table `pages` (module Crawl — Imports ne crée jamais une ligne `pages`, la revendication d'URL vit dans le map) ; l'hydratation GSC et la liste système « Search Console » (module 3 — elles entreront par `keyword_page_map.source='gsc'` et par une `keyword_list` système, véhicule d'entrée des requêtes GSC, **zéro migration** requise) ; la Matrice (module 5 — elle consomme `keyword_page_map` + `keyword_metrics_latest`).

Ce qu'Imports **fournit** aux autres modules :
1. **`normalize_url()`** (`app/core/urls.py`, §3.8) : contrat figé par sa table de cas ; Crawl l'adopte telle quelle pour `pages.url_normalized`, sans copie.
2. Le **mini-contrat `pages`** (§3.8) : `id`, `url_normalized` UNIQUE par cible, rattachement cible, statut HTTP lisible — figé ici par la fixture de `test_map_convergence`, implémenté par la migration Crawl.
3. **Le hook fin-de-crawl — une ligne côté Crawl** : `enqueue_job("map_reconcile", site_target_id=…, dedup_key=…)` à la fin de chaque run. Tout le reste (résolution, événement UI, bouton relancer) est côté Imports (T6).
4. La **fonction + vue `keyword_metrics_latest`** (§3.3) : implémentation unique de la consolidation, consommée par l'écran Keywords et la Matrice.
5. Le **socle jobs** (spec T0) : neutre, partagé, gelé par sa mini-spec.

Quand Crawl arrive, sa migration pose la FK `keyword_page_map.page_id → pages.id` (`ON DELETE SET NULL` — une page supprimée redevient « en attente », l'URL déclarée reste).

## 9. Tranches de construction (T0 → T8)

| Tranche | Contenu | Dépend de |
|---|---|---|
| **T0** | Mini-spec jobs **validée par l'utilisateur**, puis table `jobs` + worker SKIP LOCKED + `GET /jobs/{id}` (`app/jobs/`) | — |
| **T1** | Modèle DATA pur DDL, une migration squashée (§3 complet + `normalize_url` et sa table de cas testée). Décisions n°2 (locale) et n°6 (cardinalité manuelle) actées avant | Sites |
| **T2** | Pipeline worker CSV (§4) : DuckDB + charset-normalizer, registre d'adaptateurs (`semrush_positions`, `semrush_magic`, `generic_csv`) et signatures multilingues, lots bornés ON CONFLICT/DO UPDATE, **jointure de résolution encapsulée non branchée** (§3.8 — le branchement arrive en T6 avec `pages`), reprise à mi-course **testée**, rapport. **Benchmark 500 k lignes (concaténation d'exports réels) avant merge, import + requête réservoir consolidée** | T0 + T1 |
| **T3** | API FastAPI (§6) : upload streamé + sha256 + dedup soft, preview/détection, POST import, listings par curseur, Problem Details, `CurrentOrgDep` | T2 |
| **T4** | Web imports : upload direct navigateur → FastAPI (décision n°8 — JWT même-origine + CORS strict, pas de proxy ni de spike), composant partagé upload+polling (§7.3), wizard, liste des imports | T3 |
| **T5** | Web réservoir + attache (§7.5) : table consolidée, badges, popover, statuts, picker + URL déclarée, actions de masse. **Livrable et utile sans Crawl** (picker vide, déclaration manuelle seule — test d'acceptation du cas §10.1) | T3 (T4 pour le shell) |
| **T6** | Résolution rétroactive : job `map_reconcile` (`dedup_scope='queue'`) + hook fin-de-crawl (une ligne côté Crawl) + **branchement de la résolution à l'insertion** (lookup `pages`, §3.9) + événement UI « N attaches créées » + bouton relancer. Sans Crawl : no-op propre | `pages` (module Crawl) |
| **T7** | Adaptateurs CSV restants (Mangools, SE Ranking, Keysearch, LowFruits, Keywords Everywhere) — **un par PR, fixture réelle d'abord**. Parallélisable dès la fin de T2 | T2 |
| **T8** | BYOK + synchro : tables `integrations` (org_id, provider, credentials chiffrés, config jsonb, status) et `import_schedules` (org_id, integration_id FK, site_target_id FK CASCADE, sync_mode `manual\|weekly\|monthly`, next_run_at, enabled, UNIQUE(integration_id, site_target_id) — migration propre à cette feature, une seule) ; écran Intégrations org (mode de synchro par cible, prochaine échéance, dernier rapport) ; adaptateur DataForSEO `fetch()` (mapping location_code/language_code → locale) ; job `import_api` idempotent (source, fetched_at) ; **planificateur = sweep périodique du worker existant** : les schedules dus (`next_run_at <= now, enabled`) sont enfilés avec `dedup_key = (source, cible, période)` — un tick raté se rattrape au tick suivant, jamais de doublon ; endpoints : CRUD `integrations` (clé jamais renvoyée en clair), CRUD `import_schedules`, `POST /integrations/{id}/import` (déclenchement manuel par cible). Indépendant de Crawl | T3 |

Ordre inversé (décision n°1) : T0→T5 se construisent **avant** le module Crawl ; T6 attend `pages`. Une tranche = une branche `feature/*` ; migrations : T0 (`jobs`), T1 (`imports`), T8 (`byok`) — les autres tranches n'en portent aucune.

## 10. Cas résiduels assumés (synthèse du panel)

1. **Orphelins sans URL** : aucun chemin de rattachement automatique avant le module GSC — d'ici là, seul le geste manuel attache ; l'attache de masse reste un manque réel jusqu'à GSC (assumé : embeddings v2.x, le crawl seul ne porte aucun signal keyword↔page).
2. **Réglage fin de consolidation par org et par métrique** différé (conforme data §7) : la source préférée d'org est actée (décision n°3.d — elle gagne dès qu'elle a une valeur, métrique par métrique), mais le défaut de repli « fraîcheur + priorités par métrique » reste un pari d'affichage ; si une source pauvre fraîche gêne malgré les priorités, le réglage fin sera re-priorisé plus tôt.
3. **Colonnes réelles non vérifiées** (Keysearch, LowFruits, Mangools `lid`, Keywords Everywhere) : aucune signature possible avant fixtures d'exports réels ; d'ici là, `generic_csv` + mapping manuel.
4. **`fetched_at` déclaratif** quand le CSV ne porte pas sa date : deux imports du même export avec des dates différentes fabriquent un faux historique ; le warning sha256 mitige (même fichier), n'élimine pas (export re-téléchargé = sha différent).
5. **Capacité de `jobs` pour Crawl** (concurrence par site, rate limiting, reprise longue) : prouvée seulement au design du module Crawl ; la mini-spec T0 est une mitigation, pas une garantie.
6. **Croissance non bornée du réservoir org** : rétention à la suppression + historique jamais écrasé × N sources × rafraîchissements ; v1 = surveillance, purge/agrégation différées, dette notée en `pre-prod-checklist.md`.
7. **Typo dans une URL déclarée** : attache « en attente de crawl » perpétuelle, indétectable — bénin par construction (aucune page fantôme), aucune détection ni relance en v1.

## 11. Tests attendus

### 11.1 pytest (`api/tests/`, base `data_test`, `-W error`)

- `test_normalize_url.py` — la **table de cas** du §3.8, exhaustive (trailing slash, query triée/tracking, clés de query dupliquées — tri stable, ordre des valeurs préservé —, query vide `?`, casse, punycode, ports, fragment, espaces/percent-encoding, entrées invalides) : c'est le contrat inter-modules, elle fait foi.
- `test_import_pipeline.py` — fixture Semrush réelle : compteurs exacts lues/insérées/ignorées/erreurs ; **rejeu forcé du même fichier = état final de la base strictement identique** (updates sans effet, lignes comptées « ignorées ») ; **re-import correctif** : même fichier re-parsé après correction d'adaptateur → les métriques de la même observation (même clé) sont remplacées, les autres sources intactes (C1) ; map `import_url` rafraîchi par une observation plus récente (position 12 → 5), jamais rétrogradé par une plus ancienne (I2) ; doublons intra-fichier attendus distingués dans le rapport ; lignes en erreur collectées + errors.csv ; keyword normalisé (NFC/casse/espaces) dédoublonné ; map `import_url` créé quand URL présente.
- `test_import_resume.py` — **reprise à mi-course sans doublon** : job interrompu après N lots, rejoué, **état final de la *base* strictement identique** à un run ininterrompu — **compteurs du rapport inclus** (précision T2 : ils sont identiques après reprise, le curseur fencé garantit qu'aucun lot n'est recompté) ; scénario exercé aussi sur `keyword_page_map` (fixture avec URL).
- `test_map_convergence.py` — **invariant de convergence** : les scénarios import-puis-crawl, crawl-puis-import, déclaration-manuelle-puis-crawl (table `pages` simulée par une fixture **du mini-contrat §3.8**) aboutissent au même état de `keyword_page_map` ; le map manuel n'est jamais écrasé ; `map_reconcile` idempotent (double exécution = même état) ; no-op propre sans `pages` ; **cible supprimée entre enfilage et exécution → job `done` no-op, jamais `failed`** ; crawl finissant pendant un run de reconcile → nouveau job enfilé et exécuté (pas de lost-wakeup, socle §1).
- `test_keywords_api.py` — réservoir : **prédicat d'appartenance** (membre d'une liste de la cible OU map sur la cible ; keyword retiré de toute liste mais mappé reste visible ; keyword d'org sans lien invisible), filtres (liste, tag, source, intent, `attach_status`, `updated_since`, `volume_min`), recherche tsvector/trgm, curseur (ordre stable, pas de trou), vue consolidée (**source préférée gagnante dès qu'elle a une valeur, même plus ancienne, métrique par métrique** ; repli valeur non nulle la plus récente + priorité par métrique quand elle est trouée — une source trouée ne masque jamais la valeur d'une autre ; provenance exposée), vue par source.
- `test_attach.py` — PUT map manuel : unicité par (cible, keyword), remplacement par l'utilisateur, 422 URL invalide, résolution immédiate si page connue.
- `test_import_status.py` — **cohérence `imports.status`** (I3) : exception du handler → `failed` + `error` recopiés dans `imports` via le callback `on_fail` ; `failed` définitif posé par le sweep (`max_attempts`) → même recopie ; jamais d'import `queued`/`running` à perpétuité.
- `test_imports_org.py` / `test_keywords_org.py` — isolation : tout ce qui appartient à l'org A est invisible (404) pour l'org B, y compris fichiers uploadés et jobs.
- `test_deletion_retention.py` — suppression de cible : listes/items/maps/imports disparus, `keywords`/`keyword_metrics`/`tags` intacts ; `usage` renvoie les bons décomptes.
- Adaptateurs (T2, T7) : un test par adaptateur **sur sa fixture d'export réel**, signature multilingue reconnue, Positions/Magic distingués par colonnes.
- **Benchmark T2 (bloquant avant merge)** : fixture 500 k lignes **synthétisée par concaténation d'exports réels** (plafonds d'export Semrush : 10–50 k) — durée totale (garde-fou 10 min, data §4), **requête réservoir consolidée** (`volume_min` sur valeur calculée + curseur), locks observés, bloat post-import ; chiffres consignés dans la PR.
- `test_migrations.py` (`alembic check`) couvre mécaniquement le registre étendu.

### 11.2 vitest (`web/lib/`)

Parsing Zod de `keywords.ts` / `imports.ts` / `jobs.ts` sur fixtures de réponses (dont malformées → throw) ; logique pure d'affichage (formatage provenance, libellés de statut d'attache).

## 12. Écarts à la synthèse

Réglés par l'amendement du 2026-08-31 (ne sont plus des questions ouvertes) : l'ancien écart n°1 — `org_settings` et le sens de la source préférée sont **actés** (décision n°3.d, §2 : la préférée gagne dès qu'elle a une valeur, métrique par métrique) ; l'ancien écart n°2 — la double implémentation vue/requête est remplacée par **une fonction SQL unique** appelée par la vue par défaut et par le service (§3.3, reco du reviewer suivie).

Restent assumés :

1. **`org_settings`** (§3.10) : table absente du `modele_final` de la synthèse — ajout minimal (PK org_id, une colonne), seule façon simple de porter la décision n°3.d ; jugée « justifiée et propre » par la review.
2. **Événements de rattachement** (onglet Imports) : la synthèse ne dit pas où ils vivent ; choix au plus simple = lecture des jobs `map_reconcile` entrelacée dans `GET /imports` (curseur spécifié en §6), aucune table d'événements. Dépendance actée : `jobs` jamais purgée en v1 (socle §1) — une purge future devra épargner ou archiver ces lignes.
3. **Priorités par métrique v1** (§3.3) : la synthèse ne donne que deux exemples (volume, position) ; la table complète des défauts est proposée ici en `CASE` unique, à ajuster à la validation. La « position » n'est pas un champ consolidé de `keyword_metrics` (canon intact) : elle s'affiche depuis `keyword_page_map`/`raw`.
4. **FK `page_id`** : la synthèse dit « FK posée par la migration Crawl » sans préciser le `ON DELETE` ; proposé `SET NULL` (une page supprimée redevient « en attente », cohérent avec l'invariant de convergence) — à confirmer dans la spec Crawl.
5. **Mini-contrat `pages` pré-figé** (§3.8, ajouté suite à review I4) : la spec assume de geler trois éléments du futur module Crawl (`url_normalized` UNIQUE par cible, id, statut lisible) — c'était déjà vrai de fait (fixture de `test_map_convergence`), le déclarer vaut mieux que le nier.
