Volver al blog

Crawlee para Python: Cola, Dedupe y Renderizar un Rastreo Real

Alex Johnson
Alex Johnson

Senior Web Scraping Engineer

04-Aug-2026

TL;DR:

  • Crawlee para Python te ofrece una cola de solicitudes, deduplicación automática de URL, enqueue_links(), y un escritor de conjuntos de datos, por lo que un rastreador paginado es una sola función manejadora.
  • El almacenamiento de Crawlee se comparte por proceso, no por rastreador. purge_on_start es True y aún no aísla dos rastreadores en un script.
  • Medido: dos rastreadores idénticos con max_requests_per_crawl=2 en un script. El primero terminó 2 solicitudes y escribió 20 elementos; el segundo terminó 3 solicitudes y reportó 50 elementos a través de 5 páginas.
  • BeautifulSoupCrawler no ejecuta JavaScript. En una página renderizada por el cliente terminó la solicitud y produjo 0 elementos.
  • La clase base HttpClient de Crawlee tiene cuatro métodos. Implementar uno que llame a la API de Raspado Universal de Scrapeless devolvió 10 elementos de esa misma página, sin cambiar el manejador de enrutamiento.
  • El plan gratuito de Scrapeless cubre cada solicitud en esta guía.

Crawlee para Python es la parte de un raspador que de otro modo escribirías tú mismo: la cola que almacena URLs, el conjunto que evita que obtengas una dos veces, el limitador de concurrencia, y el escritor que coloca resultados en disco. Proporcionas una función manejadora que recibe una página analizada.

Debido a que Crawlee posee la cola y el almacenamiento, sus valores predeterminados deciden cómo lucen tus resultados — y dos de ellos producen números que son incorrectos de maneras que ninguna excepción te informará.

Esta guía construye un rastreador funcional contra un sitio en vivo, mide lo que el almacenamiento predeterminado hace a un segundo rastreador, y luego cambia el transporte para que el mismo manejador funcione en una página que se renderiza en el navegador.

Lo que Crawlee Te Ofrece

Crawlee ofrece varias clases de rastreadores que comparten una interfaz. La que elijas decide cómo se analiza la página:

  • BeautifulSoupCrawler y ParselCrawler obtienen datos a través de HTTP y le entregan a tu manejador un árbol analizado.
  • HttpCrawler te ofrece la respuesta en bruto sin análisis.
  • PlaywrightCrawler y AdaptivePlaywrightCrawler manejan un navegador real.

Todos aceptan el mismo enrutador, la misma configuración de concurrencia y el mismo almacenamiento. Cambiar entre ellos altera el objeto de contexto que recibe tu manejador, por eso pasar de un rastreador HTTP a un rastreador de navegador no es un cambio de una sola línea.

Instalar

bash Copy
pip install 'crawlee[beautifulsoup]'

Lo extra importa: el paquete base crawlee no incluye Beautiful Soup. La ejecución de verificación utilizó crawlee 1.9.0 con beautifulsoup4 4.15.0 en Python 3.12.

Tu Primer Rastreador

Un rastreador es una clase más un manejador decorado. El manejador recibe un contexto que lleva la página analizada, la solicitud, y los métodos para enviar datos y encolar más URLs.

python Copy
def build(*, storage_dir: str | None = None, http_client=None, max_requests: int = 3):
    crawler = BeautifulSoupCrawler(
        http_client=http_client,
        max_requests_per_crawl=max_requests,
        concurrency_settings=ConcurrencySettings(desired_concurrency=2, max_concurrency=2),
        configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
    )

    @crawler.router.default_handler
    async def handler(context: BeautifulSoupCrawlingContext) -> None:
        for quote in context.soup.select("div.quote"):
            await context.push_data({
                "text": quote.select_one("span.text").get_text(strip=True),
                "author": quote.select_one("small.author").get_text(strip=True),
                "url": context.request.url,
            })
        await context.enqueue_links(selector="li.next a")

    return crawler

context.soup es un objeto de Beautiful Soup, por lo que los selectores existentes se trasladan sin cambios. context.push_data() agrega al conjunto de datos. context.enqueue_links(selector=...) encuentra anclas que coinciden con ese selector, resuelve cada una contra la página actual utilizando las reglas de base-URL en el Estándar de URL de WHATWG, y añade los resultados a la cola — ya deduplicados, por lo que un enlace "siguiente" que apunta de vuelta a una página visitada no tiene ningún costo.

ConcurrencySettings rechaza un max_concurrency por debajo de su desired_concurrency, así que establece ambos cuando lo disminuyas.

Ejecutado contra tres páginas de un sitio de citas en vivo:

text Copy
  estático
    solicitudes finalizadas : 3
    elementos del conjunto de datos: 30
    páginas distintas    : 3
    primera cita       : “El mundo tal como lo hemos creado es un proceso de nuestro pensar
    primer autor      : Albert Einstein

Tres solicitudes, diez citas cada una, tres URLs de origen distintas. El manejador nunca construyó una URL ni hizo un seguimiento de un conjunto visitado.

A Dónde Van los Datos

push_data() escribe en un conjunto de datos bajo ./storage, y crawler.get_data() lo lee de nuevo:

python Copy
async def report(label, crawler, start_url):
    await crawler.run([start_url])
    data = await crawler.get_data()
    print(f"  {label}")
    print(f"    solicitudes finalizadas : {crawler.statistics.state.requests_finished}")
    print(f"    elementos del conjunto de datos : {data.count}")
es Copy
print(f"    páginas distintas    : {len({i['url'] for i in data.items})}")
    return data

crawler.statistics.state.requests_finished es el conteo que Crawlee realmente completó, lo cual vale la pena imprimir junto al conteo del conjunto de datos. Cuando esos dos no coinciden con lo que esperas, la razón suele ser la siguiente sección.

El Almacenamiento Sobrevive a Tu Crawler

Configuration().purge_on_start es True. Esto se lee como una garantía de que cada ejecución comienza con un conjunto de datos vacío y una cola vacía. No es así: la purga ocurre una vez, cuando el almacenamiento se abre por primera vez en el proceso, así que un segundo crawler construido en el mismo script se une al almacenamiento que dejó el primero.

Dos crawlers, construidos por la misma función, ambos limitados a dos solicitudes, ambos comenzando desde la misma URL:

python Copy
await report("crawler A", build(max_requests=2), "https://quotes.toscrape.com/")
await report("crawler B", build(max_requests=2), "https://quotes.toscrape.com/")
text Copy
  crawler A
    solicitudes finalizadas : 2
    elementos del conjunto   : 20
    páginas distintas       : 2
  crawler B
    solicitudes finalizadas : 3
    elementos del conjunto   : 50
    páginas distintas       : 5

El crawler B estaba configurado para dos solicitudes y finalizó con tres. Su conjunto de datos informa 50 elementos en 5 páginas distintas, lo cual incluye todo lo que escribió el crawler A. No se levantó ninguna excepción, y ambas ejecuciones se registraron como exitosas.

La URL de inicio que se le dio al crawler B ya había sido visitada, por lo que la deduplicación la desechó, mientras que la página que el crawler A había encolado y nunca alcanzó aún estaba esperando. El límite y el conjunto de datos que un crawler informa son propiedades del almacenamiento compartido, no de ese crawler.

Dale a cada crawler su propio directorio de almacenamiento cuando compartan un proceso. Ese es el argumento que el constructor anterior toma precisamente por esta razón:

python Copy
        configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,

Vuelve a ejecutar las mismas tres etapas con storage_dir configurado por crawler y los conteos se convierten en los que configuraste. Un crawler por proceso es la otra respuesta, y la más sencilla para producción.

https://quotes.toscrape.com/js/ construye su DOM a partir de un arreglo de JavaScript. BeautifulSoupCrawler lo obtiene sin quejas:

text Copy
  javascript
    solicitudes finalizadas : 1
    elementos del conjunto   : 0
    páginas distintas       : 0

Una solicitud finalizada, cero elementos. El marcado que recibió Crawlee no contiene elementos div.quote, y un crawler HTTP no tiene nada que los cree.

La respuesta documentada es PlaywrightCrawler, lo que significa una dependencia del navegador, un objeto de contexto diferente, y una reescritura del análisis del manejador. El cambio más específico es mantener BeautifulSoupCrawler y reemplazar solo su transporte, lo cual es compatible con Crawlee a través del parámetro http_client.

HttpClient tiene cuatro métodos, y solo dos de ellos requieren trabajo real. Un objeto de respuesta que satisfaga el tipo estructural HttpResponse de Crawlee —un protocolo en el sentido de la especificación del protocolo de tipos de Python— envuelve el HTML renderizado. Tiene que exponer un código de estado y cabeceras porque Crawlee los trata como la especificación semántica de HTTP los define:

python Copy
class RenderedResponse:
    """Adapta una cadena HTML renderizada al protocolo HttpResponse de Crawlee."""

    def __init__(self, body: bytes, status_code: int = 200) -> None:
        self._body = body
        self._status_code = status_code

    @property
    def http_version(self) -> str:
        return "HTTP/1.1"

    @property
    def status_code(self) -> int:
        return self._status_code

    @property
    def headers(self) -> HttpHeaders:
        return HttpHeaders({"content-type": "text/html; charset=utf-8"})

    async def read(self) -> bytes:
        return self._body

    async def read_stream(self) -> AsyncIterator[bytes]:
        raise RuntimeError("el streaming no es soportado por este cliente")
        yield b""

El cliente en sí llama a la API Universal de Scraping de Scrapeless. Ese endpoint renderiza la página y devuelve el HTML como una cadena. La llamada bloqueante pasa por asyncio.to_thread para que no detenga el bucle de eventos que la documentación de tareas asyncio describe:

python Copy
class ScrapelessHttpClient(HttpClient):
    """Rutea cada solicitud de Crawlee a través de la API Universal de Scraping."""

    def __init__(self, *, proxy_country: str = "US") -> None:
        super().__init__()
        self._proxy_country = proxy_country
        self._token = os.environ["SCRAPELESS_API_KEY"]

    def _render(self, url: str) -> bytes:
es Copy
payload = {
            "actor": "unlocker.webunlocker",
            "input": {"url": url, "proxy_country": self._proxy_country, "js_render": True},
        }
        request = urllib.request.Request(
            UNLOCKER,
            data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json", "x-api-token": self._token},
        )
        with urllib.request.urlopen(request, timeout=180) as response:
            return json.loads(response.read().decode())["data"].encode("utf-8")

    async def crawl(self, request, *, session=None, proxy_info=None, statistics=None,
                    timeout: timedelta | None = None) -> HttpCrawlingResult:
        body = await asyncio.to_thread(self._render, request.url)
        return HttpCrawlingResult(http_response=RenderedResponse(body))

    async def send_request(self, url, *, method="GET", headers=None, payload=None,
                           session=None, proxy_info=None, timeout=None) -> HttpResponse:
        body = await asyncio.to_thread(self._render, url)
        return RenderedResponse(body)

    def stream(self, url, **kwargs):
        raise NotImplementedError("este cliente no transmite")

    async def cleanup(self) -> None:
        return None

Pásalo al mismo rastreador y ejecuta la misma página:

text Copy
  javascript+api
    solicitudes finalizadas : 1
    elementos del conjunto de datos : 10
    páginas distintas : 1
    primera cita : “El mundo tal como lo hemos creado es un proceso de nuestro pensamiento

Diez elementos de la página que produjeron cero. El controlador de enrutamiento, los selectores, la llamada al conjunto de datos y enqueue_links permanecen intactos: la cola y la deduplicación de Crawlee siguen funcionando, porque solo se reemplazó el objeto que obtiene bytes. Mantén la clave en el entorno como SCRAPELESS_API_KEY; la guía de inicio rápido de la API de raspado universal enumera los otros parámetros de solicitud. Si necesitas enrutamiento de proxy para el cliente HTTP predeterminado, la guía de proxy de Crawlee cubre esa configuración.

Comenzar toma un minuto: crea una cuenta gratuita en Scrapeless y el plan gratuito cubre todo aquí.

Ejecútalo

bash Copy
export SCRAPELESS_API_KEY="tu-clave-api"
python3 crawlee_demo.py

La salida completa de la ejecución de verificación:

text Copy
crawlee 1.9.0 | beautifulsoup4 4.15.0
purge_on_start por defecto: True
--- sitio estático, cliente HTTP predeterminado, almacenamiento aislado ---
  estático
    solicitudes finalizadas : 3
    elementos del conjunto de datos : 30
    páginas distintas : 3
    primera cita : “El mundo tal como lo hemos creado es un proceso de nuestro pensamiento
    primer autor : Albert Einstein
--- sitio javascript, cliente HTTP predeterminado, almacenamiento aislado ---
  javascript
    solicitudes finalizadas : 1
    elementos del conjunto de datos : 0
    páginas distintas : 0
--- sitio javascript, ScrapelessHttpClient, almacenamiento aislado ---
  javascript+api
    solicitudes finalizadas : 1
    elementos del conjunto de datos : 10
    páginas distintas : 1
    primera cita : “El mundo tal como lo hemos creado es un proceso de nuestro pensamiento
--- dos rastreadores, un proceso, almacenamiento predeterminado ---
  rastreador A
    solicitudes finalizadas : 2
    elementos del conjunto de datos : 20
    páginas distintas : 2
  rastreador B
    solicitudes finalizadas : 3
    elementos del conjunto de datos : 50
    páginas distintas : 5

Solución de problemas

El conjunto de datos tiene más elementos de los que produjo esta ejecución. El almacenamiento se comparte por proceso. Configura Configuration(storage_dir=...) por rastreador, o elimina ./storage entre ejecuciones, o ejecuta un rastreador por proceso.

desired_concurrency no puede ser mayor que max_concurrency. ConcurrencySettings valida el par en la construcción. Reducir max_concurrency solo genera un error; establece desired_concurrency para que coincida.

ModuleNotFoundError: No module named 'bs4'. El paquete base no tiene parser. Instala crawlee[beautifulsoup] o crawlee[parsel].

ImportError en HttpHeaders. Se exporta desde el paquete crawlee de nivel superior, no desde un submódulo.

Cero elementos y una solicitud finalizada. La página se renderiza del lado del cliente. Imprime await context.http_response.read() y búscalo para un valor que puedas ver en la página; si el valor está ausente, ningún selector lo encontrará.

Conclusión

El valor de Crawlee es la maquinaria en torno a tu controlador: una cola, deduplicación, concurrencia limitada y un conjunto de datos. Esa maquinaria también es lo que debes observar, porque mantiene el estado que sobrevive al objeto rastreador. Las dos medidas en esta guía provienen de ese hecho: un segundo rastreador en un proceso informa de 50 elementos cuando obtuvo muchos menos, y una página renderizada por el cliente devuelve un limpio cero.

Copy
Ambos son diagnosticables en una línea. Imprime `requests_finished` junto con el conteo del conjunto de datos en cada ejecución; cuando no coinciden con tu configuración, observa el almacenamiento antes de los selectores. Y cuando el conteo es cero porque el marcado llegó vacío, la solución más pequeña es cambiar el transporte y dejar el manejador sin tocar.

¿Listo para intentarlo? <a href="https://app.scrapeless.com/passport/login?utm_source=website&utm_medium=blog&utm_campaign=universalscrapingapi&utm_term=crawlee-python-web-scraping"><strong>Comienza con el plan gratuito de Scrapeless</strong></a> y consulta los <a href="https://www.scrapeless.com/es/pricing?utm_source=website&utm_medium=blog&utm_campaign=universalscrapingapi&utm_term=crawlee-python-web-scraping"><strong>precios actuales</strong></a> para volúmenes más altos.

## Preguntas Frecuentes

**P: ¿Qué clase de crawler de Crawlee debería usar?**

Comienza con `BeautifulSoupCrawler` si los datos están en el HTML servido, porque cuesta una solicitud HTTP por página y te entrega un árbol parseado familiar. Pasa a `ParselCrawler` si prefieres XPath, `HttpCrawler` si deseas los bytes en bruto, y un crawler de Playwright solo cuando la página realmente necesite un navegador.

**P: ¿En qué se diferencia Crawlee de escribir el bucle yo mismo?**

Crawlee proporciona la cola de solicitudes, desduplicación de URL, concurrencia limitada y persistencia del conjunto de datos. En la ejecución anterior, `enqueue_links(selector="li.next a")` recorrió tres páginas sin que el manejador construyera una sola URL o rastreara qué páginas había visto.

**P: ¿Por qué mi conjunto de datos contiene resultados de una ejecución anterior?**

Porque el almacenamiento de Crawlee se comparte por proceso y `purge_on_start` se activa una vez cuando el almacenamiento se abre por primera vez, no por crawler. Dos crawlers en un script comparten un conjunto de datos y una cola de solicitudes. Dale a cada uno una `Configuration(storage_dir=...)`, o ejecuta un crawler por proceso.

**P: ¿Tengo que cambiar a PlaywrightCrawler para páginas de JavaScript?**

No. `PlaywrightCrawler` es una opción, pero cambia la clase de crawler y el contexto que recibe tu manejador. Implementar la interfaz `HttpClient` de Crawlee solo cambia cómo se obtienen los bytes, que es la razón por la que el manejador en esta guía pasó de 0 elementos a 10 sin una edición.

**P: ¿Dónde escribe Crawlee su salida?**

Bajo `./storage` por defecto, con conjuntos de datos en `storage/datasets/`. `crawler.get_data()` lee el conjunto de datos en el mismo proceso, y `Configuration(storage_dir=...)` mueve todo el árbol a otro lugar.

En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.

Artículos más populares

Catalogar