Зачем нужен трекер позиций Google на Python с резидентными прокси
Чтобы создать трекер позиций Google на Python с резидентными прокси, нужно сначала понять главную проблему: разовые ручные проверки позиций ненадёжны. Результаты зависят от местоположения IP, устройства, времени суток, истории поиска и персонализации. Два разработчика, проверяющих один и тот же запрос из разных локаций, увидят разные выдачи. Единственный способ получить воспроизводимые данные — ежедневно собирать снимки SERP из фиксированной геолокации и хранить историю позиций в структурированном виде.
Это руководство предназначено для SEO-инженеров и Python-разработчиков, которые автоматизируют мониторинг позиций. Мы пройдём от модели данных до production-готового трекера с ретраями, определением CAPTCHA и конкурентностью. Все примеры используют curl_cffi с имперсонацией Chrome и прокси ProxyHat для обхода TLS-фингерпринтинга.
Модель данных: keyword, domain, country, device, position, captured_at
Хорошая модель данных — основа трекера. Каждая запись о позиции должна содержать как минимум шесть полей: ключевое слово, целевой домен, страну, тип устройства, номер позиции и временную метку. Без captured_at невозможно отследить динамику позиций во времени. Без country и device нельзя сравнивать результаты, потому что Google показывает разные выдачи для десктопа и мобильных устройств, а также для разных стран.
Ежедневные снимки SERP превосходят разовые проверки по нескольким причинам:
- Тренды видны только в динамике. Позиция №7 сегодня и №12 вчера — это сигнал, который разовая проверка не покажет.
- Сглаживание шума. Google постоянно тестирует выдачу. Позиция может колебаться на ±3 позиции в течение дня. Среднее за неделю стабильнее.
- Корреляция с событиями. Сопоставляя даты падения позиций с датами обновлений Google, можно найти причины.
Создадим SQLite-схему и dataclass для записи:
import sqlite3
from dataclasses import dataclass
from datetime import datetime
@dataclass
class RankRecord:
keyword: str
target_domain: str
country: str
device: str
position: int | None
url: str | None
captured_at: datetime
def init_db(db_path: str = "ranks.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS rank_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
target_domain TEXT NOT NULL,
country TEXT NOT NULL,
device TEXT NOT NULL,
position INTEGER,
url TEXT,
captured_at TIMESTAMP NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_kw_domain_country_time
ON rank_history(keyword, target_domain, country, captured_at DESC)
""")
conn.commit()
return conn
def save_record(conn: sqlite3.Connection, rec: RankRecord):
conn.execute(
"""INSERT INTO rank_history
(keyword, target_domain, country, device, position, url, captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(rec.keyword, rec.target_domain, rec.country, rec.device,
rec.position, rec.url, rec.captured_at.isoformat())
)
conn.commit()
SQLite достаточно для трекинга сотен ключевых слов. При масштабировании на десятки тысяч ключевых слов имеет смысл перейти на PostgreSQL или ClickHouse для аналитики.
Получение SERP: пагинация вместо num=100
До сентября 2025 года можно было использовать параметр num=100 в URL Google Search, чтобы получить до 100 результатов на одной странице. Google ограничил этот параметр, и теперь нужно пагинировать через параметр start со значениями 0, 10, 20, 30 и так далее. Для топ-100 нужно 10 запросов вместо одного.
Каждый запрос — это отдельный HTTP-вызов через прокси, что увеличивает риск блокировок и расходы трафика. Поэтому важно:
- Останавливаться, когда найдена позиция целевого домена (не парсить все 100 результатов каждый раз).
- Использовать sticky-сессии, чтобы все страницы одного ключевого слова шли с одного IP.
- Добавлять задержку между запросами (2–5 секунд).
Базовый запрос SERP с curl_cffi и прокси ProxyHat:
from curl_cffi import requests
def fetch_serp(
keyword: str,
country: str = "US",
city: str = "chicago",
session_id: str | None = None,
page: int = 0,
) -> str:
"""Fetch Google SERP HTML via ProxyHat residential proxy."""
username = f"user-country-{country}-city-{city}"
if session_id:
username += f"-session-{session_id}"
proxy = f"http://{username}:PASSWORD@gate.proxyhat.com:8080"
url = "https://www.google.com/search"
params = {
"q": keyword,
"start": page * 10,
"num": 10,
"hl": "en",
"gl": country.lower(),
}
resp = requests.get(
url,
params=params,
proxies={"https": proxy, "http": proxy},
impersonate="chrome",
timeout=30,
)
resp.raise_for_status()
return resp.text
Параметр impersonate="chrome" в curl_cffi заставляет библиотеку использовать TLS-фингерпринт, идентичный реальному Chrome, включая JA3/JA4 hash, порядок cipher suites и расширения TLS. Обычный requests или httpx используют TLS-стек Python, который Google легко распознаёт как автоматизированный.
Почему резидентные прокси необходимы для трекера позиций
Google использует несколько уровней защиты от скрапинга:
- TLS-фингерпринтинг (JA3/JA4). Google анализирует параметры TLS-handshake, чтобы отличить браузер от бота.
curl_cffiсimpersonate="chrome"решает эту проблему на уровне TLS. - IP-репутация. IP-адреса из дата-центров (AWS, GCP, DigitalOcean) помечены как подозрительные. Запросы с таких IP получают CAPTCHA или пустую выдачу.
- Гео-таргетинг. Google показывает разные результаты для разных локаций. Если вы трекаете позиции в США, IP должен быть из США — желательно из нужного города.
- Частота запросов. Слишком много запросов с одного IP за короткое время — триггер для блокировки.
Резидентные прокси решают проблемы IP-репутации и гео-таргетинга, потому что они используют реальные IP-адреса домашних интернет-провайдеров. Сравнение типов прокси для SERP-трекинга:
| Характеристика | Резидентные | Дата-центр | Мобильные |
|---|---|---|---|
| IP-репутация | Высокая | Низкая | Высокая |
| Скорость | ~200ms | ~50ms | ~500ms |
| Риск CAPTCHA | Низкий | Высокий | Очень низкий |
| Geo-таргетинг | Страна + город | Ограниченный | Страна + оператор |
| Цена за GB | $5–15 | $0.5–2 | $10–30 |
Для трекинга позиций резидентные прокси — оптимальный баланс цены, скорости и надёжности. Мобильные прокси ещё лучше, но значительно дороже. Дата-центр прокси не подходят: Google блокирует их в течение нескольких запросов. Подробнее о доступных локациях ProxyHat — на странице локаций прокси.
Практическая реализация: curl_cffi + ProxyHat
Сначала создадим класс-обёртку для ProxyHat, который строит URL прокси с гео-таргетингом и sticky-сессиями:
class ProxyHatClient:
"""Wrapper for ProxyHat proxy gateway with geo-targeting and sticky sessions."""
GATEWAY = "gate.proxyhat.com"
HTTP_PORT = 8080
SOCKS5_PORT = 1080
def __init__(self, username: str, password: str):
self.username = username
self.password = password
def http_proxy(
self,
country: str | None = None,
city: str | None = None,
session_id: str | None = None,
) -> str:
user = self.username
if country:
user += f"-country-{country}"
if city:
user += f"-city-{city}"
if session_id:
user += f"-session-{session_id}"
return f"http://{user}:{self.password}@{self.GATEWAY}:{self.HTTP_PORT}"
def socks5_proxy(
self,
country: str | None = None,
city: str | None = None,
session_id: str | None = None,
) -> str:
user = self.username
if country:
user += f"-country-{country}"
if city:
user += f"-city-{city}"
if session_id:
user += f"-session-{session_id}"
return f"socks5://{user}:{self.password}@{self.GATEWAY}:{self.SOCKS5_PORT}"
Теперь парсер органических результатов. Google заворачивает органические результаты в div.g, но HTML-структура часто меняется. Поэтому используем комбинацию CSS-селекторов и regex как fallback:
from bs4 import BeautifulSoup
import re
def parse_organic_results(html: str, max_results: int = 100) -> list[dict]:
"""Parse organic results from Google SERP HTML.
Skips ads, featured snippets, and other SERP features."""
soup = BeautifulSoup(html, "html.parser")
results = []
# Primary selector: div.g contains organic results
for div in soup.select("div.g"):
link = div.select_one("a[href]")
if not link:
continue
href = link.get("href", "")
# Skip internal Google links
if href.startswith("/") or "google." in href or not href.startswith("http"):
continue
title_elem = div.select_one("h3")
if not title_elem:
continue
results.append({
"url": href,
"title": title_elem.get_text(strip=True),
})
# Fallback: regex for &url= patterns in Google redirect links
if len(results) < 3:
pattern = r'/url\?q=(https?://[^&]+)'
for match in re.finditer(pattern, html):
url = match.group(1)
if "google." not in url and url not in [r["url"] for r in results]:
results.append({"url": url, "title": ""})
return results[:max_results]
def find_position(results: list[dict], target_domain: str) -> tuple[int | None, str | None]:
"""Find the position of target_domain in organic results."""
target = target_domain.lower().replace("www.", "")
for i, r in enumerate(results, 1):
url_lower = r["url"].lower().replace("www.", "")
if target in url_lower:
return i, r["url"]
return None, None
Теперь объединим всё в функцию трекинга одного ключевого слова. Используем sticky-сессию на ключевое слово, чтобы все страницы пагинации шли с одного IP:
import hashlib
from datetime import datetime
def track_keyword(
keyword: str,
target_domain: str,
client: ProxyHatClient,
country: str = "US",
city: str = "chicago",
db_path: str = "ranks.db",
max_pages: int = 10,
) -> RankRecord:
"""Track a single keyword and store the result."""
session_id = hashlib.md5(keyword.encode()).hexdigest()[:8]
all_results = []
found_pos = None
found_url = None
for page in range(max_pages):
try:
html = fetch_serp(
keyword, country=country, city=city,
session_id=session_id, page=page
)
page_results = parse_organic_results(html)
all_results.extend(page_results)
# Early exit if target found
pos, url = find_position(page_results, target_domain)
if pos:
found_pos = len(all_results) - len(page_results) + pos
found_url = url
break
import time
time.sleep(2) # rate limit between pages
except Exception as e:
print(f"[WARN] Page {page} for '{keyword}': {e}")
continue
# If not found in per-page check, search all results
if found_pos is None and all_results:
found_pos, found_url = find_position(all_results, target_domain)
record = RankRecord(
keyword=keyword,
target_domain=target_domain,
country=country,
device="desktop",
position=found_pos,
url=found_url,
captured_at=datetime.now(),
)
conn = init_db(db_path)
save_record(conn, record)
conn.close()
return record
Эту функцию можно запускать по расписанию через cron или Celery. Для экспорта данных в CSV добавьте простую функцию:
import csv
def export_csv(db_path: str = "ranks.db", output: str = "ranks_export.csv"):
conn = sqlite3.connect(db_path)
rows = conn.execute(
"SELECT keyword, target_domain, country, device, position, url, captured_at "
"FROM rank_history ORDER BY captured_at DESC"
).fetchall()
with open(output, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["keyword", "target_domain", "country", "device", "position", "url", "captured_at"])
writer.writerows(rows)
conn.close()
Production-готовность: повторные попытки, CAPTCHA, конкурентность
Production-трекер должен справляться с нестабильностью сети, CAPTCHA и ограничениями частоты запросов. Вот ключевые элементы надёжности:
1. Повторные попытки с экспоненциальной задержкой. Сетевые ошибки и временные блокировки — норма. Повторяйте запросы с задержкой 2^n + jitter:
import time
import random
def fetch_with_retry(
keyword: str,
country: str,
city: str,
session_id: str,
page: int,
max_retries: int = 3,
) -> str:
"""Fetch SERP with exponential backoff and CAPTCHA detection."""
captcha_signals = [
"unusual traffic", "captcha", "recaptcha",
"sorry/image", "detected unusual traffic",
"our systems have detected",
]
for attempt in range(max_retries):
try:
html = fetch_serp(
keyword, country=country, city=city,
session_id=session_id, page=page
)
html_lower = html.lower()
if any(sig in html_lower for sig in captcha_signals):
raise RuntimeError("CAPTCHA detected — rotating session")
if len(html) < 1000:
raise RuntimeError("Empty or blocked response")
return html
except Exception as e:
if attempt < max_retries - 1:
wait = (2 ** attempt) + random.uniform(0.5, 1.5)
print(f"[RETRY {attempt + 1}/{max_retries}] {e} — waiting {wait:.1f}s")
time.sleep(wait)
# Rotate session on CAPTCHA
if "CAPTCHA" in str(e):
session_id = hashlib.md5(
f"{keyword}{attempt}".encode()
).hexdigest()[:8]
else:
raise
2. Конкурентность с ограничением. Для трекинга сотен ключевых слов используйте asyncio с семафором. Не более 5–10 одновременных запросов, чтобы не перегружать прокси-пул:
import asyncio
from curl_cffi.requests import AsyncSession
async def track_keyword_async(
session: AsyncSession,
keyword: str,
target_domain: str,
client: ProxyHatClient,
country: str = "US",
city: str = "chicago",
) -> RankRecord:
session_id = hashlib.md5(keyword.encode()).hexdigest()[:8]
all_results = []
for page in range(10):
proxy = client.http_proxy(country=country, city=city, session_id=session_id)
try:
resp = await session.get(
"https://www.google.com/search",
params={"q": keyword, "start": page * 10, "num": 10, "hl": "en", "gl": country.lower()},
proxies={"https": proxy, "http": proxy},
impersonate="chrome",
timeout=30,
)
page_results = parse_organic_results(resp.text)
all_results.extend(page_results)
pos, url = find_position(page_results, target_domain)
if pos:
offset = len(all_results) - len(page_results) + pos
return RankRecord(keyword, target_domain, country, "desktop", offset, url, datetime.now())
await asyncio.sleep(2)
except Exception as e:
print(f"[ERR] '{keyword}' page {page}: {e}")
continue
pos, url = find_position(all_results, target_domain) if all_results else (None, None)
return RankRecord(keyword, target_domain, country, "desktop", pos, url, datetime.now())
async def track_all(keywords: list[str], target_domain: str, client: ProxyHatClient, concurrency: int = 5):
sem = asyncio.Semaphore(concurrency)
async with AsyncSession(impersonate="chrome") as session:
async def limited(kw):
async with sem:
return await track_keyword_async(session, kw, target_domain, client)
results = await asyncio.gather(*[limited(kw) for kw in keywords])
# Persist to SQLite
conn = init_db()
for r in results:
save_record(conn, r)
conn.close()
return results
# Run: asyncio.run(track_all(["python tutorial", "flask vs django"], "example.com", client))
3. Сглаживание волатильности рангов. Google часто тестирует выдачу, и позиция может прыгать на ±3 места в течение дня. Используйте скользящее среднее за 7 дней для отображения трендов:
def smoothed_position(conn: sqlite3.Connection, keyword: str, domain: str, window: int = 7) -> float | None:
rows = conn.execute(
"SELECT position FROM rank_history "
"WHERE keyword=? AND target_domain=? AND position IS NOT NULL "
"ORDER BY captured_at DESC LIMIT ?",
(keyword, domain, window)
).fetchall()
if not rows:
return None
positions = [r[0] for r in rows if r[0] is not None]
return sum(positions) / len(positions)
Подробнее о настройке прокси-пулов и тарифах — на странице тарифов ProxyHat. Документация по API доступна на docs.proxyhat.com.
Этика и ограничения
Сбор SERP Google — это серая зона с юридическими и этическими нюансами. Следуйте этим принципам:
- Трекайте свои домены. Отслеживание позиций собственных сайтов — это легитимный SEO-мониторинг.
- Соблюдайте robots.txt. Google разрешает индексирование, но его ToS запрещает автоматизированный доступ. Используйте Custom Search API при низких объёмах (до 100 запросов в день бесплатно).
- Ограничьте частоту. Не более 1 запроса в 2–3 секунды на IP. 50 одновременных сессий — разумный максимум.
- GDPR и CCPA. Не собирайте персональные данные из SERP. Трекайте только органические позиции.
- Логируйте всё. Сохраняйте HTTP-статусы, время ответа и IP-адрес прокси для каждого запроса — это поможет при дебаге.
При объёмах более 1000 ключевых слов в день официальный Google Custom Search API стоит $5 за 1000 запросов — это может быть дешевле и надёжнее, чем скрапинг. Но API возвращает только 10 результатов за запрос и не всегда совпадает с реальной выдачей. Для точного трекинга позиций скрапинг SERP через резидентные прокси остаётся стандартом.
Ключевые выводы
Что нужно помнить при создании трекера позиций Google на Python:
- Используйте
curl_cffiсimpersonate="chrome"для обхода TLS-фингерпринтинга.- Резидентные прокси с city-level гео-таргетингом — единственный надёжный способ скрапинга Google SERP.
- Пагинируйте через
start=0,10,20…вместо устаревшегоnum=100.- Sticky-сессии на ключевое слово обеспечивают консистентность результатов.
- Храните историю в SQLite минимум 90 дней для анализа трендов.
- При низких объёмах (<100 запросов/день) предпочитайте официальный Google API.
- Сглаживайте волатильность скользящим средним за 7 дней.
Готовы начать? Изучите use-case веб-скрапинга и SERP-трекинга на ProxyHat, выберите подходящий тариф на странице тарифов и запустите свой трекер позиций уже сегодня.






