# Sync SE-Ranking — enrichissement des mots-clés sélectionnés (slice 1)

**Date** : 2026-07-14 · **Branche** : `feature/seranking-enrichment`

## Contexte & objectif

Le réservoir contient des mots-clés sans métriques SEO (tout ce qui vient de GSC,
des ajouts manuels, des imports partiels). Aujourd'hui, les enrichir passe par le
flow « exports d'enrichissement » : export CSV → outil externe → réimport manuel.

L'API SE-Ranking Keyword Research (`POST https://api.seranking.com/v1/keywords/export`)
permet de court-circuiter cet aller-retour : on sélectionne des mots-clés dans le
réservoir, on clique « Sync SE-Ranking », les métriques arrivent directement.

**Décisions produit verrouillées** :
- **Clé plateforme** (`SERANKING_API_KEY`, déjà en `.env.local`) — pas de BYO key.
  Feature « incluse » ; les crédits SE-Ranking sont à notre charge.
- **Enrichissement uniquement** — PAS de découverte de mots-clés
  (similar/related/questions/longtail = slices futures, hors périmètre).
- **Manuel uniquement** — déclenché par l'utilisateur sur sa sélection ; pas
  d'auto-enrich post-sync GSC pour l'instant.
- **Normalisation en Rust** (principe rust-maximal) — dans `crates/import-core`,
  où vit déjà le modèle canonique keyword et le parseur CSV seranking.

## L'API SE-Ranking (bulk export)

- `POST https://api.seranking.com/v1/keywords/export?source=<db>` ·
  header `Authorization: Token <SERANKING_API_KEY>`.
- Body : `keywords[]` (1–5000), option `history_from`/`history_to`
  (fenêtre 12 mois max, mois calendaires `YYYY-MM-DD`).
- Réponse par mot-clé : `is_data_found` (bool), `keyword`, `volume` (int),
  `cpc` (float), `competition` (float 0–1), `difficulty` (int 0–100),
  `intents` (lettres `I/C/T/N/L`), `history_trend` (objet `YYYY-MM-DD` → volume).
- **Coût : 100 crédits par requête** (indépendant du nombre de kw ≤ 5000).
- `source` = code base régionale **alpha-2 minuscule** (195+ pays).
  ⚠️ Gotcha : Royaume-Uni = `uk` (PAS `gb`).
- Erreurs : 400 (paramètres invalides, « Insufficient funds »), 403 (licence
  expirée), 429 (rate limit), 5xx.
- **Rate limit : 10 RPS par clé API** (Data API). La clé étant partagée par
  toute la plateforme, le débit doit être régulé **au niveau du process**, pas
  de l'action (voir §File d'attente).
- **Validé sur la collection Postman officielle (serankingdev/se-ranking-developers)** :
  réponse = tableau top-level `[{is_data_found, keyword, volume, cpc,
  difficulty, intents:["I"], competition, history_trend:{"YYYY-MM-01": n}}]` ;
  body requête = JSON brut (`{"keywords":[...], "history_to":"YYYY-MM-01"}`) ;
  succès = **201 Created** (tester `res.ok`, pas `status === 200`).

## Architecture

Même split que l'intégration GSC : **fetch HTTP en TS (server action) → wasm
Rust normalise → upsert Drizzle en TS**.

```
reservoir.tsx (sélection) ──KeywordSelection──▶ syncSerankingAction (TS)
                                                   │ session + site + quota
                                                   │ résout la sélection → kw rows
                                                   │ groupe par pays → source db
                                                   │ fetch bulk ×N (chunks 5000)
                                                   ▼
                                    wasm parse_seranking_api (Rust, import-core)
                                                   │ rows canoniques + trend
                                                   ▼
                                        upsert enrichment-safe (TS, Drizzle)
                                                   │
                                                   ▼
                                            toast récap + refresh
```

### 1. Rust — `crates/import-core/src/keyword.rs`

Nouvelle fonction wasm `parse_seranking_api(json: &str, recent_months) →
Vec<KeywordRowResult>` :
- Parse la réponse bulk JSON (serde), écarte `is_data_found == false`.
- Mapping métriques : `volume`, `cpc`, `difficulty` → kd,
  `competition` → `compDensity` (même convention que le CSV seranking),
  `numberOfResults` = null.
- Intents lettres `I/C/T/N/L` → vocabulaire complet (`local` inclus) — réutilise
  le mapping du parseur CSV seranking existant.
- `history_trend` → `TrendPoint[]` (mois `YYYY-MM`) + calcul `growthPct` selon la
  même règle que les autres sources (fenêtre `recent_months`).
- Le terme est renvoyé tel que SE-Ranking l'écho (matching insensible à la casse
  côté TS via lowercase).

Gotcha build : `bun run build:wasm` + `bun install` + restart du dev server
(le wasm n'est pas HMR'd).

### 2. TS — `src/lib/seranking/`

- `client.ts` — fetch minimal : `bulkKeywordMetrics(source, keywords, window)`.
  Timeout 30 s, 1 retry sur 429/5xx (backoff court), erreurs typées
  (`insufficient-credits`, `rate-limited`, `invalid-source`, autre).
  Clé lue depuis `env` (ajout `SERANKING_API_KEY: z.string().optional()` dans
  `src/env.ts` + flag dérivé `serankingEnabled`).
- `rate-limit.ts` — **file d'attente process-wide** devant l'API : la clé est
  partagée par toutes les orgs, donc N server actions concurrentes doivent se
  partager les 10 RPS. Limiteur module-level (token bucket, plafond prudent
  **5 RPS** — marge pour la slice découverte future) : chaque appel du client
  passe par `schedule(fn)` qui sérialise/espace les requêtes. Logique pure
  testable (horloge injectée). Suffisant car la prod tourne en **process
  unique** (systemd natif) ; si on scale multi-instance un jour, le limiteur
  devra migrer côté broker — noté hors périmètre.
- `country-db.ts` — `serankingSource(country: string): string | null` :
  alpha-2 lowercase, `GB → uk`, pays non couvert → null (groupe sauté +
  signalé). Liste des codes supportés embarquée (195 codes de la doc).
- `sync.ts` — l'orchestrateur :
  1. Résout la `KeywordSelection` (`ids` | `filter`+`except`) en lignes keyword
     (term, country, lang, metrics, sources) via la couche list-query existante.
  2. Groupe par pays, dédupe par terme lowercasé, chunke à 5000.
  3. Un fetch par chunk ; réponse → wasm → rows canoniques.
  4. Upsert **enrichment-safe** (voir §Merge).
  5. Retourne le récap : `{ enriched, noData, skippedCountries: string[],
     requests }` (`requests × 100` = crédits consommés, loggé serveur).
- `actions.ts` — `syncSerankingAction(siteId, selection)` : session,
  `assertSiteInActiveOrg`, même gate de rôle que l'import CSV/GSC,
  `assertWithinQuota("keywords")` avec le delta projeté (nb de kw de la
  sélection n'ayant pas encore `seranking` dans `sources[]`), puis `sync.ts`.

### 3. Merge enrichment-safe (≠ import CSV)

Un enrichissement ne réécrit PAS l'identité du mot-clé. UPDATE ciblé par id
(les kw existent déjà — jamais d'INSERT) :
- `metrics` : `metrics || {seranking: {...}}` (merge jsonb).
- `trendBySource` : `|| {seranking: [...]}` ; `trend` (fallback global) mis à
  jour seulement si vide.
- `sources` : union avec `seranking`.
- `intent` : union (les intents SE-Ranking s'ajoutent, n'écrasent pas).
- `primarySource` : bascule vers `seranking` **uniquement si** le kw n'avait
  aucune métrique (`metrics = {}` ou volume null sur la primary) — cas GSC.
- Jamais touchés : `term`, `lang`, `country`, `status`, `theme`, `stages`,
  `serpFeatures` (l'API bulk n'en renvoie pas), briefs, gsc.

### 4. UI — réservoir uniquement

- Entrée « Sync SE-Ranking » dans le menu toolbar du réservoir (à côté de
  « Synchroniser GSC »), **activée seulement si sélection non vide** et si
  `serankingEnabled` (sinon masquée).
- Clic → action directe (pas de dialog) → spinner sur l'item → toast récap :
  « 312 enrichis · 8 sans données SE-Ranking · pays non couvert : XK » →
  `router.refresh()` + clear sélection.
- Erreurs : quota keywords dépassé → toast avec le payload QuotaExceeded
  existant ; crédits SE-Ranking épuisés → toast explicite « Crédits SE-Ranking
  épuisés — contacte l'équipe » ; 429 → « Réessaie dans quelques minutes ».

## Cas limites

| Cas | Comportement |
|---|---|
| `is_data_found=false` | Rien d'écrit, le kw reste N/A ; compté dans `noData`. ⚠️ Vérifié en QA (2026-07-14) : l'API bulk lit la base régionale **pré-calculée** — un terme absent renvoie no-data même si l'UI web SE-Ranking le trouve (elle déclenche une analyse à la demande, badge « Updated », qui ALIMENTE ensuite la base : le même appel API réussit quelques minutes après une recherche UI). Pas d'endpoint API pour forcer l'analyse. Un re-sync ultérieur peut donc réussir là où le premier a dit no-data. |
| Pays sans base SE-Ranking | Groupe entier sauté, listé dans le toast. |
| Sélection vide | Item de menu désactivé (pas d'action). |
| Kw déjà enrichi seranking | Re-sync = rafraîchit les métriques (updatedAt) ; pas de double quota (source déjà présente). |
| Terme dupliqué (2 kw même terme, même pays) | 1 seul envoi, résultat appliqué aux deux ids. |
| Échec réseau mid-run (chunk 2/3) | Chunks déjà upsertés restent (chacun transactionnel) ; toast erreur avec compte partiel. |
| Clé absente / invalide | Item masqué (`serankingEnabled=false`) ; 403 runtime → toast erreur. |
| Syncs concurrents (plusieurs users/orgs) | Requêtes sérialisées par la file process-wide (5 RPS) — jamais plus de 10 RPS sur la clé. |

## Quota & crédits

- Pas de nouveau compteur : ajouter `seranking` à `sources[]` consomme le quota
  keywords existant (compté par kw×source — `countOrgKeywords`).
- `assertWithinQuota("keywords")` avant l'appel API, delta = kw sans la source.
- Crédits SE-Ranking : loggés (`console.info` structuré `[seranking]` : org,
  site, requêtes, kw) — le métering fin par org arrivera avec la slice
  découverte (10 crédits/kw, là ça comptera).

## Tests

- **Rust** : `parse_seranking_api` — mapping métriques/intents (dont `L→local`),
  `history_trend`→trend+growthPct, `is_data_found=false` écarté, JSON malformé.
- **TS unitaires** : `serankingSource` (GB→uk, inconnu→null) ; client (erreurs
  typées, retry 429 — fetch injecté, pas de mock.module global) ; rate-limit
  (espacement à 5 RPS, sérialisation d'appels concurrents — horloge injectée) ;
  grouping/dédup/chunking de `sync.ts` (logique pure extraite) ; merge
  enrichment-safe (primarySource conditionnel) sur la DB de test.
- **Action** : gates (session, site hors org, quota dépassé, sélection vide).
- Gate global : `bun run test:all` vert.

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

- Découverte (similar/related/questions/longtail) et page Recherche dédiée.
- Auto-enrich après sync GSC ; métering crédits par org ; bouton sur la page
  Recherche ; worker batch/cron Rust dédié.
- **Monétisation (décision 2026-07-14)** : la feature SE-Ranking n'est comprise
  dans AUCUN forfait — elle sera vendue en **top-up** (pack) avec **marge ×5**
  sur le coût crédit. Coût réel plateforme : $540/an (annuel only) = 3M
  crédits ; bulk = 100 crédits/requête (~$0.018). Le métering crédits par org
  arrive avec la slice découverte.
