Лучшие практики гео-таргетированного скрейпинга SERP начинаются с одного простого факта: один и тот же поисковый запрос в Google возвращает разные результаты в зависимости от того, откуда он отправлен. Запрос «pizza near me» из Нью-Йорка и из Берлина даст принципиально разные органические результаты, локальные пакеты (Local Pack) и карты. Если вы строите систему rank-tracking для клиентов в нескольких городах, таргетинга на уровне страны недостаточно — вам нужна точность до города, а иногда и до района.
Почему один запрос даёт разные результаты по локации
Google определяет релевантность результатов на основе множества сигналов, среди которых геолокация пользователя — один из ключевых. Согласно документации Google Search, поисковая система учитывает IP-адрес, историю местоположений, явные параметры URL и настройки аккаунта. Для SEO-инженеров это означает: нельзя собрать «каноничный» SERP без явного указания локации.
Таргетинг только на уровне страны (через параметр gl=us) скрывает критические различия. Запрос «plumber» в Чикаго и в Майами покажет разных локальных подрядчиков, разные Local Pack и даже разные органические результаты, потому что Google применяет локальный алгоритм даже для запросов без явного гео-модификатора. По данным BrightLocal, 76% пользователей, ищущих локальный бизнес, просматривают результаты с привязкой к городу — это означает, что ваши клиенты видят именно локальный SERP, а не национальный.
Уровни гео-точности
| Уровень | Параметр | Точность | Когда использовать |
|---|---|---|---|
| Страна | gl | Национальный SERP | Бренд-трекинг, общий рейтинг |
| Язык интерфейса | hl | Текст интерфейса | Мультиязычные рынки (Канада, Швейцария) |
| Домен Google | google_domain | Регональный домен | google.de, google.co.jp, google.com.br |
| Город/район | uule | Точный город | Local SEO, rank-tracking по городам |
Параметры локализации SERP: gl, hl, google_domain, lr, uule, pws
Google принимает несколько параметров URL для управления локализацией. Вот полный набор, который нужно использовать в продакшене:
gl— код страны (ISO 3166-1 alpha-2), напримерgl=us,gl=de,gl=jp. Влияет на национальную версию SERP.hl— язык интерфейса, напримерhl=en,hl=de,hl=ja. Не путать с языком результатов — это язык кнопок и подсказок.google_domain— домен поисковика, напримерgoogle.com,google.de,google.co.jp. Должен соответствоватьgl.lr— ограничение языка результатов (lang_en, lang_de, lang_ja). Полезно для мультиязычных стран.uule— закодированная каноническая локация (город/район). Самый мощный параметр для локального SEO.pws=0— отключает персонализацию. Критично для получения воспроизводимого базового SERP.
Конструкция параметра uule
Параметр uule (User-Location Lat-Enc) — это base64-кодированная строка с каноническим названием места. Формат строго фиксирован:
uule = "w+CAIQICI" + base64(canonical_place_name)
Где w+CAIQICI — фиксированный префикс (8 символов), за которым следует base64-представление канонического названия города в формате Google. Например, для Нью-Йорка каноническое имя — Brooklyn, New York, NY, для Берлина — Berlin, Germany. Длина base64-строки добавляется как однобайтовый символ перед base64-полезной нагрузкой.
Вот Python-функция для генерации uule:
import base64
def build_uule(place_name: str) -> str:
"""Конструирует параметр uule для Google SERP.
Args:
place_name: Каноническое название места,
например 'Brooklyn, New York, NY' или 'Berlin, Germany'.
Returns:
Строка uule, готовая для URL-параметра.
"""
prefix = "w+CAIQICI"
encoded = base64.b64encode(place_name.encode("utf-8")).decode("ascii")
# Длина base64-строки как однобайтовый символ
length_byte = chr(len(encoded))
return prefix + length_byte + encoded
# Примеры
uule_nyc = build_uule("Brooklyn, New York, NY")
uule_berlin = build_uule("Berlin, Germany")
uule_tokyo = build_uule("Tokyo, Tokyo, Japan")
print(f"NYC: {uule_nyc}")
print(f"Berlin: {uule_berlin}")
print(f"Tokyo: {uule_tokyo}")
Всегда добавляйте pws=0 к запросу, чтобы отключить персонализацию на основе истории поиска. Без этого параметра результаты будут непредсказуемо варьироваться между запусками.
Согласованность параметров и IP-географии
Критическое правило, которое часто упускают: Google кросс-проверяет параметры локализации с IP-адресом запроса. Если вы отправляете gl=us&uule=...New York с IP-адреса из Франкфурта, Google может:
- Проигнорировать
uuleи показать немецкие результаты. - Показать смешанный SERP с элементами обеих локаций.
- Пометить запрос как подозрительный и вернуть CAPTCHA.
Поэтому запросы должны маршрутизироваться через резидентные прокси из той же локации, что и целевые параметры. ProxyHat поддерживает таргетинг по стране и городу через имя пользователя:
# Резидентный прокси для США, Нью-Йорк
http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080
# Резидентный прокси для Германии, Берлин
http://user-country-DE-city-berlin:pass@gate.proxyhat.com:8080
# Резидентный прокси для Японии, Токио
http://user-country-JP-city-tokyo:pass@gate.proxyhat.com:8080
Подробности по доступным локациям — на странице локаций ProxyHat.
Пример 1: Сбор SERP с uule и ротацией резидентных IP
Ниже — полный пример на Python с использованием curl_cffi для имперсонализации TLS-отпечатка Chrome и ProxyHat-прокси для гео-таргетинга:
import base64
import time
import logging
from curl_cffi import requests as cffi_requests
from urllib.parse import urlencode, quote_plus
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("serp_scraper")
# --- Конфигурация локаций ---
LOCALES = [
{
"label": "NYC",
"gl": "us", "hl": "en", "google_domain": "google.com",
"uule_place": "Brooklyn, New York, NY",
"proxy_user": "user-country-US-city-newyork",
"proxy_pass": "YOUR_PASSWORD",
},
{
"label": "Berlin",
"gl": "de", "hl": "de", "google_domain": "google.de",
"uule_place": "Berlin, Germany",
"proxy_user": "user-country-DE-city-berlin",
"proxy_pass": "YOUR_PASSWORD",
},
{
"label": "Tokyo",
"gl": "jp", "hl": "ja", "google_domain": "google.co.jp",
"uule_place": "Tokyo, Tokyo, Japan",
"proxy_user": "user-country-JP-city-tokyo",
"proxy_pass": "YOUR_PASSWORD",
},
]
PROXY_GATEWAY = "gate.proxyhat.com"
PROXY_PORT = 8080
def build_uule(place_name: str) -> str:
"""Конструирует uule: фиксированный префикс + length byte + base64."""
prefix = "w+CAIQICI"
encoded = base64.b64encode(place_name.encode("utf-8")).decode("ascii")
length_byte = chr(len(encoded))
return prefix + length_byte + encoded
def build_serp_url(query: str, locale: dict) -> str:
"""Собирает URL Google SERP с параметрами локализации."""
uule = build_uule(locale["uule_place"])
params = {
"q": query,
"gl": locale["gl"],
"hl": locale["hl"],
"google_domain": locale["google_domain"],
"uule": uule,
"pws": "0",
"num": "20",
"output": "serp",
}
base = f"https://www.{locale['google_domain']}/search"
return f"{base}?{urlencode(params)}"
def fetch_serp(query: str, locale: dict) -> str:
"""Отправляет запрос SERP через резидентный прокси ProxyHat.
Использует curl_cffi для имперсонализации TLS-отпечатка Chrome.
"""
proxy_url = (
f"http://{locale['proxy_user']}:{locale['proxy_pass']}"
f"@{PROXY_GATEWAY}:{PROXY_PORT}"
)
serp_url = build_serp_url(query, locale)
logger.info(f"[{locale['label']}] Fetching: {query}")
try:
response = cffi_requests.get(
serp_url,
proxies={"http": proxy_url, "https": proxy_url},
impersonate="chrome120",
timeout=30,
allow_redirects=True,
)
response.raise_for_status()
logger.info(f"[{locale['label']}] Status: {response.status_code}, len={len(response.text)}")
return response.text
except Exception as e:
logger.error(f"[{locale['label']}] Error: {e}")
raise
# --- Запуск ---
if __name__ == "__main__":
query = "best coffee shop"
for locale in LOCALES:
html = fetch_serp(query, locale)
# Сохраняем для парсинга
filename = f"serp_{locale['label']}_{int(time.time())}.html"
with open(filename, "w", encoding="utf-8") as f:
f.write(html)
logger.info(f"Saved: {filename}")
time.sleep(2) # Вежливая задержка между локациями
Тот же запрос через curl для быстрой отладки:
# US / New York через резидентный прокси ProxyHat
curl -s \
--proxy "http://user-country-US-city-newyork:YOUR_PASSWORD@gate.proxyhat.com:8080" \
-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" \
"https://www.google.com/search?q=best+coffee+shop&gl=us&hl=en&pws=0&num=20&uule=w+CAIQICIRQnJvb2tseW4sIE5ldyBZb3JrLCBOWQ==" \
-o serp_nyc.html
Пример 2: Парсинг органических результатов и Local Pack с selectolax
После получения HTML нужно извлечь органические результаты и Local Pack. Используем selectolax — быстрый HTML-парсер на основе Lexbor:
from selectolax.parser import HTMLParser
import json
import re
def parse_organic_results(html: str) -> list[dict]:
"""Извлекает органические результаты из HTML SERP."""
tree = HTMLParser(html)
results = []
# Google использует div.g для органических результатов
for div in tree.css("div.g"):
title_tag = div.css_first("h3")
link_tag = div.css_first("a[href]")
snippet_tag = div.css_first("div[data-sncf], .VwiC3b, span.aCOpRe")
if not title_tag or not link_tag:
continue
title = title_tag.text(strip=True)
url = link_tag.attributes.get("href", "")
snippet = snippet_tag.text(strip=True) if snippet_tag else ""
# Фильтруем внутренние ссылки Google
if url.startswith("http") and "google.com" not in url.split("/")[2]:
results.append({
"position": len(results) + 1,
"title": title,
"url": url,
"snippet": snippet,
})
return results
def parse_local_pack(html: str) -> list[dict]:
"""Извлекает Local Pack (3-pack) из SERP."""
tree = HTMLParser(html)
local_results = []
# Local Pack обычно в div с role="main" или в .local-packed
for item in tree.css("div.eT1oC, div[jsname] > div.g"):
name_tag = item.css_first("span.Jr5vfb, .dbg0pd, .OSrXXb")
rating_tag = item.css_first("span.MHleUd, span[aria-label*=rating]")
address_tag = item.css_first("span.LrzXKn, .text")
if not name_tag:
continue
name = name_tag.text(strip=True)
rating = rating_tag.text(strip=True) if rating_tag else "N/A"
address = address_tag.text(strip=True) if address_tag else ""
local_results.append({
"name": name,
"rating": rating,
"address": address,
})
return local_results
def parse_serp(html: str, locale_label: str, query: str) -> dict:
"""Полный парсинг SERP: органика + Local Pack."""
organic = parse_organic_results(html)
local_pack = parse_local_pack(html)
return {
"query": query,
"locale": locale_label,
"organic_count": len(organic),
"local_pack_count": len(local_pack),
"organic": organic[:10], # Топ-10
"local_pack": local_pack,
}
# --- Использование ---
if __name__ == "__main__":
with open("serp_NYC_1700000000.html", "r", encoding="utf-8") as f:
html = f.read()
data = parse_serp(html, "NYC", "best coffee shop")
print(json.dumps(data, indent=2, ensure_ascii=False))
Пример 3: Sticky-сессии для многостраничного сбора SERP
При сборе нескольких страниц SERP (page=1, page=2, ...) важно сохранять один IP-адрес в рамках сессии. Если IP меняется между страницами, Google может показать разные результаты из-за смены геолокации. ProxyHat поддерживает sticky-сессии через флаг session-XXX в имени пользователя:
import base64
import time
import logging
from curl_cffi import requests as cffi_requests
from urllib.parse import urlencode
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("multi_page_serp")
def build_uule(place_name: str) -> str:
prefix = "w+CAIQICI"
encoded = base64.b64encode(place_name.encode("utf-8")).decode("ascii")
length_byte = chr(len(encoded))
return prefix + length_byte + encoded
def fetch_serp_page(
query: str,
page: int,
locale: dict,
session_id: str,
) -> str:
"""Запрашивает конкретную страницу SERP с sticky-сессией ProxyHat.
Sticky-сессия сохраняет один IP для всех страниц,
обеспечивая консистентность геолокации.
"""
# Sticky-сессия: session-ID в имени пользователя
proxy_user = f"{locale['proxy_user']}-session-{session_id}"
proxy_url = (
f"http://{proxy_user}:{locale['proxy_pass']}"
f"@gate.proxyhat.com:8080"
)
uule = build_uule(locale["uule_place"])
start = (page - 1) * 10 # Google использует start= для пагинации
params = {
"q": query,
"gl": locale["gl"],
"hl": locale["hl"],
"uule": uule,
"pws": "0",
"num": "10",
"start": str(start),
}
serp_url = f"https://www.{locale['google_domain']}/search?{urlencode(params)}"
logger.info(f"[{locale['label']}] Page {page}, session={session_id}")
response = cffi_requests.get(
serp_url,
proxies={"http": proxy_url, "https": proxy_url},
impersonate="chrome120",
timeout=30,
)
response.raise_for_status()
return response.text
def scrape_all_pages(
query: str,
locale: dict,
max_pages: int = 3,
) -> list[str]:
"""Собирает несколько страниц SERP с одной sticky-сессией."""
session_id = f"loc{locale['label']}{int(time.time())}"
pages_html = []
for page in range(1, max_pages + 1):
try:
html = fetch_serp_page(query, page, locale, session_id)
pages_html.append(html)
logger.info(f"[{locale['label']}] Page {page} OK, len={len(html)}")
except Exception as e:
logger.error(f"[{locale['label']}] Page {page} failed: {e}")
break
if page < max_pages:
time.sleep(2.5) # Задержка между страницами
return pages_html
# --- Запуск ---
if __name__ == "__main__":
locale = {
"label": "NYC",
"gl": "us", "hl": "en", "google_domain": "google.com",
"uule_place": "Brooklyn, New York, NY",
"proxy_user": "user-country-US-city-newyork",
"proxy_pass": "YOUR_PASSWORD",
}
all_pages = scrape_all_pages("plumber", locale, max_pages=3)
logger.info(f"Collected {len(all_pages)} pages for {locale['label']}")
CAPTCHA, 429 и экспоненциальный backoff
Даже с резидентными прокси Google может вернуть CAPTCHA или HTTP 429 при интенсивном скрейпинге. Вот стратегия обработки с экспоненциальным backoff, повторными попытками и переключением прокси:
import time
import random
import logging
from curl_cffi import requests as cffi_requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("serp_retry")
# Словарь пулов прокси по странам
PROXY_POOLS = {
"US": [
"http://user-country-US-city-newyork-session-s1:PASS@gate.proxyhat.com:8080",
"http://user-country-US-city-chicago-session-s2:PASS@gate.proxyhat.com:8080",
"http://user-country-US-city-miami-session-s3:PASS@gate.proxyhat.com:8080",
],
"DE": [
"http://user-country-DE-city-berlin-session-s1:PASS@gate.proxyhat.com:8080",
"http://user-country-DE-city-munich-session-s2:PASS@gate.proxyhat.com:8080",
],
}
MAX_RETRIES = 5
BASE_DELAY = 2.0 # секунд
def fetch_with_retry(url: str, country: str, impersonate="chrome120") -> str | None:
"""Запрос с экспоненциальным backoff и ротацией прокси.
- При 429/503 ждёт и повторяет с новым прокси из пула.
- При CAPTCHA (302 на sorry/index) меняет прокси и повторяет.
- При 200 возвращает HTML.
"""
pool = PROXY_POOLS.get(country, [])
if not pool:
raise ValueError(f"No proxy pool for country: {country}")
for attempt in range(1, MAX_RETRIES + 1):
proxy_url = random.choice(pool)
try:
response = cffi_requests.get(
url,
proxies={"http": proxy_url, "https": proxy_url},
impersonate=impersonate,
timeout=30,
allow_redirects=False, # Не следуем за CAPTCHA-редиректами
)
# Проверяем CAPTCHA-редирект
if response.status_code == 302 and "sorry" in response.headers.get("location", ""):
logger.warning(f"Attempt {attempt}: CAPTCHA redirect detected")
delay = BASE_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 1)
time.sleep(delay)
continue
# Rate limiting
if response.status_code in (429, 503):
delay = BASE_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 1)
logger.warning(f"Attempt {attempt}: HTTP {response.status_code}, retry in {delay:.1f}s")
time.sleep(delay)
continue
response.raise_for_status()
logger.info(f"Attempt {attempt}: OK, len={len(response.text)}")
return response.text
except Exception as e:
logger.error(f"Attempt {attempt}: {e}")
delay = BASE_DELAY * (2 ** (attempt - 1))
time.sleep(delay)
logger.error(f"All {MAX_RETRIES} attempts failed for {url}")
return None
# --- Использование ---
if __name__ == "__main__":
serp_url = (
"https://www.google.com/search?q=plumber&gl=us&hl=en&pws=0&num=10"
"&uule=w+CAIQICIRQnJvb2tseW4sIE5ldyBZb3JrLCBOWQ=="
)
html = fetch_with_retry(serp_url, "US")
if html:
print(f"Success! HTML length: {len(html)}")
else:
print("Failed after all retries")
Советы по продакшен-надёжности
- Пер-страновые пулы прокси: держите минимум 3-5 IP на каждую локацию. Это позволяет ротировать при CAPTCHA без потери гео-точности.
- Задержки 1-3 секунды между запросами к одному домену. Случайный джиттер снижает вероятность rate-limit.
- Логирование метрик: записывайте success rate, latency, код ответа для каждого прокси. ProxyHat dashboard показывает статистику по сессиям.
- Circuit breaker: если пул прокси для страны даёт >30% ошибок за 5 минут, ставьте его на паузу и переключайтесь на резервный.
- Мониторинг CAPTCHA: если доля CAPTCHA превышает 5%, снижайте частоту запросов на 50%.
Сравнение типов прокси для SERP-скрейпинга
| Тип прокси | Success rate (Google) | Гео-точность | Стоимость | Рекомендация |
|---|---|---|---|---|
| Дата-центровые | ~40-60% | Страна | Низкая | Не рекомендуется для Google |
| Резидентные | ~90-95% | Страна + Город | Средняя | Оптимальный выбор |
| Мобильные | ~95-98% | Страна | Высокая | Для критичных задач |
ProxyHat предоставляет все три типа. Для гео-таргетированного SERP-скрейпинга рекомендуется резидентный план с поддержкой city-level таргетинга. Подробнее о применении — в разделе SERP tracking и web scraping.
Юридические и этические ограничения
Сбор публичных данных из SERP Google находится в серой правовой зоне. robots.txt Google запрещает автоматический доступ к /search. Технически это не имеет силы закона, но Google ToS прямо запрещают автоматизированные запросы. В деле hiQ Labs v. LinkedIn (9th Circuit, 2022) суд постановил, что сбор публично доступных данных не нарушает CFAA, но это не освобождает от ответственности за нарушение ToS конкретной платформы.
Практические рекомендации:
- Соблюдайте разумные лимиты частоты запросов (не более 1 req/sec на IP).
- Не собирайте персональные данные без правового основания (GDPR/CCPA).
- Если используете данные коммерчески, рассмотрите официальное API — Google Custom Search API (100 запросов/день бесплатно, далее $5/1000 запросов).
- Храните собранные данные не дольше, чем необходимо для бизнес-задачи.
Полная документация по настройке прокси доступна на docs.proxyhat.com.
Ключевые выводы
Гео-таргетированный скрейпинг SERP требует согласованности трёх уровней: параметров URL (gl/hl/uule), IP-географии (прокси) и TLS-отпечатка (имперсонализация браузера). Без согласованности любого из уровней результаты будут искажены.
- uule — ключевой параметр для локального SEO. Без него вы получаете национальный SERP, а не городской. Всегда добавляйте
pws=0для воспроизводимости. - Резидентные прокси обязательны для Google SERP. Дата-центровые IP блокируются в 40-60% случаев. ProxyHat резидентные прокси через
gate.proxyhat.com:8080с city-level таргетингом обеспечивают ~90-95% success rate. - Sticky-сессии через флаг
session-XXXсохраняют IP между страницами, что критично для консистентности многостраничных SERP. - Экспоненциальный backoff с ротацией прокси — минимальный набор для продакшена. Circuit breaker и пер-страновые пулы повышают надёжность до уровня SaaS.
- Соблюдайте ToS: сбор SERP нарушает условия Google. Оценивайте риски и рассмотрите официальное API для критичных задач.
FAQ
Что такое гео-таргетированный скрейпинг SERP?
Это сбор результатов поиска Google с привязкой к конкретной локации — стране, региону или городу. Для точного гео-таргетинга используют параметры gl, hl, google_domain и uule, а запросы маршрутизируют через резидентные прокси из той же локации, чтобы IP-география совпадала с целевым рынком.
Почему гео-таргетированный скрейпинг важен для пользователей прокси?
Google возвращает разные результаты в зависимости от IP-адреса, языка интерфейса и явных параметров локализации. Если IP-география не совпадает с параметром gl или uule, Google может проигнорировать их или показать смешанные результаты. Резидентные прокси из нужной локации обеспечивают согласованность и снижают риск блокировок.
Какой тип прокси лучше всего подходит для гео-таргетированного скрейпинга SERP?
Резидентные прокси — оптимальный выбор. Они используют IP-адреса реальных ISP, что делает их неотличимыми от обычных пользователей. Мобильные прокси подходят для высокой анонимности, но дороже. Дата-центровые прокси часто блокируются Google. ProxyHat предоставляет резидентные прокси с таргетингом по стране и городу через gate.proxyhat.com:8080.
Как избежать блокировок при гео-таргетированном скрейпинге SERP?
Используйте ротацию IP между запросами, задержки 1-3 секунды, экспоненциальный backoff при 429/503, имперсонализацию браузера (TLS fingerprint Chrome), согласованность параметров gl/hl/uule с IP-географией, и уважайте robots.txt. Sticky-сессии помогают для многостраничных запросов, сохраняя один IP.






