# 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. ```