A fecha de 7 de octubre de 2026, Shifter cuenta con el mayor pool de IPs estadounidenses activas, más que Oxylabs y Bright Data, a una fracción del coste.Shifter ahora tiene el mayor conjunto de IPs de EE. UU. activas

Ver comparativas

Scraping

Tu scraper es lento y probablemente no sea el proxy

Aceleramos un scraper que funcionaba con proxies residenciales 7,5 veces sin tocar el proxy. Cómo encontrar a dónde va realmente el tiempo y solucionarlo.

Matt Brown

8 de octubre de 2026 · 12 min de lectura

“Tus proxies van lentos” es uno de los mensajes más habituales que recibimos de los clientes. A veces es cierto. Más a menudo, cuando revisamos el scraper juntos, la mayor parte del tiempo se va en cosas que el propio scraper se hace a sí mismo: abrir una conexión nueva para cada página, analizar con una librería lenta, esperar una petición cada vez.

Para mostrar cuánto importa esto, cogimos un scraper sencillo, lo ejecutamos a través de nuestros proxies residenciales y cambiamos una cosa cada vez. Las mismas 120 páginas pasaron de 298 segundos a 40, una mejora de 7,5 veces, sin cambiar nada del proxy. Esta guía repasa esa prueba, la herramienta que usamos para encontrar las partes lentas y una lista de comprobación para diagnosticar tu propio scraper.

Conclusiones clave

  • Mide antes de cambiar nada. Un temporizador por etapas que registre cuánto tiempo llevan la obtención, el análisis y la escritura te dirá en minutos dónde se va el tiempo.
  • Reutiliza las conexiones. Abrir una conexión nueva a través de un proxy para cada petición más que triplicó el tiempo por obtención en nuestra prueba. Una Session lo solucionó.
  • El análisis puede costar tanto como la obtención. Cambiar el analizador HTML redujo el tiempo de análisis a más de la mitad.
  • La concurrencia da la mayor mejora y después expone el siguiente cuello de botella. Ocho workers redujeron el tiempo de ejecución a la mitad y mostraron que el análisis limitado por CPU se había convertido en el límite.
  • No todo lo lento merece arreglarse. Las escrituras en la base de datos se llevaron 0,3 segundos de 298, así que agruparlas en lotes no cambió nada.

La prueba

Recogimos 120 artículos de la Wikipedia en inglés a través de los proxies residenciales de Shifter con salidas en EE. UU., desde una máquina en Europa. Los artículos de Wikipedia son páginas grandes, a menudo con varios cientos de kilobytes de HTML. Para cada página, el scraper obtiene el HTML, lo analiza con Beautiful Soup para leer el título y contar los enlaces, y escribe una fila en SQLite.

Ejecutamos el mismo trabajo cinco veces, cambiando una cosa cada vez. El scraper se identificaba con un User-Agent descriptivo, que Wikipedia exige (sus servidores rechazan el User-Agent por defecto de Python), y reintentaba una petición fallida hasta dos veces.

VersiónCambioTiempo totalObtención por páginaAnálisis por página
1. BaseConexión nueva por petición, html.parser, una cada vez298 s1.906 ms577 ms
2. Reutilización de conexionesUn requests.Session125 s568 ms473 ms
3. Analizador más rápidolxml en lugar de html.parser76 s426 ms205 ms
4. Concurrencia8 hilos de trabajo40 s551 ms2.024 ms
5. Escrituras en loteUna transacción en lugar de una por fila42 s579 ms2.141 ms

Los tiempos de obtención a través de salidas residenciales varían de una ejecución a otra en unos cien milisegundos; las diferencias anteriores son mucho mayores que eso.

Paso uno: medir dónde se va el tiempo

La herramienta es un pequeño temporizador que suma el tiempo de reloj por etapa, de forma segura entre hilos:

import time
from collections import defaultdict
from contextlib import contextmanager
from threading import Lock


class StageTimer:
    """Adds up wall-clock time per pipeline stage, safely across threads."""

    def __init__(self):
        self.totals = defaultdict(float)
        self.counts = defaultdict(int)
        self._lock = Lock()

    @contextmanager
    def stage(self, name):
        start = time.perf_counter()
        try:
            yield
        finally:
            elapsed = time.perf_counter() - start
            with self._lock:
                self.totals[name] += elapsed
                self.counts[name] += 1

    def report(self, wall_seconds):
        busy = sum(self.totals.values())
        print(f"wall {wall_seconds:.1f}s, stage time {busy:.1f}s")
        for name, total in sorted(self.totals.items(), key=lambda kv: -kv[1]):
            print(f"{name:>8}: {total:7.1f}s  {100 * total / busy:5.1f}%  "
                  f"{1000 * total / self.counts[name]:7.1f} ms per call")

Envuelve cada etapa de tu scraper con with timer.stage("fetch"):, with timer.stage("parse"):, etc., e imprime el informe al final. Cuesta casi nada ejecutarlo y convierte “el scraper va lento” en una respuesta concreta.

Aquí está el scraper de prueba completo con el temporizador ya incorporado. La función run() recibe los cuatro ajustes que cambiamos:

import os
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor

import requests
from bs4 import BeautifulSoup

from stagetimer import StageTimer
from urls import URLS

user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]
PROXY = f"http://{user}-country-us:{password}@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
HEADERS = {"User-Agent": "ExampleScraper/1.0 (+https://example.com/bot)"}  # name your scraper


def scrape(url, session, parser, timer):
    with timer.stage("fetch"):
        for attempt in range(3):
            try:
                if session is None:               # a new connection for every request
                    response = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=20)
                else:                             # reuse connections through one Session
                    response = session.get(url, timeout=20)
                response.raise_for_status()
                break
            except requests.RequestException:
                if attempt == 2:
                    raise
        html = response.text
    with timer.stage("parse"):
        soup = BeautifulSoup(html, parser)
        title = soup.find("h1").get_text(strip=True)
        links = len(soup.select("a[href]"))
    return url, title, links


def run(reuse=False, parser="html.parser", workers=1, batch_writes=False):
    timer = StageTimer()
    session = None
    if reuse:
        session = requests.Session()
        session.proxies = PROXIES
        session.headers.update(HEADERS)
        adapter = requests.adapters.HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
        session.mount("https://", adapter)
    db = sqlite3.connect("pages.db", check_same_thread=False)
    db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, title TEXT, links INTEGER)")
    db.execute("DELETE FROM pages")
    db.commit()

    start = time.perf_counter()
    with ThreadPoolExecutor(workers) as pool:
        rows = pool.map(lambda u: scrape(u, session, parser, timer), URLS)
        if batch_writes:
            rows = list(rows)
            with timer.stage("write"):
                db.executemany("INSERT INTO pages VALUES (?, ?, ?)", rows)
                db.commit()
        else:
            for row in rows:
                with timer.stage("write"):
                    db.execute("INSERT INTO pages VALUES (?, ?, ?)", row)
                    db.commit()                   # one transaction per row
    wall = time.perf_counter() - start
    timer.report(wall)
    return wall


if __name__ == "__main__":
    steps = [
        ("baseline", {}),
        ("reuse connections", {"reuse": True}),
        ("faster parser", {"reuse": True, "parser": "lxml"}),
        ("8 workers", {"reuse": True, "parser": "lxml", "workers": 8}),
        ("batch writes", {"reuse": True, "parser": "lxml", "workers": 8, "batch_writes": True}),
    ]
    for name, config in steps:
        print(f"\n== {name}")
        run(**config)

urls.py contiene la lista de 120 URL de artículos. Al ejecutarlo se imprimió:

== baseline
wall 298.2s, stage time 298.1s
   fetch:   228.7s   76.7%   1905.7 ms per call
   parse:    69.2s   23.2%    576.6 ms per call
   write:     0.3s    0.1%      2.2 ms per call

== reuse connections
wall 125.0s, stage time 125.2s
   fetch:    68.2s   54.5%    568.3 ms per call
   parse:    56.7s   45.3%    472.7 ms per call
   write:     0.3s    0.2%      2.1 ms per call

== faster parser
wall 75.7s, stage time 75.9s
   fetch:    51.1s   67.4%    425.8 ms per call
   parse:    24.6s   32.4%    204.8 ms per call
   write:     0.2s    0.3%      1.6 ms per call

== 8 workers
wall 39.7s, stage time 309.8s
   parse:   242.8s   78.4%   2023.6 ms per call
   fetch:    66.1s   21.3%    551.0 ms per call
   write:     0.8s    0.3%      6.9 ms per call

== batch writes
wall 42.1s, stage time 326.4s
   parse:   256.9s   78.7%   2141.0 ms per call
   fetch:    69.5s   21.3%    579.0 ms per call
   write:     0.0s    0.0%      1.8 ms per call

Qué hizo cada cambio

Reutilizar las conexiones redujo el tiempo de obtención en un 70%. En la versión base, cada llamada a requests.get() abre una conexión completamente nueva. A través de un proxy, eso implica una conexión TCP al proxy, una petición de túnel y un handshake TLS completo con el destino a través de la salida, antes de que llegue un solo byte de la página. Un requests.Session mantiene las conexiones abiertas y las reutiliza, de modo que la mayoría de las peticiones se saltan toda esa configuración. El tiempo de obtención por página bajó de 1,9 segundos a 0,57. Casi cualquier librería HTTP tiene un equivalente: un objeto cliente que creas una vez y compartes.

Un analizador más rápido redujo el tiempo de análisis a la mitad. Beautiful Soup puede usar varios analizadores. El html.parser integrado es Python puro; lxml está escrito en C. El cambio redujo el tiempo de análisis de 473 a 205 milisegundos por página en estas páginas grandes, con un solo argumento modificado. Si solo necesitas unos pocos campos, seleccionarlos directamente con lxml o extraer el JSON incrustado es todavía más rápido.

La concurrencia redujo el tiempo total a la mitad y desplazó el cuello de botella. La mayor parte de una obtención consiste en esperar a la red, y los hilos pueden esperar en paralelo. Con ocho workers, la ejecución bajó de 76 a 40 segundos. Pero fíjate en la columna de análisis: 205 milisegundos por página se convirtieron en más de 2 segundos. En CPython, los hilos comparten un único lock del intérprete, así que el trabajo intensivo en CPU, como el análisis, no se ejecuta en paralelo; ocho hilos hacen cola para el mismo núcleo. El temporizador por etapas lo hace visible: el análisis pasó de un tercio del tiempo de etapa a casi un 80%. Llegados a ese punto, el scraper está limitado por la CPU, y el siguiente paso es analizar en procesos separados o analizar menos.

Agrupar las escrituras no cambió nada, porque las escrituras nunca fueron el problema: 0,3 segundos de 298 en la versión base. La pequeña diferencia entre las versiones 4 y 5 es ruido de una ejecución a otra. Este es precisamente el sentido de medir primero. Agrupar las escrituras en la base de datos es un buen consejo en general, y aquí habría sido un esfuerzo desperdiciado.

Una lista de comprobación para un scraper lento

Lo que observasCausa probableQué comprobar
Cada petición es lenta, la CPU está inactivaUna petición cada vezAñade concurrencia; la mayoría de los scrapers pasan su tiempo esperando
La primera petición a cada host es mucho más lenta que el restoConfiguración de la conexiónReutiliza una sesión o un cliente; comprueba que el keep-alive no esté desactivado
La CPU está al 100% en un núcleoAnálisis u otro trabajo limitado por CPUUsa un analizador más rápido, extrae menos, o traslada el análisis a procesos
El rendimiento cae al aumentar la concurrenciaEl destino te está limitando, o tu propia máquina está saturadaVigila los códigos de estado y los tiempos de respuesta por destino; consulta nuestra guía sobre planificar el volumen de peticiones y la concurrencia
Algunas peticiones ocasionales tardan entre 20 y 30 segundosTimeouts y reintentosEstablece timeouts explícitos, limita los reintentos y regístralos; consulta reintentos y backoff para scrapers web
El scraper se ralentiza con el paso de las horasColas que crecen, presión de memoria, búferes sin límiteAñade backpressure; consulta backpressure y control de flujo
Las páginas son lentas y pesadasRenderizar en un navegador cuando bastaría con el HTMLObtén el HTML directamente, o bloquea imágenes, medios y fuentes

Cuando de verdad es el proxy

A veces el proxy sí es el cuello de botella, y vale la pena saber cómo distinguirlo.

  • Compara con una referencia directa. Mide las mismas peticiones sin el proxy desde la misma máquina. Si ambas son lentas, el problema está en el destino o en tu código.
  • Observa el tiempo de obtención tras reutilizar la conexión. Si las obtenciones siguen siendo lentas con una sesión reutilizada y el destino es rápido en directo, la ruta a través del proxy está añadiendo ese tiempo.
  • Revisa tu segmentación. Los filtros estrechos, como una ciudad concreta, dejan menos salidas entre las que elegir. Las salidas lejos del destino añaden tiempo de ida y vuelta a cada petición.
  • Vigila los percentiles, no las medias. Unas pocas salidas muy lentas pueden arrastrar una media hacia arriba mientras la mayoría de las peticiones van bien.

Nuestra guía sobre reducir la latencia con proxies residenciales trata en detalle los ajustes del lado del proxy. Si has medido y los números apuntan al proxy, envíalos a nuestro equipo de soporte: tener tiempos por etapa como los de arriba agiliza mucho la investigación de un informe de proxy lento.

Preguntas frecuentes

¿Por qué mi scraper va lento con proxies?

Las causas más habituales están en el propio scraper: abrir una conexión nueva para cada petición, obtener una página cada vez y un análisis lento. En nuestra prueba, corregir eso hizo que el mismo trabajo fuera 7,5 veces más rápido sin cambiar el proxy.

¿Funciona la reutilización de conexiones a través de un proxy?

Sí. Un requests.Session con los proxies configurados mantiene las conexiones abiertas, lo que ahorra la configuración de la conexión y del TLS en cada petición. En nuestra prueba redujo el tiempo de obtención por página de 1,9 segundos a 0,57.

¿Cuántos hilos debería usar un scraper?

Los suficientes para mantener la red ocupada, dentro de lo que permitan el destino y tu plan. Mide a medida que lo aumentas: cuando el tiempo de análisis por página o las tasas de error empiecen a subir, habrás encontrado el límite. El trabajo limitado por CPU, como el análisis, no se acelera con más hilos en CPython.

¿Es lento BeautifulSoup?

Beautiful Soup en sí es una capa de conveniencia; la mayor parte de su velocidad depende del analizador que hay detrás. Con lxml en lugar del html.parser integrado, el análisis fue más del doble de rápido en nuestra prueba.

En resumen

Antes de culpar al proxy, mide el scraper. Un temporizador por etapas muestra en minutos si el tiempo se va en obtener, analizar o escribir. En nuestra prueba, reutilizar conexiones, cambiar de analizador y añadir concurrencia hicieron que el mismo trabajo fuera 7,5 veces más rápido con el mismo proxy. Arregla primero la etapa más costosa, mide de nuevo, y detente cuando el tiempo restante sea de verdad la red.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar