Зачем создавать трекер позиций Google на Python с резидентными прокси
Если вы SEO-инженер или Python-разработчик, вам нужен надёжный способ отслеживать позиции сайта в выдаче Google. Готовые SaaS-решения стоят от $50 до $500 в месяц и часто ограничивают частоту и географию проверок. Решение — создать трекер позиций Google на Python с резидентными прокси: полный контроль над данными, частотой запросов и локацией без зависимости от сторонних сервисов.
Проблема в том, что Google активно блокирует автоматизированные запросы. Дата-центр прокси отклоняются ещё на этапе TLS-рукопожатия из-за отпечатков JA3/JA4 и низкого IP-рейтинга. Решение — резидентные прокси с геотаргетингом по городам и TLS-имперсонацией браузера через curl_cffi.
В этом руководстве вы построите продакшн-готовый трекер ранжирования на Python: от модели данных до конкурентной обработки с ретраями и CAPTCHA-детекцией.
Модель данных: почему ежедневные срезы SERP лучше разовых проверок
Разовая проверка позиций даёт снимок на момент запроса. Google обновляет индекс непрерывно — позиции колеблются в течение дня. Ежедневные срезы SERP позволяют отследить тренды, выявить аномальные скачки и сгладить волатильность.
Модель данных трекера позиций:
keyword -- отслеживаемый запрос, напр. "купить прокси"
target_domain -- домен, позиции которого отслеживаем, напр. "proxyhat.com"
country -- код страны геотаргетинга, напр. "US"
device -- "desktop" или "mobile"
position -- позиция в органической выдаче (1-100)
url -- URL найденной страницы
captured_at -- временная метка среза SERP
Эта модель хранится в SQLite — достаточно для 10 000+ ключевых слов при ежедневной проверке. Для больших объёмов переходите на PostgreSQL с партицированием по дате.
Почему именно ежедневные срезы? Google обновляет выдачу несколько раз в сутки. Разовая проверка может попасть на момент кратковременного скачка или провала. Серия срезов за 7-14 дней позволяет вычислить медианную позицию и отфильтровать шум.
Пагинация SERP после удаления num=100 в сентябре 2025
До сентября 2025 года можно было передать параметр num=100 и получить до 100 результатов на одной странице. Google удалил этот параметр, и теперь выдача жёстко ограничена 10 результатами на страницу. Для сбора топ-100 нужно делать 10 запросов с параметром start=0,10,20,...,90.
Это увеличивает нагрузку в 10 раз — вместо одного запроса на ключевое слово вы делаете 10. Без прокси это означает мгновенный бан IP. С резидентными прокси и ротацией IP между запросами вы распределяете нагрузку.
import urllib.parse
def build_serp_urls(keyword: str, max_results: int = 100) -> list[str]:
base = "https://www.google.com/search"
urls = []
for start in range(0, max_results, 10):
params = urllib.parse.urlencode({
"q": keyword,
"start": start,
"num": 10,
"hl": "en",
"gl": "US",
})
urls.append(f"{base}?{params}")
return urls
# Пример: 10 URL для топ-100
urls = build_serp_urls("buy proxies")
print(f"Сгенерировано {len(urls)} URL для пагинации")
Каждый URL — отдельный запрос через прокси. Важно: между запросами для одного ключевого слова используйте разные IP-адреса, чтобы Google не связал их в одну сессию.
Почему нужны резидентные прокси с геотаргетингом по городам
Google использует несколько уровней защиты от ботов:
- TLS-фингерпринтинг (JA3/JA4) — анализ параметров TLS-рукопожатия. Стандартные HTTP-клиенты Python (requests, httpx) имеют отпечаток, отличный от браузера. Google отклоняет такие запросы.
- IP-репутация — дата-центр IP-адреса помечены как бот-сети. Резидентные IP от интернет-провайдеров имеют высокий уровень доверия.
- Геотаргетинг — результаты Google зависят от местоположения пользователя. Для точного трекинга нужен IP из целевого города.
Сравнение типов прокси для трекинга позиций:
| Параметр | Дата-центр | Резидентные | Мобильные |
|---|---|---|---|
| IP-репутация | Низкая | Высокая | Высокая |
| Скорость | ~50 мс | ~200 мс | ~500 мс |
| Блокировка Google | Часто (>50%) | Редко (<5%) | Очень редко |
| Геотаргетинг | Страна | Город | Город / ASN |
| Цена | Низкая | Средняя | Высокая |
Для трекинга позиций оптимальны резидентные прокси с геотаргетингом по городам. Они обеспечивают баланс скорости, надёжности и точности локации. Подробности о доступных локациях — на странице покрытия ProxyHat.
Практическая реализация: curl_cffi + ProxyHat
Теперь соберём всё вместе. Мы используем curl_cffi с параметром impersonate='chrome' для имитации TLS-отпечатка Chrome и ProxyHat для ротации резидентных IP с геотаргетингом.
Базовый запрос через curl
# curl с ProxyHat резидентным прокси, геотаргетинг США/Чикаго
curl -x "http://user-country-US-city-chicago-session-key001:pass@gate.proxyhat.com:8080" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
"https://www.google.com/search?q=buy+proxies&num=10&hl=en&gl=US"
Класс ProxyHatClient — обёртка над прокси-конфигурацией
from dataclasses import dataclass
from urllib.parse import quote
@dataclass
class ProxyHatClient:
username: str = "user"
password: str = "pass"
host: str = "gate.proxyhat.com"
port: int = 8080
def proxy_url(self, country: str = None, city: str = None,
session: str = None) -> str:
"""Создаёт URL прокси с геотаргетингом и липкой сессией."""
parts = [self.username]
if country:
parts.append(f"country-{country}")
if city:
parts.append(f"city-{city}")
if session:
parts.append(f"session-{session}")
user = "-".join(parts)
return f"http://{user}:{self.password}@{self.host}:{self.port}"
def proxies(self, country: str = None, city: str = None,
session: str = None) -> dict:
"""Возвращает dict прокси для requests/curl_cffi."""
url = self.proxy_url(country, city, session)
return {"http": url, "https": url}
Получение SERP через curl_cffi
from curl_cffi import requests as cffi_requests
from curl_cffi.requests import AsyncSession
import asyncio
import re
async def fetch_serp_page(url: str, proxy_client: ProxyHatClient,
country: str, city: str,
session_id: str) -> str:
"""Получает HTML страницы SERP через резидентный прокси."""
proxy_url = proxy_client.proxy_url(country, city, session_id)
try:
resp = await cffi_requests.get(
url,
proxy=proxy_url,
impersonate="chrome",
timeout=30,
allow_redirects=True,
)
if resp.status_code == 200:
return resp.text
elif resp.status_code == 429:
raise Exception("Rate limited (429)")
else:
raise Exception(f"HTTP {resp.status_code}")
except Exception as e:
raise Exception(f"SERP fetch failed: {e}")
# Пример запуска
async def main():
client = ProxyHatClient(username="user", password="pass")
url = "https://www.google.com/search?q=buy+proxies&num=10&hl=en&gl=US"
html = await fetch_serp_page(url, client, "US", "chicago", "kw-001")
print(f"Получено {len(html)} байт HTML")
asyncio.run(main())
Парсинг органических результатов
import re
from dataclasses import dataclass
@dataclass
class OrganicResult:
position: int
title: str
url: str
domain: str
def parse_organic_results(html: str, start_rank: int = 1) -> list[OrganicResult]:
"""Парсит органические результаты из HTML SERP.
Пропускает рекламу, featured snippets и другие SERP-фичи."""
results = []
# Ищем блоки органических результатов
# Google использует div с классом, содержащим 'g' для органики
pattern = re.compile(
r'<a href="/url\?q=(https?://[^&]+)"[^>]*>(.*?)</a>',
re.DOTALL
)
matches = pattern.findall(html)
seen_domains = set()
rank = start_rank
for url, title_html in matches:
# Пропускаем Google-сервисы и рекламу
if "google.com" in url or "googleads" in url:
continue
# Извлекаем домен
domain_match = re.match(r'https?://([^/]+)', url)
if not domain_match:
continue
domain = domain_match.group(1)
if domain in seen_domains:
continue
seen_domains.add(domain)
# Очищаем заголовок от HTML-тегов
title = re.sub(r'<[^>]+>', '', title_html).strip()
if not title:
continue
results.append(OrganicResult(
position=rank,
title=title,
url=url,
domain=domain
))
rank += 1
return results
def find_domain_position(results: list[OrganicResult],
target_domain: str) -> int | None:
"""Находит позицию целевого домена в результатах."""
for r in results:
if target_domain in r.domain:
return r.position
return None
Хранение истории в SQLite
import sqlite3
from datetime import datetime, timezone
def init_db(db_path: str = "rank_tracker.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS rankings (
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 TEXT NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_kw_domain
ON rankings(keyword, target_domain, country, device)
""")
conn.commit()
return conn
def save_ranking(conn: sqlite3.Connection, keyword: str,
target_domain: str, country: str,
device: str, position: int | None,
url: str | None) -> None:
conn.execute(
"""INSERT INTO rankings
(keyword, target_domain, country, device, position, url, captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(keyword, target_domain, country, device, position, url,
datetime.now(timezone.utc).isoformat())
)
conn.commit()
# Экспорт в CSV для отчётов
import csv
def export_csv(conn: sqlite3.Connection, output_path: str = "rankings.csv") -> None:
rows = conn.execute("SELECT * FROM rankings ORDER BY captured_at DESC").fetchall()
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["id", "keyword", "domain", "country",
"device", "position", "url", "captured_at"])
writer.writerows(rows)
Продакшн-надёжность: ретраи, CAPTCHA, конкурентность
Трекер позиций работает в условиях нестабильности: прокси могут отключаться, Google может показывать CAPTCHA, страницы могут загружаться медленно. Вот что нужно для продакшна.
Ретраи с экспоненциальной задержкой
import asyncio
import logging
from functools import wraps
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rank_tracker")
def retry_with_backoff(max_retries: int = 3, base_delay: float = 1.0):
"""Декоратор для ретраев с экспоненциальной задержкой."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
logger.error(f"Финальная ошибка после {max_retries} попыток: {e}")
return None
delay = base_delay * (2 ** attempt)
logger.warning(
f"Попытка {attempt + 1}/{max_retries} провалена: {e}. "
f"Повтор через {delay:.1f}с"
)
await asyncio.sleep(delay)
return None
return wrapper
return decorator
def detect_captcha(html: str) -> bool:
"""Детектирует CAPTCHA и блокировки Google."""
captcha_markers = [
"detected unusual traffic",
"captcha",
"g-recaptcha",
"sorry/index",
"Our systems have detected",
]
html_lower = html.lower()
return any(marker.lower() in html_lower for marker in captcha_markers)
Полный трекер с конкурентностью и сглаживанием волатильности
import asyncio
from datetime import datetime, timezone
from curl_cffi.requests import AsyncSession
import sqlite3
async def track_keyword(
keyword: str,
target_domain: str,
country: str,
city: str,
proxy_client: ProxyHatClient,
conn: sqlite3.Connection,
semaphore: asyncio.Semaphore,
) -> int | None:
"""Отслеживает позицию домена для одного ключевого слова."""
async with semaphore:
session_id = f"{keyword[:20]}-{datetime.now().strftime('%Y%m%d')}"
all_results = []
for page_start in range(0, 100, 10):
url = (
f"https://www.google.com/search?q={keyword}"
f"&start={page_start}&num=10&hl=en&gl={country}"
)
# Разный session_id для каждой страницы = разный IP
page_session = f"{session_id}-p{page_start//10}"
@retry_with_backoff(max_retries=3, base_delay=2.0)
async def fetch_page(u=url, s=page_session):
html = await fetch_serp_page(
u, proxy_client, country, city, s
)
if detect_captcha(html):
raise Exception("CAPTCHA detected")
return html
html = await fetch_page()
if html is None:
logger.warning(f"Страница start={page_start} не получена для '{keyword}'")
continue
page_results = parse_organic_results(html, start_rank=page_start + 1)
all_results.extend(page_results)
await asyncio.sleep(2) # Задержка между страницами
position = find_domain_position(all_results, target_domain)
save_ranking(conn, keyword, target_domain, country,
"desktop", position, None)
logger.info(f"'{keyword}' -> {target_domain}: позиция {position}")
return position
async def run_tracker(keywords: list[str], target_domain: str,
country: str = "US", city: str = "chicago",
max_concurrent: int = 10):
"""Запускает трекер для списка ключевых слов."""
client = ProxyHatClient(username="user", password="pass")
conn = init_db()
semaphore = asyncio.Semaphore(max_concurrent)
tasks = [
track_keyword(kw, target_domain, country, city,
client, conn, semaphore)
for kw in keywords
]
results = await asyncio.gather(*tasks, return_exceptions=True)
success = sum(1 for r in results if r is not None and not isinstance(r, Exception))
logger.info(f"Успешно: {success}/{len(keywords)} ключевых слов")
export_csv(conn)
conn.close()
# Запуск
keywords = ["buy proxies", "residential proxies", "best proxy service"]
asyncio.run(run_tracker(keywords, "proxyhat.com", "US", "chicago", 5))
Сглаживание волатильности: вместо отображения одной позиции вычисляйте медиану за последние 7 дней. Это фильтрует суточные скачки и даёт стабильную картину тренда.
def get_smoothed_position(conn: sqlite3.Connection,
keyword: str, domain: str,
days: int = 7) -> float | None:
"""Возвращает медианную позицию за последние N дней."""
rows = conn.execute(
"""SELECT position FROM rankings
WHERE keyword=? AND target_domain=?
AND captured_at >= datetime('now', ?)
AND position IS NOT NULL
ORDER BY captured_at DESC""",
(keyword, domain, f'-{days} days')
).fetchall()
if not rows:
return None
positions = sorted([r[0] for r in rows])
mid = len(positions) // 2
if len(positions) % 2 == 0:
return (positions[mid - 1] + positions[mid]) / 2
return float(positions[mid])
Конкурентность: используйте asyncio.Semaphore для ограничения одновременных запросов. Рекомендуемый лимит — 10-20 параллельных запросов на один прокси-пул, чтобы не превышать 200 запросов/мин.
Этика и ограничения трекинга позиций
Трекинг позиций — это серая зона. Google's Terms of Service запрещают автоматизированные запросы. Однако отслеживание позиций собственного сайта и публично доступных данных широко практикуется в SEO-индустрии.
Рекомендации:
- Соблюдайте лимиты — не более 200 запросов/мин с одного IP-пула.
- Отслеживайте свои домены — это снижает этические риски.
- Используйте официальный API при низком объёме — Google Custom Search API даёт 100 бесплатных запросов/день. Для трекинга 10-20 ключевых слов этого достаточно. Подробнее — в документации ProxyHat.
- Уважайте robots.txt — проверяйте директивы перед скрапингом.
- GDPR/CCPA — не собирайте персональные данные из SERP.
Для масштабирования трекинга сверьтесь с тарифами ProxyHat — резидентные прокси начинаются от доступных планов. Подробнее о применении прокси для скрапинга — в разделе веб-скрапинг и SERP-трекинг. Информацию о подключении и параметрах смотрите в официальной документации.
Ключевые выводы
Ежедневные срезы SERP с медианным сглаживанием за 7 дней дают стабильную картину позиций. Разовые проверки — это шум.
- Модель данных: keyword + target_domain + country + device + position + captured_at в SQLite — достаточно для 10 000+ ключевых слов.
- Пагинация: после удаления num=100 в сентябре 2025 нужно 10 запросов на ключевое слово через
start=0,10,...,90. - Прокси: только резидентные с геотаргетингом по городам. Дата-центр IP блокируются Google на этапе TLS.
- TLS-имперсонация: используйте
curl_cffiсimpersonate='chrome'— это имитирует отпечаток Chrome и проходит проверки JA3/JA4. - Липкие сессии: параметр
session-в username ProxyHat закрепляет IP за ключевым словом — важно для консистентности геолокации. - Продакшн: ретраи с экспоненциальной задержкой (3 попытки, базовая задержка 2 с), детекция CAPTCHA, лимит конкурентности 10-20 потоков.
- Этика: отслеживайте свои домены, соблюдайте лимиты, рассмотрите официальный API при малом объёме.
Готовы начать? Настройте геотаргетинг ProxyHat и запустите первый трекер позиций за 15 минут.






