Scraping SERP géociblé : meilleures pratiques pour développeurs

Guide technique complet pour le scraping SERP géociblé : paramètres uule, gl, hl, proxies résidentiels par ville, parsing du local pack, et patterns de production en Python.

Geo-Targeted SERP Scraping Best Practices for Local SEO Data
Dans cet article

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=fr avec un proxy US et google_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:8080 avec -country-US-city-newyork dans 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.

Questions fréquentes

Qu'est-ce que le scraping SERP géociblé ?

Le scraping SERP géociblé consiste à collecter les résultats de recherche Google en simulant une requête depuis une localisation précise (pays, ville, district). Il combine des paramètres d'URL (gl, hl, uule, pws) avec des proxies résidentiels dont l'IP correspond à la zone ciblée, afin d'obtenir des classements représentatifs du marché local réel.

Pourquoi le scraping SERP géociblé importe-t-il pour les utilisateurs de proxies ?

Google personnalise les résultats selon l'IP, la localisation et l'historique. Sans géociblage précis, les données SERP collectées sont bruitées et non représentatives. Les proxies résidentiels géociblés permettent de simuler une connexion depuis la ville cible, ce qui est essentiel pour le suivi de classement local et l'analyse concurrentielle par marché.

Quel type de proxy fonctionne le mieux pour le scraping SERP géociblé ?

Les proxies résidentiels sont le meilleur choix car leur IP provient de vrais FAI et passe les vérifications de Google. Les datacenter proxies sont facilement détectés. ProxyHat offre des proxies résidentiels avec géociblage par ville via gate.proxyhat.com:8080, en utilisant le format user-country-US-city-newyork dans le nom d'utilisateur pour cibler précisément la zone souhaitée.

Comment éviter les blocages lors du scraping SERP géociblé ?

Alignez toujours la géographie IP avec les paramètres gl, hl et uule. Utilisez pws=0 pour un baseline non personnalisé. Implémentez un backoff exponentiel sur les erreurs 429, un circuit breaker par pays, des sessions sticky pour la pagination, et un délai respectueux entre requêtes. L'impersonation TLS via curl_cffi ou des clients similaires réduit également la détection.

Comment construire le paramètre uule de Google ?

Le paramètre uule se construit en encodant le nom canonique de la ville (ex. « Austin,Texas,United States ») en base64, puis en ajoutant le préfixe fixe « w+CAIQICI » et une clé de longueur correspondant à la taille de la chaîne base64. La clé est un caractère issue d'une table de correspondance où A=2, B=4, C=6, etc. Le résultat final ressemble à w+CAIQICIRQXVzdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=.

Prêt à commencer ?

Proxys résidentiels, ISP et mobiles dans plus de 148 pays. Créez un compte gratuit.

Créer un compte gratuit
← Retour au Blog