Scrapy es a lo que te gradúas cuando los scripts con requests dejan de escalar. Es un framework de crawling completo para Python: escribes una pequeña clase spider que indica qué extraer y qué enlaces seguir, y Scrapy se encarga de la programación, la concurrencia, los reintentos, la deduplicación y la exportación. Este tutorial te lleva desde scrapy startproject hasta un crawl de dos niveles con configuración lista para producción, usando la sintaxis actual de Scrapy 2.x en todo momento.
¿Por qué Scrapy en lugar de requests + BeautifulSoup?
Si ya escribiste algunos scrapers con requests y BeautifulSoup — el stack que cubrimos en nuestra guía de web scraping con Python y el tutorial de BeautifulSoup — ya conoces el patrón: obtener una página, parsearla, repetir. Eso funciona muy bien para una página o una lista. Se derrumba cuando el trabajo se convierte en crawling: visitar muchas páginas, descubrir enlaces sobre la marcha y no volver a pedir la misma URL dos veces. Ese ciclo de descubrimiento y programación es lo que en realidad es el web crawling, y es precisamente la parte que Scrapy domina:
- Un scheduler y una cola de peticiones. Tú produces (yield) peticiones; Scrapy decide cuándo enviarlas, cuántas ejecutar en paralelo y reintenta los fallos.
- Una frontera de URLs con deduplicación. Sigue el mismo enlace desde cinco páginas y Scrapy lo obtiene una sola vez.
- Pipelines de items. Cada registro extraído pasa por una cadena de pequeñas clases que lo limpian, validan o almacenan — separado de la lógica de parsing.
- Exportación de feeds. Salida en JSON, JSON Lines y CSV desde un flag de línea de comandos, sin código de serialización.
Para ser claros con la comparación: BeautifulSoup es una biblioteca de parsing y muy buena; Scrapy tiene su propio motor de selectores y es una dependencia más pesada con una curva de aprendizaje más pronunciada. Para una sola página, Scrapy es excesivo. Para quinientas páginas detrás de paginación, reemplaza cien líneas de plomería de colas y reintentos que de otro modo escribirías mal una vez y depurarías para siempre.
Configuración del proyecto: qué crea realmente startproject
Instala Scrapy (se recomienda encarecidamente un entorno virtual) y genera un proyecto:
pip install scrapy
scrapy startproject quotes_crawler
cd quotes_crawler
El árbol generado se ve así, y cada archivo tiene una función:
quotes_crawler/
├── scrapy.cfg # punto de entrada de despliegue/config — le dice a Scrapy dónde viven los settings
└── quotes_crawler/
├── items.py # contenedores tipados opcionales para los datos extraídos
├── middlewares.py # hooks en el ciclo de petición/respuesta (headers, proxies)
├── pipelines.py # postprocesamiento de items extraídos (limpiar, validar, almacenar)
├── settings.py # ajustes a nivel de proyecto: concurrencia, delays, throttling
└── spiders/ # aquí viven tus clases spider — una clase por objetivo de crawl
Por ahora puedes ignorar items.py y middlewares.py — los diccionarios simples y el middleware por defecto son suficientes para un primer proyecto. Los dos archivos que realmente tocarás hoy son un nuevo spider en spiders/ y, más adelante, settings.py y pipelines.py.
Genera un spider con genspider:
scrapy genspider quotes quotes.toscrape.com
Tu primer spider
Vamos a extraer datos de quotes.toscrape.com, el sitio de pruebas que usa el tutorial oficial de Scrapy. Reemplaza el archivo generado spiders/quotes.py con:
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = ["https://quotes.toscrape.com/page/1/"]
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(),
}
Tres ideas sostienen todo el framework:
start_urlses donde comienza el crawl. Scrapy obtiene cada URL y pasa la respuesta aparse(). (Desde Scrapy 2.13 puedes definir en su lugarasync def start(self)para peticiones de inicio dinámicas;start_urlssigue siendo el caso simple idiomático, pero ten en cuenta que el método antiguostart_requests()está deprecado).response.css()selecciona elementos con selectores CSS —::textextrae nodos de texto,::attr(href)extrae un atributo,.get()devuelve la primera coincidencia o None,.getall()devuelve todas las coincidencias como lista.response.xpath()está ahí para cuando CSS no pueda expresar lo que necesitas; por debajo, los selectores CSS de todas formas se traducen a XPath. Es el mismo problema de parsing de HTML que resuelve BeautifulSoup, solo que con la API de selectores de Scrapy.yieldde un diccionario y Scrapy lo trata como un item extraído — sin valores de retorno, sin acumular listas.
Ejecútalo y exporta directamente a JSON:
scrapy crawl quotes -O quotes.json
El flag -O sobrescribe el archivo de salida en cada ejecución; la -o minúscula agrega en su lugar, lo cual es útil sobre todo con el formato JSON Lines (-o quotes.jsonl). Abre quotes.json y encontrarás diez registros estructurados — el contenido de una sola página, porque todavía no se sigue ningún enlace.
Seguir enlaces: paginación y crawls de dos niveles
El crawling comienza cuando parse() produce peticiones junto con items. El sitio de citas tiene un botón Next, y response.follow() lo maneja en dos líneas — acepta URLs relativas directamente, así que no hace falta urljoin manual:
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 is not None:
yield response.follow(next_page, callback=self.parse)
Ejecuta scrapy crawl quotes -O quotes.json de nuevo y obtendrás las diez páginas — aproximadamente cien citas. El scheduler encoló cada enlace Next, lo obtuvo y enrutó la respuesta de vuelta a través de parse(). Las URLs duplicadas se habrían filtrado automáticamente.
El patrón que desbloquea proyectos reales es el crawl de dos niveles: una página de listado produce enlaces a páginas de detalle, y un segundo callback parsea cada página de detalle. Aquí tienes un spider que recorre los listados de citas pero extrae datos de la página de biografía de cada autor:
import scrapy
class AuthorSpider(scrapy.Spider):
name = "authors"
start_urls = ["https://quotes.toscrape.com/"]
def parse(self, response):
# Nivel 1: páginas de listado — sigue cada enlace de autor
author_links = response.css(".author + a")
yield from response.follow_all(author_links, callback=self.parse_author)
# ...y sigue paginando el listado en sí
yield from response.follow_all(css="li.next a", callback=self.parse)
def parse_author(self, response):
# Nivel 2: páginas de detalle
yield {
"name": response.css("h3.author-title::text").get().strip(),
"born": response.css(".author-born-date::text").get(),
"bio": response.css(".author-description::text").get().strip()[:200],
}
response.follow_all() produce una petición por cada enlace que coincide, y cada callback solo se preocupa por su propio tipo de página. Aunque muchas citas apuntan al mismo autor, cada biografía se obtiene una sola vez — deduplicación otra vez. La misma estructura escala a un catálogo de e-commerce (páginas de categoría a páginas de producto, como en books.toscrape.com) o a un portal de empleo (resultados de búsqueda a publicaciones).
Para crawls donde «seguir cada enlace que coincida con este patrón» es toda la estrategia, Scrapy incluye CrawlSpider, una subclase donde declaras objetos Rule con un LinkExtractor en lugar de escribir la lógica de seguimiento a mano. Empieza con un Spider simple — entiendes exactamente qué obtiene — y recurre a CrawlSpider cuando tus reglas de seguimiento se vuelvan repetitivas.
Configuración para producción: sé rápido, pero no grosero
Los valores por defecto están ajustados para el sandbox, no para el servidor de otra persona. Scrapy ejecutará felizmente 16 peticiones concurrentes sin ningún delay, lo cual es una buena forma de disparar el rate limiting o dañar un sitio pequeño. Antes de cualquier crawl real, abre settings.py:
# settings.py — una base educada para producción
# Respeta robots.txt (activado por defecto en proyectos nuevos — déjalo activado)
ROBOTSTXT_OBEY = True
# Identifícate honestamente
USER_AGENT = "quotes_crawler (+https://yourdomain.example/contact)"
# Limita la concurrencia por sitio y agrega un delay base
CONCURRENT_REQUESTS_PER_DOMAIN = 4
DOWNLOAD_DELAY = 1.0 # segundos; Scrapy lo aleatoriza un poco por defecto
# Deja que Scrapy adapte la velocidad a la latencia real del servidor
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 5.0
AUTOTHROTTLE_MAX_DELAY = 60.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
AutoThrottle es el ajuste que la mayoría de los tutoriales se saltan y el que más vale la pena conocer. Mide la latencia de respuesta y ajusta el delay dinámicamente — cuando el servidor se ralentiza o falla, Scrapy reduce la velocidad hacia AUTOTHROTTLE_MAX_DELAY; cuando está saludable, Scrapy acelera hacia tu concurrencia objetivo. DOWNLOAD_DELAY actúa como el piso del cual nunca bajará, y CONCURRENT_REQUESTS_PER_DOMAIN sigue siendo un tope duro. Activa AUTOTHROTTLE_DEBUG = True temporalmente para ver las decisiones del throttle en el log.
ROBOTSTXT_OBEY merece una frase: hace que Scrapy obtenga y respete las reglas de robots.txt de cada sitio antes de crawlear. Viene activado por defecto en los proyectos generados. Déjalo activado.
Un pipeline de items mínimo
Los pipelines mantienen la limpieza fuera de tu código de parsing. Cada item extraído pasa por process_item() en cada clase pipeline habilitada:
# pipelines.py
from scrapy.exceptions import DropItem
class CleanQuotePipeline:
def process_item(self, item, spider):
if not item.get("text"):
raise DropItem("missing quote text")
item["text"] = item["text"].strip("“” ") # elimina comillas tipográficas
item["author"] = item["author"].strip()
return item
Habilítalo en settings.py — el número es una prioridad de orden, el más bajo se ejecuta primero:
ITEM_PIPELINES = {
"quotes_crawler.pipelines.CleanQuotePipeline": 300,
}
La misma estructura maneja validación, deduplicación por campo o escritura a una base de datos (los pipelines también tienen hooks open_spider y close_spider para conexiones). El parsing se mantiene enfocado en selectores; los pipelines son responsables de la calidad de los datos.
- El spider produce (yield) diccionarios desde parse() y sigue enlaces con response.follow / follow_all
- Los selectores se probaron en scrapy shell antes de incluirlos en el spider
- ROBOTSTXT_OBEY está activado y el USER_AGENT identifica a tu crawler
- AUTOTHROTTLE_ENABLED es true, con DOWNLOAD_DELAY como piso
- La limpieza y validación viven en un pipeline, no en parse()
- La salida se verificó con scrapy crawl name -O output.json
Dónde Scrapy se detiene: JavaScript y sistemas anti-bot
Scrapy es excelente en lo que hace — es difícil de superar para crawlear HTML estático a gran escala. Pero hay dos muros que vale la pena conocer antes de toparte con ellos.
Páginas renderizadas con JavaScript. El downloader de Scrapy obtiene HTML crudo; no ejecuta un motor de navegador. Si los datos aparecen solo después del renderizado del lado del cliente, tus selectores no encontrarán nada. La solución estándar es el plugin scrapy-playwright, que enruta las peticiones a través de un navegador headless mientras conserva la programación de Scrapy — o un stack de navegador independiente, como se cubre en nuestra guía de scraping con Playwright. De cualquier forma, el renderizado cuesta CPU y memoria reales por página.
Sistemas anti-bot a gran escala. Las plataformas grandes identifican huellas de handshakes TLS, puntúan la reputación de IPs y emiten desafíos de JavaScript. Un spider de Scrapy bien regulado desde una sola IP igualmente será bloqueado ahí, y apilar middleware de proxies y trucos de headers se convierte en un segundo proyecto de ingeniería — cubrimos esa carrera armamentista en scraping de sitios que bloquean bots.
Para esos objetivos, la decisión pragmática es mantener Scrapy para los sitios que puede manejar y llamar a una API de scraping para los que no puede. Crawlora expone plataformas blindadas como endpoints documentados que devuelven JSON estructurado — aquí tienes una búsqueda en Amazon, sin proxies ni parsers de tu lado:
import requests
resp = requests.get(
"https://api.crawlora.net/api/v1/amazon/search",
params={"k": "mechanical keyboard"},
headers={"x-api-key": "YOUR_API_KEY"},
)
results = resp.json()["data"] # listados estructurados, no HTML
Incluso puedes llamarla desde dentro de un pipeline o spider para enriquecer los registros extraídos. La facturación es por éxito — solo se te cobra por respuestas 2xx exitosas — y el plan gratuito es de 2,000 créditos al mes, sin tarjeta. Explora el catálogo de endpoints en los docs o revisa los precios para calcular el volumen.
En resumen
Ahora tienes el ciclo completo de Scrapy: crear el andamiaje con startproject y genspider, extraer con response.css, producir (yield) diccionarios, seguir enlaces con response.follow, exportar con -O, regular la velocidad con AutoThrottle y limpiar con pipelines. Eso cubre una gran parte del trabajo real de crawling. Cuando un objetivo se renderice en JavaScript o se defienda con sistemas anti-bot, agrega scrapy-playwright o delega ese sitio a una API — y deja que tu spider siga haciendo aquello en lo que realmente es excelente.
¿Te topas con sitios que tu spider no puede crawlear?
Mantén Scrapy para los objetivos fáciles y llama a endpoints documentados para los difíciles — JSON estructurado, proxies y reintentos gestionados. 2,000 créditos gratis al mes, sin tarjeta.
Lectura relacionada: la guía de web scraping con Python para el panorama completo, el tutorial de BeautifulSoup si prefieres un stack más ligero, y web scraping con Playwright para objetivos con mucho JavaScript.
Preguntas frecuentes
What is Scrapy used for?
Scrapy is a Python framework for crawling websites and extracting structured data. Unlike a parsing library, it manages the whole crawl: scheduling requests, following links, deduplicating URLs, retrying failures, throttling speed, and exporting results to JSON or CSV. It is the standard tool once a project grows beyond fetching a handful of pages.
Is Scrapy better than BeautifulSoup?
They solve different problems. BeautifulSoup parses one HTML document; Scrapy is a full crawling framework with its own selectors plus scheduling, concurrency, deduplication, pipelines, and feed exports. For a single page, requests plus BeautifulSoup is simpler. For crawling many pages behind pagination or link-following, Scrapy replaces the queue-and-retry plumbing you would otherwise write yourself.
Can Scrapy handle JavaScript?
Not by itself. Scrapy fetches raw HTML and does not run a browser engine, so content rendered client-side is invisible to its selectors. The common fix is the scrapy-playwright plugin, which renders pages in headless Chromium while keeping Scrapy's scheduling, or handing JavaScript-heavy sites to a scraping API that manages rendering for you.
How do I run a Scrapy spider and save the output?
From the project directory run scrapy crawl spidername -O output.json. The -O flag overwrites the file each run; lowercase -o appends, which pairs best with JSON Lines (output.jsonl). Scrapy's feed exports also support CSV and XML with no extra code.
How does Scrapy follow links and paginate?
Your parse() callback yields requests alongside items. response.follow() accepts a relative URL straight from a selector, and response.follow_all() yields a request per matched link — for example following every next-page link or every detail-page link to a second callback. Scrapy filters duplicate URLs automatically.
How do I stop Scrapy from overloading a website?
Enable AutoThrottle (AUTOTHROTTLE_ENABLED = True) so Scrapy adapts its delay to the server's latency, set DOWNLOAD_DELAY as a floor, cap CONCURRENT_REQUESTS_PER_DOMAIN, and keep ROBOTSTXT_OBEY on. These settings live in the project's settings.py.
What are Scrapy item pipelines?
Pipelines are small classes that every scraped item passes through after parsing. Each defines process_item() to clean, validate, deduplicate, or store the item — raising DropItem to discard bad records. They are enabled in ITEM_PIPELINES in settings.py with a priority number, keeping data-quality logic out of your spider's parse code.
