Volver al blog

Scrapy Web Scraping: Crea una araña que maneje páginas de JavaScript

Daniel Kim
Daniel Kim

Lead Scraping Automation Engineer

04-Aug-2026

TL;DR:

  • Scrapy es un framework de rastreo, no un cliente HTTP. Te proporciona un programador, un filtro de duplicados, un descargador asincrónico y una tubería de ítems, por lo que una araña se mantiene en unas veinte líneas incluso cuando camina por cien páginas.
  • Una araña es una clase con tres elementos requeridos: un nombre, una URL de inicio y un método parse que devuelve diccionarios. Todo lo demás es configuración.
  • Scrapy nunca ejecuta JavaScript. La misma araña que devuelve 10 ítems de una página renderizada por el servidor devuelve 0 ítems de su versión renderizada por el cliente, porque el HTML que llega contiene un contenedor vacío y una etiqueta de script.
  • Un middleware de descargador soluciona eso sin tocar la araña. Renderizar la página aguas arriba y devolverse a Scrapy una HtmlResponse ordinaria restauró los 10 ítems mientras que el método parse se mantuvo idéntico a nivel de bytes.
  • El fijado de versiones importa más que de costumbre aquí. La capa TLS de Scrapy se basa en Twisted y pyOpenSSL, y dos combinaciones específicas fallan en cada descarga HTTPS con errores que nombran certificados en lugar de dependencias.
  • Comienza gratis. La API Universal de Scraping utilizada en el pivote tiene un nivel gratuito, por lo que puedes realizar toda la comparación en esta publicación sin un plan de pago.

Scrapy separa las partes de un rastreo que te importan de las que no. Escribes un selector. Scrapy maneja la cola de solicitudes, la concurrencia, la deduplicación, la detección de codificación y la serialización.

Luego lo apuntas a una página construida por un framework de frontend y obtienes un archivo vacío.

Esta guía construye una araña funcional, la rompe deliberadamente en una página renderizada por JavaScript y la repara con un middleware de descargador: la pieza de Scrapy que te permite cambiar cómo se obtienen las páginas sin cambiar cómo se analizan.

Lo que Scrapy te da que un bucle de solicitudes no da

Scrapy es un motor de rastreo con una opinión sobre la estructura. Un bucle manual sobre una lista de URLs funciona hasta que necesitas las cosas que Scrapy ya tiene:

  • Un programador con un filtro de duplicados. Las solicitudes se ponen en cola, se deduplican por huella y se envían con concurrencia limitada.
  • Descargas asincrónicas sin sintaxis async. Scrapy se ejecuta en el reactor de Twisted, por lo que muchas solicitudes están en vuelo mientras tu método parse se lee como código síncrono ordinario.
  • Selectores integrados. response.css() y response.xpath() provienen de Parsel, la misma biblioteca de selectores cubierta en la guía de selectores CSS y XPath.
  • Exportaciones de alimentación. -O results.json escribe en JSON, JSON Lines, CSV o XML sin necesidad de código de serialización.
  • Configuraciones de cortesía. ROBOTSTXT_OBEY, DOWNLOAD_DELAY y AUTOTHROTTLE_ENABLED son configuraciones en lugar de algo que implementas. El primero de esos lee el archivo descrito en el estándar del Protocolo de Exclusión de Robots.

El costo es que Scrapy tiene una forma que debes aprender. La recompensa llega alrededor de la tercera página de un rastreo.

Instalar Scrapy

Instala en un nuevo entorno virtual y fija explícitamente la pila TLS:

bash Copy
python3 -m venv .venv
source .venv/bin/activate
pip install "scrapy==2.17.0" "twisted==26.4.0" "pyopenssl==25.3.0"

Esos tres fijados son deliberados. El soporte HTTPS de Scrapy se basa en Twisted, que a su vez llama a pyOpenSSL, y los dos modos de fallo documentados al final de esta publicación provienen de esa pila en lugar de Scrapy en sí.

Confirma las versiones antes de escribir cualquier código de araña:

bash Copy
python3 -c "import importlib.metadata as m; print(m.version('scrapy'), m.version('twisted'), m.version('pyopenssl'))"

Escribe tu primera araña

Una araña de Scrapy es una clase con un nombre, una lista de URLs de inicio y un método parse que recibe una respuesta y produce ítems. Guarda esto como quotes_spider.py:

python Copy
import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
                "tags": quote.css("div.tags a.tag::text").getall(),
            }

        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

response.css("div.quote") devuelve una lista de selectores, por lo que el bucle itera sobre elementos en lugar de cadenas. ::text es el pseudo-elemento de Scrapy para el nodo de texto, y .get() devuelve la primera coincidencia mientras que .getall() devuelve todas las coincidencias—por eso tags es una lista y author no. response.follow acepta directamente el href relativo, resolviéndolo en relación con la URL actual, por lo que no hay necesidad de llamar a urljoin.
El último bloque es la paginación. Enviar una solicitud desde parse la vuelve a poner en el planificador, y apuntar su callback a parse recorre toda la lista. Las formas generales que cubre este patrón están expuestas en la guía sobre paginación en la extracción de datos web.

Ejecútalo sin un Proyecto

scrapy startproject genera un paquete con configuraciones, pipelines y un directorio de arañas. No necesitas nada de eso todavía. runspider ejecuta un solo archivo, y -s sobrescribe cualquier configuración desde la línea de comandos:

bash Copy
scrapy runspider quotes_spider.py -O quotes.json \
  -s LOG_LEVEL=ERROR \
  -s CLOSESPIDER_PAGECOUNT=3

-O trunca el archivo de salida, mientras que -o lo añade — una distinción que vale la pena interiorizar pronto. CLOSESPIDER_PAGECOUNT=3 limita la extracción a tres páginas, lo que mantiene una ejecución didáctica educada y repetible.

Ese comando escribió 30 elementos: diez citas por página en tres páginas, con el primer registro leyendo Albert Einstein y etiquetas ['cambio', 'pensamientos profundos', 'pensar', 'mundo'].

Veinte líneas de araña, tres páginas rastreadas, paginación seguida, JSON en disco. Esta es la parte en la que Scrapy es realmente bueno.

Donde Scrapy se Detiene: Páginas Renderizadas por JavaScript

Apunta la araña idéntica al gemelo renderizado por el cliente del mismo sitio cambiando una línea:

python Copy
    start_urls = ["https://quotes.toscrape.com/js/"]

Luego ejecútalo nuevamente:

bash Copy
scrapy runspider quotes_spider.py -O js.json -s LOG_LEVEL=ERROR -s CLOSESPIDER_PAGECOUNT=1

El resultado es un arreglo vacío. Cero elementos, sin error, código de salida 0.

No hay nada de malo con el selector. La página devolvió HTTP 200 y Scrapy la analizó correctamente — el marcado que recibió simplemente no tiene elementos div.quote. Las citas se escriben en el DOM mediante un script después de la carga, y la ejecución del script es un comportamiento del navegador definido por el modelo de scripting del Estándar HTML. Scrapy es un cliente HTTP con un analizador HTML adjunto. Fetch bytes; no ejecuta un motor de JavaScript, y la propia guía de Scrapy sobre el contenido cargado dinámicamente así lo indica directamente.

Presta atención a ese cero silencioso. Un rastreo de una página de JavaScript se ve idéntico, tanto en la salida como en el código de salida, a un rastreo de una página sin nada en ella.

Las respuestas habituales adjuntan un navegador: scrapy-playwright controla Chromium por solicitud, y Splash ejecuta un servicio de renderizado junto con el rastreo. Ambos funcionan, y ambos significan que cada solicitud ahora lleva el costo de memoria y arranque de un navegador, más un segundo tiempo de ejecución para implementar.

Mover el renderizado completamente de la máquina deja el modelo de solicitud de Scrapy intacto.

Renderiza En Ascenso Con un Middleware de Descarga

Un middleware de descarga se sienta entre el motor de Scrapy y su descargador, y tiene exactamente el gancho que este problema necesita. El comportamiento está especificado: cuando process_request() devuelve un objeto Request, la referencia del middleware de descarga establece que "Scrapy dejará de llamar a los métodos process_request() y volverá a programar la solicitud devuelta". Su contraparte process_response() luego devuelve una Response de vuelta en la cadena.

Así que el middleware puede intercambiar cada solicitud saliente por un POST a un punto final de renderizado, y luego desenvuelve la respuesta en un HtmlResponse ordinario que lleva la URL original. La araña nunca se entera de que algo ha sucedido.

Guarda esto como scrapeless_middleware.py:

python Copy
import json
import os

from scrapy.http import HtmlResponse

UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"


class ScrapelessMiddleware:
    """Renderiza cada solicitud en ascenso y luego entrega a Scrapy una HtmlResponse ordinaria."""

    def __init__(self, token, country):
        self.token = token
        self.country = country

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            os.environ["SCRAPELESS_API_KEY"],
            crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
        )

    def process_request(self, request, spider):
        if request.meta.get("scrapeless"):
            return None
        payload = {
            "actor": "unlocker.webunlocker",
            "input": {
                "url": request.url,
                "proxy_country": self.country,
                "js_render": True,
            },
        }
        return request.replace(
            url=UNLOCKER,
            method="POST",
            body=json.dumps(payload),
            headers={"Content-Type": "application/json", "x-api-token": self.token},
            meta={**request.meta, "scrapeless": True, "origin_url": request.url},
            dont_filter=True,
        )
```python
def process_response(self, request, response, spider):
        if not request.meta.get("scrapeless"):
            return response
        rendered = json.loads(response.text)["data"]
        return HtmlResponse(
            url=request.meta["origin_url"],
            body=rendered,
            encoding="utf-8",
            request=request,
        )

La flag scrapeless en request.meta previene la recursión infinita. Sin ella, el POST reprogramado volvería a entrar en process_request y sería envuelto de nuevo. dont_filter=True es necesario porque cada solicitud renderizada ahora apunta a la misma URL de endpoint, y el filtro de duplicados de otro modo descartaría todo menos el primero.

origin_url es lo que hace el intercambio invisible. La HtmlResponse se construye con la dirección real de la página en lugar de la de la API, por lo que response.url es correcto y response.follow continúa resolviendo enlaces relativos contra la base correcta. La solicitud que llega a la red es un POST, según la especificación semántica de HTTP, mientras que la respuesta que ve la araña es un documento HTML ordinario.

Finalmente, la clave de API se lee de la variable de entorno SCRAPELESS_API_KEY dentro de from_crawler, por lo que no se escribe ninguna credencial en un archivo de configuración. La documentación completa de parámetros se encuentra en la referencia de la API de Scraping Universal, y el comportamiento de renderizado detrás de js_render se cubre en la guía de renderizado de páginas.

Conéctalo y Ejecuta la Misma Araña de Nuevo

Exporta la clave, luego habilita el middleware con una configuración. PYTHONPATH=. permite que runspider importe un módulo del directorio de trabajo:

bash Copy
export SCRAPELESS_API_KEY="tu_clave_api"

PYTHONPATH=. scrapy runspider quotes_spider.py -O js_unlocked.json \
  -s LOG_LEVEL=ERROR \
  -s CLOSESPIDER_PAGECOUNT=1 \
  -s 'DOWNLOADER_MIDDLEWARES={"scrapeless_middleware.ScrapelessMiddleware": 543}'

Esa ejecución produjo 10 elementos, con el primer registro leyendo nuevamente Albert Einstein y etiquetas ['cambio', 'pensamientos profundos', 'pensar', 'mundo'] — los mismos registros que la página renderizada por el servidor ofrece gratuitamente.

quotes_spider.py no cambió en ninguna línea entre esa ejecución y la fallida. Los selectores, la paginación, la forma del ítem y la exportación del feed sobrevivieron a un cambio completo en cómo se obtienen las páginas, lo que es el argumento para poner el renderizado en un middleware en lugar de en la araña.

Para comenzar no se necesita tarjeta — el plan gratuito cubre una ejecución de este tamaño.

Prueba los Tres Casos en Un Script

Es fácil ejecutar tres comandos separados de manera inconsistente. Este script ejecuta las tres rastreos en un solo proceso e imprime una comparación, por lo que la afirmación anterior puede ser revisada en una sola vez:

python Copy
import json
import os

import scrapy
from scrapy import signals
from scrapy.crawler import CrawlerProcess
from scrapy.http import HtmlResponse

UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"


class ScrapelessMiddleware:
    """Renderiza cada solicitud en el upstream, luego entrega a Scrapy una HtmlResponse ordinaria."""

    def __init__(self, token, country):
        self.token = token
        self.country = country

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            os.environ["SCRAPELESS_API_KEY"],
            crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
        )

    def process_request(self, request, spider):
        if request.meta.get("scrapeless"):
            return None
        payload = {
            "actor": "unlocker.webunlocker",
            "input": {"url": request.url, "proxy_country": self.country, "js_render": True},
        }
        return request.replace(
            url=UNLOCKER,
            method="POST",
            body=json.dumps(payload),
            headers={"Content-Type": "application/json", "x-api-token": self.token},
            meta={**request.meta, "scrapeless": True, "origin_url": request.url},
            dont_filter=True,
        )

    def process_response(self, request, response, spider):
        if not request.meta.get("scrapeless"):
            return response
        rendered = json.loads(response.text)["data"]
        return HtmlResponse(
            url=request.meta["origin_url"],
            body=rendered,
            encoding="utf-8",
            request=request,
        )


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def __init__(self, url, **kwargs):
        super().__init__(**kwargs)
python Copy
self.start_urls = [url]

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
                "tags": quote.css("div.tags a.tag::text").getall(),
            }


class SpiderCitasSinScrapy(SpiderCitas):
    name = "citas-sin-scrapy"
    custom_settings = {"DOWNLOADER_MIDDLEWARES": {MiddlewareSinScrapy: 543}}


def main():
    import importlib.metadata as md

    print(
        "scrapy", md.version("scrapy"),
        "| twisted", md.version("twisted"),
        "| pyopenssl", md.version("pyopenssl"),
    )

    trabajos = [
        ("página estática, Scrapy simple", SpiderCitas, "https://quotes.toscrape.com/"),
        ("página javascript, Scrapy simple", SpiderCitas, "https://quotes.toscrape.com/js/"),
        ("página javascript, middleware SinScrapy", SpiderCitasSinScrapy, "https://quotes.toscrape.com/js/"),
    ]
    recolectado = {etiqueta: [] for etiqueta, _, _ in trabajos}

    # Scrapy mantiene los manejadores de señal de manera débil, así que mantén una referencia fuerte a cada uno.
    manejadores = []

    def colector(etiqueta):
        def en_articulo(articulo, respuesta, araña):
            recolectado[etiqueta].append(articulo)
        manejadores.append(en_articulo)
        return en_articulo

    proceso = CrawlerProcess({
        "LOG_LEVEL": "ERROR",
        "SCRAPELESS_PROXY_COUNTRY": "US",
    })
    for etiqueta, clase_araña, url in trabajos:
        crawler = proceso.create_crawler(clase_araña)
        crawler.signals.connect(colector(etiqueta), signal=signals.item_scraped)
        proceso.crawl(crawler, url=url)
    proceso.start()

    for etiqueta, _, _ in trabajos:
        items = recolectado[etiqueta]
        print(f"{etiqueta}: {len(items)} elementos")
        if items:
            print(f"  primer autor: {items[0]['author']}")
            print(f"  primeras etiquetas: {items[0]['tags']}")

    print("método de análisis compartido por ambas arañas:", SpiderCitasSinScrapy.parse is SpiderCitas.parse)


if __name__ == "__main__":
    main()

Al ejecutarlo imprime:

text Copy
scrapy 2.17.0 | twisted 26.4.0 | pyopenssl 25.3.0
página estática, Scrapy simple: 10 elementos
  primer autor: Albert Einstein
  primeras etiquetas: ['cambio', 'pensamientos profundos', 'pensar', 'mundo']
página javascript, Scrapy simple: 0 elementos
página javascript, middleware SinScrapy: 10 elementos
  primer autor: Albert Einstein
  primeras etiquetas: ['cambio', 'pensamientos profundos', 'pensar', 'mundo']
método de análisis compartido por ambas arañas: True

SpiderCitasSinScrapy es una subclase de SpiderCitas y no agrega nada más que custom_settings, por lo que la última línea es True: ambas búsquedas despacharon el mismo objeto de función parse. El 0 en el medio es el problema de JavaScript y el 10 debajo de él es la solución, medida contra el parser idéntico.

custom_settings en una clase de araña limita las configuraciones a esa araña, lo que permite que un proceso ejecute búsquedas con y sin el middleware. Scrapy también mantiene los receptores de señales por referencia débil, así que un cierre de colector creado en línea se recolecta antes de que finalice la búsqueda y no registra nada en silencio: la lista manejadores existe para retenerlos.

Resolución de Problemas

Cada solicitud HTTPS falla con 'X509' object has no attribute 'get_extension'. Twisted es demasiado antiguo para el pyOpenSSL instalado. Twisted 24.3.0, que varias distribuciones de Linux aún empaquetan, llama a un método que las versiones actuales de pyOpenSSL ya no proporcionan. Instalar twisted==26.4.0 lo soluciona. Esto surge más a menudo cuando Scrapy está instalado en un Python del sistema en lugar de en un entorno virtual.

Cada solicitud HTTPS falla con verificación de certificado fallida. Scrapy 2.17.0 emparejado con Twisted 26.4.0 y pyOpenSSL 26.3.0 falla en la verificación de certificados en sitios públicos ordinarios. Fijar pyopenssl==25.3.0 lo resuelve, que es por eso que el paso de instalación menciona las tres versiones.

El middleware nunca se ejecuta. runspider no añade el directorio de trabajo a la ruta de importación, por lo que la clase nombrada en DOWNLOADER_MIDDLEWARES no puede importarse. Prefija el comando con PYTHONPATH=., o mueve la araña a un proyecto creado por scrapy startproject, donde se maneja la resolución de módulos por ti.

Cada solicitud después de la primera se descarta. El filtro duplicado está fingerprinting el punto final de renderización, que es idéntico para cada página. dont_filter=True en la solicitud de reemplazo es lo que previene esto.

La araña genera elementos pero response.follow construye URL incorrectas. La HtmlResponse fue construida con el API de punto final como su URL en lugar de la dirección de la página original. Los enlaces relativos se resuelven contra response.url, por lo que origin_url debe ser transportado a través de request.meta.

Conclusión

La división de trabajo de Scrapy es lo que lo hace valer la curva de aprendizaje. La araña posee lo que significa datos; el descargador posee cómo llegan los bytes. Mantener esos separados es la razón por la que una página que no devolvió nada pudo ser hecha para devolver diez registros sin editar un solo selector.

Construya primero la araña contra lo que el servidor envía. Cuando los selectores regresen vacíos, verifique si el marcado alguna vez los contuvo antes de recurrir a un navegador. Si no los contenía, renderizar en upstream a través de un middleware mantiene la rastreo asíncrono, mantiene el despliegue en un proceso de Python y deja el código de análisis que ya probó exactamente como estaba.

¿Listo para ejecutar esto contra sus propios objetivos? Cree una cuenta gratuita en Scrapeless, exporte su clave y coloque el middleware en una araña existente. Consulte la página del producto Universal Scraping API para la superficie de renderizado y la página de precios para los límites del plan.

Preguntas Frecuentes

P: ¿Puede Scrapy raspar sitios web renderizados con JavaScript por sí solo?

No. Scrapy obtiene HTML a través de HTTP y lo analiza, sin un motor de JavaScript en la línea de procesamiento. Una página que construye su contenido del lado del cliente llega como un contenedor vacío más una etiqueta de script, y los selectores no coinciden con nada. El renderizado tiene que ocurrir en otro lugar: en un navegador conectado al rastreo o en upstream en una API de renderizado cuya salida se retroalimenta a través de un middleware de descargador.

P: ¿Cuál es la diferencia entre un middleware de descargador y un middleware de araña?

Un middleware de descargador se sitúa entre el motor y el descargador, por lo que ve cada solicitud antes de enviarse y cada respuesta antes de analizarse, que es el lugar adecuado para proxies, encabezados y renderizado. Un middleware de araña se sitúa entre el motor y la araña, manejando los elementos y solicitudes que producen sus devoluciones de llamada. Cambiar cómo se obtiene una página pertenece a un middleware de descargador.

P: ¿Necesito scrapy startproject, o es suficiente un solo archivo?

Un solo archivo ejecutado con scrapy runspider es suficiente para una araña, y cada comando en esta guía lo utiliza. Cree un proyecto una vez que necesite configuraciones compartidas, canalizaciones de elementos, varias arañas o despliegue; la estructura del proyecto le brinda un módulo de configuraciones y rutas de importación que se resuelven sin PYTHONPATH.

P: ¿Por qué mi araña devuelve cero elementos sin un mensaje de error?

Porque una coincidencia de selector vacía no es un error en Scrapy. Las causas más comunes son contenido inyectado por JavaScript después de la carga, un selector escrito contra un marcado que el inspector de un navegador muestra pero la respuesta cruda no contiene, o una respuesta que devolvió una página intersticial con HTTP 200. Imprima len(response.text) y busque en el cuerpo una cadena que espera antes de asumir que el selector está mal.

P: ¿El middleware ralentiza el rastreo?

Cada solicitud se convierte en una obtención renderizada en lugar de una cruda, por lo que la latencia por solicitud aumenta. Sin embargo, el modelo de concurrencia de Scrapy no cambia: las solicitudes aún se ejecutan a través del mismo programador y el mismo límite de CONCURRENT_REQUESTS, sin un proceso de navegador por solicitud. Active el middleware solo para los dominios que lo necesiten y deje el resto en el descargador simple.

P: ¿Cómo mantengo mi clave API fuera de la base de código?

Lea desde el entorno dentro de from_crawler, como hace el middleware aquí con os.environ["SCRAPELESS_API_KEY"]. La clave nunca aparece en un archivo de configuraciones, así que nada sensible se comete y el mismo código se ejecuta en desarrollo y producción contra diferentes credenciales.

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