# Sync GSC — dimensions pays + page

**Date:** 2026-06-10 · **Linear:** [ELEVA-57](https://linear.app/elevaseo/issue/ELEVA-57) · **CdC:** keywords/recherche F5 (métriques par pays réelles) · alimente aussi `online`.

**Périmètre v1:** étendre la **sync interactive** (`syncGscKeywordsAction`) aux dimensions **pays** (overlay par pays cible) et **page** (URL qui ranke par query). Le worker Rust (snapshots datés) n'est pas touché.

---

## A. Données

### 1. Pays (1 migration — 0021)

- **Appels** : un appel searchAnalytics **par pays cible** du site (`sites.targetCountries`, 1-6 pays) : `dimensions: ["query"]` + `dimensionFilterGroups: [{ filters: [{ dimension: "country", operator: "equals", expression: <alpha3 minuscule> }] }]`, mêmes fenêtre 28 j / rowLimit 25000.
- **Mapping pur** `src/lib/gsc/country-iso3.ts` (TDD) : `alpha2ToIso3(code: string): string | null` — table couvrant au moins FR, BE, CH, CA, US, GB + les pays européens/anglophones courants ; inconnu → `null` (pays sauté + compté).
- **Stockage** : `keywords.gsc_by_country` jsonb `Partial<Record<string /* alpha2 */, GscTracking>>` notNull default `{}` (miroir du pattern `serp` Record-par-locale). **`keywords.gsc` reste l'agrégat global inchangé.**
- **Écriture** : overlay **uniquement sur les keywords matchés** (lower(term)) — aucune création par pays, aucun changement de status/stages par pays (le passage global s'en charge). Merge jsonb par pays synchronisé (`gsc_by_country || {FR: {...}}`), les pays non re-synchronisés sont préservés.

### 2. Page (zéro migration)

- **Appel** : un appel `dimensions: ["query", "page"]` (28 j, rowLimit 25000).
- **Réduction pure** `topPageByQuery(rows): Map<queryLower, url>` (TDD) — par query, la page au **max de clics** (tie-break : max impressions, puis première).
- **Stockage** : `GscTracking.page?: string | null` — renseigné dans le blob **global** `keywords.gsc` lors du passage matched existant (`page: topPage ?? null`).

### 3. Parser + client

- `parse.ts` : `parseSearchAnalyticsPages(body): GscQueryPageRow[]` (keys[0]=query, keys[1]=page) — pur, TDD.
- `search-analytics.ts` : `querySearchAnalytics` gagne `opts.countryIso3?: string` (ajoute le dimensionFilterGroup) ; nouvelle `querySearchAnalyticsPages(token, property, opts)`.
- `sync.ts` : `syncGscForSite(siteId, rows, extras?: { byCountry?: Record<alpha2, GscQueryRow[]>; topPageByQuery?: Map<string, string> })` — passage matched global pose `gsc.page` ; second passage écrit `gsc_by_country`.
- `actions.ts` : l'action orchestre — global (cœur, échec = échec) puis pays et pages **best-effort** (try/catch par appel ; compteurs `countries: {synced, failed}` et `pages: boolean` ajoutés au résultat, message UI inchangé sinon enrichi).

## B. UI v1

1. **Matrice Recherche (CdC F5)** : sous le badge de ciblage de chaque cellule pays, la position GSC réelle du pays si présente — `p. 7,4` (`text-muted-foreground text-xs tabular-nums`). Source : `k.gscByCountry?.[code]?.position`.
2. **Keyword sheet** : section « GSC par pays » (lignes drapeau · position · clics · impressions, seulement les pays présents) + ligne « Page qui ranke » (URL cliquable, `target="_blank"`, tronquée) quand `gsc.page` existe.
3. **Online** : `OnlineRow.gscPage: string | null` ; quand `gscPage` existe **et diffère** de l'URL de l'article (comparaison normalisée — même normalisation d'URL que le match articles de /content/stats si réutilisable, sinon trim trailing slash + lowercase host), badge « ⚠️ autre page ranke » avec tooltip l'URL.

## C. Tests

- **Purs (TDD)** : `alpha2ToIso3` (mapping, casse, inconnu→null) · `parseSearchAnalyticsPages` (keys, lignes malformées sautées) · `topPageByQuery` (max clics, tie-break impressions) · mismatch URL normalisé (online).
- **DB** : `syncGscForSite` avec `extras` — `gsc_by_country` écrit par pays sur les matchés seulement (un terme non matché ne crée rien par pays), merge préserve les autres pays ; `gsc.page` posé ; sans `extras`, comportement strictement identique à l'existant (tests sync.test.ts au vert sans modification).
- **Live (d3k, Acieb, GSC réel)** : « Synchroniser GSC » → `gsc_by_country.FR` rempli en DB, matrice avec positions FR, sheet « GSC par pays » + page qui ranke, online (si données).

## Hors scope

Historique par pays (extension future du worker Rust `date×query×country`) · création de keywords par pays · tri de la matrice par position · fan-out inter-pays (ELEVA-55) · breakdown par device/apparence.
