Pourquoi le scraping SERP géociblé exige une précision au niveau ville
Le scraping SERP géociblé consiste à collecter les résultats de recherche Google en simulant une requête émise depuis une localisation géographique précise. Pour les SEO engineers et les développeurs de rank trackers, c'est la seule façon d'obtenir des données de classement représentatives de ce que voient réellement les utilisateurs dans un marché donné.
Le problème est bien connu : une même requête comme « plombier urgence » retourne des résultats radicalement différents selon que l'utilisateur se trouve à Paris, Lyon ou Marseille. Google croise l'adresse IP, les paramètres d'URL, l'historique de recherche et le signal GPS pour personnaliser les résultats. D'après les observations de l'industrie, près de 50% des requêtes locales produisent des SERP significativement différentes entre deux villes d'un même pays. Une collecte sans géociblage précis produit un jeu de données bruité et inexploitable.
Le ciblage au niveau pays est insuffisant. Un proxy US vous donne une IP américaine, mais Google peut la géolocaliser à New York, Chicago ou Austin — trois marchés locaux distincts avec des classements différents. Pour du local SERP scraping by city, il faut contrôler simultanément trois couches : l'IP (proxy résidentiel géociblé), les paramètres d'URL (gl, hl, uule), et le domaine Google (google_domain).
Paramètres de localisation Google : gl, hl, google_domain, lr, uule, pws
Google expose plusieurs paramètres d'URL qui contrôlent la localisation des résultats. Les comprendre et les combiner correctement est la base du geo-targeted SERP scraping.
| Paramètre | Rôle | Exemple |
|---|---|---|
gl |
Code pays (2 lettres) pour le marché cible | gl=us, gl=fr, gl=de |
hl |
Langue de l'interface Google | hl=fr, hl=en |
google_domain |
Domaine Google à interroger | google.com, google.fr, google.de |
lr |
Filtre la langue des résultats (lang_ + code) |
lr=lang_fr, lr=lang_en |
uule |
Localisation précise (ville/district) encodée en base64 | uule=w+CAIQICIR... |
pws |
Désactive la personnalisation web (0 = off) | pws=0 |
La combinaison serp gl hl google_domain couvre le ciblage national et linguistique. Le paramètre uule ajoute la précision ville. Et pws=0 garantit un baseline non personnalisé — indispensable pour des mesures de classement reproductibles. Pour aller plus loin sur les paramètres de recherche Google, consultez la documentation officielle de Google sur la recherche locale.
Construction du paramètre uule
Le paramètre uule Google encode un nom de lieu canonique en base64, précédé d'un préfixe fixe et d'une clé de longueur. Le format est :
uule = w+CAIQICI + length_key + base64(canonical_name)
Le nom canonique suit le format « Ville,État,Région,Pays » ou « Ville,Pays » selon la granularity souhaitée. La clé de longueur est un caractère unique qui encode la longueur de la chaîne base64. Voici la fonction de construction :
import base64
# Table de correspondance longueur base64 -> clé
# A=2, B=4, C=6, ... Z=52, a=54, b=56, ... 9=62, -=64
LENGTH_KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
def build_uule(canonical_name: str) -> str:
"""Construit le paramètre uule Google à partir d'un nom canonique.
Args:
canonical_name: ex. "Brooklyn,New York,United States"
ou "Paris,France"
Returns:
Valeur uule complète, ex. "w+CAIQICIRQnJvb2tseW5u...
"""
encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("utf-8")
length = len(encoded)
# Chaque clé représente une longueur paire à partir de 2
key_index = (length - 2) // 2
if key_index < 0 or key_index >= len(LENGTH_KEYS):
raise ValueError(f"Longueur base64 hors plage: {length}")
key = LENGTH_KEYS[key_index]
return f"w+CAIQICI{key}{encoded}"
# Exemples
print(build_uule("Austin,Texas,United States"))
# -> w+CAIQICIRQXVzdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=
print(build_uule("Paris,France"))
# -> w+CAIQICIRUGFyaXMsRnJhbmNl
print(build_uule("München,Bayern,Germany"))
# -> w+CAIQICITTXD8bmNoZW4sQmF5ZXJuLEdlcm1hbnk=
Pour plus d'informations sur l'encodage base64, consultez l'article Wikipedia sur Base64.
Cohérence entre géographie IP et paramètres SERP
C'est l'erreur la plus fréquente : un développeur configure gl=us et uule=Austin,Texas,United States mais route la requête via une IP française. Google détecte l'incohérence et peut soit ignorer les paramètres de localisation, soit déclencher un CAPTCHA, soit retourner des résultats hybrides non représentatifs.
La règle d'or : les paramètres d'URL doivent toujours correspondre à la géographie de l'IP. Si vous ciblez Austin, Texas, votre proxy doit sortir des États-Unis — idéalement de la région de Texas. C'est là que le géociblage par ville des proxies résidentiels devient indispensable.
Avec ProxyHat, vous contrôlez le pays et la ville de sortie directement dans le nom d'utilisateur :
# Proxy résidentiel à New York, US
http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080
# Proxy résidentiel à Austin, Texas, US
http://user-country-US-city-austin:pass@gate.proxyhat.com:8080
# Proxy résidentiel à Paris, FR
http://user-country-FR-city-paris:pass@gate.proxyhat.com:8080
Implémentation en Python : requêtes SERP géociblées
Voici deux approches côte à côte : l'utilisation brute du proxy avec curl_cffi (impersonation Chrome) et un client ProxyHat structuré qui encapsule la logique de géociblage.
Approche 1 : proxy brut avec curl_cffi
from curl_cffi import requests as cffi_requests
import urllib.parse
import base64
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
LENGTH_KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
def build_uule(canonical_name: str) -> str:
encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("utf-8")
key = LENGTH_KEYS[(len(encoded) - 2) // 2]
return f"w+CAIQICI{key}{encoded}"
def fetch_serp_raw(
query: str,
country: str = "us",
city: str = "Austin,Texas,United States",
hl: str = "en",
google_domain: str = "google.com",
max_retries: int = 3,
) -> str:
"""Récupère le HTML SERP avec proxy résidentiel géociblé.
Utilise curl_cffi pour l'impersonation Chrome (TLS fingerprint).
"""
uule = build_uule(city)
params = {
"q": query,
"gl": country,
"hl": hl,
"uule": uule,
"pws": "0",
"num": "100",
}
url = f"https://www.{google_domain}/search?" + urllib.parse.urlencode(params)
# Proxy résidentiel correspondant à la ville cible
city_slug = city.split(",")[0].lower().replace(" ", "")
proxy_url = f"http://user-country-{country.upper()}-city-{city_slug}:pass@gate.proxyhat.com:8080"
for attempt in range(1, max_retries + 1):
try:
response = cffi_requests.get(
url,
proxies={"https": proxy_url, "http": proxy_url},
impersonate="chrome",
timeout=30,
)
if response.status_code == 200:
logger.info(f"SERP récupéré pour '{query}' à {city} (attempt {attempt})")
return response.text
elif response.status_code == 429:
wait = 2 ** attempt
logger.warning(f"429 Too Many Requests, retry dans {wait}s")
time.sleep(wait)
elif response.status_code == 302:
logger.warning(f"302 redirect — probable CAPTCHA. Changement d'IP requis.")
raise RuntimeError("CAPTCHA détecté, rotation d'IP nécessaire")
else:
logger.error(f"HTTP {response.status_code} inattendu")
response.raise_for_status()
except Exception as e:
logger.error(f"Tentative {attempt} échouée: {e}")
if attempt < max_retries:
time.sleep(2 ** attempt)
else:
raise
raise RuntimeError(f"Échec après {max_retries} tentatives")
# Utilisation
html = fetch_serp_raw(
query="best coffee shops",
country="us",
city="Austin,Texas,United States",
hl="en",
google_domain="google.com",
)
print(f"Longueur HTML: {len(html)} caractères")
Approche 2 : client ProxyHat structuré
from curl_cffi import requests as cffi_requests
import urllib.parse
import base64
import logging
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
LENGTH_KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
@dataclass
class GeoTarget:
"""Définit une cible géographique pour le scraping SERP."""
country: str # Code pays: "us", "fr", "de"
city: str # Nom canonique: "Austin,Texas,United States"
hl: str = "en" # Langue interface
google_domain: str = "google.com"
lr: Optional[str] = None # Filtre langue résultats: "lang_en"
@property
def city_slug(self) -> str:
return self.city.split(",")[0].lower().replace(" ", "")
@property
def uule(self) -> str:
encoded = base64.b64encode(self.city.encode("utf-8")).decode("utf-8")
key = LENGTH_KEYS[(len(encoded) - 2) // 2]
return f"w+CAIQICI{key}{encoded}"
class ProxyHatClient:
"""Client structuré pour le scraping SERP géociblé via ProxyHat.
Encapsule la construction du proxy, la gestion des sessions,
et la logique de retry/backoff. Référence: https://docs.proxyhat.com/
"""
GATEWAY = "gate.proxyhat.com"
HTTP_PORT = 8080
SOCKS5_PORT = 1080
def __init__(self, username: str, password: str, use_socks5: bool = False):
self.username = username
self.password = password
self.port = self.SOCKS5_PORT if use_socks5 else self.HTTP_PORT
self.scheme = "socks5" if use_socks5 else "http"
def proxy_url(self, target: GeoTarget, session_id: Optional[str] = None) -> str:
"""Construit l'URL proxy avec géociblage pays + ville."""
user_parts = [f"user-country-{target.country.upper()}",
f"city-{target.city_slug}"]
if session_id:
user_parts.append(f"session-{session_id}")
user = ":".join(user_parts)
return f"{self.scheme}://{user}:{self.password}@{self.GATEWAY}:{self.port}"
def fetch_serp(
self,
query: str,
target: GeoTarget,
num_results: int = 100,
max_retries: int = 3,
session_id: Optional[str] = None,
) -> str:
"""Récupère le HTML SERP avec géociblage complet."""
params = {
"q": query,
"gl": target.country,
"hl": target.hl,
"uule": target.uule,
"pws": "0",
"num": str(num_results),
}
if target.lr:
params["lr"] = target.lr
url = f"https://www.{target.google_domain}/search?" + urllib.parse.urlencode(params)
proxy = self.proxy_url(target, session_id)
for attempt in range(1, max_retries + 1):
try:
resp = cffi_requests.get(
url,
proxies={"https": proxy, "http": proxy},
impersonate="chrome",
timeout=30,
)
if resp.status_code == 200:
logger.info(f"OK: '{query}' @ {target.city} (attempt {attempt})")
return resp.text
elif resp.status_code == 429:
wait = min(2 ** attempt, 60)
logger.warning(f"429 — backoff {wait}s")
time.sleep(wait)
elif resp.status_code in (302, 503):
logger.warning(f"{resp.status_code} — possible CAPTCHA")
# Forcer une nouvelle IP en changeant de session
session_id = f"retry-{int(time.time())}"
proxy = self.proxy_url(target, session_id)
time.sleep(3)
else:
resp.raise_for_status()
except Exception as e:
logger.error(f"Tentative {attempt}/{max_retries}: {e}")
if attempt >= max_retries:
raise
time.sleep(2 ** attempt)
raise RuntimeError(f"Échec après {max_retries} tentatives")
# --- Utilisation ---
client = ProxyHatClient(username="user", password="pass")
target_us_austin = GeoTarget(
country="us",
city="Austin,Texas,United States",
hl="en",
google_domain="google.com",
lr="lang_en",
)
html = client.fetch_serp("best coffee shops", target_us_austin)
print(f"Résultats récupérés: {len(html)} chars")
Le client structuré sépare les préoccupations : GeoTarget définit le marché, ProxyHatClient gère l'infrastructure proxy. Pour découvrir d'autres patterns d'usage, consultez nos cas d'usage en SERP tracking et web scraping.
Parsing des résultats organiques et du local pack
Une fois le HTML récupéré, il faut extraire les résultats organiques et le local pack (les 3 résultats locaux avec carte). selectolax offre un parsing rapide et tolérant aux variations de structure de Google.
from selectolax.parser import HTMLParser
from dataclasses import dataclass
from typing import List, Optional
import json
@dataclass
class OrganicResult:
position: int
title: str
url: str
snippet: str
is_local_pack: bool = False
def parse_serp(html: str) -> List[OrganicResult]:
"""Parse le HTML SERP Google : résultats organiques + local pack.
Retourne une liste ordonnée par position dans la SERP.
"""
tree = HTMLParser(html)
results: List[OrganicResult] = []
position = 0
# --- Local Pack (3 résultats avec carte) ---
local_pack = tree.css_first("div[data-attrid='local_map_links']")
if not local_pack:
# Fallback : chercher le conteneur du local pack
local_pack = tree.css_first("div[jsname='Nj6kVc']") or tree.css_first("div.eT3znd")
if local_pack:
for item in local_pack.css("div[data-cid]"):
position += 1
title_el = item.css_first("div.dbg0p") or item.css_first("span")
link_el = item.css_first("a[href]")
snippet_el = item.css_first("span[jsname='Ud7xff']")
results.append(OrganicResult(
position=position,
title=title_el.text(strip=True) if title_el else "",
url=link_el.attributes.get("href", "") if link_el else "",
snippet=snippet_el.text(strip=True) if snippet_el else "",
is_local_pack=True,
))
# --- Résultats organiques ---
for div in tree.css("div.g"):
link = div.css_first("a[href]")
title = div.css_first("h3")
snippet = div.css_first("div[data-sncf]") or div.css_first("span.aCOpRe")
if not link or not title:
continue
position += 1
results.append(OrganicResult(
position=position,
title=title.text(strip=True),
url=link.attributes.get("href", ""),
snippet=snippet.text(strip=True) if snippet else "",
is_local_pack=False,
))
return results
# --- Utilisation ---
# html = client.fetch_serp("coffee shop near me", target_us_austin)
# results = parse_serp(html)
# for r in results:
# tag = "[LOCAL]" if r.is_local_pack else "[ORG]"
# print(f"{tag} #{r.position} {r.title} — {r.url}")
# Export JSON pour le stockage
# data = [{"position": r.position, "title": r.title, "url": r.url,
# "snippet": r.snippet, "is_local_pack": r.is_local_pack}
# for r in results]
# with open("serp_austin_coffee.json", "w") as f:
# json.dump(data, f, indent=2, ensure_ascii=False)
Conseil production : Google change régulièrement ses sélecteurs CSS. Utilisez des sélecteurs multiples en fallback et surveillez le taux d'extraction. Si vous passez sous 90% de résultats extraits sur un échantillon de 100 SERP, c'est qu'un changement de structure a eu lieu.
Sessions sticky pour SERP multi-pages
Pour collecter les pages 2, 3, 4+ d'une même SERP, vous devez conserver la même IP sortante sur toute la pagination. Sans session sticky, chaque page vient d'une IP différente et Google peut retourner des résultats incohérents ou déclencher un CAPTCHA.
import uuid
import time
import urllib.parse
from curl_cffi import requests as cffi_requests
def fetch_paginated_serp(
client: ProxyHatClient,
query: str,
target: GeoTarget,
max_pages: int = 5,
delay_seconds: float = 2.0,
) -> list[dict]:
"""Collecte plusieurs pages de SERP avec session sticky.
Garde la même IP (même session ProxyHat) pour toutes les pages.
"""
session_id = f"serp-{uuid.uuid4().hex[:8]}"
all_results = []
for page in range(0, max_pages):
start = page * 10 # Google utilise start=0,10,20,30...
params = {
"q": query,
"gl": target.country,
"hl": target.hl,
"uule": target.uule,
"pws": "0",
"num": "10",
"start": str(start),
}
url = f"https://www.{target.google_domain}/search?" + urllib.parse.urlencode(params)
proxy = client.proxy_url(target, session_id=session_id)
try:
resp = cffi_requests.get(
url,
proxies={"https": proxy, "http": proxy},
impersonate="chrome",
timeout=30,
)
if resp.status_code == 200:
page_results = parse_serp(resp.text)
for r in page_results:
r.position += start # Position globale
all_results.extend(page_results)
print(f"Page {page + 1}: {len(page_results)} résultats (session {session_id})")
elif resp.status_code == 429:
print(f"429 sur page {page + 1} — pause 15 min recommandée")
break
else:
print(f"HTTP {resp.status_code} sur page {page + 1}")
break
except Exception as e:
print(f"Erreur page {page + 1}: {e}")
break
time.sleep(delay_seconds) # Respectful delay entre pages
return all_results
# --- Utilisation ---
# client = ProxyHatClient("user", "pass")
# target = GeoTarget(country="us", city="Austin,Texas,United States")
# results = fetch_paginated_serp(client, "best coffee shops", target, max_pages=3)
# print(f"Total: {len(results)} résultats sur 3 pages")
Gestion des CAPTCHA, erreurs 429 et retries
Google défend activement contre le scraping automatisé. Les signaux de détection incluent le volume de requêtes par IP, le pattern de navigation, et l'empreinte TLS. Voici une stratégie de backoff avec circuit breaker :
import time
import logging
from collections import defaultdict
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class SERPBackoffManager:
"""Gestionnaire de backoff avec circuit breaker par pays.
Suit le taux d'erreur par marché et désactive temporairement
un pays si le taux d'échec dépasse le seuil.
"""
def __init__(
self,
max_retries: int = 3,
base_delay: float = 2.0,
max_delay: float = 60.0,
error_threshold: int = 5,
cooldown_minutes: int = 15,
):
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.error_threshold = error_threshold
self.cooldown = timedelta(minutes=cooldown_minutes)
self.error_counts: dict[str, int] = defaultdict(int)
self.circuit_open_until: dict[str, datetime] = {}
def can_request(self, country: str) -> bool:
"""Vérifie si le circuit breaker autorise les requêtes pour ce pays."""
until = self.circuit_open_until.get(country)
if until and datetime.now() < until:
return False
if until and datetime.now() >= until:
del self.circuit_open_until[country]
self.error_counts[country] = 0
return True
def record_success(self, country: str):
self.error_counts[country] = 0
def record_failure(self, country: str, status_code: int):
self.error_counts[country] += 1
if status_code == 429:
delay = min(self.base_delay * 2 ** self.error_counts[country], self.max_delay)
logger.warning(f"429 pour {country} — backoff {delay:.0f}s")
time.sleep(delay)
if self.error_counts[country] >= self.error_threshold:
self.circuit_open_until[country] = datetime.now() + self.cooldown
logger.error(
f"Circuit breaker OUVERT pour {country} — "
f"cooldown {self.cooldown.total_seconds()/60:.0f} min"
)
# --- Utilisation dans une boucle de collecte ---
backoff = SERPBackoffManager(max_retries=3, cooldown_minutes=15)
def safe_fetch(client: ProxyHatClient, query: str, target: GeoTarget) -> str | None:
if not backoff.can_request(target.country):
logger.warning(f"Circuit ouvert pour {target.country}, skip")
return None
try:
html = client.fetch_serp(query, target, max_retries=3)
backoff.record_success(target.country)
return html
except Exception as e:
backoff.record_failure(target.country, 429)
logger.error(f"Échec {target.country}: {e}")
return None
Pools de proxies par pays
Pour du US geo-targeted SERP scraping à grande échelle, vous devez maintenir des pools séparés par marché. Chaque pays a son propre rythme de collecte, son propre circuit breaker, et ses propres sessions sticky. Voici un pattern async avec asyncio :
import asyncio
import uuid
from dataclasses import dataclass
from typing import List
import logging
logger = logging.getLogger(__name__)
@dataclass
class ScrapingTask:
query: str
target: GeoTarget
priority: int = 0
class CountryProxyPool:
"""Pool de collecte asynchrone par pays avec contrôle de concurrence."""
def __init__(
self,
client: ProxyHatClient,
max_concurrent: int = 10,
requests_per_second: float = 2.0,
):
self.client = client
self.semaphore = asyncio.Semaphore(max_concurrent)
self.min_interval = 1.0 / requests_per_second
self._last_request = 0.0
async def fetch_one(self, task: ScrapingTask) -> dict | None:
async with self.semaphore:
# Rate limiting simple
now = asyncio.get_event_loop().time()
elapsed = now - self._last_request
if elapsed < self.min_interval:
await asyncio.sleep(self.min_interval - elapsed)
self._last_request = asyncio.get_event_loop().time()
session_id = f"pool-{uuid.uuid4().hex[:8]}"
try:
# Note: curl_cffi ne supporte pas asyncio nativement.
# En production, utilisez httpx avec proxy ou exécutez
# dans un ThreadPoolExecutor.
loop = asyncio.get_event_loop()
html = await loop.run_in_executor(
None,
lambda: self.client.fetch_serp(
task.query, task.target, session_id=session_id
)
)
results = parse_serp(html)
return {
"query": task.query,
"city": task.target.city,
"country": task.target.country,
"results_count": len(results),
"local_pack_count": sum(1 for r in results if r.is_local_pack),
}
except Exception as e:
logger.error(f"Échec {task.query} @ {task.target.city}: {e}")
return None
async def fetch_batch(self, tasks: List[ScrapingTask]) -> List[dict]:
coros = [self.fetch_one(t) for t in tasks]
results = await asyncio.gather(*coros, return_exceptions=True)
return [r for r in results if r is not None and not isinstance(r, Exception)]
# --- Utilisation ---
# client = ProxyHatClient("user", "pass")
# pool = CountryProxyPool(client, max_concurrent=10, requests_per_second=2.0)
#
# tasks = [
# ScrapingTask("coffee shop", GeoTarget("us", "Austin,Texas,United States")),
# ScrapingTask("coffee shop", GeoTarget("us", "Brooklyn,New York,United States")),
# ScrapingTask("coffee shop", GeoTarget("us", "Chicago,Illinois,United States")),
# ScrapingTask("boulangerie", GeoTarget("fr", "Paris,France", hl="fr", google_domain="google.fr")),
# ScrapingTask("bäckerei", GeoTarget("de", "München,Bayern,Germany", hl="de", google_domain="google.de")),
# ]
# batch_results = asyncio.run(pool.fetch_batch(tasks))
# for r in batch_results:
# print(f"{r['city']}: {r['results_count']} résultats ({r['local_pack_count']} local pack)")
Erreurs courantes et cas limites
- Paramètres incohérents :
gl=fravec un proxy US etgoogle_domain=google.de. Google ignore ou mélange les signaux. Alignez toujours les trois. - uule trop précis : un nom canonique comme « 5th Avenue,Austin,Texas,United States » peut ne pas être reconnu. Restez au niveau ville ou ville+état.
- Encodage base64 incorrect : utilisez UTF-8, pas ASCII. Les noms de villes avec accents (München, São Paulo) doivent être encodés correctement. Consultez la spécification Base64 pour les détails.
- Oubli de pws=0 : sans ce paramètre, Google personnalise les résultats selon l'historique de la session IP. Vos mesures deviennent non reproductibles.
- Latence excessive : un proxy résidentiel ajoute 200–800ms de latence. Si vous scrapez 50 villes, parallélisez avec des pools par pays plutôt que de tout séquentialiser.
- Rotation d'IP trop agressive : changer d'IP à chaque requête peut sembler plus suspect qu'une navigation normale. Pour la pagination, utilisez des sessions sticky.
Considérations légales et conditions d'utilisation
Le scraping de Google SERP soulève des questions légales. Les conditions d'utilisation de Google interdisent l'accès automatisé sans autorisation. En pratique, de nombreuses entreprises collectent des données SERP pour le suivi de classement, mais cela doit se faire dans le respect des limites techniques, des robots.txt, et des lois applicables (RGPD en Europe, CCPA en Californie). Consultez toujours les conditions d'utilisation de Google et, si nécessaire, un conseiller juridique. Pour des données SERP à grande échelle, envisagez l'API de recherche programmable de Google qui offre un accès officiel et documenté.
Points clés à retenir
- Alignez IP, gl, hl, google_domain et uule — Google croise ces signaux. Une incohérence déclenche CAPTCHA ou résultats non représentatifs.
- Le paramètre uule encode le nom canonique de la ville en base64 avec un préfixe fixe et une clé de longueur. C'est le seul moyen de cibler une ville précise.
- pws=0 est obligatoire pour des baselines non personnalisés et reproductibles.
- Utilisez des proxies résidentiels géociblés via
gate.proxyhat.com:8080avec-country-US-city-newyorkdans le nom d'utilisateur.- Sessions sticky pour la pagination multi-pages : conservez la même IP sur toute la collecte d'une SERP.
- Circuit breaker par pays : si un marché dépasse 5 erreurs consécutives, mettez-le en cooldown 15 minutes plutôt que de forcer.
- Surveillez vos sélecteurs CSS : Google change sa structure HTML régulièrement. Un taux d'extraction sous 90% signale un changement.
Pour configurer vos pools de proxies résidentiels géociblés, consultez notre page de tarification et la documentation ProxyHat pour les détails d'intégration.





