Search Console API : automatiser le reporting SEO

L'interface web de Google Search Console plafonne à 1 000 lignes exportables. Pour un site e-commerce de 20 000 pages produit, ça revient à analyser 5 % de vos données de performance. L'API Search Console supprime cette limite et ouvre la porte à des rapports que l'interface ne permettra jamais : croisement requêtes × pages × devices sur 16 mois, détection automatique de chutes de CTR, alerting sur les régressions de positionnement.

Comprendre les limites de l'API (et les contourner)

L'API Search Console (officiellement "Search Analytics API", intégrée à l'API Google Search Console v1) n'est pas un miroir parfait de l'interface. Plusieurs contraintes techniques conditionnent la façon dont vous allez structurer vos appels.

Quotas et rate limiting

Google impose un quota de 1 200 requêtes par minute par projet et 50 000 requêtes par jour. Pour la majorité des sites, c'est largement suffisant. Le problème survient quand vous interrogez plusieurs propriétés (multi-sites, versions www/non-www, domaines internationaux) depuis le même projet GCP.

Chaque appel à searchAnalytics.query retourne au maximum 25 000 lignes. Si votre combinaison dimensions/filtres dépasse ce seuil, les données sont tronquées silencieusement — pas d'erreur, pas de warning. C'est le piège classique.

La fenêtre de données

L'API expose les données des 16 derniers mois, avec un délai de 2 à 3 jours. Les données des 48 dernières heures sont instables — Google les recalcule. Planifier vos pulls à J-3 minimum évite les écarts entre deux extractions successives.

Dimensions disponibles

Cinq dimensions sont combinables : query, page, country, device, searchAppearance. Vous pouvez en combiner jusqu'à trois dans un seul appel. Au-delà, l'API refuse la requête. Ce qui signifie que pour obtenir une vue query × page × device × country, il faut deux appels distincts et un join côté client.

Un point souvent ignoré dans la documentation : la dimension searchAppearance est mutuellement exclusive avec les autres combinaisons de trois dimensions. Si vous l'utilisez, limitez-vous à deux dimensions supplémentaires.

Configurer l'authentification OAuth2

Avant toute chose, vous avez besoin d'un projet Google Cloud Platform avec l'API Search Console activée. La documentation officielle détaille la procédure : Google Search Console API - Getting Started.

Créer un service account

Pour un reporting automatisé (cron, CI/CD, serverless), le service account est la seule option viable. L'OAuth2 interactif avec refresh token finit toujours par casser en production — tokens expirés, révocations silencieuses.

# Créer le service account via gcloud CLI
gcloud iam service-accounts create gsc-reporting \
  --display-name="GSC Reporting Bot" \
  --project=mon-projet-seo

# Générer la clé JSON
gcloud iam service-accounts keys create gsc-credentials.json \
  --iam-account=gsc-reporting@mon-projet-seo.iam.gserviceaccount.com

# Activer l'API Search Console sur le projet
gcloud services enable searchconsole.googleapis.com \
  --project=mon-projet-seo

Étape critique : ajoutez l'email du service account ([email protected]) comme utilisateur avec droits complets dans la Search Console, section "Paramètres > Utilisateurs et autorisations". Sans cela, l'API retourne un 403 sans message explicite.

Script d'authentification Python

La librairie google-auth gère le cycle de vie des tokens automatiquement. Évitez oauth2client, déprécié depuis 2020.

from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ['https://www.googleapis.com/auth/webmasters.readonly']
CREDENTIALS_FILE = 'gsc-credentials.json'

def get_gsc_service():
    credentials = service_account.Credentials.from_service_account_file(
        CREDENTIALS_FILE, scopes=SCOPES
    )
    return build('searchconsole', 'v1', credentials=credentials)

service = get_gsc_service()

# Vérifier l'accès : lister les propriétés disponibles
sites = service.sites().list().execute()
for site in sites.get('siteEntry', []):
    print(f"{site['siteUrl']}{site['permissionLevel']}")

Si vous gérez une propriété de domaine (format sc-domain:example.com), utilisez cette syntaxe exacte comme siteUrl dans vos appels. Le format https://example.com/ correspond à une propriété URL prefix — les données diffèrent.

Construire des requêtes analytiques avancées

L'endpoint searchAnalytics.query accepte un body JSON avec dimensions, filtres, et paramètres de pagination. C'est ici que la puissance de l'API se révèle par rapport à l'interface.

Extraire toutes les données sans troncature

Le problème des 25 000 lignes se résout par segmentation. Plutôt que de demander query × page pour tout le site, segmentez par répertoire, par device, ou par plage de dates.

import datetime
import time
import pandas as pd

def fetch_gsc_data(service, site_url, start_date, end_date, dimensions, row_limit=25000):
    """
    Extraction paginée avec gestion du seuil de 25K lignes.
    L'API ne supporte pas la pagination native — on segmente par date.
    """
    all_rows = []
    current_date = start_date

    while current_date <= end_date:
        request_body = {
            'startDate': current_date.isoformat(),
            'endDate': current_date.isoformat(),
            'dimensions': dimensions,
            'rowLimit': row_limit,
            'dataState': 'final'  # Exclut les données incomplètes
        }

        response = service.searchanalytics().query(
            siteUrl=site_url,
            body=request_body
        ).execute()

        rows = response.get('rows', [])
        for row in rows:
            record = {}
            for i, dim in enumerate(dimensions):
                record[dim] = row['keys'][i]
            record['clicks'] = row['clicks']
            record['impressions'] = row['impressions']
            record['ctr'] = row['ctr']
            record['position'] = row['position']
            record['date'] = current_date.isoformat()
            all_rows.append(record)

        # Détection de troncature
        if len(rows) == row_limit:
            print(f"⚠ Troncature probable le {current_date}{len(rows)} lignes")

        current_date += datetime.timedelta(days=1)
        time.sleep(0.1)  # Respecter le rate limit

    return pd.DataFrame(all_rows)


# Usage : extraction query × page sur 30 jours
site = 'sc-domain:monsite-ecommerce.fr'
start = datetime.date(2026, 3, 1)
end = datetime.date(2026, 3, 31)

df = fetch_gsc_data(
    service=get_gsc_service(),
    site_url=site,
    start_date=start,
    end_date=end,
    dimensions=['query', 'page']
)

print(f"Total : {len(df)} lignes, {df['clicks'].sum()} clics")

Le paramètre dataState: 'final' mérite attention. Par défaut, l'API retourne les données "all" (incluant les estimations fraîches). En forçant "final", vous obtenez des chiffres stabilisés mais perdez les 2-3 derniers jours. Pour un reporting hebdomadaire, c'est le bon choix. Pour du monitoring temps réel, gardez "all" et acceptez les fluctuations.

Filtres avancés : isoler les segments qui comptent

L'API supporte des filtres sur chaque dimension avec les opérateurs contains, equals, notContains, notEquals, et includingRegex / excludingRegex. Les regex sont des RE2, pas du PCRE — pas de lookahead ni de backreference.

# Isoler les performances des pages catégories d'un e-commerce
request_body = {
    'startDate': '2026-03-01',
    'endDate': '2026-03-31',
    'dimensions': ['page', 'query'],
    'dimensionFilterGroups': [{
        'filters': [
            {
                'dimension': 'page',
                'operator': 'includingRegex',
                'expression': '/categorie/[a-z-]+/$'
            },
            {
                'dimension': 'query',
                'operator': 'excludingRegex',
                'expression': 'marque|brand|monsite'
            }
        ]
    }],
    'rowLimit': 25000,
    'dataState': 'final'
}

Ce type de filtre est exactement ce que l'interface ne permet pas facilement. Ici, on isole les requêtes non-branded sur les pages catégories — le segment qui mesure réellement la performance SEO organique, particulièrement pertinent pour optimiser vos pages catégories e-commerce.

Scénario concret : monitoring de migration SSR

Contexte réel : un site média de 8 000 articles migre d'une SPA Angular vers Next.js avec SSR. L'équipe SEO a besoin de mesurer l'impact de la migration semaine par semaine, page par page.

Le problème

Après le déploiement progressif (20 % des URLs la première semaine, 100 % en semaine 4), l'interface Search Console montre une courbe agrégée. Impossible de distinguer les pages déjà migrées des pages encore en SPA. Impossible de détecter qu'un template spécifique (les pages "auteur", 450 URLs) a un bug SSR qui retourne un <title> vide.

La solution automatisée

Un script Python tourne chaque lundi à 6h via un cron sur un serveur interne. Il compare les métriques de chaque URL entre la semaine N et N-1, filtrées par un fichier CSV des URLs migrées chaque semaine.

import pandas as pd

def migration_impact_report(service, site_url, migrated_urls_csv, week_start, week_end, prev_week_start, prev_week_end):
    """
    Compare les performances pre/post migration pour un batch d'URLs.
    """
    migrated = pd.read_csv(migrated_urls_csv)  # colonnes: url, migration_date
    
    # Données semaine courante
    df_current = fetch_gsc_data(
        service, site_url,
        start_date=week_start,
        end_date=week_end,
        dimensions=['page', 'device']
    )
    
    # Données semaine précédente
    df_previous = fetch_gsc_data(
        service, site_url,
        start_date=prev_week_start,
        end_date=prev_week_end,
        dimensions=['page', 'device']
    )
    
    # Agréger par page
    current_agg = df_current.groupby('page').agg(
        clicks=('clicks', 'sum'),
        impressions=('impressions', 'sum'),
        avg_position=('position', 'mean')
    ).reset_index()
    
    previous_agg = df_previous.groupby('page').agg(
        clicks_prev=('clicks', 'sum'),
        impressions_prev=('impressions', 'sum'),
        avg_position_prev=('position', 'mean')
    ).reset_index()
    
    # Merge et calcul des deltas
    merged = current_agg.merge(previous_agg, on='page', how='outer')
    merged = merged.merge(migrated[['url']], left_on='page', right_on='url', how='inner')
    
    merged['clicks_delta_pct'] = (
        (merged['clicks'] - merged['clicks_prev']) / merged['clicks_prev'] * 100
    ).round(1)
    
    merged['position_delta'] = (
        merged['avg_position'] - merged['avg_position_prev']
    ).round(2)
    
    # Alertes : pages avec chute > 30% de clics
    regressions = merged[merged['clicks_delta_pct'] < -30].sort_values('clicks_delta_pct')
    
    if not regressions.empty:
        print(f"🚨 {len(regressions)} pages avec régression > 30% :")
        for _, row in regressions.head(20).iterrows():
            print(f"  {row['page']}: {row['clicks_delta_pct']}% clics, "
                  f"position {row['avg_position_prev']:.1f}{row['avg_position']:.1f}")
    
    return merged

Résultats observés

Sur les 450 pages auteur, le script a détecté une chute de 45 % des impressions dès la semaine 2 de la migration. Investigation : le composant React <AuthorHead> ne s'exécutait pas côté serveur, générant un <title> par défaut "Loading..." dans le HTML rendu par le serveur. Googlebot indexait ce titre. L'interface Search Console montrait une baisse globale de 3 % — noyée dans le bruit. Le script, lui, isolait précisément le répertoire /auteur/ comme anomalie.

Ce type de régression silencieuse est exactement ce qui se produit lors de déploiements mal supervisés et fait partie des régressions SEO les plus fréquentes en environnement JavaScript. La détection via l'API Search Console intervient à J+3 au mieux — un outil de monitoring comme Seogard détecte ce type de divergence SSR/CSR en temps réel, avant même que Google n'indexe la page cassée.

Rapports personnalisés : aller au-delà des dashboards standards

Les dashboards Looker Studio (ex Data Studio) connectés à la Search Console souffrent des mêmes limitations que l'interface : 1 000 lignes, pas de filtres regex, pas de croisement avancé. L'API vous permet de construire des rapports qu'aucun outil standard ne propose.

Rapport de cannibalisation de mots-clés

La cannibalisation — deux pages qui se disputent la même requête — se détecte quand une requête génère des impressions sur plusieurs URLs avec des positions qui oscillent. L'interface ne permet pas cette analyse. L'API, si.

def detect_cannibalization(df, min_impressions=50, min_pages=2):
    """
    Identifie les requêtes pour lesquelles plusieurs pages se positionnent,
    signe potentiel de cannibalisation.
    
    Input: DataFrame avec colonnes 'query', 'page', 'clicks', 'impressions', 'position'
    """
    # Agréger par query + page sur la période
    agg = df.groupby(['query', 'page']).agg(
        total_clicks=('clicks', 'sum'),
        total_impressions=('impressions', 'sum'),
        avg_position=('position', 'mean'),
        position_std=('position', 'std')  # Volatilité de position
    ).reset_index()
    
    # Filtrer les requêtes avec assez d'impressions
    agg = agg[agg['total_impressions'] >= min_impressions]
    
    # Compter le nombre de pages par requête
    pages_per_query = agg.groupby('query')['page'].nunique().reset_index()
    pages_per_query.columns = ['query', 'page_count']
    
    cannibalized = pages_per_query[pages_per_query['page_count'] >= min_pages]
    
    # Enrichir avec les détails
    result = agg.merge(cannibalized[['query']], on='query')
    result = result.sort_values(['query', 'total_impressions'], ascending=[True, False])
    
    # Score de cannibalisation : forte si les positions sont proches
    # et les impressions réparties entre les pages
    for query in result['query'].unique():
        query_data = result[result['query'] == query]
        if len(query_data) >= 2:
            top_two = query_data.head(2)
            position_gap = abs(top_two.iloc[0]['avg_position'] - top_two.iloc[1]['avg_position'])
            impression_ratio = top_two.iloc[1]['total_impressions'] / top_two.iloc[0]['total_impressions']
            
            # Cannibalisation sévère : positions proches ET impressions réparties
            if position_gap < 5 and impression_ratio > 0.3:
                result.loc[result['query'] == query, 'severity'] = 'high'
            else:
                result.loc[result['query'] == query, 'severity'] = 'low'
    
    return result

# Exécution
cannib = detect_cannibalization(df, min_impressions=100)
high_severity = cannib[cannib['severity'] == 'high']
print(f"{high_severity['query'].nunique()} requêtes avec cannibalisation sévère")

Ce rapport, exécuté mensuellement, alimente directement la stratégie de maillage interne : les pages cannibalisées sont candidates à une consolidation (redirect 301 + fusion de contenu) ou à une différenciation intentionnelle.

Rapport de contenu "zombie"

Croisez les données GSC avec votre crawl Screaming Frog pour identifier les pages indexées qui ne génèrent ni clic ni impression — un travail impossible manuellement au-delà de quelques centaines de pages.

def find_zombie_pages(gsc_df, crawl_export_csv, min_days=90):
    """
    Croise les données GSC (16 mois) avec l'export Screaming Frog
    pour trouver les pages indexables sans aucune visibilité.
    """
    crawl = pd.read_csv(crawl_export_csv)
    
    # Pages indexables selon le crawl
    indexable = crawl[
        (crawl['Status Code'] == 200) &
        (crawl['Indexability'] == 'Indexable')
    ]['Address'].tolist()
    
    # Pages ayant au moins 1 impression sur la période
    visible = set(gsc_df[gsc_df['impressions'] > 0]['page'].unique())
    
    # Zombies = indexables sans aucune impression
    zombies = [url for url in indexable if url not in visible]
    
    print(f"{len(zombies)} pages zombies sur {len(indexable)} indexables "
          f"({len(zombies)/len(indexable)*100:.1f}%)")
    
    return zombies

Sur un e-commerce de 15 000 pages, ce script révèle typiquement 20 à 35 % de pages zombies — souvent des pages produit en rupture de stock, des variations de filtres mal canonicalisées, ou du contenu thin généré par des combinaisons de facettes. Ces pages grignotent le crawl budget sans aucun retour.

Automatiser l'exécution et la distribution

Un script qui tourne manuellement sur votre machine n'est pas de l'automatisation. Pour qu'un reporting soit réellement utile, il doit tourner seul, alerter quand les seuils sont franchis, et distribuer les résultats aux bonnes personnes.

Orchestration avec GitHub Actions

Pour les équipes qui n'ont pas d'infrastructure cron dédiée, GitHub Actions offre un runner gratuit (2 000 minutes/mois sur les repos privés) avec la gestion native des secrets pour les credentials.

# .github/workflows/gsc-reporting.yml
name: Weekly GSC Report

on:
  schedule:
    - cron: '0 6 * * 1'  # Chaque lundi à 6h UTC
  workflow_dispatch:  # Permet l'exécution manuelle

jobs:
  generate-report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install google-api-python-client google-auth pandas

      - name: Write credentials
        run: echo '${{ secrets.GSC_CREDENTIALS_JSON }}' > gsc-credentials.json

      - name: Run report
        run: python scripts/weekly_gsc_report.py
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SITE_URL: 'sc-domain:monsite-ecommerce.fr'

      - name: Upload report artifact
        uses: actions/upload-artifact@v4
        with:
          name: gsc-report-${{ github.run_number }}
          path: reports/
          retention-days: 90

L'intégration dans la CI/CD n'est pas un luxe : c'est la suite logique de la démarche d'automatisation des checks SEO que toute équipe technique devrait avoir en place.

Alerting Slack avec seuils configurables

La partie alerting est ce qui transforme un reporting passif en système de surveillance actif. Les seuils d'alerte doivent être calibrés au contexte du site — un site média avec une forte saisonnalité n'aura pas les mêmes seuils qu'un SaaS B2B.

import json
import urllib.request

def send_slack_alert(webhook_url, regressions_df, threshold_pct=-20):
    """
    Envoie une alerte Slack si des pages franchissent le seuil de régression.
    """
    critical = regressions_df[regressions_df['clicks_delta_pct'] < threshold_pct]
    
    if critical.empty:
        return
    
    total_lost_clicks = int(
        critical['clicks_prev'].sum() - critical['clicks'].sum()
    )
    
    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": f"🚨 {len(critical)} pages en régression SEO"
            }
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*{total_lost_clicks} clics perdus* cette semaine vs semaine précédente.\n"
                        f"Seuil d'alerte : {threshold_pct}%"
            }
        }
    ]
    
    # Top 5 des pires régressions
    for _, row in critical.head(5).iterrows():
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"• `{row['page']}`\n"
                        f"  Clics : {int(row['clicks_prev'])}{int(row['clicks'])} "
                        f"({row['clicks_delta_pct']}%) | "
                        f"Position : {row['avg_position_prev']:.1f}{row['avg_position']:.1f}"
            }
        })
    
    payload = json.dumps({"blocks": blocks}).encode('utf-8')
    req = urllib.request.Request(
        webhook_url,
        data=payload,
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)

Aller plus loin : combiner l'API GSC avec d'autres sources

L'API Search Console seule donne la visibilité organique. Combinée à d'autres sources de données, elle devient un véritable système de diagnostic.

Croisement avec les logs serveur

L'API vous dit combien Google affiche et clique vos pages. Les logs serveur vous disent combien Googlebot les crawle. Le croisement révèle trois catégories critiques :

  • Crawlé mais invisible : Googlebot visite la page régulièrement, mais elle n'apparaît jamais dans les résultats. Problème d'indexabilité (noindex non détecté, canonical vers une autre page, contenu dupliqué).
  • Visible mais non crawlé récemment : la page génère des impressions, mais le dernier crawl remonte à 30+ jours. Risque de désindexation progressive.
  • Ni crawlé ni visible : page orpheline ou enterrée dans l'architecture. Le choix de structure flat vs deep influence directement ce ratio.

Croisement avec l'API URL Inspection

L'API URL Inspection (disponible depuis 2022) permet de vérifier programmatiquement le statut d'indexation de chaque URL. Limitée à 2 000 requêtes/jour/propriété, elle est trop lente pour un crawl complet mais parfaite pour vérifier les URLs identifiées comme problématiques par votre rapport GSC.

def check_indexation_status(service, site_url, urls):
    """
    Vérifie le statut d'indexation via l'API URL Inspection.
    Attention : 2000 requêtes/jour max par propriété.
    """
    results = []
    for url in urls[:2000]:  # Hard limit
        try:
            response = service.urlInspection().index().inspect({
                'inspectionUrl': url,
                'siteUrl': site_url
            }).execute()
            
            result = response.get('inspectionResult', {})
            index_status = result.get('indexStatusResult', {})
            
            results.append({
                'url': url,
                'verdict': index_status.get('verdict', 'UNKNOWN'),
                'coverage_state': index_status.get('coverageState', ''),
                'robots_txt_state': index_status.get('robotsTxtState', ''),
                'last_crawl_time': index_status.get('lastCrawlTime', ''),
                'page_fetch_state': index_status.get('pageFetchState', '')
            })
            
            time.sleep(0.5)  # Respecter le quota
            
        except Exception as e:
            results.append({'url': url, 'verdict': 'ERROR', 'error': str(e)})
    
    return pd.DataFrame(results)

Ce croisement vous donne une image complète : performance de recherche (GSC Analytics API) + statut d'indexation (URL Inspection API) + comportement de crawl (logs). C'est la base d'un monitoring SEO continu et actionnable, bien au-delà de ce que permettent les rapports ponctuels.

Les données brutes de la Search Console, enfin exploitées sans limite de lignes ni de dimensions, deviennent le socle d'un système de surveillance SEO qui détecte les problèmes avant qu'ils n'impactent le trafic. Pour les régressions qui nécessitent une détection en temps réel — un SSR cassé, un canonical qui disparaît après un déploiement — combiner ces rapports API avec un monitoring côté crawl comme Seogard couvre l'intégralité du spectre, du diagnostic post-hoc à l'alerte instantanée.

Articles connexes

Analytics8 avril 2026

KPIs SEO technique : les métriques qui comptent vraiment

Quels KPIs suivre pour mesurer la santé SEO technique d'un site. Crawl, indexation, Core Web Vitals, codes HTTP : métriques actionnables et méthodes de suivi.