Dlaczego geotargetowane scrapowanie SERP wymaga precyzji na poziomie miasta
Kiedy wpisujesz to samo zapytanie w Google z Warszawy i z Nowego Jorku, otrzymujesz zupełnie różne wyniki. Geotargetowane scrapowanie SERP to nie tylko zmiana języka interfejsu — to sztuka wymuszenia na Google zwrócenia wyników tak, jakby zapytanie przyszło z konkretnego miasta, a nawet dzielnicy. Dla inżynierów SEO budujących rank trackery i zbiory danych lokalnych wyników, precyzja geograficzna jest fundamentem wiarygodności danych.
Problem polega na tym, że Google lokalizuje wyniki na podstawie wielu sygnałów: adresu IP, parametrów URL (gl, hl, google_domain, uule), ciasteczek, historii konta oraz sygnałów z przeglądarki. Jeśli którykolwiek z tych sygnałów jest niespójny — np. wysyłasz zapytanie z IP z Frankfurtu, ale ustawiasz gl=us i uule dla Chicago — Google wykrywa anomalię i może zwrócić wyniki dla Frankfurtu, pokazać CAPTCHA lub zablokować IP.
Dlatego us geo-targeted serp scraping best practices wymagają trzech elementów działających razem: poprawnych parametrów URL, proxy rezydencjalnego z lokalizacji zgodnej z parametrami oraz konfiguracji przeglądarki, która nie zdradza automatyzacji. W tym przewodniku pokazujemy, jak to osiągnąć z ProxyHat i Pythonem.
Lokalizacja na poziomie kraju to za mało — dlaczego potrzebujesz precyzji na poziomie miasta
Wyniki Google dla zapytania „pizza restaurant” w Miami różnią się od tych w Seattle, mimo że oba miasta leżą w USA. Pakiety lokalne (Local Pack), wyniki Map, a nawet rankingi organicne są silnie zależne od odległości użytkownika od firmy. Jeśli Twój rank tracker zbiera dane tylko z parametrem gl=us, tracisz sygnały lokalne — Google domyślnie użyje lokalizacji IP serwera proxy, co może być Los Angeles lub Ashburn, a nie miasto, które Cię interesuje.
Dla lokalnego SEO precyzja na poziomie miasta lub dzielnicy jest krytyczna. Według analiz Moz Local Search Ranking Factors, odległość od użytkownika jest jednym z najsilniejszych sygnałów rankingowych w wynikach lokalnych. Dlatego local serp scraping by city wymaga parametru uule, który precyzyjnie przypina wyniki do konkretnej lokalizacji.
Kluczowe parametry lokalizacji: gl, hl, google_domain, lr, uule
Google oferuje kilka parametrów URL do kontrolowania lokalizacji i języka wyników. Oto pełna rozkładka:
| Parametr | Opis | Przykład |
|---|---|---|
gl | Kod kraju (ISO 3166-1 alpha-2) | gl=us |
hl | Język interfejsu (ISO 639-1) | hl=en |
google_domain | Domena Google | google_domain=google.com |
lr | Ograniczenie języka wyników | lr=lang_en |
uule | Zakodowana lokalizacja (miasto/dzielnica) | uule=w+CAIQICIE...New+York |
pws=0 | Wyłączenie personalizacji | pws=0 |
Konstrukcja parametru uule
Parametr uule (User-Defined Location) to najważniejszy parametr dla local serp scraping by city. Format to stały prefiks + klucz długości + base64 kanonicznej nazwy miejsca. Według dokumentacji społeczności i analiz takich jak SEO crawler community docs, struktura wygląda następująco:
uule = "w+CAIQICI" + length_key + base64(canonical_place_name)Klucz długości zależy od długości nazwy miejsca po kodowaniu base64. Oto funkcja w Pythonie, która konstruuje uule poprawnie:
import base64
def build_uule(city: str) -> str:
"""Buduje parametr uule dla danej nazwy miasta."""
# Stały prefiks używany przez Google
prefix = "w+CAIQICI"
# Kanoniczna nazwa miejsca (np. "New York,New York,United States")
canonical = city
# Kodowanie base64
encoded = base64.b64encode(canonical.encode('utf-8')).decode('utf-8')
# Klucz długości — liczba znaków zakodowanej nazwy
length = len(encoded)
# Mapowanie długości na klucz (uproszczone)
length_key = chr(ord('A') + (length - 8)) if length > 8 else 'E'
return f"{prefix}{length_key}{encoded}"
# Przykłady
print(build_uule("New York,New York,United States"))
print(build_uule("Chicago,Illinois,United States"))
print(build_uule("Berlin,Berlin,Germany"))Wskazówka: Zawsze dodawaj pws=0 do URL, aby wyłączyć personalizację wyników. Bez tego Google może modyfikować wyniki na podstawie historii konta lub ciasteczek, co zaburza powtarzalność danych.
Dlaczego parametry muszą zgadzać się z geografią IP
Google cross-checkuje parametry URL z lokalizacją IP. Jeśli wyślesz zapytanie z gl=us i uule dla Miami, ale z IP z Londynu, Google wykryje niespójność. W najlepszym przypadku zignoruje Twoje parametry i zwróci wyniki dla Londynu. W najgorszym — pokaże CAPTCHA lub zwróci HTTP 429.
Dlatego musisz kierować zapytania przez proxy rezydencjalne z lokalizacji zgodnej z rynkiem docelowym. ProxyHat umożliwia geotargetowanie na poziomie kraju i miasta w nazwie użytkownika:
# Proxy dla Nowego Jorku (USA)
http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080
# Proxy dla Berlina (Niemcy)
http://user-country-DE-city-berlin:pass@gate.proxyhat.com:8080
# Proxy dla Londynu (Wielka Brytania)
http://user-country-GB-city-london:pass@gate.proxyhat.com:8080Pełną listę dostępnych lokalizacji znajdziesz na stronie lokalizacji ProxyHat.
Implementacja w Pythonie: requests i curl_cffi z rotacją IP
Poniżej pokazujemy dwa podejścia side-by-side: klasyczne requests oraz curl_cffi z impersonacją Chrome, która omija podstawowe fingerprinting botów.
Przykład 1: curl z uule, gl, hl i pws=0
# curl — podstawowe zapytanie z pełną lokalizacją
curl -x http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080 \
"https://www.google.com/search?q=pizza+restaurant&gl=us&hl=en&google_domain=google.com&lr=lang_en&pws=0&uule=w+CAIQICIE...New+York" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-H "Accept-Language: en-US,en;q=0.9" \
--compressedPrzykład 2: Python requests z konfiguracją proxy i uule
import requests
import base64
from urllib.parse import urlencode
PROXYHAT_USER = "user-country-US-city-newyork"
PROXYHAT_PASS = "pass"
PROXY_URL = f"http://{PROXYHAT_USER}:{PROXYHAT_PASS}@gate.proxyhat.com:8080"
proxies = {"http": PROXY_URL, "https": PROXY_URL}
def build_uule(city: str) -> str:
prefix = "w+CAIQICI"
encoded = base64.b64encode(city.encode('utf-8')).decode('utf-8')
length = len(encoded)
length_key = chr(ord('A') + (length - 8)) if length > 8 else 'E'
return f"{prefix}{length_key}{encoded}"
def scrape_serp(query: str, country: str, city: str, hl: str = "en") -> dict:
uule = build_uule(city)
params = {
"q": query,
"gl": country,
"hl": hl,
"google_domain": "google.com" if country == "us" else f"google.{country}",
"lr": f"lang_{hl}",
"pws": "0",
"uule": uule,
"num": "100",
}
url = f"https://www.google.com/search?{urlencode(params)}"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": f"{hl}-{country.upper()},{hl};q=0.9",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
try:
resp = requests.get(url, headers=headers, proxies=proxies, timeout=30)
resp.raise_for_status()
return {"status": resp.status_code, "html": resp.text}
except requests.exceptions.ProxyError as e:
print(f"Proxy error: {e}")
return {"status": 0, "error": str(e)}
except requests.exceptions.RequestException as e:
print(f"Request error: {e}")
return {"status": 0, "error": str(e)}
result = scrape_serp("pizza restaurant", "us", "New York,New York,United States")
print(f"Status: {result['status']}, HTML length: {len(result.get('html', ''))}")Przykład 3: curl_cffi z impersonacją Chrome i rotacją IP per lokalizacja
from curl_cffi import requests as cffi_requests
import base64
from urllib.parse import urlencode
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def build_uule(city: str) -> str:
prefix = "w+CAIQICI"
encoded = base64.b64encode(city.encode('utf-8')).decode('utf-8')
length = len(encoded)
length_key = chr(ord('A') + (length - 8)) if length > 8 else 'E'
return f"{prefix}{length_key}{encoded}"
def make_proxy_url(country: str, city: str, session_id: str = None) -> str:
user = f"user-country-{country.upper()}-city-{city.lower()}"
if session_id:
user += f"-session-{session_id}"
return f"http://{user}:pass@gate.proxyhat.com:8080"
def scrape_with_impersonation(query: str, country: str, city: str, hl: str = "en") -> dict:
proxy_url = make_proxy_url(country, city)
uule = build_uule(city)
params = {
"q": query,
"gl": country,
"hl": hl,
"google_domain": "google.com" if country == "us" else f"google.{country}",
"lr": f"lang_{hl}",
"pws": "0",
"uule": uule,
"num": "100",
}
url = f"https://www.google.com/search?{urlencode(params)}"
try:
resp = cffi_requests.get(
url,
impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
logging.info(f"[{country}/{city}] Status: {resp.status_code}, Length: {len(resp.text)}")
return {"status": resp.status_code, "html": resp.text}
except Exception as e:
logging.error(f"[{country}/{city}] Error: {e}")
return {"status": 0, "error": str(e)}
# Równoległe zapytania dla wielu miast USA
targets = [
("us", "newyork", "New York,New York,United States", "en"),
("us", "chicago", "Chicago,Illinois,United States", "en"),
("us", "miami", "Miami,Florida,United States", "en"),
("us", "seattle", "Seattle,Washington,United States", "en"),
("us", "losangeles", "Los Angeles,California,United States", "en"),
]
for country, city_slug, city_full, hl in targets:
result = scrape_with_impersonation("best coffee shops", country, city_full, hl)
# Przetwarzaj result...Parsowanie wyników organicznych i Local Pack z selectolax
Po pobraniu HTML musisz wyciągnąć wyniki organiczne oraz pakiety lokalne. selectolax jest szybszy niż BeautifulSoup i dobrze radzi sobie z zagnieżdżonym HTML Google.
Przykład 4: Parsowanie organic + local pack
from selectolax.parser import HTMLParser
import json
import re
def parse_serp(html: str) -> dict:
tree = HTMLParser(html)
results = {"organic": [], "local_pack": [], "related_searches": []}
# Wyniki organiczne — div.g zawiera główne wyniki
for node in tree.css("div.g"):
title_node = node.css_first("h3")
link_node = node.css_first("a[href]")
snippet_node = node.css_first("div[data-sncf]") or node.css_first("span.aCOpRe")
if title_node and link_node:
results["organic"].append({
"title": title_node.text(strip=True),
"url": link_node.attributes.get("href", ""),
"snippet": snippet_node.text(strip=True) if snippet_node else "",
})
# Local Pack — zwykle w div[role='heading'] lub div.eimjXd
local_section = tree.css_first("div.eimjXd") or tree.css_first("div[jscontroller]")
if local_section:
for item in local_section.css("div.rllt__link"):
name_node = item.css_first("div.dbg0pd")
rating_node = item.css_first("span.BTtC6e")
addr_node = item.css_first("span.rllt__featured")
results["local_pack"].append({
"name": name_node.text(strip=True) if name_node else "",
"rating": rating_node.text(strip=True) if rating_node else "",
"address": addr_node.text(strip=True) if addr_node else "",
})
# Related searches
for node in tree.css("div.EbZp0e"):
text = node.text(strip=True)
if text:
results["related_searches"].append(text)
return results
# Użycie
parsed = parse_serp(result["html"])
print(json.dumps(parsed, indent=2, ensure_ascii=False))Sesje lepkie (sticky) dla wielostronicowych SERP
Google paginuje wyniki — domyślnie 10 wyników na stronę. Jeśli zmienisz IP między stronami wyników, Google może zwrócić inne wyniki na stronie 2, ponieważ lokalizacja IP jest inna. Dlatego używaj sticky sessions, aby utrzymać to samo IP przez całą sesję paginacji.
Przykład 5: Sticky session dla wielu stron SERP
import uuid
from curl_cffi import requests as cffi_requests
import base64
from urllib.parse import urlencode
import time
def build_uule(city: str) -> str:
prefix = "w+CAIQICI"
encoded = base64.b64encode(city.encode('utf-8')).decode('utf-8')
length = len(encoded)
length_key = chr(ord('A') + (length - 8)) if length > 8 else 'E'
return f"{prefix}{length_key}{encoded}"
def scrape_multi_page(query: str, country: str, city: str, max_pages: int = 5) -> list:
session_id = str(uuid.uuid4())[:12]
user = f"user-country-{country.upper()}-city-{city.lower()}-session-{session_id}"
proxy_url = f"http://{user}:pass@gate.proxyhat.com:8080"
uule = build_uule(city)
all_results = []
for page in range(max_pages):
start = page * 10
params = {
"q": query,
"gl": country,
"hl": "en",
"google_domain": "google.com",
"pws": "0",
"uule": uule,
"num": "10",
"start": str(start),
}
url = f"https://www.google.com/search?{urlencode(params)}"
try:
resp = cffi_requests.get(
url,
impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
if resp.status_code == 200:
parsed = parse_serp(resp.text)
parsed["page"] = page + 1
all_results.append(parsed)
print(f"Page {page + 1}: {len(parsed['organic'])} organic results")
time.sleep(2) # Uprzejme opóźnienie między stronami
elif resp.status_code == 429:
print(f"Rate limited on page {page + 1}, backing off...")
time.sleep(30)
continue
else:
print(f"Unexpected status {resp.status_code} on page {page + 1}")
break
except Exception as e:
print(f"Error on page {page + 1}: {e}")
break
return all_results
pages = scrape_multi_page("plumber", "us", "Chicago,Illinois,United States", max_pages=5)
print(f"Total pages scraped: {len(pages)}")CAPTCHA, HTTP 429 i strategie backoff
Nawet z najlepszymi proxy rezydencjalnymi Google nałoży limit na zbyt agresywne zapytania. Oto produkcyjna strategia retry z wykładniczym backoffem i circuit breakerem:
Przykład 6: Retry z backoffem i per-country proxy pool
import time
import random
import logging
from curl_cffi import requests as cffi_requests
from functools import wraps
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
MAX_RETRIES = 5
INITIAL_BACKOFF = 2 # sekundy
MAX_BACKOFF = 120 # 2 minuty max
CIRCUIT_BREAKER_THRESHOLD = 5 # po 5 nieudanych próbach zatrzymaj
class CircuitBreaker:
def __init__(self, threshold: int = 5, reset_time: int = 300):
self.failure_count = 0
self.threshold = threshold
self.reset_time = reset_time
self.last_failure_time = 0
def record_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
def record_success(self):
self.failure_count = 0
def is_open(self) -> bool:
if self.failure_count >= self.threshold:
if time.time() - self.last_failure_time > self.reset_time:
self.failure_count = 0
return False
return True
return False
breaker = CircuitBreaker(threshold=CIRCUIT_BREAKER_THRESHOLD)
def retry_with_backoff(func):
@wraps(func)
def wrapper(*args, **kwargs):
if breaker.is_open():
raise Exception("Circuit breaker open — pausing requests")
for attempt in range(MAX_RETRIES):
try:
result = func(*args, **kwargs)
if result.get("status") == 200:
breaker.record_success()
return result
elif result.get("status") == 429:
backoff = min(INITIAL_BACKOFF * (2 ** attempt) + random.uniform(0, 1), MAX_BACKOFF)
logging.warning(f"429 received — backing off {backoff:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})")
time.sleep(backoff)
continue
elif result.get("status") == 503:
logging.warning(f"503 — Google may be blocking. Backing off.")
time.sleep(60)
continue
else:
breaker.record_failure()
return result
except Exception as e:
logging.error(f"Request failed: {e} (attempt {attempt + 1}/{MAX_RETRIES})")
breaker.record_failure()
backoff = min(INITIAL_BACKOFF * (2 ** attempt), MAX_BACKOFF)
time.sleep(backoff)
return {"status": 0, "error": "Max retries exceeded"}
return wrapper
@retry_with_backoff
def scrape_with_retry(query: str, country: str, city: str) -> dict:
proxy_url = f"http://user-country-{country.upper()}-city-{city.lower()}:pass@gate.proxyhat.com:8080"
uule = build_uule(city)
params = {
"q": query, "gl": country, "hl": "en",
"google_domain": "google.com", "pws": "0", "uule": uule,
}
url = f"https://www.google.com/search?{urlencode(params)}"
resp = cffi_requests.get(
url, impersonate="chrome",
proxies={"http": proxy_url, "https": proxy_url},
timeout=30,
)
return {"status": resp.status_code, "html": resp.text}
# Per-country proxy pools — różne miasta dla tego samego kraju
us_cities = ["newyork", "chicago", "miami", "seattle", "losangeles", "dallas", "atlanta"]
city_index = 0
for keyword in ["dentist", "lawyer", "restaurant", "gym", "plumber"]:
city_slug = us_cities[city_index % len(us_cities)]
city_full = f"{city_slug.title()},United States"
result = scrape_with_retry(keyword, "us", city_full)
print(f"[{city_slug}] {keyword}: status={result['status']}")
city_index += 1
time.sleep(3) # Uprzejme opóźnienie między zapytaniamiWskazówki produkcyjne:
- Utrzymuj opóźnienie 2–5 sekund między zapytaniami z tej samej sesji.
- Rotuj miasta w obrębie tego samego kraju, aby rozproszyć obciążenie.
- Ustaw
timeoutna 30 sekund — proxy rezydencjalne są wolniejsze niż datacenter (zwykle 200–800ms vs 50–100ms). - Monitoruj success rate — jeśli spada poniżej 90%, zwiększ opóźnienie lub zmień pulę IP.
- Używaj oddzielnych sesji (sticky sessions) dla każdego miasta, aby uniknąć zmiany IP w trakcie paginacji.
ProxyHat SDK — uproszczona integracja
ProxyHat oferuje SDK, które automatyzuje rotację IP i zarządzanie sesjami. Oto porównanie surowego proxy vs SDK:
Surowe proxy (requests)
import requests
# Ręczne zarządzanie proxy URL
proxy = "http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080"
resp = requests.get("https://www.google.com/search?q=test&gl=us&hl=en&pws=0",
proxies={"http": proxy, "https": proxy})
print(resp.status_code)ProxyHat SDK
from proxyhat import ProxyHatClient
# Automatyczna rotacja, sesje, geotargeting
client = ProxyHatClient(
username="user",
password="pass",
gateway="gate.proxyhat.com",
port=8080,
)
# Sesja rezydencjalna dla Nowego Jorku
session = client.create_session(
proxy_type="residential",
country="US",
city="newyork",
sticky=True,
)
resp = session.get("https://www.google.com/search?q=test&gl=us&hl=en&pws=0")
print(f"Status: {resp.status_code}, IP: {session.current_ip}")
# Rotacja do Chicago
session.rotate(city="chicago")
resp = session.get("https://www.google.com/search?q=test&gl=us&hl=en&pws=0")
print(f"New IP: {session.current_ip}")Sprawdź pełną dokumentację na docs.proxyhat.com. Cennik proxy rezydencjalnych znajdziesz na stronie cennika ProxyHat.
Aspekty prawne i etyczne scrapowania SERP
Scrapowanie publicznych wyników Google istnieje w szarej strefie prawnej. Google's Terms of Service zabraniają automatycznego pobierania wyników, ale dane publicznie dostępne mogą być chronione przez inne przepisy. W UE Database Directive (Directive 96/9/EC) i GDPR mogą mieć zastosowanie do zbieranych danych.
Key Takeaway: Scrapuj tylko dane publicznie dostępne, przestrzegaj
robots.txt, nie przechowuj danych osobowych bez podstawy prawnej i rozważ użycie oficjalnego Google Search API dla zastosowań komercyjnych.
Najważniejsze wnioski (Key Takeaways)
- Parametry URL + IP muszą być spójne — Google cross-checkuje geografię IP z
gl,hliuule. Używaj proxy rezydencjalnych z ProxyHat z lokalizacją zgodną z parametrami. - uule to klucz do precyzji na poziomie miasta —
gl=uswystarczy tylko dla wyników krajowych. Dla lokalnego SEO potrzebujeszuulez kanoniczną nazwą miejsca. - Zawsze dodawaj
pws=0— wyłącza personalizację i zapewnia powtarzalność danych. - Sticky sessions dla paginacji — nie zmieniaj IP między stronami wyników, bo Google zwróci inne wyniki.
- Backoff i circuit breaker — Google nałoży 429 przy zbyt agresywnym scrapowaniu. Wykładniczy backoff z jitterem to standard produkcyjny.
- Impersonacja przeglądarki —
curl_cffizimpersonate="chrome"omija podstawowe fingerprinting TLS, co zmniejsza wskaźnik blokad.
Gotowy na budowę lokalizowanych zbiorów danych SERP? Odwiedź przypadek użycia SERP tracking lub web scraping, aby zobaczyć więcej implementacji. Cennik i dostępne lokalizacje znajdziesz na stronie cennika i liście lokalizacji.




