Создание трекера позиций Google на Python с резидентными прокси

Практическое руководство для разработчиков: модель данных, парсинг SERP с пагинацией, обход TLS-фingerprinting, ротация IP через ProxyHat и production-готовность трекера позиций Google.

Build a Google Rank Tracker in Python with Residential Proxies
В этой статье

Зачем нужен трекер позиций 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 использует несколько уровней защиты от скрапинга:

  1. TLS-фингерпринтинг (JA3/JA4). Google анализирует параметры TLS-handshake, чтобы отличить браузер от бота. curl_cffi с impersonate="chrome" решает эту проблему на уровне TLS.
  2. IP-репутация. IP-адреса из дата-центров (AWS, GCP, DigitalOcean) помечены как подозрительные. Запросы с таких IP получают CAPTCHA или пустую выдачу.
  3. Гео-таргетинг. Google показывает разные результаты для разных локаций. Если вы трекаете позиции в США, IP должен быть из США — желательно из нужного города.
  4. Частота запросов. Слишком много запросов с одного 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, выберите подходящий тариф на странице тарифов и запустите свой трекер позиций уже сегодня.

Часто задаваемые вопросы

Что такое создание трекера позиций Google на Python с резидентными прокси?

Это процесс разработки Python-приложения, которое ежедневно собирает снимки поисковой выдачи Google через резидентные прокси, определяет позицию целевого домена в органических результатах и сохраняет историю в базе данных. Резидентные прокси необходимы, потому что Google блокирует запросы от IP-адресов дата-центров. Трекер позволяет отслеживать динамику позиций во времени, сглаживать волатильность и коррелировать изменения с обновлениями алгоритма Google.

Зачем нужны резидентные прокси для трекера позиций Google?

Google использует IP-репутацию, TLS-фингерпринтинг (JA3/JA4) и гео-таргетинг для защиты от скрапинга. IP-адреса дата-центров быстро блокируются или получают CAPTCHA. Резидентные прокси используют реальные IP домашних провайдеров, что делает запросы неотличимыми от обычных пользователей. City-level гео-таргетинг обеспечивает консистентность результатов: позиции в Чикаго и Нью-Йорке могут отличаться.

Какой тип прокси лучше всего подходит для трекера позиций Google?

Резидентные прокси — оптимальный выбор для SERP-трекинга. Они обеспечивают высокую IP-репутацию, поддержку гео-таргетинга по странам и городам, низкий риск CAPTCHA и разумную скорость (~200ms). Мобильные прокси ещё надёжнее, но стоят в 2–3 раза дороже. Дата-центр прокси не подходят: Google блокирует их в течение нескольких запросов. Для трекинга сотен ключевых слов резидентные прокси с sticky-сессиями — лучший баланс цены и качества.

Как избежать блокировок при создании трекера позиций Google на Python?

Используйте curl_cffi с impersonate='chrome' для обхода TLS-фингерпринтинга. Ограничьте частоту запросов до 1 в 2–3 секунды на IP. Применяйте sticky-сессии на ключевое слово, чтобы все страницы пагинации шли с одного IP. Реализуйте повторные попытки с экспоненциальной задержкой и детектирование CAPTCHA. При обнаружении CAPTCHA меняйте session_id для ротации IP. Не превышайте 5–10 одновременных запросов. При низких объёмах рассмотрите официальный Google Custom Search API.

Готовы начать?

Резидентные, ISP и мобильные прокси в 148+ странах. Создайте бесплатный аккаунт.

Создать бесплатный аккаунт
← Вернуться в Блог