Scraping por lenguaje 8 min de lectura

Web scraping en Python con Scrapy

Introducción al framework Scrapy: spiders, pipelines, middlewares y ajustes que convierten un script suelto en un scraper de nivel industrial.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 9 enero 2025

Cuando hay que recorrer no una página, sino un sitio entero — miles y millones de URL —, un script casero se convierte enseguida en una maraña de colas, reintentos y rotación de proxies. Scrapy resuelve todo eso por usted: es un framework asíncrono completo para el crawling, con colas integradas, deduplicación, pipelines de procesamiento y un sistema de middlewares.

Este artículo continúa el material panorámico «Web scraping con Python». Si necesita una recolección puntual de un par de páginas, le bastará con requests + BeautifulSoup; Scrapy despliega todo su potencial a gran escala.

Índice

  1. Arquitectura de Scrapy
  2. Cómo se descarga la página: Spider y Request
  3. Parseo del contenido: selectores
  4. Tildes y codificaciones en Scrapy
  5. Concurrencia y velocidad
  6. Proxies
  7. Scraping a través de TOR
  8. HTTPS/SSL
  9. Trabajo con cookies
  10. Estado de la respuesta y cabeceras
  11. Colas y deduplicación de URL
  12. Item Pipeline: almacenamiento de los datos
  13. Pros y contras

1. Arquitectura de Scrapy

Scrapy está construido sobre un motor asíncrono (Twisted) y se compone de piezas interconectadas:

  • Spider — su clase: desde qué URL arrancar y cómo procesar las respuestas.
  • Scheduler — la cola de peticiones, con deduplicación y prioridades.
  • Downloader — descarga las páginas de forma asíncrona.
  • Middlewares — interceptores de peticiones/respuestas (proxies, cabeceras, reintentos).
  • Item Pipeline — procesamiento y guardado de los datos extraídos.

Usted escribe únicamente el Spider y el Pipeline; de todo lo demás se encarga el framework.

Creación del proyecto

bash
pip install scrapy
scrapy startproject myparser
cd myparser
scrapy genspider example example.com

2. Cómo se descarga la página: Spider y Request

En Scrapy no se escribe el bucle de peticiones a mano: usted devuelve (yield) objetos Request y el motor los ejecuta de forma asíncrona.

python
import scrapy

class CatalogSpider(scrapy.Spider):
    name = "catalog"
    start_urls = ["https://example.com/catalog"]

    custom_settings = {
        "USER_AGENT": "Mozilla/5.0 (compatible; MyBot/1.0)",
        "DOWNLOAD_DELAY": 1.0,          # pausa entre peticiones
        "ROBOTSTXT_OBEY": True,         # respetamos el robots.txt
    }

    def parse(self, response):
        # extraemos las fichas de producto
        for card in response.css(".product-card"):
            yield {
                "title": card.css(".title::text").get(),
                "price": card.css(".price::text").get(),
                "url": response.urljoin(card.css("a::attr(href)").get()),
            }

        # pasamos a la página siguiente
        next_page = response.css("a.next::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

response.follow completa automáticamente la URL relativa y pone la petición en la cola. Así la paginación queda descrita en un par de líneas. Ejecución:

bash
scrapy crawl catalog -o products.json

3. Parseo del contenido: selectores

Por debajo, Scrapy usa la biblioteca parsel (basada en lxml), que admite tanto CSS como XPath:

python
# CSS
response.css("h1::text").get()
response.css(".price::text").getall()
response.css("a::attr(href)").getall()

# XPath
response.xpath("//h1/text()").get()
response.xpath('//div[@class="price"]/text()').get()

# expresiones regulares directamente en el selector
response.css(".price::text").re_first(r"\d+")

get() devuelve el primer resultado (o None); getall(), una lista. Resulta más cómodo que el lxml «a pelo» gracias al manejo seguro de los elementos ausentes. Más detalles sobre XPath, en «Web scraping en Python con lxml».

Si en realidad la página llama a una API y devuelve JSON en lugar de HTML, en Scrapy se procesa directamente con response.json(), sin selectores de ningún tipo. Cómo limpiar y estructurar esas respuestas se muestra en detalle en «Parseo de JSON».

Items e ItemLoader

En los proyectos estructurados conviene describir los datos como Item y rellenarlos mediante ItemLoader con procesadores de limpieza (recorte de espacios, conversión de tipos). Para spiders sencillos bastan los diccionarios normales, como en el ejemplo de arriba.


4. Tildes y codificaciones en Scrapy

Lo más habitual es que Scrapy detecte por sí solo la codificación correcta a partir de las cabeceras y del <meta charset>: las tildes y las eñes «simplemente funcionan». Si aparecen caracteres corruptos, puede indicar la codificación de forma explícita al crear la respuesta o decodificar el cuerpo a mano:

python
def parse(self, response):
    # releer el cuerpo forzando la codificación correcta
    text = response.body.decode("windows-1252", errors="replace")
    sel = scrapy.Selector(text=text)

La teoría general del problema de las codificaciones está en el hub, sección «Codificaciones». Revise también FEED_EXPORT_ENCODING = "utf-8" en la configuración, para que los caracteres acentuados no acaben convertidos en secuencias \uXXXX dentro del JSON final.


5. Concurrencia y velocidad

La gran ventaja de Scrapy es la asincronía de serie. Decenas de peticiones se ejecutan a la vez, sin hilos. Se regula con la configuración:

python
# settings.py
CONCURRENT_REQUESTS = 16              # peticiones simultáneas en total
CONCURRENT_REQUESTS_PER_DOMAIN = 8    # por dominio
DOWNLOAD_DELAY = 0.5                  # pausa base
AUTOTHROTTLE_ENABLED = True           # ajuste automático de la velocidad
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0

AutoThrottle es una función especialmente inteligente: Scrapy reduce la velocidad por su cuenta si el servidor empieza a responder más despacio, equilibrando rapidez y cortesía. Eso le ahorra ajustar los retardos a mano. Como todo es asíncrono, no necesita multihilo — compárelo con el enfoque de «Scraping asíncrono en Python», que Scrapy implementa «bajo el capó».


6. Proxies

La forma más sencilla es indicar el proxy en el meta de la petición:

python
yield scrapy.Request(url, meta={"proxy": "http://user:pass@ip:port"})

Para rotar un pool es más cómodo un paquete ya hecho:

bash
pip install scrapy-rotating-proxies
python
# settings.py
ROTATING_PROXY_LIST = [
    "ip1:port",
    "ip2:port",
    "ip3:port",
]
DOWNLOADER_MIDDLEWARES = {
    "rotating_proxies.middlewares.RotatingProxyMiddleware": 610,
    "rotating_proxies.middlewares.BanDetectionMiddleware": 620,
}

El middleware rota los proxies por su cuenta, detecta los «baneados» y excluye los que están muertos. La teoría general, en el hub, sección «Proxies».


7. Scraping a través de TOR

TOR se conecta como proxy SOCKS5 a través de meta:

python
yield scrapy.Request(url, meta={"proxy": "socks5h://127.0.0.1:9050"})

El cambio de nodo de salida mediante stem (señal NEWNYM) está descrito en el hub, sección «TOR». En la práctica, con Scrapy se opta más a menudo por proxies de pago con rotación: son más rápidos y los banean menos que a los nodos de salida de TOR.


8. HTTPS/SSL

Por defecto, Scrapy verifica los certificados. Si necesita relajar la comprobación para un sitio problemático (solo con conocimiento de causa):

python
# settings.py
DOWNLOADER_CLIENT_TLS_METHOD = "TLS"
# para certificados autofirmados se puede configurar la fábrica de contexto

En la mayoría de los casos, SSL «simplemente funciona». Los principios generales de la seguridad de la conexión, en el hub, sección «HTTPS/SSL».


Las cookies vienen activadas por defecto en Scrapy (COOKIES_ENABLED = True): el motor mantiene la sesión entre peticiones de forma automática. Para pasar cookies a mano:

python
yield scrapy.Request(url, cookies={"sessionid": "abc123"})

Para iniciar sesión resulta cómodo FormRequest:

python
def parse(self, response):
    return scrapy.FormRequest.from_response(
        response,
        formdata={"username": "user", "password": "pass"},
        callback=self.after_login,
    )

from_response recoge por sí solo los campos ocultos del formulario (incluido el token CSRF), lo que elimina el típico quebradero de cabeza de la autenticación.


10. Estado de la respuesta y cabeceras

El acceso al estado y a las cabeceras se realiza a través del objeto response:

python
def parse(self, response):
    print(response.status)              # 200, 404 ...
    print(response.headers.get("Content-Type"))

Por defecto, Scrapy procesa solo los 2xx y deja pasar los 4xx/5xx. Los reintentos se gobiernan con el RetryMiddleware integrado:

python
# settings.py
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [429, 500, 502, 503, 504, 403]

El estado 429 (Too Many Requests) Scrapy sabe respetarlo junto con la cabecera Retry-After. La lógica de los códigos de estado en general, en el hub, sección «Estado y cabeceras».


11. Colas y deduplicación de URL

Aquí es donde Scrapy resulta especialmente fuerte: lo que en un scraper casero hay que construir a mano (vea el hub, sección «Colas») aquí viene integrado:

  • El Scheduler mantiene la cola de peticiones con prioridades.
  • El Dupefilter descarta automáticamente las URL ya vistas (por la huella de la petición).
  • La cola puede llevarse a disco (JOBDIR) para reanudar un recorrido interrumpido:
bash
scrapy crawl catalog -s JOBDIR=crawls/catalog-1

Para el crawling distribuido en varias máquinas existe scrapy-redis: una cola y un dupefilter compartidos en Redis, de modo que varios workers pueden recorrer un mismo sitio de forma conjunta.


12. Item Pipeline: almacenamiento de los datos

Los elementos extraídos pasan por el pipeline, donde se validan, se limpian y se guardan:

python
# pipelines.py
import pymongo

class MongoPipeline:
    def open_spider(self, spider):
        self.client = pymongo.MongoClient("mongodb://localhost:27017")
        self.db = self.client["scraping"]

    def process_item(self, item, spider):
        self.db["products"].update_one(
            {"url": item["url"]}, {"$set": dict(item)}, upsert=True
        )
        return item

    def close_spider(self, spider):
        self.client.close()
python
# settings.py
ITEM_PIPELINES = {"myparser.pipelines.MongoPipeline": 300}

Para una exportación simple no hace falta pipeline: la opción -o products.csv (o .json, .jsonl) guarda el resultado directamente.


13. Pros y contras de Scrapy

Pros:

  • Asincronía, colas, deduplicación y reintentos, todo de serie.
  • Alto rendimiento con grandes volúmenes de páginas.
  • Arquitectura limpia: Spider, Middleware y Pipeline bien separados.
  • Extensiones listas para usar: rotación de proxies, scrapy-redis, auto-throttling.
  • Trabajos reanudables y exportación cómoda a cualquier formato.

Contras:

  • Curva de entrada alta: hay que entender la arquitectura y Twisted.
  • Excesivo para un par de páginas (ahí es más simple requests + BeautifulSoup).
  • Los sitios con JavaScript exigen integración adicional (scrapy-playwright o Splash).
  • El modelo asíncrono de Twisted resulta poco familiar frente al asyncio moderno.

Para los sitios dinámicos se añade scrapy-playwright, que renderiza las páginas con un navegador real. Y si el contenido es simple y estático y ya cuenta con infraestructura Django, a veces es más sencillo scrapear desde Django.