Suivre ses positions Google manuellement est intenable dès qu'on dépasse une dizaine de mots-clés. Créer un Google Rank Tracker en Python avec des proxies résidentiels permet d'automatiser la collecte quotidienne des SERPs, de stocker l'historique des positions, et de détecter la volatilité avant qu'elle n'impacte le trafic. Ce guide vous montre comment assembler chaque brique : modèle de données, pagination post-num=100, fingerprinting TLS avec curl_cffi, rotation d'IP via ProxyHat, et durcissement production.
Pourquoi un Google Rank Tracker en Python avec des proxies résidentiels est indispensable
Google ne fournit pas de position moyenne via son API Search Console — seulement des impressions et des clics agrégés par page, avec un délai de 48 à 96 heures. Pour connaître la position organique réelle d'une URL pour un mot-clé donné, dans un pays et un device précis, il faut interroger la SERP directement. Or Google détecte et bloque les requêtes automatisées via trois mécanismes principaux :
- Fingerprinting TLS (JA3/JA4) — Google identifie le client TLS par son handshake. Les bibliothèques Python standard (
requests,urllib3) ont une signature distincte de Chrome, ce qui déclenche des CAPTCHAs. Voir la spec TLS de l'IETF pour le contexte technique. - Réputation d'IP — les plages datacenter sont signalées comme non-humaines par les bases Anti-Bot (Imperva, DataDome, Cloudflare). Un taux de blocage de 40 à 70% est courant sur des IPs datacenter.
- Comportement de session — trop de requêtes rapprochées depuis une même IP génèrent un rate limit ou un challenge interstitiel.
Les proxies résidentiels mitige ces trois problèmes : l'IP provient d'un FAI réel (donc réputation élevée), la rotation par requête évite l'accumulation de volume, et le ciblage géo (pays + ville) garantit que la SERP retournée correspond à la locale visée. Pour approfondir, consultez la documentation Search Console API de Google qui explique les limites des données agrégées.
Modèle de données : pourquoi des snapshots quotidiens battent les checks ponctuels
Un check ponctuel vous dit « je suis 4e aujourd'hui ». Un snapshot quotidien vous dit « je suis passé de la 2e à la 7e place en 5 jours, puis remonté à la 3e ». Cette volatilité est un signal SEO critique : Google teste des variantes d'algorithme, les concurrents publient du contenu, et les SERP features apparaissent ou disparaissent. Stocker l'historique permet de calculer :
- Position moyenne sur 7/30 jours
- Écart-type de position (volatilité)
- Tendances (régression linéaire simple)
- Détection de chute brutale (alerte si delta > 3 positions en 24h)
Voici le schéma de données minimal en Python :
import sqlite3
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
@dataclass
class RankRecord:
keyword: str # "chaussures running homme"
target_domain: str # "example.com"
country: str # "US"
device: str # "desktop" | "mobile"
position: int # 1 = première position organique
url: str # URL du résultat
captured_at: str # ISO 8601 UTC
session_id: str # ID de session proxy utilisée
def init_db(db_path: str = "ranks.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS rank_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
target_domain TEXT NOT NULL,
country TEXT NOT NULL,
device TEXT NOT NULL,
position INTEGER NOT NULL,
url TEXT NOT NULL,
captured_at TEXT NOT NULL,
session_id TEXT NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_kw_domain_date
ON rank_history(keyword, target_domain, captured_at)
""")
conn.commit()
return conn
def save_record(conn: sqlite3.Connection, rec: RankRecord) -> None:
conn.execute(
"INSERT INTO rank_history (keyword, target_domain, country, "
"device, position, url, captured_at, session_id) "
"VALUES (:keyword, :target_domain, :country, :device, "
":position, :url, :captured_at, :session_id)",
asdict(rec),
)
conn.commit()
Pagination SERP après la suppression de num=100
Google a retiré le paramètre num=100 en septembre 2025, ce qui limite les résultats à 10 par page. Pour capturer le top 100, il faut paginer avec le paramètre start=0,10,20,…,90. Chaque page est une requête distincte — donc 10 requêtes par mot-clé pour couvrir 100 résultats. Cela augmente le volume de requêtes et rend la rotation d'IP encore plus critique.
def build_serp_urls(keyword: str, country: str, max_results: int = 100) -> list[str]:
"""Construit les URLs SERP paginées (10 résultats par page)."""
base = "https://www.google.com/search"
urls = []
for start in range(0, max_results, 10):
params = {
"q": keyword,
"num": "10",
"start": str(start),
"hl": "en" if country == "US" else "fr",
"gl": country.lower(),
}
query = "&".join(f"{k}={v}" for k, v in params.items())
urls.append(f"{base}?{query}")
return urls
# Exemple : 10 URLs pour couvrir le top 100
urls = build_serp_urls("python web scraping", "US")
print(len(urls)) # 10
Pourquoi le fingerprinting TLS force l'usage de proxies résidentiels
Google utilise le fingerprinting JA3/JA4 pour comparer le handshake TLS de votre client à celui des navigateurs légitimes. La bibliothèque requests de Python utilise urllib3 qui produit un handshake OpenSSL distinct de Chrome. Résultat : même avec une IP résidentielle parfaite, Google peut détecter que le client n'est pas un vrai navigateur.
La solution est d'utiliser curl_cffi, un binding Python pour curl-impersonate qui reproduit le handshake TLS exact de Chrome, Firefox ou Safari. Combiné avec des proxies résidentiels et un ciblage ville, on obtient un taux de succès de 95%+ contre 30 à 50% avec requests sur IPs datacenter.
| Approche | Taux de succès typique | Fingerprint TLS | Réputation IP |
|---|---|---|---|
| requests + IP datacenter | 20–40% | OpenSSL (détectable) | Faible |
| requests + IP résidentielle | 60–75% | OpenSSL (détectable) | Élevée |
| curl_cffi + IP résidentielle | 90–98% | Chrome (authentique) | Élevée |
Exemple complet : curl_cffi + ProxyHat SDK
ProxyHat expose un endpoint HTTP sur gate.proxyhat.com:8080. Le ciblage géo et la session sticky se configurent dans le username :
user-country-US-city-chicago— IP résidentielle à Chicago, États-Unisuser-session-abc123— session sticky (même IP pour toutes les requêtes de la session)- Combinaison :
user-country-US-city-chicago-session-abc123
Pour un rank tracker, on veut une session sticky par mot-clé : si on paginer 10 pages pour un mot-clé, il faut que toutes viennent de la même IP (sinon Google peut retourner des résultats légèrement différents selon la IP). Voici l'exemple complet :
import re
import hashlib
import time
import logging
from curl_cffi import requests as cffi_requests
from bs4 import BeautifulSoup
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("ranktracker")
PROXYHAT_GATEWAY = "gate.proxyhat.com"
PROXYHAT_PORT = 8080
PROXYHAT_USER = "your_username"
PROXYHAT_PASS = "your_password"
def build_proxy_url(country: str, city: str | None, session_id: str) -> str:
"""Construit l'URL proxy ProxyHat avec geo + session sticky."""
user_part = f"user-country-{country}"
if city:
user_part += f"-city-{city}"
user_part += f"-session-{session_id}"
return f"http://{user_part}:{PROXYHAT_PASS}@{PROXYHAT_GATEWAY}:{PROXYHAT_PORT}"
def fetch_serp_page(url: str, proxy_url: str, max_retries: int = 3) -> str | None:
"""Fetch une page SERP avec curl_cffi (impersonate Chrome) + proxy ProxyHat."""
for attempt in range(max_retries):
try:
resp = cffi_requests.get(
url,
proxy=proxy_url,
impersonate="chrome",
timeout=20,
allow_redirects=True,
)
if resp.status_code == 200 and "captcha" not in resp.text.lower():
return resp.text
elif resp.status_code == 429:
wait = 2 ** attempt * 5
logger.warning(f"429 reçu, attente {wait}s (tentative {attempt+1}/{max_retries})")
time.sleep(wait)
else:
logger.warning(f"Statut {resp.status_code}, tentative {attempt+1}")
time.sleep(2 ** attempt)
except Exception as e:
logger.error(f"Erreur fetch: {e}, tentative {attempt+1}/{max_retries}")
time.sleep(2 ** attempt)
return None
def parse_organic_results(html: str) -> list[dict]:
"""Parse les résultats organiques depuis le HTML de la SERP.
Saute les ads et SERP features (People Also Ask, images, etc.)."""
soup = BeautifulSoup(html, "html.parser")
results = []
# Sélecteur CSS pour les blocs de résultats organiques
divs = soup.select("div.g div div div a")
seen_urls = set()
for a in divs:
href = a.get("href", "")
if not href.startswith("http"):
continue
if "google.com" in href or "google.fr" in href:
continue
if href in seen_urls:
continue
# Extraire le domaine
domain_match = re.search(r"https?://([^/]+)/", href)
if not domain_match:
continue
domain = domain_match.group(1).replace("www.", "")
seen_urls.add(href)
results.append({"url": href, "domain": domain})
return results
def find_position(results: list[dict], target_domain: str) -> int | None:
"""Retourne la position (1-indexée) du domaine cible, ou None."""
for i, r in enumerate(results, start=1):
if target_domain in r["domain"]:
return i
return None
Et voici comment orchestrer le tracking complet d'un mot-clé :
def track_keyword(
keyword: str,
target_domain: str,
country: str = "US",
city: str | None = None,
device: str = "desktop",
max_results: int = 100,
) -> RankRecord | None:
"""Track un mot-clé : pagine la SERP, parse, trouve la position."""
# Session sticky basée sur le hash du mot-clé (même IP pour toutes les pages)
session_id = hashlib.md5(keyword.encode()).hexdigest()[:12]
proxy_url = build_proxy_url(country, city, session_id)
all_results = []
urls = build_serp_urls(keyword, country, max_results)
for page_url in urls:
html = fetch_serp_page(page_url, proxy_url)
if html is None:
logger.error(f"Échec fetch pour: {page_url}")
continue
page_results = parse_organic_results(html)
all_results.extend(page_results)
time.sleep(2) # Délai entre pages pour éviter le rate limit
if not all_results:
return None
position = find_position(all_results, target_domain)
if position is None:
logger.info(f"{target_domain} absent du top {max_results} pour '{keyword}'")
return None
# Trouver l'URL exacte
matched = next(r for r in all_results if target_domain in r["domain"])
return RankRecord(
keyword=keyword,
target_domain=target_domain,
country=country,
device=device,
position=position,
url=matched["url"],
captured_at=datetime.now(timezone.utc).isoformat(),
session_id=session_id,
)
# Exécution
if __name__ == "__main__":
conn = init_db()
keywords = ["python web scraping", "best proxy service", "curl_cffi tutorial"]
target = "example.com"
for kw in keywords:
record = track_keyword(kw, target, country="US", city="chicago")
if record:
save_record(conn, record)
print(f"[{kw}] Position #{record.position} — {record.url}")
time.sleep(5) # Délai entre mots-clés
conn.close()
Durcissement production
Retries avec backoff exponentiel
La fonction fetch_serp_page ci-dessus implémente déjà un retry avec backoff exponentiel (2^attempt × 5s sur 429). En production, ajoutez un circuit breaker : si plus de 50% des requêtes échouent sur 5 minutes, suspendez temporairement le tracking.
import time
from collections import deque
class CircuitBreaker:
def __init__(self, failure_threshold: float = 0.5, window: int = 60):
self.failure_threshold = failure_threshold
self.window = window
self.events = deque() # (timestamp, success_bool)
def record(self, success: bool) -> None:
now = time.time()
self.events.append((now, success))
# Nettoyer les événements hors fenêtre
while self.events and self.events[0][0] < now - self.window:
self.events.popleft()
def is_open(self) -> bool:
if len(self.events) < 10:
return False
failures = sum(1 for _, s in self.events if not s)
return (failures / len(self.events)) > self.failure_threshold
# Utilisation
breaker = CircuitBreaker(failure_threshold=0.5, window=300)
if breaker.is_open():
logger.warning("Circuit breaker ouvert, pause 60s")
time.sleep(60)
Détection de CAPTCHA
Google affiche plusieurs types de challenges. Détectez-les tôt pour ne pas gaspiller des requêtes :
def detect_captcha(html: str) -> bool:
"""Détecte les CAPTCHAs et challenges Google."""
captcha_signals = [
"captcha",
"g-recaptcha",
"unusual traffic",
"detected unusual traffic",
"Our systems have detected",
"recaptcha",
]
html_lower = html.lower()
return any(signal.lower() in html_lower for signal in captcha_signals)
# Dans fetch_serp_page, après réception :
if detect_captcha(resp.text):
logger.error("CAPTCHA détecté — rotation d'IP nécessaire")
# Forcer une nouvelle session avec un nouvel ID
new_session = hashlib.md5(f"{keyword}{time.time()}".encode()).hexdigest()[:12]
proxy_url = build_proxy_url(country, city, new_session)
# Réessayer avec la nouvelle IP
Limites de concurrence et pools par pays
Ne lancez pas 50 mots-clés en parallèle depuis la même IP. Utilisez un Semaphore pour limiter la concurrence à 3–5 requêtes simultanées par session proxy, et un pool de sessions par pays :
import asyncio
from asyncio import Semaphore
async def track_keyword_async(
kw: str,
target: str,
country: str,
semaphore: Semaphore,
) -> RankRecord | None:
async with semaphore:
# curl_cffi supporte async via requests.AsyncSession
async with cffi_requests.AsyncSession(impersonate="chrome") as session:
session_id = hashlib.md5(kw.encode()).hexdigest()[:12]
proxy_url = build_proxy_url(country, None, session_id)
urls = build_serp_urls(kw, country, max_results=100)
all_results = []
for page_url in urls:
resp = await session.get(page_url, proxy=proxy_url, timeout=20)
if resp.status_code == 200 and not detect_captcha(resp.text):
all_results.extend(parse_organic_results(resp.text))
await asyncio.sleep(2)
position = find_position(all_results, target)
if position:
return RankRecord(
keyword=kw, target_domain=target, country=country,
device="desktop", position=position,
url=next(r["url"] for r in all_results if target in r["domain"]),
captured_at=datetime.now(timezone.utc).isoformat(),
session_id=session_id,
)
return None
async def main():
keywords = ["python scraping", "proxy residential", "serp tracking"]
sem = Semaphore(3) # Max 3 requêtes simultanées
tasks = [track_keyword_async(kw, "example.com", "US", sem) for kw in keywords]
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, RankRecord):
print(f"{r.keyword}: position #{r.position}")
# asyncio.run(main())
Lissage de la volatilité des positions
Une position peut fluctuer de ±2 places d'un jour à l'autre sans signification réelle. Pour éviter les fausses alertes, calculez une moyenne mobile sur 3 jours :
def get_position_history(conn: sqlite3.Connection, keyword: str, days: int = 7) -> list[int]:
rows = conn.execute(
"SELECT position, captured_at FROM rank_history "
"WHERE keyword = ? ORDER BY captured_at DESC LIMIT ?",
(keyword, days),
).fetchall()
return [r[0] for r in rows][::-1] # inverser pour ordre chronologique
def moving_average(positions: list[int], window: int = 3) -> float | None:
if len(positions) < window:
return None
return sum(positions[-window:]) / window
# Alerte si la moyenne mobile 3j chute de plus de 3 positions vs moyenne mobile 7j
ma3 = moving_average(history, 3)
ma7 = moving_average(history, 7)
if ma3 and ma7 and (ma7 - ma3) > 3:
logger.warning(f"Chute détectée pour '{keyword}': MA3={ma3:.1f} vs MA7={ma7:.1f}")
Export CSV et reporting
import csv
def export_csv(conn: sqlite3.Connection, output_path: str = "rank_report.csv") -> None:
rows = conn.execute(
"SELECT keyword, target_domain, country, device, position, url, captured_at "
"FROM rank_history ORDER BY keyword, captured_at"
).fetchall()
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["keyword", "domain", "country", "device", "position", "url", "date"])
writer.writerows(rows)
logger.info(f"Export: {len(rows)} lignes vers {output_path}")
Éthique et limites
Le scraping de SERPs Google soulève des questions légales et éthiques :
- Trackez vos propres propriétés ou des classements publics. Évitez de tracker les positions de concurrents sans justification légitime.
- Respectez les rate limits — ne dépassez pas 100 requêtes/minute par IP. Un délai de 2 à 5 secondes entre pages est raisonnable.
- Préférez une source officielle à bas volume. Pour moins de 100 mots-clés, l'API Search Console fournit des données d'impression/clic sans scraping. Pour du volume élevé, le JSON API for Custom Search de Google offre 100 requêtes gratuites/jour.
- Respectez le robots.txt et les ToS de Google. Le scraping à grande échelle peut violer les conditions d'utilisation.
- RGPD/CCPA — ne stockez pas de données personnelles (les SERPs n'en contiennent généralement pas, mais soyez vigilant sur les requêtes incluant des noms de personnes).
Pour configurer vos proxies ProxyHat en détail, consultez la documentation officielle ProxyHat. Vous pouvez aussi explorer nos cas d'usage de SERP tracking et notre guide de web scraping.
Points clés à retenir
- Modèle de données : stockez keyword, domaine, pays, device, position, URL et timestamp. Les snapshots quotidiens permettent de détecter la volatilité et les chutes brutales.
- Pagination : Google a supprimé
num=100en septembre 2025. Paginez avecstart=0,10,20,…,90pour couvrir le top 100 (10 requêtes par mot-clé).- Fingerprinting TLS : utilisez
curl_cffiavecimpersonate="chrome"pour reproduire le handshake TLS de Chrome.requestsstandard est détectable.- Proxies résidentiels : ProxyHat sur
gate.proxyhat.com:8080avec ciblage pays+ville et session sticky par mot-clé. Taux de succès 90–98% vs 20–40% en datacenter.- Production : retries avec backoff exponentiel, circuit breaker, détection CAPTCHA, concurrence limitée à 3–5, lissage par moyenne mobile.
- Éthique : trackez vos propres positions, respectez les rate limits, préférez l'API Search Console à bas volume.
Pour commencer, consultez nos tarifs ProxyHat et la liste des localisations disponibles pour le ciblage géo.
FAQ
Qu'est-ce que créer un Google Rank Tracker en Python avec des proxies résidentiels ?
C'est construire un script Python qui interroge automatiquement les pages de résultats Google (SERPs) via des proxies résidentiels — des IPs issues de FAI réels — pour suivre les positions organiques d'un domaine cible sur des mots-clés donnés, dans des pays et devices spécifiques, en stockant l'historique pour analyser la volatilité et les tendances.
Pourquoi est-ce important pour les utilisateurs de proxies ?
Google bloque les requêtes automatisées via fingerprinting TLS (JA3/JA4) et scoring de réputation d'IP. Les proxies résidentiels offrent une réputation d'IP élevée (FAI réel) et le ciblage géo garantit que la SERP correspond à la locale visée. Sans proxies résidentiels, le taux de blocage dépasse 50% et les données sont inutilisables.
Quel type de proxy fonctionne le mieux pour un rank tracker ?
Les proxies résidentiels avec ciblage ville sont optimaux. Ils offrent la meilleure réputation d'IP, le ciblage géo précis (pays + ville), et la rotation par session. Les proxies datacenter sont détectés et bloqués à 40–70%. Les proxies mobiles fonctionnent mais sont plus lents et plus chers. ProxyHat propose les trois types sur gate.proxyhat.com:8080.
Comment éviter les blocages en implémentant un rank tracker ?
Utilisez curl_cffi avec impersonate="chrome" pour masquer le fingerprint TLS, des proxies résidentiels avec session sticky par mot-clé, un délai de 2–5 secondes entre pages, des retries avec backoff exponentiel sur les 429, et un circuit breaker pour suspendre le tracking en cas de taux d'échec élevé. Détectez les CAPTCHAs et changez de session immédiatement.
Combien de requêtes faut-il pour tracker le top 100 d'un mot-clé ?
Depuis la suppression de num=100 en septembre 2025, il faut 10 requêtes paginées (start=0,10,…,90) pour couvrir le top 100. Avec un délai de 2 secondes entre pages, cela prend environ 20 secondes par mot-clé. Pour 50 mots-clés, comptez environ 17 minutes de collecte séquentielle, ou 4 minutes avec 5 sessions concurrentes.





