diff --git a/docs/superpowers/specs/2026-07-09-gsc-crux-data-layer-design.md b/docs/superpowers/specs/2026-07-09-gsc-crux-data-layer-design.md new file mode 100644 index 0000000..d992bea --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-gsc-crux-data-layer-design.md @@ -0,0 +1,372 @@ +# Spec — Couche data GSC + CrUX pour `/seo` (+`/geo`) FULL + +- **Date** : 2026-07-09 +- **Statut** : Design validé — prêt pour `/writing-plans` +- **Auteur** : Bastien Chanot (design assisté) +- **Repo** : claude-config (`~/Documents/claude`, symlinké dans `~/.claude` via `link.sh`) +- **Cycle de vie** : document de travail **transitoire** — à supprimer une fois la feature livrée, + documentée (`/document-release` ou `/doc`) et capitalisée (`decisions.md`). Ne pas conserver à long terme. + +--- + +## 1. Contexte & objectif + +L'audit comparatif entre les skills perso `/seo` + `/geo` et l'outil marketplace +`agricidaniel/claude-seo` a isolé **un seul gap structurel** : les skills perso ne +peuvent pas lire la **donnée Google réelle** d'un site (requêtes/positions/impressions +de la Search Console, statut d'indexation, Core Web Vitals **terrain**). Ils se limitent +à l'API PageSpeed anonyme (données *labo*) et à `WebSearch`. + +**Objectif** : combler ce gap **sans** installer l'outil tiers (860 KB de Python, mainteneur +unique, surface supply-chain + credentials OAuth à confier). On ajoute une couche data +minimale, isolée, sous contrôle, branchée sur les analyzers existants. + +**Ce que ça débloque concrètement** : +- CWV **terrain** (CrUX, 75e percentile, mobile + desktop, historique) au lieu du seul labo. +- Requêtes GSC : le pattern « **positions 4-10 à fort volume d'impressions** » = quick wins + que les skills ne pouvaient pas voir. +- Indexation réelle par URL (GSC URL Inspection) au lieu d'une déduction. + +--- + +## 2. Principes directeurs (non négociables) + +1. **Sécurité avant tout.** Secrets hors git, permissions `0600`, scope OAuth **lecture seule**, + redaction systématique, aucun secret dans un rapport/log. Toute surface secret nouvelle sous + `~/.claude` est **explicitement allowlistée** dans `.gitleaks.toml`. +2. **Dégradation gracieuse (fail-open audit).** Creds absents / token révoqué / quota 429 → + l'audit FULL **continue** en retombant sur PageSpeed anonyme + une action utilisateur. + Jamais de crash. +3. **Consentement OAuth one-shot, runs silencieux ensuite.** Le consentement navigateur ne se + fait qu'au setup (ou à l'ajout d'un compte). Les audits suivants sont non-interactifs. +4. **Multi-compte, zéro conflit.** Plusieurs comptes Google connectables ; deux audits de deux + sites en simultané sont **isolés par construction** (compte + propriété = paramètres explicites + par appel, jamais un état global mutable). +5. **Isolation.** Le moteur ne connaît rien du SEO (rend du JSON) ; les analyzers ne connaissent + rien d'OAuth (consomment du JSON). Deps Python isolées dans un venv dédié. + +--- + +## 3. Décisions actées + +| # | Décision | Choix | +|---|---|---| +| Auth GSC | OAuth2 installed-app, consentement one-time, refresh token stocké | **OAuth2** | +| Scope data v1 | GSC Search Analytics + URL Inspection + CrUX field | **oui** (GA4/Ads/Indexing hors v1) | +| Surface | Fold dans `/seo` (+`/geo`) FULL ; pas de nouveau skill d'audit | **oui** (setup = `make seo-connect`, pas un skill d'audit) | +| Langage | Helper Python + `google-auth`, venv isolé | **oui** | +| Multi-compte | Store keyé par compte ; sélection à **chaque** audit FULL | **oui** | +| Persistance token | Auto-écriture idempotente dans le store (write-temp→rename) | **oui** | +| Déclencheur consentement | `install.sh` (proposé) **et** `make seo-connect` (toujours dispo) | **oui, les deux** | +| Gitleaks | Allowlister le token store (comme `~/.claude/.env`) | **oui** | + +--- + +## 4. Architecture + +### 4.1 Vue d'ensemble + +``` +lib/seo-data/ ← LE MOTEUR (nouveau, isolé, sans logique SEO) +├── fetch.sh entrypoint bash : source ~/.claude/.env → active venv → dispatch +│ sous-commandes → JSON sur stdout → dégrade proprement si creds absents +├── google_seo.py appels GSC (Search Analytics, URL Inspection, sites.list) + CrUX, +│ refresh OAuth via google-auth, normalisation JSON +├── connect.py consentement OAuth one-time (InstalledAppFlow) + écriture store +├── tokenstore.py lecture/écriture atomique du store keyé (partagé par connect+fetch) +├── requirements.txt deps épinglées : google-auth, google-auth-oauthlib, requests +└── README.md contrat d'usage + sous-commandes + +~/.claude/.venv-seo-data/ ← venv isolé (deps hors système, reproductible) +~/.claude/seo-data/tokens.json ← store keyé par compte (0600, hors git) + +CONSOMMATEURS (existants, patchés) : +agents/seo-analyzer.md STEP 4 (CWV) → data terrain CrUX quand dispo ; nouvelle + sous-section « Performance GSC » ; STEP 9 axe Technical nourri au réel. +skills/seo/SKILL.md STEP 0 → sélection compte + propriété (main loop, interactif). +``` + +### 4.2 Composants & frontières + +| Unité | Rôle unique | Utilisée comment | Dépend de | +|---|---|---|---| +| `fetch.sh` | orchestre env→venv→python, dégrade, redige | `bash fetch.sh --account … --property …` → JSON | `~/.claude/.env`, venv, tokenstore | +| `google_seo.py` | appelle GSC + CrUX, normalise en JSON | appelé par `fetch.sh` | google-auth, requests | +| `connect.py` | consentement OAuth one-time + découverte propriétés | `make seo-connect` / STEP 0 | google-auth-oauthlib, tokenstore | +| `tokenstore.py` | I/O atomique du store keyé | importé par connect + google_seo | stdlib (json, os, fcntl) | +| seo-analyzer (patch) | consomme le JSON, score, rapporte | inchangé pour l'utilisateur | `fetch.sh` (optionnel) | +| seo/SKILL.md (patch) | sélectionne compte+propriété en STEP 0 | interactif, main loop | `fetch.sh accounts` | + +--- + +## 5. Authentification & secrets (multi-compte) + +### 5.1 Modèle OAuth + +- **Type** : OAuth2 « installed app » (client Desktop créé dans la console GCP par l'utilisateur). +- **Scope unique** : `https://www.googleapis.com/auth/webmasters.readonly` (GSC lecture seule). + Least privilege strict : le token ne peut **rien modifier** sur GSC, révocable côté Google. +- **Flow** : `connect.py` construit la config client **en mémoire** (`InstalledAppFlow.from_client_config`) + à partir des vars d'env — **aucun `client_secret.json` sur disque**. Le consentement ouvre un + serveur local + navigateur ; au retour, on obtient un `refresh_token` (durable) écrit dans le store. +- **Runs d'audit** : `google_seo.py` échange le `refresh_token` contre un `access_token` **éphémère + en mémoire** (jamais persisté). Donc **aucune écriture disque pendant un audit**. + +### 5.2 Vault `~/.claude/.env` (app partagée + CrUX) + +Ne contient que ce qui est **commun à tous les comptes** : + +``` +# ── Google SEO data layer (lib/seo-data) ── +# App OAuth Desktop partagée (console GCP → APIs & Services → Identifiants). +# Scope demandé : webmasters.readonly. Setup : make seo-connect +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +# Clé API CrUX + PageSpeed (console GCP → clé API restreinte à ces deux APIs). +# Get it: https://developer.chrome.com/docs/crux/api (bouton "Get a key") +CRUX_API_KEY= +``` + +Les **refresh tokens ne sont PAS ici** (multi-compte → store dédié). + +### 5.3 Token store keyé + +`~/.claude/seo-data/tokens.json`, permissions `0600`, dossier `0700` : + +```json +{ + "version": 1, + "accounts": { + "client-a": { + "refresh_token": "", + "scopes": ["https://www.googleapis.com/auth/webmasters.readonly"], + "granted_at": "2026-07-09T…", + "properties": ["sc-domain:site-a.com", "https://www.site-a.com/"] + }, + "client-b": { "…": "…" } + } +} +``` + +- Clé = **label choisi par l'utilisateur** au moment du `connect` (ex. `client-a`), **pas** l'email. + Raison sécurité : keyer par email obligerait à élargir le scope OAuth (`userinfo.email`) juste pour + l'identification. On reste à `webmasters.readonly` strict ; le label suffit à distinguer les comptes. + Collision de label → `connect` demande confirmation (écraser / renommer). +- `properties` = propriétés GSC accessibles (via `sites.list`, **déjà** dans le scope + `webmasters.readonly`), pré-remplies au `connect` pour la sélection en STEP 0. +- Écriture **uniquement** au `connect` (jamais pendant un audit), **atomique** : write vers + `tokens.json.tmp` → `fsync` → `rename` ; verrou `fcntl` exclusif le temps de l'échange + read-modify-write pour couvrir deux `connect` simultanés. + +### 5.4 Gitleaks allowlist + gitignore + +- `~/.claude/seo-data/tokens.json` vit **hors du repo** (le repo ne symlink que + `hooks agents skills lib templates rules`). Il n'entre donc jamais en git directement. +- Mais `make scan-secrets` (gitleaks) balaie `~/.claude` à la recherche de copies de secrets. + On **ajoute une entrée d'allowlist** dans `.gitleaks.toml` `[allowlist].paths`, exactement + comme `~/.claude/.env` l'est déjà : + ```toml + # Token store OAuth de la couche seo-data — secret local légitime (BDR-026 pattern), + # hors git, 0600. On l'allowliste pour ne pas noyer scan-secrets de faux positifs. + '''(^|/)\.claude/seo-data/tokens\.json$''', + ``` +- `.gitignore` : ajouter `.venv-seo-data/` et `seo-data/tokens.json` par prudence (au cas où un + chemin relatif les ferait apparaître sous le repo), en complément de l'exclusion `.env*` existante. + +--- + +## 6. Sélection de compte & propriété (STEP 0, main loop) + +Interactif → se déroule **dans le dispatcher `/seo` (main loop)**, jamais dans le subagent +(qui ne peut pas interagir). Uniquement en **FULL** (LOCAL n'a pas de donnée live). + +1. Lister les comptes connectés : `bash lib/seo-data/fetch.sh accounts` → JSON `{accounts:[…]}`. +2. Présenter à l'utilisateur (label + propriétés découvertes) : + ``` + COMPTE GOOGLE pour cet audit FULL : + 1) client-a (sc-domain:site-a.com, https://www.site-a.com/) + 2) client-b (sc-domain:site-b.com) + N) Connecter un nouveau compte (choisir un label) + S) Ignorer (audit sans donnée GSC — CWV terrain via CrUX seulement) + ``` +3. « Connecter un nouveau compte » → demande un **label**, lance `connect.py` dans le main loop + (consentement navigateur), puis auto-découverte des propriétés (`sites.list`) → re-liste. +4. Compte choisi → si plusieurs propriétés, demander **laquelle** correspond au site audité. +5. Le couple `(account, property)` retenu est passé **explicitement** dans le contexte de + l'analyzer (bloc BUSINESS CONTEXT du dispatch, STEP 1), qui appellera + `fetch.sh queries|inspect --account --property

`. + +CrUX ne demande pas de compte (clé API publique) → toujours tenté si `CRUX_API_KEY` présent, +indépendamment du choix de compte. + +--- + +## 7. Sûreté concurrentielle (2 sites en parallèle) + +Garantie **par construction**, pas par verrou global : + +- **Pas d'état « compte courant ».** Le compte + la propriété sont des **arguments explicites** + de chaque `fetch.sh`. Deux audits (2 sessions Claude, ou 2 sites) ne partagent aucune variable + mutable de sélection. +- **Audits = lecture seule** du store. Les access tokens sont éphémères en mémoire, jamais écrits. + Donc deux audits concurrents ne s'écrivent jamais dessus. +- **Écriture = seulement au `connect`**, atomique (`tmp`→`fsync`→`rename`) sous verrou `fcntl`, + pour couvrir le cas rare de deux consentements simultanés. +- Le venv est en lecture seule à l'exécution (créé/maj uniquement par `make seo-connect`). + +--- + +## 8. Périmètre data & mapping dans le rapport + +| Donnée | Source | Sous-commande | Atterrit dans `SEO.md` | +|---|---|---|---| +| CWV terrain (LCP/INP/CLS 75e pct, mobile+desktop, historique) | CrUX API | `fetch.sh crux` | §2 Audit technique — **note primaire** ; PageSpeed labo gardé en secondaire diagnostic | +| Requêtes (impressions, clics, CTR, position) | GSC Search Analytics | `fetch.sh queries` | §2/§8 — sous-section « Performance GSC » + **quick wins position 4-10** | +| Pages (perf par URL) | GSC Search Analytics | `fetch.sh queries --dim page` | idem — top pages | +| Indexation par URL | GSC URL Inspection | `fetch.sh inspect` | §2 indexabilité — **fait** vs déduction | + +- **Scoring** : l'axe *Technical* (STEP 9 de `seo-analyzer`) se calcule sur le **terrain** quand + dispo ; sinon labo (dégradation). +- **Nouveau contenu de rapport** : une sous-section « Performance GSC (90 j) » dans §2, listant top + requêtes + les quick wins position 4-10. Reste en **français**, cohérent avec l'existant. + +--- + +## 9. Interface du moteur (`fetch.sh`) + +Contrat stable que les analyzers consomment (JSON sur stdout, exit 0 même en dégradé) : + +```bash +fetch.sh accounts + → {"status":"ok","accounts":[{"email":"…","properties":[…]}]} + → {"status":"empty"} # aucun compte connecté + +fetch.sh crux --url https://ex.com [--strategy mobile|desktop] + → {"status":"ok","source":"crux","metrics":{"lcp_p75_ms":…,"inp_p75_ms":…,"cls_p75":…}, "history":[…]} + → {"status":"degraded","reason":"no_crux_key"|"no_field_data"} + +fetch.sh queries --account a@x --property sc-domain:ex.com [--days 90] [--dim query|page] + → {"status":"ok","source":"gsc","rows":[{"key":"…","clicks":…,"impressions":…,"ctr":…,"position":…}]} + → {"status":"degraded","reason":"no_credentials"|"token_revoked"|"rate_limited"} + +fetch.sh inspect --account a@x --property … --url https://ex.com/page + → {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…"} + → {"status":"degraded","reason":"…"} +``` + +Règles : **jamais** de secret dans la sortie ; messages d'erreur génériques ; exit 0 en dégradé +(l'analyzer décide de la suite), exit ≠ 0 uniquement sur mauvais usage (args invalides). + +--- + +## 10. Dégradation gracieuse & posture sécurité + +- **Fail-open audit / fail-closed data** : creds manquants, refresh échoué, 429 → `{"status":"degraded"}`, + exit 0. L'analyzer bascule sur PageSpeed anonyme et émet en §11 « Connecter GSC : `make seo-connect` » + (réutilise la formulation `automation-catalog.md`). +- **Redaction** : `fetch.sh` ne logge jamais les variables d'env ni le token ; stdout = JSON de + données uniquement ; stderr = messages génériques. +- **Least privilege** : scope `webmasters.readonly` seul ; clé CrUX restreinte à CrUX + PageSpeed. +- **Supply-chain maîtrisée** : 3 libs Google officielles, **épinglées** dans `requirements.txt`, + isolées dans un venv — surface auditablement listée, sans commune mesure avec l'outil tiers. +- **Reprise sur token révoqué** : `doctor.sh` signale, message pointe vers `make seo-connect` pour re-consentir. + +--- + +## 11. Install & déploiement (touch-list) + +Séquence respectant l'ordre critique **`link.sh` → vault joignable → consentement** (évite le +blocker connu : le symlink `~/.claude/.env` est créé par `link.sh`, absent sur machine fraîche ; +tout lecteur de creds vise le **canonical `~/.claude/.env`**, pas `$REPO/.env`). + +| Fichier | Modification | +|---|---| +| `.env.example` | Ajouter les 3 vars (client id/secret, CrUX key) au format existant (`# Used by:` / `# Get it:` + placeholder). **Seul fichier versionné touché côté secrets.** | +| `install.sh` | Après `link.sh` (§5) et `claude login` (§3) : étape **optionnelle idempotente** (moule « Press Enter to connect… ») → si aucun compte dans le store, propose `make seo-connect` ; skip sinon. | +| `Makefile` | Cible user-facing `seo-connect` : crée/maj le venv + `pip install -r lib/seo-data/requirements.txt`, lance `connect.py` (consentement + découverte propriétés). Rejouable. `make test` ramasse déjà le nouveau test. | +| `doctor.sh` | Nouveau check (lit `~/.claude/.env` canonical) : venv + deps présents ? au moins un compte dans le store ? `CRUX_API_KEY` présent ? → **PASS / WARN, jamais fatal**. | +| `.gitleaks.toml` | Allowlist du token store (cf. §5.4). | +| `.gitignore` | Ajouter `.venv-seo-data/` et `seo-data/tokens.json` (ceinture + bretelles). | + +--- + +## 12. Tests + +`lib/tests/seo-data.test.sh`, convention du repo (`tf`/`tr_`/`tn` + compteurs PASS/FAIL, +découvert par `make test`), **sans appel réseau** : + +- Parsing des sous-commandes/args de `fetch.sh` (bons/mauvais usages, exit codes). +- Parsing de forme JSON sur **fixtures commitées** (réponses GSC/CrUX mockées) → shape attendue. +- **Redaction** : erreur simulée (token bidon) → aucune valeur secrète dans stdout/stderr. +- **Dégradation** : creds absents → `{"status":"degraded"}` + **exit 0** (pas 1). +- Isolement : deux invocations `--account` différentes → sélections indépendantes (pas d'état partagé). + +Fixtures sous `lib/tests/fixtures/seo-data/` (réponses synthétiques, aucun vrai secret/PII). + +--- + +## 13. Hors périmètre (YAGNI v1) + +- GA4 (trafic organique), Google Ads / Keyword Planner, Indexing API. +- Modification de `geo-analyzer` (le GSC pourrait plus tard éclairer quelles requêtes déclenchent + des AI Overviews — v2). +- Audit multi-propriétés en un seul run (une propriété par audit en v1). +- Monitoring/drift dans le temps (SQLite) — l'outil tiers le fait ; hors scope v1. +- Nouveau skill d'audit dédié `/gsc` (le setup passe par `make seo-connect`, l'usage par `/seo` FULL). + +--- + +## 14. Liste des fichiers touchés + +**Créés** +- `lib/seo-data/fetch.sh` +- `lib/seo-data/google_seo.py` +- `lib/seo-data/connect.py` +- `lib/seo-data/tokenstore.py` +- `lib/seo-data/requirements.txt` +- `lib/seo-data/README.md` +- `lib/tests/seo-data.test.sh` +- `lib/tests/fixtures/seo-data/*.json` + +**Modifiés** +- `.env.example` (3 vars) +- `install.sh` (étape consentement optionnelle post-link) +- `Makefile` (cible `seo-connect`) +- `doctor.sh` (check creds non-fatal) +- `.gitleaks.toml` (allowlist token store) +- `.gitignore` (venv + token store) +- `agents/seo-analyzer.md` (STEP 4 CWV terrain + sous-section Performance GSC ; STEP 9 axe Technical) +- `skills/seo/SKILL.md` (STEP 0 sélection compte + propriété en FULL) +- `agents/resources/automation-catalog.md` (section « Google Search Console — connexion OAuth » réutilisable en §11) + +**Hors repo (générés au setup, jamais commités)** +- `~/.claude/.venv-seo-data/` +- `~/.claude/seo-data/tokens.json` + +--- + +## 15. Risques & mitigations + +| Risque | Mitigation | +|---|---| +| Symlink `~/.claude/.env` absent sur machine fraîche | Lecture du **canonical** `~/.claude/.env` ; consentement **après** `link.sh` | +| Deux `connect` simultanés corrompent le store | Écriture atomique `tmp`→`rename` + verrou `fcntl` | +| Deux audits sur 2 sites se marchent dessus | Compte+propriété **explicites par appel** ; audits en lecture seule | +| Secret loggé par erreur | Redaction imposée + test dédié | +| Token store flaggé par scan-secrets | Allowlist gitleaks explicite (§5.4) | +| `python3`/venv absent | `doctor.sh` warn ; `make seo-connect` crée le venv ; dégradation si absent | +| Quota GSC/CrUX (429) | Traité comme `degraded` → audit continue | + +--- + +## 16. Questions ouvertes résolues + +- **Scopes** → `webmasters.readonly` seul. +- **CrUX vs PageSpeed** → les deux : CrUX terrain primaire, PageSpeed labo secondaire/fallback. +- **Forme data en §2** → terrain 75e pct primaire, labo en secondaire. +- **GSC obligatoire ?** → non, optionnel, dégradation gracieuse ; proposé à chaque FULL. +- **Token expiré** → `degraded` + `doctor.sh` pointe `make seo-connect`. +- **Locale** → rapports en français, cohérent avec l'existant. +- **Multi-compte** → store keyé par **label utilisateur** (scope inchangé) ; sélection par audit ; + isolation par arguments explicites. +```