Создать трекер позиций Google на Python с резидентными прокси: руководство 2026

Полное руководство для разработчиков: модель данных, пагинация SERP после удаления num=100, обход TLS-фингерпринтинга JA3/JA4, curl_cffi + ProxyHat, хранение в SQLite и продакшн-надёжность.

Build a Google Rank Tracker in Python with Residential Proxies

Зачем создавать трекер позиций 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 минут.

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

Доступ к более чем 50 млн резидентных IP в 148+ странах с AI-фильтрацией.

Смотреть ценыРезидентные прокси
← Вернуться в Блог