Best Practices per Scraping SERP Geo-Targetato: Perché la Posizione Cambia Tutto
Se hai mai cercato la stessa keyword da due città diverse e ottenuto risultati completamente differenti, hai già sperimentato il problema centrale del scraping SERP geo-targetato. Google personalizza i risultati in base alla posizione geografica dell'utente: un'azienda di pizzerie a Milano appare nei risultati locali per "pizza" cercato da Roma solo se ha una sede lì, e il local pack (le tre mappe in cima) cambia radicalmente tra quartieri della stessa città.
Per i team SEO e i rank tracker, questo significa che raccogliere SERP senza specificare una località precisa produce dati inutilizzabili. Il targeting a livello paese non basta: "caffè a Roma" e "caffè a Napoli" generano SERP diverse, e anche dentro Roma i risultati del centro storico differiscono da quelli della periferia. Le best practices per scraping SERP geo-targetato richiedono precisione a livello città, parametri di localizzazione coerenti e IP residenziali allineati al mercato target.
Perché la Stessa Query Restituisce Risultati Diversi per Località
Google utilizza almeno tre segnali per localizzare i risultati di ricerca:
- Geolocalizzazione IP: Google deduce la posizione approssimativa dall'indirizzo IP del richiedente. Un IP statunitense produce risultati US, un IP italiano risultati IT.
- Parametri URL:
gl,hl,google_domain,uuleepwscomunicano esplicitamente la regione, la lingua e la città desiderate. - Account e cookie: lo storico di ricerca e le impostazioni dell'account influenzano la personalizzazione — ma per scraping impersonale si usa
pws=0per disattivarla.
Il problema è che Google cross-controlla questi segnali. Se invii gl=US ma il tuo IP è in Francia, Google può ignorare il parametro o mostrare risultati ibridi. Per risultati affidabili e riproducibili, IP e parametri URL devono concordare. Per approfondire il funzionamento dei parametri di localizzazione, consulta la documentazione ufficiale di Google.
Perché il Targeting a Livello Paese Non È Sufficiente
Il local pack — quel blocco di tre risultati con mappa che appare per query con intento locale — è sensibile alla posizione fino al livello di quartiere. Un'azienda di idraulici a Brooklyn non appare nel local pack per "idraulico" cercato da Manhattan, anche se entrambe sono a New York City. Per il rank tracking locale serve precisione a livello città o distretto, non solo paese.
Secondo le impostazioni di regione di Google, la localizzazione influisce su ordinamento, local pack, suggerimenti e persino le featured snippets. Ignorare questo significa raccogliere dati SERP che non riflettono ciò che vedono gli utenti reali nel mercato target.
Parametri di Localizzazione Core: gl, hl, google_domain, lr, uule, pws
Ecco i parametri essenziali per il scraping SERP geo-targetato, con la loro funzione e valori comuni:
| Parametro | Funzione | Esempio |
|---|---|---|
gl | Codice paese (ISO 3166-1 alpha-2) | gl=us, gl=it |
hl | Lingua interfaccia (ISO 639-1) | hl=it, hl=en |
google_domain | Dominio Google regionale | google_domain=google.it |
lr | Filtro lingua dei risultati | lang_it, lang_en |
uule | Località precisa (città/distretto) | uule=w+CAIQICI... |
pws | Personalizzazione (0 = off) | pws=0 |
Costruire il Parametro uule
Il parametro uule è il più critico e meno documentato. Permette di specificare una località precisa a livello città. Il formato è:
uule = "w+CAIQICI" + base64(canonical_place_name)
Dove canonical_place_name è una stringa nel formato "Città,Regione,Paese" (in inglese canonico). Ad esempio:
- "Rome,Lazio,Italy" per Roma
- "Milan,Lombardy,Italy" per Milano
- "Brooklyn,New York,United States" per Brooklyn, NYC
La regola d'oro: aggiungi sempre pws=0 per ottenere un baseline non personalizzato, riproducibile tra esecuzioni. Senza pws=0, Google può iniettare risultati personalizzati basati su segnali dell'IP, rendendo i dati non riproducibili.
Allineamento IP-Parametri: Perché Serve un Proxy Residenziale
Se i tuoi parametri URL dicono gl=US&uule=...New York ma il tuo IP è in Germania, Google rileva l'incongruenza. I datacenter IP sono inoltre facilmente identificabili e spesso bloccati. La soluzione è routare le richieste attraverso proxy residenziali localizzati nella stessa area geografica dei parametri.
Con ProxyHat, puoi specificare paese e città direttamente nel username del proxy:
http://user-country-US-city-newyork:password@gate.proxyhat.com:8080
Questo garantisce che ogni richiesta arrivi da un IP residenziale statunitense localizzato a New York, allineato con gl=us e uule=...New York. Consulta tutte le locazioni disponibili per verificare la copertura e il piano tariffario più adatto al tuo volume.
Implementazione Pratica: Esempi di Codice
1. Costruire uule e Inviare una Richiesta SERP con curl
#!/bin/bash
# Costruisce uule per Rome, Italy e invia una richiesta SERP tramite proxy residenziale ProxyHat
UULE=$(echo -n "Rome,Lazio,Italy" | base64)
QUERY="pizzeria"
URL="https://www.google.com/search?q=${QUERY}&gl=it&hl=it&google_domain=google.it&pws=0&uule=w+CAIQICI${UULE}&num=20"
curl -sS \
--proxy "http://user-country-IT-city-rome:password@gate.proxyhat.com:8080" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-H "Accept-Language: it-IT,it;q=0.9,en;q=0.8" \
"$URL" -o serp_rome.html
2. Python requests con Proxy Residenziale e uule (Raw Proxy)
import base64
import requests
from urllib.parse import urlencode
def build_uule(canonical_name: str) -> str:
"""Costruisce il parametro uule di Google per targeting a livello città."""
prefix = "w+CAIQICI"
encoded = base64.b64encode(canonical_name.encode("utf-8")).decode("ascii")
return prefix + encoded
def scrape_serp_raw(
query: str,
country: str,
lang: str,
google_domain: str,
canonical_place: str,
proxy_country: str,
proxy_city: str,
proxy_pass: str,
num: int = 20,
) -> str:
"""Scraping SERP geo-targetato con proxy residenziale raw."""
uule = build_uule(canonical_place)
params = {
"q": query,
"gl": country,
"hl": lang,
"google_domain": google_domain,
"uule": uule,
"pws": "0",
"num": str(num),
}
url = f"https://www.google.{google_domain.split('.')[-1]}/search?" + urlencode(params)
proxy_url = f"http://user-country-{proxy_country}-city-{proxy_city}:{proxy_pass}@gate.proxyhat.com:8080"
proxies = {"http": proxy_url, "https": proxy_url}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": f"{lang}-{country.upper()},{lang};q=0.9,en;q=0.8",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
resp = requests.get(url, proxies=proxies, headers=headers, timeout=30)
resp.raise_for_status()
return resp.text
# Esempio: SERP per "pizzeria" a Roma
html = scrape_serp_raw(
query="pizzeria",
country="it",
lang="it",
google_domain="google.it",
canonical_place="Rome,Lazio,Italy",
proxy_country="IT",
proxy_city="rome",
proxy_pass="tua_password",
)
print(f"Ricevuti {len(html)} byte di HTML")
3. ProxyHat SDK Client (Wrapper) per Gestione Multi-Locale
import base64
import logging
from dataclasses import dataclass, field
from typing import Optional
import requests
logger = logging.getLogger("proxyhat_serp")
@dataclass
class LocaleConfig:
"""Configurazione locale per scraping SERP geo-targetato."""
country: str # es. "US"
city: str # es. "newyork"
gl: str # es. "us"
hl: str # es. "en"
google_domain: str # es. "google.com"
canonical_place: str # es. "New York,New York,United States"
lr: Optional[str] = None
@dataclass
class ProxyHatClient:
"""Client ProxyHat per scraping SERP geo-targetato con rotazione residenziale."""
username: str
password: str
gateway: str = "gate.proxyhat.com"
http_port: int = 8080
socks5_port: int = 1080
session: requests.Session = field(default_factory=requests.Session)
def _proxy_url(self, locale: LocaleConfig, session_id: Optional[str] = None) -> str:
"""Costruisce l'URL del proxy con geo-targeting."""
user = f"user-country-{locale.country}-city-{locale.city}"
if session_id:
user += f"-session-{session_id}"
return f"http://{user}:{self.password}@{self.gateway}:{self.http_port}"
def build_uule(self, canonical_place: str) -> str:
prefix = "w+CAIQICI"
return prefix + base64.b64encode(
canonical_place.encode("utf-8")
).decode("ascii")
def scrape(self, query: str, locale: LocaleConfig,
num: int = 20, session_id: Optional[str] = None) -> str:
"""Esegue scraping SERP geo-targetato con IP residenziale allineato."""
uule = self.build_uule(locale.canonical_place)
params = {
"q": query, "gl": locale.gl, "hl": locale.hl,
"google_domain": locale.google_domain,
"uule": uule, "pws": "0", "num": str(num),
}
if locale.lr:
params["lr"] = locale.lr
url = f"https://www.{locale.google_domain}/search?" + \
"&".join(f"{k}={v}" for k, v in params.items())
proxy_url = self._proxy_url(locale, session_id)
proxies = {"http": proxy_url, "https": proxy_url}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": f"{locale.hl}-{locale.country},{locale.hl};q=0.9",
}
logger.info(f"Scraping '{query}' per {locale.city} via {proxy_url}")
resp = self.session.get(url, proxies=proxies, headers=headers, timeout=30)
resp.raise_for_status()
return resp.text
# Utilizzo
client = ProxyHatClient(username="user", password="pass")
us_nyc = LocaleConfig(
country="US", city="newyork", gl="us", hl="en",
google_domain="google.com",
canonical_place="New York,New York,United States",
lr="lang_en",
)
html = client.scrape("best pizza near me", us_nyc, num=20)
4. curl_cffi con Impersonazione Chrome e Proxy
import base64
from curl_cffi import requests as cffi_requests
def build_uule(canonical_name: str) -> str:
prefix = "w+CAIQICI"
return prefix + base64.b64encode(
canonical_name.encode("utf-8")
).decode("ascii")
def scrape_serp_curl_cffi(query: str, canonical_place: str,
gl: str, hl: str, domain: str,
proxy_country: str, proxy_city: str,
password: str) -> str:
"""Scraping SERP con curl_cffi e impersonazione Chrome 120."""
uule = build_uule(canonical_place)
params = {
"q": query, "gl": gl, "hl": hl,
"google_domain": domain, "uule": uule,
"pws": "0", "num": "20",
}
url = f"https://www.{domain}/search?" + \
"&".join(f"{k}={v}" for k, v in params.items())
proxy = (f"http://user-country-{proxy_country}-city-{proxy_city}:"
f"{password}@gate.proxyhat.com:8080")
# impersonate=chrome120 replica TLS fingerprint e JA3 di Chrome 120
resp = cffi_requests.get(
url,
impersonate="chrome120",
proxies={"http": proxy, "https": proxy},
timeout=30,
)
resp.raise_for_status()
return resp.text
# US geo-targeted SERP scraping best practices: New York
html_nyc = scrape_serp_curl_cffi(
"plumber", "New York,New York,United States",
"us", "en", "google.com", "US", "newyork", "pass"
)
print(f"NYC SERP: {len(html_nyc)} byte")
5. Parsing Risultati Organici e Local Pack con selectolax
from selectolax.parser import HTMLParser
from dataclasses import dataclass
from typing import List
@dataclass
class OrganicResult:
position: int
title: str
url: str
snippet: str
@dataclass
class LocalPackResult:
name: str
rating: str
address: str
def parse_organic(html: str) -> List[OrganicResult]:
"""Estrae i risultati organici dalla SERP."""
tree = HTMLParser(html)
results = []
for i, div in enumerate(tree.css("div.g"), start=1):
title_el = div.css_first("h3")
link_el = div.css_first("a[href]")
snippet_el = div.css_first("div.VwiC3b") or div.css_first("span.aCOpRe")
if title_el and link_el:
results.append(OrganicResult(
position=i,
title=title_el.text(strip=True),
url=link_el.attributes.get("href", ""),
snippet=snippet_el.text(strip=True) if snippet_el else "",
))
return results
def parse_local_pack(html: str) -> List[LocalPackResult]:
"""Estrae il local pack (risultati con mappa)."""
tree = HTMLParser(html)
local_results = []
for item in tree.css("div[data-cid]"):
name_el = item.css_first("div.fontHandle") or item.css_first(".dbg0pd")
if name_el:
name = name_el.text(strip=True)
rating = ""
address = ""
details = item.css_first("div.rllt__details")
if details:
spans = details.css("span")
if spans:
rating = spans[0].text(strip=True) if spans else ""
address = spans[-1].text(strip=True) if len(spans) > 1 else ""
local_results.append(LocalPackResult(
name=name, rating=rating, address=address,
))
return local_results
# Utilizzo
organic = parse_organic(html_nyc)
local = parse_local_pack(html_nyc)
print(f"Risultati organici: {len(organic)}")
for r in organic[:5]:
print(f" #{r.position}: {r.title} -- {r.url}")
print(f"\nLocal pack: {len(local)}")
for lp in local:
print(f" {lp.name} -- {lp.rating} -- {lp.address}")
6. Sticky Session per SERP Multi-Pagina
import uuid
import time
import logging
from typing import List, Dict
logger = logging.getLogger("serp_multipage")
def scrape_multi_page_serp(
client: ProxyHatClient,
query: str,
locale: LocaleConfig,
max_pages: int = 3,
) -> List[Dict]:
"""Scraping SERP multi-pagina con sticky session per coerenza IP."""
session_id = uuid.uuid4().hex[:12] # ID sessione stabile
all_results = []
for page in range(max_pages):
start_param = page * 10
uule = client.build_uule(locale.canonical_place)
params = {
"q": query, "gl": locale.gl, "hl": locale.hl,
"google_domain": locale.google_domain,
"uule": uule, "pws": "0",
"start": str(start_param),
}
url = f"https://www.{locale.google_domain}/search?" + \
"&".join(f"{k}={v}" for k, v in params.items())
proxy_url = client._proxy_url(locale, session_id=session_id)
proxies = {"http": proxy_url, "https": proxy_url}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": f"{locale.hl}-{locale.country}",
}
logger.info(f"Pagina {page+1}/{max_pages} -- session={session_id}")
resp = client.session.get(url, proxies=proxies, headers=headers, timeout=30)
resp.raise_for_status()
organic = parse_organic(resp.text)
if not organic:
logger.warning(f"Nessun risultato a pagina {page+1}, interruzione")
break
for r in organic:
all_results.append({
"page": page + 1,
"position": r.position + start_param,
"title": r.title,
"url": r.url,
"snippet": r.snippet,
})
time.sleep(2) # Rate limiting rispettoso
return all_results
# Esempio: 3 pagine di SERP per "dentist" a Chicago
chicago = LocaleConfig(
country="US", city="chicago", gl="us", hl="en",
google_domain="google.com",
canonical_place="Chicago,Illinois,United States",
)
results = scrape_multi_page_serp(client, "dentist", chicago, max_pages=3)
print(f"Totali: {len(results)} risultati su {results[-1]['page']} pagine")
7. Retry, Backoff e Per-Country Proxy Pools
import time
import logging
from functools import wraps
from requests.exceptions import RequestException, HTTPError
logger = logging.getLogger("serp_retry")
def retry_with_backoff(max_retries: int = 3, base_delay: float = 2.0,
backoff_factor: float = 2.0):
"""Decorator per retry con backoff esponenziale su 429/503."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
delay = base_delay
for attempt in range(1, max_retries + 1):
try:
return func(*args, **kwargs)
except HTTPError as e:
status = e.response.status_code if e.response else 0
if status in (429, 503):
wait = delay + (attempt * 1.5)
logger.warning(
f"Tentativo {attempt}/{max_retries}: "
f"HTTP {status}, attesa {wait:.1f}s"
)
time.sleep(wait)
delay *= backoff_factor
else:
raise
except RequestException as e:
logger.error(f"Errore di rete: {e}")
if attempt == max_retries:
raise
time.sleep(delay)
delay *= backoff_factor
raise RuntimeError(f"Fallito dopo {max_retries} tentativi")
return wrapper
return decorator
class CountryProxyPool:
"""Gestisce pool di proxy residenziali per paese con rotazione."""
def __init__(self, username: str, password: str):
self.username = username
self.password = password
self.gateway = "gate.proxyhat.com"
self.port = 8080
# Mapping paese -> lista citta per rotazione
self.country_cities = {
"US": ["newyork", "losangeles", "chicago", "miami", "seattle"],
"IT": ["rome", "milan", "naples", "turin", "florence"],
"DE": ["berlin", "munich", "frankfurt", "hamburg"],
"GB": ["london", "manchester", "birmingham", "liverpool"],
}
self._rotation_idx = {}
def get_proxy(self, country: str, city: str = None,
session_id: str = None) -> str:
"""Restituisce URL proxy per paese/citta con rotazione automatica."""
if city is None:
cities = self.country_cities.get(country, [country.lower()])
idx = self._rotation_idx.get(country, 0)
city = cities[idx % len(cities)]
self._rotation_idx[country] = idx + 1
user = f"user-country-{country}-city-{city}"
if session_id:
user += f"-session-{session_id}"
return f"http://{user}:{self.password}@{self.gateway}:{self.port}"
# Utilizzo combinato
pool = CountryProxyPool("user", "password")
@retry_with_backoff(max_retries=4, base_delay=2.0)
def scrape_with_pool(query: str, locale: LocaleConfig,
pool: CountryProxyPool) -> str:
proxy = pool.get_proxy(locale.country)
proxies = {"http": proxy, "https": proxy}
uule = build_uule(locale.canonical_place)
url = (f"https://www.{locale.google_domain}/search?"
f"q={query}&gl={locale.gl}&hl={locale.hl}"
f"&uule={uule}&pws=0&num=20")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": f"{locale.hl}-{locale.country}",
}
resp = requests.get(url, proxies=proxies, headers=headers, timeout=30)
resp.raise_for_status()
return resp.text
# Esecuzione con retry automatico
try:
html = scrape_with_pool("restaurant", chicago, pool)
print(f"Successo: {len(html)} byte")
except RuntimeError as e:
logger.error(f"Scraping fallito: {e}")
Errori Comuni e Edge Case
- Parametri e IP non concordanti: il problema più frequente.
gl=UScon IP tedesco produce risultati inaffidabili. Soluzione: usa sempre proxy nella stessa area dei parametri. - uule malformato: il base64 deve essere dell'encoding UTF-8 del nome canonico. Nomi non inglesi (es. "Roma" invece di "Rome") non funzionano.
- Rate limiting aggressivo: più di 50 richieste/minuto dallo stesso IP possono innescare CAPTCHA. Usa rotazione IP e delay di 2-5 secondi tra richieste.
- Sticky session troppo lunga: una sessione che dura più di 30 minuti può essere flaggata. Rigenera session_id periodicamente.
- Ignorare robots.txt e ToS: i robots.txt di Google e i Termini di Servizio vietano l'accesso automatizzato non autorizzato. Valuta alternative come la Google Custom Search API per volumi moderati (100 query/giorno gratuite, poi $5 per 1000 query).
Considerazioni su Dati Pubblici e ToS
Lo scraping di Google Search solleva questioni legali e etiche. I Termini di Servizio di Google proibiscono l'accesso automatizzato non autorizzato. Per dataset di produzione, considera:
- La Google Custom Search JSON API ufficiale (100 query/giorno gratuite, poi $5 per 1000 query).
- Rispetta il rate limiting e non sovraccaricare i server.
- Non raccogliere dati personali (GDPR/CCPA) dai risultati SERP.
- Per uso commerciale su larga scala, consulta un legale esperto in data scraping.
Punti Chiave (Key Takeaways)
Il scraping SERP geo-targetato richiede tre elementi allineati: parametri URL corretti (gl, hl, uule, pws=0), IP residenziale nella stessa area geografica, e parsing robusto che gestisca CAPTCHA e cambiamenti di layout. Senza allineamento IP-parametri, i dati raccolti sono inaffidabili.
- uule è essenziale per precisione a livello città — il solo
glnon basta per local SEO. - pws=0 elimina la personalizzazione per baseline riproducibili.
- Proxy residenziali su
gate.proxyhat.com:8080con flag-country-XX-city-yyygarantiscono allineamento IP-parametri. - Sticky session per SERP multi-pagina: stesso IP per coerenza tra pagine.
- Retry con backoff su 429/503 e rotazione IP per-country per evitare CAPTCHA.
- curl_cffi con impersonate=chrome supera i controlli TLS fingerprint meglio di requests puro.
Per approfondire, consulta la documentazione sui prezzi di ProxyHat e i nostri casi d'uso per SERP tracking. La documentazione ufficiale ProxyHat contiene tutti i dettagli sulla configurazione avanzata dei proxy residenziali.





