Une page catégorie d'un e-commerce mode se positionne sur 340 requêtes dans Google Search Console. CTR moyen : 1,2%. En isolant les 40 requêtes alignées avec l'intent réel de la page, le CTR monte à 8,7%. Les 300 autres requêtes sont des intent gaps — des signaux de décalage entre ce que Google pense que votre page répond et ce que les utilisateurs cherchent réellement.
Ce qu'est un intent gap (et pourquoi le CTR seul ne suffit pas)
Un intent gap n'est pas simplement un mot-clé sur lequel vous ne rankez pas. C'est un décalage mesurable entre l'intention de recherche d'un utilisateur et la réponse que votre page fournit, alors même que Google vous positionne sur cette requête.
Prenez une page /chaussures-running-homme qui reçoit des impressions sur la requête "avis chaussures running pronateur". Google considère la page suffisamment pertinente pour l'afficher. L'utilisateur, lui, cherche un contenu éditorial comparatif. Il voit un listing produit, ne clique pas (ou clique et rebondit). C'est un intent gap.
Le CTR brut par requête est un indicateur, mais il masque le problème. Un CTR de 2% en position 8 est normal. Un CTR de 2% en position 2, c'est un signal d'alerte. La métrique qui révèle les intent gaps, c'est le ratio CTR/position attendu — l'écart entre le CTR que vous devriez avoir à une position donnée et celui que vous obtenez réellement.
Les courbes de CTR par position varient selon les industries et les types de SERP (présence de featured snippets, de PAA, de résultats shopping). Mais les études de référence comme celles d'Advanced Web Ranking montrent des patterns stables : position 1 entre 25% et 35% de CTR, position 3 entre 8% et 15%, position 5 entre 4% et 7%. Tout écart significatif à la baisse pour une position donnée mérite investigation.
L'approche proposée par Search Engine Land dans leur article récent va dans le bon sens en utilisant GSC comme source de données brutes. Mais elle reste en surface. Ce qui suit est une méthode technique complète pour extraire, classifier et prioriser les intent gaps à l'échelle.
Extraire les données brutes via l'API GSC
L'interface web de Google Search Console est limitée à 1 000 lignes par export et ne permet pas de croiser facilement pages et requêtes. Pour une analyse sérieuse sur un site de plus de 500 pages, l'API est indispensable.
Configuration de l'extraction
Voici un script Python qui extrait les données page + query pour les 90 derniers jours, avec gestion de la pagination :
from google.oauth2 import service_account
from googleapiclient.discovery import build
import pandas as pd
from datetime import datetime, timedelta
SCOPES = ['https://www.googleapis.com/auth/webmasters.readonly']
SERVICE_ACCOUNT_FILE = 'credentials.json'
SITE_URL = 'https://www.votresite-ecommerce.fr'
credentials = service_account.Credentials.from_service_account_file(
SERVICE_ACCOUNT_FILE, scopes=SCOPES
)
service = build('searchconsole', 'v1', credentials=credentials)
end_date = datetime.now() - timedelta(days=3) # GSC a ~3j de latence
start_date = end_date - timedelta(days=90)
all_rows = []
start_row = 0
ROW_LIMIT = 25000
while True:
request = {
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'dimensions': ['page', 'query'],
'rowLimit': ROW_LIMIT,
'startRow': start_row,
'dimensionFilterGroups': [{
'filters': [{
'dimension': 'country',
'expression': 'fra' # Filtrer par pays cible
}]
}]
}
response = service.searchanalytics().query(
siteUrl=SITE_URL, body=request
).execute()
rows = response.get('rows', [])
if not rows:
break
for row in rows:
all_rows.append({
'page': row['keys'][0],
'query': row['keys'][1],
'clicks': row['clicks'],
'impressions': row['impressions'],
'ctr': row['ctr'],
'position': row['position']
})
start_row += ROW_LIMIT
if len(rows) < ROW_LIMIT:
break
df = pd.DataFrame(all_rows)
df.to_csv('gsc_page_query_raw.csv', index=False)
print(f"Extracted {len(df)} page/query combinations")
Sur un site e-commerce de 12 000 pages actives, cette extraction retourne typiquement entre 200 000 et 800 000 combinaisons page/query. C'est cette granularité qui permet l'analyse des intent gaps — impossible à obtenir via l'interface web.
Pour automatiser cette extraction, vous pouvez consulter notre guide sur l'automatisation du reporting SEO via l'API Search Console.
Nettoyage et filtrage
Filtrez impérativement les données avant analyse. Les requêtes avec moins de 10 impressions sur 90 jours sont du bruit statistique. Les requêtes de marque doivent être isolées — elles faussent les moyennes de CTR.
# Filtrer le bruit et segmenter
df_clean = df[df['impressions'] >= 10].copy()
# Séparer requêtes brand / non-brand
BRAND_TERMS = ['votresite', 'votre-site', 'votre site']
df_clean['is_brand'] = df_clean['query'].str.lower().apply(
lambda q: any(term in q for term in BRAND_TERMS)
)
df_nonbrand = df_clean[~df_clean['is_brand']].copy()
print(f"Non-brand combinations: {len(df_nonbrand)}")
Calculer le score d'intent gap par combinaison page/query
Le cœur de la méthode repose sur la comparaison entre le CTR observé et le CTR attendu à une position donnée. Un écart négatif important signale un décalage d'intention.
Modèle de CTR attendu
Plutôt que d'utiliser des benchmarks externes, construisez votre propre courbe de CTR à partir de vos données GSC. Chaque site a un profil de CTR différent selon sa présence en SERP, la nature de ses snippets, et son secteur.
import numpy as np
# Calculer le CTR médian par tranche de position (sur vos propres données)
df_nonbrand['position_bucket'] = df_nonbrand['position'].apply(
lambda p: min(int(p), 20) # Regrouper positions > 20
)
# Utiliser la médiane plutôt que la moyenne (résistante aux outliers)
ctr_benchmarks = df_nonbrand.groupby('position_bucket').agg(
median_ctr=('ctr', 'median'),
count=('ctr', 'size')
).reset_index()
# Créer un dictionnaire de lookup
ctr_expected = dict(zip(
ctr_benchmarks['position_bucket'],
ctr_benchmarks['median_ctr']
))
# Calculer le gap score pour chaque combinaison
df_nonbrand['expected_ctr'] = df_nonbrand['position_bucket'].map(ctr_expected)
df_nonbrand['ctr_gap'] = df_nonbrand['ctr'] - df_nonbrand['expected_ctr']
# Pondérer par les impressions (un gap sur 5000 impressions > gap sur 15)
df_nonbrand['weighted_gap'] = df_nonbrand['ctr_gap'] * df_nonbrand['impressions']
# Trier par impact négatif
intent_gaps = df_nonbrand[df_nonbrand['ctr_gap'] < -0.02].sort_values(
'weighted_gap', ascending=True
)
print(f"Intent gaps detected: {len(intent_gaps)}")
print(intent_gaps[['page', 'query', 'position', 'ctr', 'expected_ctr', 'ctr_gap', 'impressions']].head(20))
Interpréter le score
Un ctr_gap de -0.05 en position 3 signifie que votre page obtient 5 points de CTR de moins que la médiane de votre site à cette position. Les causes possibles :
- Décalage d'intent pur : la requête est informationnelle mais la page est transactionnelle (ou l'inverse).
- Snippet mal optimisé : le title ou la meta description ne reflète pas le contenu attendu pour cette requête.
- Cannibalisation : une autre page de votre site se positionne aussi, et l'utilisateur hésite ou choisit l'autre.
- SERP features : un featured snippet ou un PAA capte l'attention au-dessus de votre résultat.
Le weighted_gap permet de prioriser : un écart de 3 points de CTR sur une requête à 8 000 impressions/mois vaut plus qu'un écart de 10 points sur 20 impressions.
Classifier l'intention à l'échelle avec des heuristiques
L'étape suivante consiste à attribuer un type d'intention à chaque requête pour identifier les patterns. Quatre catégories suffisent en pratique : informationnelle, transactionnelle, navigationnelle, investigation commerciale.
Classification par signaux lexicaux
Les modèles de NLP sont tentants mais overkill pour un premier tri. Des heuristiques lexicales bien calibrées couvrent 70-80% des cas :
INTENT_PATTERNS = {
'informational': [
r'\b(comment|pourquoi|qu est ce|c est quoi|definition|guide|tutoriel|'
r'difference entre|vs|comparatif|que signifie|quand|combien)\b',
r'\b(how to|what is|why|when|tutorial|guide|explain)\b'
],
'transactional': [
r'\b(acheter|achat|commander|prix|tarif|promo|soldes|pas cher|'
r'livraison|code promo|reduction|bon plan)\b',
r'\b(buy|price|cheap|deal|discount|order|shop)\b'
],
'commercial_investigation': [
r'\b(meilleur|top \d|avis|test|review|comparaison|alternative|'
r'quel choisir|lequel|recommandation)\b',
r'\b(best|top|review|comparison|versus|worth it)\b'
],
'navigational': [
r'\b(login|connexion|mon compte|espace client|contact|'
r'service client|telephone|adresse)\b'
]
}
import re
def classify_intent(query):
query_lower = query.lower()
for intent_type, patterns in INTENT_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, query_lower):
return intent_type
return 'ambiguous'
df_nonbrand['query_intent'] = df_nonbrand['query'].apply(classify_intent)
# Classifier aussi l'intent de la page via son URL pattern
def classify_page_intent(url):
if '/blog/' in url or '/guide/' in url or '/article/' in url:
return 'informational'
if '/produit/' in url or '/product/' in url or '/p/' in url:
return 'transactional'
if re.search(r'/categorie/|/collection/|/c/', url):
return 'transactional'
if re.search(r'/comparatif/|/avis/|/test/', url):
return 'commercial_investigation'
return 'ambiguous'
df_nonbrand['page_intent'] = df_nonbrand['page'].apply(classify_page_intent)
# Identifier les mismatches
df_nonbrand['intent_mismatch'] = (
(df_nonbrand['query_intent'] != 'ambiguous') &
(df_nonbrand['page_intent'] != 'ambiguous') &
(df_nonbrand['query_intent'] != df_nonbrand['page_intent'])
)
mismatches = df_nonbrand[df_nonbrand['intent_mismatch']].sort_values(
'impressions', ascending=False
)
print(f"Intent mismatches: {len(mismatches)}")
Les limites de l'approche lexicale
Cette classification rate les requêtes ambiguës ou multi-intent. "Nike Air Max 90" est-elle transactionnelle ou navigationnelle ? Ça dépend du contexte utilisateur. Pour les cas ambigus, la SERP elle-même est le meilleur signal : si Google affiche 7 résultats produit et 3 résultats éditoriaux, l'intention dominante est transactionnelle.
Screaming Frog permet de scraper les SERP à petite échelle pour valider la classification. Mais attention au rate limiting — Google tolère mal le scraping massif de ses résultats. Pour un volume important, des APIs tierces (SerpAPI, DataForSEO) sont plus fiables.
L'article de Search Engine Land propose de croiser manuellement les données GSC avec l'analyse SERP. C'est pertinent sur 50 requêtes prioritaires. Sur 5 000, il faut automatiser.
Scénario concret : un media tech avec 3 200 articles
Un site média tech publie 3 200 articles actifs (recevant au moins une impression/mois). L'extraction GSC sur 90 jours retourne 420 000 combinaisons page/query non-brand.
Les chiffres avant optimisation
Après application du modèle de CTR gap :
- 12 400 combinaisons avec un
ctr_gapinférieur à -0.03 (intent gap significatif) - Concentrées sur 380 pages (11,8% du site)
- Impressions cumulées de ces combinaisons : 2,1 millions sur 90 jours
- Clicks récupérables estimés (en ramenant le CTR au niveau attendu) : ~45 000 clicks sur 90 jours
Les patterns dominants identifiés :
-
Articles "guide" rankant sur des requêtes transactionnelles (38% des mismatches). Exemple : un article "Guide : comprendre les processeurs ARM" ranke en position 4 sur "acheter MacBook M3 Pro". CTR : 0,8% vs 6,2% attendu. Google considère l'article pertinent thématiquement, mais l'utilisateur veut un comparateur ou une fiche produit.
-
Articles d'actualité anciens sur des requêtes evergreen (27%). Un article de 2024 "Apple annonce le M3" ranke sur "benchmark M3 vs M2" en 2026. Le contenu est daté, l'utilisateur cherche des données actuelles.
-
Pages catégorie trop génériques sur des requêtes spécifiques (22%). La page
/categorie/smartphonesranke sur "smartphone compact 2026 petit format" — une intention très précise que la page catégorie ne satisfait pas.
Actions correctives
Pour le pattern 1, deux options : créer une page dédiée à l'intention transactionnelle, ou accepter que cette requête n'est pas votre cible. Si le volume justifie une page dédiée, créez-la et assurez le maillage interne. Si non, optimisez le title et la meta description de l'article existant pour mieux refléter son contenu informatif — les utilisateurs qui cherchent à acheter ne cliqueront toujours pas, mais ceux qui cherchent à comprendre (intention secondaire de la requête) cliqueront davantage.
Pour le pattern 2, c'est un problème de thin content ou de contenu obsolète. Actualisez l'article ou redirigez-le vers un contenu frais. Le site média a actualisé 45 articles et en a redirigé 12 vers des versions plus récentes.
Pour le pattern 3, la solution est de créer du contenu intermédiaire. Entre la page catégorie et la fiche produit, il manque souvent une page de type "guide d'achat" ou "sélection" qui répond à l'investigation commerciale.
Résultat après 8 semaines
Le site a traité les 120 pages avec le plus fort weighted_gap négatif. Résultat mesuré dans GSC :
- CTR moyen des pages traitées : de 2,1% à 4,8%
- Clicks organiques sur ces pages : +14 200 clicks/mois
- Positions moyennes quasi inchangées (le contenu n'a pas bougé en ranking, c'est le CTR qui a été optimisé)
Ce dernier point est important : corriger un intent gap n'améliore pas nécessairement votre position. Il améliore votre taux de capture sur les positions existantes. Ce sont des clicks que vous laissiez sur la table.
Agréger les gaps au niveau page pour prioriser
L'analyse par combinaison page/query donne la granularité. Mais pour prioriser les actions, il faut remonter au niveau page.
# Agréger les métriques d'intent gap par page
page_gaps = df_nonbrand.groupby('page').agg(
total_impressions=('impressions', 'sum'),
total_clicks=('clicks', 'sum'),
avg_ctr_gap=('ctr_gap', 'mean'),
total_weighted_gap=('weighted_gap', 'sum'),
n_queries=('query', 'count'),
n_mismatch=('intent_mismatch', 'sum'),
mismatch_impressions=('impressions', lambda x: x[df_nonbrand.loc[x.index, 'intent_mismatch']].sum()),
dominant_query_intent=('query_intent', lambda x: x.value_counts().index[0])
).reset_index()
# Score de priorité composite
page_gaps['mismatch_ratio'] = page_gaps['n_mismatch'] / page_gaps['n_queries']
page_gaps['priority_score'] = (
page_gaps['total_weighted_gap'].abs() *
(1 + page_gaps['mismatch_ratio'])
)
# Top 50 pages à traiter
priority_pages = page_gaps.nlargest(50, 'priority_score')
priority_pages[['page', 'total_impressions', 'avg_ctr_gap', 'mismatch_ratio',
'dominant_query_intent', 'priority_score']].to_csv(
'intent_gap_priorities.csv', index=False
)
Le mismatch_ratio est révélateur. Une page dont 60% des requêtes sont d'un type d'intention différent de celui de la page a un problème structurel. Soit la page essaie de répondre à trop d'intentions à la fois, soit Google l'a mal catégorisée.
Pour visualiser ces données, Chrome DevTools n'est pas l'outil adapté — mais il reste indispensable pour diagnostiquer les problèmes techniques qui peuvent aggraver les intent gaps (temps de chargement, rendering JS incomplet).
Automatiser la détection continue des intent gaps
L'analyse ponctuelle est utile. L'analyse continue est transformatrice. Les intent gaps évoluent : Google reteste constamment le positionnement, les intentions des utilisateurs changent avec les saisons, et vos modifications de contenu créent de nouveaux décalages.
Pipeline d'alertes automatisé
Un cron hebdomadaire qui extrait les données GSC, calcule les gaps, et alerte sur les nouvelles dégradations :
# alert_intent_gaps.py — à exécuter chaque lundi
import json
from pathlib import Path
HISTORY_FILE = 'gap_history.json'
ALERT_THRESHOLD = -0.05 # 5 points de CTR en dessous de l'attendu
MIN_IMPRESSIONS = 50 # Minimum pour que le signal soit fiable
def load_history():
if Path(HISTORY_FILE).exists():
return json.loads(Path(HISTORY_FILE).read_text())
return {}
def detect_new_gaps(current_df, history):
alerts = []
current_gaps = current_df[
(current_df['ctr_gap'] < ALERT_THRESHOLD) &
(current_df['impressions'] >= MIN_IMPRESSIONS)
]
for _, row in current_gaps.iterrows():
key = f"{row['page']}|{row['query']}"
previous_gap = history.get(key, {}).get('ctr_gap', 0)
# Alerte si le gap est nouveau ou s'est aggravé de > 2 points
if previous_gap == 0 or (row['ctr_gap'] - previous_gap) < -0.02:
alerts.append({
'page': row['page'],
'query': row['query'],
'position': round(row['position'], 1),
'ctr': round(row['ctr'] * 100, 2),
'expected_ctr': round(row['expected_ctr'] * 100, 2),
'gap': round(row['ctr_gap'] * 100, 2),
'impressions': int(row['impressions']),
'previous_gap': round(previous_gap * 100, 2)
})
return sorted(alerts, key=lambda x: x['impressions'], reverse=True)
# Intégrer dans un workflow Slack / email
def format_alert(alerts, top_n=15):
lines = [f"🔍 {len(alerts)} nouveaux intent gaps détectés\n"]
for a in alerts[:top_n]:
lines.append(
f" {a['page'].split('/')[-1]}\n"
f" Query: {a['query']}\n"
f" Pos: {a['position']} | CTR: {a['ctr']}% vs {a['expected_ctr']}% attendu "
f"(gap: {a['gap']}pp) | {a['impressions']} impr."
)
return '\n'.join(lines)
Cette approche de monitoring continu rejoint la philosophie de détection de régressions. Si vous modifiez un title tag ou restructurez une page, un outil de monitoring comme Seogard détecte la modification technique. Le pipeline ci-dessus détecte l'impact sur l'alignement d'intention. Les deux sont complémentaires.
Croiser avec les données de crawl
Les intent gaps ont parfois une cause technique invisible dans GSC. Une page qui charge son contenu principal en JavaScript côté client peut afficher un contenu différent au crawler et à l'utilisateur. Le résultat : Google indexe un contenu partiel, le positionne sur des requêtes qui matchent ce contenu partiel, et l'utilisateur qui arrive voit un contenu différent.
Pour détecter ce type de décalage, croisez vos données d'intent gap avec un crawl Screaming Frog configuré en mode rendu JavaScript. Comparez le title, le H1 et les 500 premiers caractères du body entre le rendu HTML brut et le rendu JS. Toute différence significative est un candidat pour expliquer un intent gap technique.
Les rapports GSC sous-utilisés peuvent aussi révéler des anomalies : le rapport de couverture montre si Google indexe bien toutes vos pages, et le rapport d'amélioration signale les problèmes de données structurées qui affectent l'apparence en SERP.
Au-delà des données GSC : enrichir l'analyse avec des signaux externes
GSC est la source de vérité pour les données de positionnement et de CTR. Mais elle a des angles morts.
Ce que GSC ne vous dit pas
- Le comportement post-clic : un utilisateur qui clique et revient en 3 secondes sur la SERP (pogo-sticking) confirme l'intent gap, mais GSC n'enregistre que le click. Croisez avec vos données Analytics pour mesurer le taux de rebond par landing page × source organique.
- Les requêtes que vous ne voyez pas : GSC masque les requêtes à très faible volume et les requêtes sensibles. Sur certains sites, 30-40% des impressions tombent dans la catégorie "other" non détaillée.
- L'évolution de l'intention : une requête comme "IA générative" était informationnelle en 2023 ("qu'est-ce que c'est ?"), puis est devenue transactionnelle en 2025 ("quel outil acheter ?"). GSC montre le CTR qui baisse, pas le pourquoi.
Pour aller plus loin, les données de contenu elles-mêmes comptent. Si vous produisez du contenu conçu pour les systèmes d'IA, la question de l'alignement d'intention se pose aussi pour les AI Overviews de Google. Un intent gap dans les résultats classiques se manifeste différemment quand un AI Overview capte une partie du trafic en haut de SERP.
Enrichir via les données structurées de la SERP
Pour les 50 requêtes prioritaires, une analyse manuelle de la SERP reste le gold standard. Notez :
- Le type de résultats affichés (PAA, featured snippet, images, shopping, vidéos)
- Le ratio pages informationnelles / transactionnelles dans le top 10
- La présence d'un AI Overview et son contenu
Si 8 résultats sur 10 sont des guides d'achat et votre page est un article de blog technique, l'intent dominant est commercial. Votre page est en décalage structurel, et aucune optimisation de title ne corrigera le problème — il faut un contenu différent, ou il faut accepter que cette requête n'est pas votre bataille.
Transformer les intent gaps en roadmap produit
Les intent gaps ne sont pas qu'un problème de SEO. Ce sont des signaux produit. Quand votre site e-commerce ranke sur "alternative [produit concurrent]" et que vous n'avez pas de page de comparaison, ce n'est pas juste un gap de contenu — c'est un gap dans votre stratégie d'acquisition.
Structurez les résultats de l'analyse en trois buckets :
-
Quick wins : gap de CTR corrigeable par une optimisation de title/meta description. La page répond à l'intention, mais le snippet ne le communique pas. Effort : 30 minutes par page. Impact typique : +2 à 4 points de CTR.
-
Content gaps : la page existe mais ne couvre pas l'angle attendu. Il faut restructurer ou enrichir le contenu. Effort : 2-4 heures par page. Impact : variable, mais peut doubler le CTR sur les requêtes cibles.
-
Structural gaps : il manque un type de page entier dans votre architecture. Pages comparatives, guides d'achat, pages FAQ dédiées. Effort : création de contenu + intégration dans le maillage interne. Impact : capture de trafic entièrement nouveau.
Pour les KPIs à suivre après correction, ne vous limitez pas au CTR. Mesurez le taux de conversion par landing page organique. Un intent gap corrigé devrait améliorer à la fois le CTR (plus de clicks) et le taux de conversion (des clicks mieux qualifiés).
Les intent gaps mesurés via GSC sont la donnée la plus sous-exploitée du SEO technique. Chaque site perd du trafic qualifié non pas parce qu'il manque de positions, mais parce que ses positions existantes sont mal alignées avec la demande réelle. L'extraction systématique via l'API, la classification automatisée, et le monitoring continu transforment ce diagnostic en avantage concurrentiel mesurable. Des outils comme Seogard complètent cette approche en détectant les régressions techniques qui créent de nouveaux intent gaps — un title modifié par erreur, un SSR cassé qui altère le contenu crawlé, un canonical mal configuré qui oriente Google vers la mauvaise page.