Construye un rastreador de rankings de Google en Python con proxies residenciales

Guía práctica para desarrolladores: modelo de datos, paginación SERP, fingerprinting TLS, proxies residenciales con geo-targeting y endurecimiento en producción usando curl_cffi y el SDK de ProxyHat.

Build a Google Rank Tracker in Python with Residential Proxies

Construir un rastreador de rankings de Google en Python con proxies residenciales es uno de los proyectos más útiles —y más exigentes— que puede emprender un ingeniero SEO. Google endureció su defensa anti-bot en 2025: eliminó el parámetro num=100, intensificó el fingerprinting TLS/JA3-JA4 y elevó el peso de la reputación IP en su scoring. En esta guía vas a implementar un tracker productivo que combina curl_cffi, el SDK de ProxyHat y SQLite para capturar posiciones diarias sin que te bloqueen.

Por qué construir un rastreador de rankings en Python con proxies residenciales

Un chequeo puntual de rankings te dice dónde estás hoy, pero no captura la volatilidad. Los algoritmos de Google actualizan resultados continuamente y un dominio puede saltar de la posición 4 a la 12 y volver en 48 horas. Sin snapshots diarios no puedes separar ruido de tendencia ni correlacionar caídas con cambios on-page o actualizaciones de búsqueda.

Los proxies residenciales son necesarios porque Google clasifica las IPs por reputación: los rangos de datacenters conocidos (AWS, DigitalOcean, OVH) reciben challenge rates significativamente más altos que IPs de ISPs reales. Si haces 200 peticiones por día desde una IP de datacenter, en menos de 24 horas verás CAPTCHAs o redirecciones a sorry/index. Las IPs residenciales rotas a nivel de ciudad reducen ese riesgo porque imitan tráfico de usuarios reales.

Cuándo NO construir tu propio tracker

Si solo necesitas 20–50 keywords y no tienes requisitos de customización, una API oficial de SERP como la Custom Search JSON API de Google o un proveedor especializado es más barato y mantenible. Construye tu tracker cuando necesites control total sobre el parsing, volúmenes altos (1 000+ keywords), países múltiples o integración con pipelines de datos propios.

Modelo de datos: keyword, dominio, país, dispositivo, posición, fecha

El núcleo de un rank tracker es una tabla de snapshots. Cada fila representa una observación puntual del ranking de un dominio objetivo para una keyword, en un país y dispositivo concretos, en una marca temporal determinada. Este modelo te permite calcular deltas, promedios móviles y detectar outliers.

CampoTipoDescripción
keywordTEXTConsulta rastreada, normalizada (minúsculas, sin espacios extra)
target_domainTEXTDominio a rastrear, ej. example.com
countryTEXT (ISO-2)Geo del SERP, ej. US, DE, ES
deviceTEXTdesktop o mobile
positionINTEGERPosición orgánica (1 = primera). NULL si no aparece
captured_atTIMESTAMPUTC exacto del snapshot

Guarda también la URL completa del resultado y el título para auditoría. Nunca sobrescribas filas: cada snapshot es inmutable. Esto te permite reconstruir historial y comparar contra actualizaciones conocidas de Google.

Paginación SERP tras la eliminación de num=100 en septiembre 2025

En septiembre de 2025 Google retiró el soporte de num=100 en búsquedas web, forzando a los scrapers a paginar. El parámetro start sigue funcionando con incrementos de 10: start=0, start=10, start=20, hasta start=90 para cubrir el top 100. Cada página requiere su propia petición y, por tanto, su propia sesión de proxy.

Estrategia de paginación:

  • Página 1: https://www.google.com/search?q=KEYWORD&gl=COUNTRY&hl=LANG&num=10&start=0
  • Página 2: ...&start=10
  • ...hasta start=90 para 100 resultados orgánicos.

Al parsear, salta anuncios (clases uEierd, contenedores con data-text-ad) y features como People Also Ask, Featured Snippets y Knowledge Panels. Solo cuenta resultados orgánicos puros con URL visible.

Por qué el fingerprinting TLS y la reputación IP exigen proxies residenciales

Google no solo mira la IP. Usa fingerprinting TLS (JA3/JA4) para identificar el cliente HTTP. Una petición con requests estándar produce un fingerprint de Python que Google marca como automatizado. curl_cffi con impersonate='chrome' replica el handshake TLS de Chrome real, incluyendo orden de cipher suites y extensiones.

Combinado con IPs residenciales geolocalizadas a nivel de ciudad, tu tráfico se indistingue de un usuario real buscando desde su casa. El geo-targeting ciudad es crítico: Google personaliza resultados por ubicación granular, y rastrear desde IPs de una sola ciudad introduce sesgo. ProxyHat permite especificar país y ciudad en el username:

http://user-country-US-city-chicago-session-kw123:pass@gate.proxyhat.com:8080

La flag -session- mantiene la misma IP durante toda la sesión, ideal para paginar un keyword sin cambiar de IP entre páginas (lo que sería sospechoso).

Implementación: curl_cffi + SDK de ProxyHat

Vamos a construir el tracker completo. Primero, el esquema SQLite y el cliente SERP con curl_cffi.

1. Esquema de base de datos

import sqlite3
from datetime import datetime, timezone

DB_PATH = "rank_tracker.db"

def init_db(path: str = DB_PATH) -> None:
    conn = sqlite3.connect(path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS rank_snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            keyword TEXT NOT NULL,
            target_domain TEXT NOT NULL,
            country TEXT NOT NULL,
            device TEXT NOT NULL DEFAULT 'desktop',
            position INTEGER,
            result_url TEXT,
            result_title TEXT,
            captured_at TEXT NOT NULL,
            UNIQUE(keyword, target_domain, country, device, captured_at)
        )
    """)
    conn.execute(
        "CREATE INDEX IF NOT EXISTS idx_kw_domain ON rank_snapshots(keyword, target_domain)"
    )
    conn.commit()
    conn.close()

def insert_snapshot(
    keyword: str,
    target_domain: str,
    country: str,
    device: str,
    position: int | None,
    result_url: str,
    result_title: str,
    captured_at: str | None = None,
) -> None:
    if captured_at is None:
        captured_at = datetime.now(timezone.utc).isoformat()
    conn = sqlite3.connect(DB_PATH)
    try:
        conn.execute(
            "INSERT OR REPLACE INTO rank_snapshots "
            "(keyword, target_domain, country, device, position, result_url, result_title, captured_at) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (keyword, target_domain, country, device, position, result_url, result_title, captured_at),
        )
        conn.commit()
    finally:
        conn.close()

2. Cliente SERP con curl_cffi y proxy ProxyHat

import re
from urllib.parse import quote_plus, urlparse
from curl_cffi import requests as cffi_requests

PROXYHAT_GATEWAY = "gate.proxyhat.com"
PROXYHAT_PORT = 8080
PROXYHAT_USER = "TU_USER"
PROXYHAT_PASS = "TU_PASS"

def build_proxy_url(country: str, city: str | None, session_id: str) -> str:
    """Construye la URL de proxy con geo-targeting y sesión sticky."""
    user = f"{PROXYHAT_USER}-country-{country}"
    if city:
        user += f"-city-{city}"
    user += f"-session-{session_id}"
    return f"http://{user}:{PROXYHAT_PASS}@{PROXYHAT_GATEWAY}:{PROXYHAT_PORT}"

def fetch_serp_page(
    keyword: str,
    country: str,
    start: int = 0,
    lang: str = "en",
    device: str = "desktop",
    city: str | None = None,
    session_id: str = "default",
    timeout: int = 30,
) -> str:
    """Descarga una página de resultados de Google (10 resultados)."""
    base = "https://www.google.com/search"
    params = {
        "q": keyword,
        "gl": country,
        "hl": lang,
        "num": 10,
        "start": start,
    }
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/131.0.0.0 Safari/537.36"
        ),
        "Accept-Language": f"{lang}-{country.lower()},{lang};q=0.9",
    }
    proxy = build_proxy_url(country, city, session_id)
    resp = cffi_requests.get(
        base,
        params=params,
        headers=headers,
        proxies={"http": proxy, "https": proxy},
        impersonate="chrome",
        timeout=timeout,
    )
    resp.raise_for_status()
    html = resp.text
    if "sorry/index" in html or "g-recaptcha" in html:
        raise RuntimeError(f"CAPTCHA detectado para keyword='{keyword}' start={start}")
    return html

3. Parsing de resultados orgánicos

from html.parser import HTMLParser

def parse_organic_results(html: str) -> list[dict]:
    """Extrae resultados orgánicos: (position, url, title).
    Salta anuncios y features con regex sobre el HTML."""
    results = []
    # Google envuelve URLs orgánicas en /url?q= en algunos casos
    pattern = re.compile(
        r'<a href="/url\?q=(https?://[^&"\s]+)[^"]*"[^>]*>(.*?)</a>',
        re.DOTALL,
    )
    # Alternativa: contenedores con data-header-feature
    div_pattern = re.compile(
        r'<div[^>]*class="[^"]*(?:g|Ww4FFb|tF2Cxc)[^"]*"[^>]*>.*?'
        r'<a href="(https?://[^"\s]+)"[^>]*>(.*?)</a>',
        re.DOTALL,
    )
    seen = set()
    for m in div_pattern.finditer(html):
        url = m.group(1)
        if url in seen or "google.com" in urlparse(url).netloc:
            continue
        # Limpiar título de tags HTML
        title = re.sub(r"<[^>]+>", "", m.group(2)).strip()
        if not title or len(title) < 3:
            continue
        seen.add(url)
        results.append({"url": url, "title": title})
    return results

def find_position(results: list[dict], target_domain: str) -> int | None:
    """Devuelve la posición 1-indexada del dominio objetivo, o None."""
    for i, r in enumerate(results, start=1):
        netloc = urlparse(r["url"]).netloc.lower()
        if netloc == target_domain.lower() or netloc.endswith(f".{target_domain.lower()}"):
            return i
    return None

4. Orquestación: paginación top-100 con sesión sticky por keyword

import time
import hashlib

def track_keyword(
    keyword: str,
    target_domain: str,
    country: str = "US",
    lang: str = "en",
    device: str = "desktop",
    city: str | None = None,
    max_pages: int = 10,
) -> dict:
    """Rastrea una keyword en el top-100 y guarda el snapshot."""
    session_id = hashlib.md5(f"{keyword}-{country}-{device}".encode()).hexdigest()[:12]
    all_results = []
    for page in range(max_pages):
        start = page * 10
        for attempt in range(3):
            try:
                html = fetch_serp_page(
                    keyword=keyword,
                    country=country,
                    start=start,
                    lang=lang,
                    device=device,
                    city=city,
                    session_id=session_id,
                )
                page_results = parse_organic_results(html)
                all_results.extend(page_results)
                break
            except Exception as e:
                wait = 2 ** (attempt + 1)  # 4s, 8s, 16s
                print(f"  [retry {attempt+1}/3] {e} — esperando {wait}s")
                time.sleep(wait)
        else:
            print(f"  [FAIL] keyword='{keyword}' start={start} tras 3 intentos")
            break
        time.sleep(1.5)  # cortesía entre páginas

    position = find_position(all_results, target_domain)
    best_url = ""
    best_title = ""
    if position:
        best_url = all_results[position - 1]["url"]
        best_title = all_results[position - 1]["title"]

    insert_snapshot(
        keyword=keyword,
        target_domain=target_domain,
        country=country,
        device=device,
        position=position,
        result_url=best_url,
        result_title=best_title,
    )
    return {
        "keyword": keyword,
        "position": position,
        "total_parsed": len(all_results),
    }

5. Ejecución concurrente con límite de concurrencia

from concurrent.futures import ThreadPoolExecutor, as_completed

KEYWORDS = [
    ("mejores zapatillas running", "example.com", "ES", "es"),
    ("best running shoes", "example.com", "US", "en"),
    ("beste laufschuhe", "example.com", "DE", "de"),
]

def run_batch(keywords: list[tuple], max_workers: int = 5) -> None:
    """Ejecuta tracking concurrente con límite de workers."""
    init_db()
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {
            pool.submit(
                track_keyword,
                kw=kw,
                target_domain=domain,
                country=country,
                lang=lang,
            ): kw
            for kw, domain, country, lang in keywords
        }
        for future in as_completed(futures):
            kw = futures[future]
            try:
                result = future.result()
                print(f"✓ {result['keyword']} → pos {result['position']} ({result['total_parsed']} resultados)")
            except Exception as e:
                print(f"✗ {kw} → ERROR: {e}")

if __name__ == "__main__":
    run_batch(KEYWORDS, max_workers=5)

Endurecimiento en producción

Retries con backoff exponencial

Nunca dependas de una sola petición. Implementa backoff exponencial con jitter para evitar thundering herd. El código anterior usa 3 intentos con esperas de 4s, 8s y 16s. Añade un jitter aleatorio de ±20% para dispersar el tráfico.

Detección de CAPTCHA

Google responde con HTTP 200 pero inserta un formulario reCAPTCHA en el HTML. Detecta patrones como sorry/index, g-recaptcha o unusual traffic. Al detectar un CAPTCHA, marca la sesión como comprometida y rota a una nueva IP cambiando el session_id.

Pools de proxy por país

Mantén pools separados por país para evitar contaminación de reputación. Si una IP de US recibe un CAPTCHA, no afecta a tus queries de DE. ProxyHat permite geo-targeting granular: consulta las ubicaciones disponibles para planificar tu cobertura.

Límites de concurrencia

No lances más de 5–10 keywords en paralelo por país desde la misma cuenta. Google detecta ráfagas de peticiones desde un mismo ASN. Usa un Semaphore o ThreadPoolExecutor(max_workers=5) para limitar. Si necesitas más throughput, reparte entre múltiples sesiones con IPs de ciudades distintas.

Suavizado de volatilidad de rankings

Los rankings fluctúan día a día. Para reporting, calcula un promedio móvil de 7 días en lugar de reportar el último valor. Esto elimina el ruido de personalización y actualizaciones menores:

import statistics

def moving_average(positions: list[int | None], window: int = 7) -> float | None:
    """Promedio móvil ignorando None (no ranqueado)."""
    valid = [p for p in positions[-window:] if p is not None]
    if not valid:
        return None
    return round(statistics.mean(valid), 1)

Comparación: proxies residenciales vs datacenter vs móvil

TipoTasa de éxito SERPLatencia típicaCoste relativoIdeal para
Datacenter30–50%100–200msBajoPruebas rápidas, no SERP
Residencial90–98%300–800msMedioRank tracking, scraping SERP
Móvil (4G/5G)95–99%500–1500msAltoRanking móvil, apps

Para rank tracking de escritorio, residenciales son el equilibrio óptimo. Para rankings móviles, proxies móviles dan mayor precisión porque Google sirve resultados diferentes en mobile. Revisa las opciones de pricing de ProxyHat para elegir el plan adecuado.

Ética y límites

Rastrear rankings es legal cuando monitorizas dominios propios o públicos, pero hay límites:

  • Respeta robots.txt de Google —aunque permite /search con restricciones, revisa las reglas actuales.
  • No excedas 100 peticiones/minuto por IP. Mantén un delay de 1–2s entre peticiones.
  • Si tu volumen es bajo (<50 keywords), considera la Custom Search JSON API oficial: 100 queries/día gratis, $5 por 1 000 adicionales.
  • Cumple con ToS de Google y GDPR/CCPA si almacenas datos personales (raro en SERP, pero relevante si capturas snippets con datos de personas).

Para casos de uso más amplios de web scraping, consulta nuestra guía de web scraping con proxies y la documentación de SERP tracking. La documentación oficial de ProxyHat tiene detalles completos sobre autenticación y parámetros de geo-targeting.

Puntos clave

  • Modelo inmutable: cada snapshot es una fila nueva, nunca sobrescribas. Esto permite auditoría histórica.
  • Pagina con start=0,10,20…90: tras la eliminación de num=100 en septiembre 2025, necesitas 10 peticiones para cubrir el top-100.
  • Fingerprinting TLS: usa curl_cffi con impersonate='chrome' para que tu handshake sea indistinguible de Chrome real.
  • Sesión sticky por keyword: mantiene la misma IP durante toda la paginación de un keyword; cambia de sesión entre keywords.
  • Geo-targeting ciudad: rota entre ciudades de un mismo país para evitar sesgo de personalización.
  • Backoff + CAPTCHA detection: 3 retries con backoff exponencial y detección de sorry/index son el mínimo viable.
  • Promedio móvil de 7 días: reporta tendencia, no ruido.

FAQ

¿Qué es construir un rastreador de rankings de Google en Python con proxies residenciales?

Es la implementación de un sistema que captura diariamente la posición de un dominio en los resultados orgánicos de Google para keywords específicas, usando proxies residenciales para evitar bloqueos. Combina un cliente HTTP con fingerprinting TLS realista (curl_cffi), geo-targeting a nivel de ciudad, paginación SERP con start=0,10,20…90, y almacenamiento histórico en SQLite o CSV para análisis de tendencias.

¿Por qué usar proxies residenciales para un rank tracker en Python?

Google puntúa la reputación de cada IP. Los rangos de datacenter (AWS, OVH, DigitalOcean) reciben CAPTCHAs y redirecciones a sorry/index con mucha más frecuencia que IPs de ISPs reales. Los proxies residenciales imitan tráfico de usuarios reales desde casas, con ASN de ISP, reduciendo el challenge rate del 30–50% al 2–10%. Esto permite volúmenes sostenidos de 500+ keywords/día sin bloqueos.

¿Qué tipo de proxy funciona mejor para rastrear rankings de Google?

Para rankings de escritorio, proxies residenciales con geo-targeting de ciudad son el equilibrio óptimo entre coste y tasa de éxito (90–98%). Para rankings móviles, los proxies móviles 4G/5G ofrecen mayor precisión porque Google sirve resultados diferentes en mobile y verifica el ASN celular. Los proxies datacenter solo sirven para pruebas rápidas, no para SERP scraping productivo.

¿Cómo evitar bloqueos al implementar un rank tracker con proxies residenciales?

Combina cuatro técnicas: (1) usa curl_cffi con impersonate='chrome' para replicar el fingerprint TLS/JA3 de Chrome; (2) mantén sesiones sticky por keyword para que la paginación top-100 venga de la misma IP; (3) implementa retries con backoff exponencial (4s, 8s, 16s) y detección de CAPTCHA en el HTML; (4) limita la concurrencia a 5–10 threads por país y añade delays de 1–2s entre peticiones.

¿Sigue funcionando el parámetro num=100 de Google en 2026?

No. Google retiró el soporte de num=100 en septiembre de 2025. Ahora debes paginar con start=0,10,20,30…90 para cubrir el top-100 orgánico. Cada página requiere su propia petición HTTP y, idealmente, mantener la misma sesión de proxy (sticky) para que las 10 peticiones provengan de la misma IP, imitando el comportamiento de un usuario que navega por las páginas de resultados.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog