Web scraping con Scrapy: guía completa para Python
Scrapy es el framework de web scraping más potente para Python. A diferencia de scripts con Requests + BeautifulSoup, Scrapy ofrece: arquitectura asíncrona (Twisted), middlewares, pipelines de datos, manejo automático de robots.txt, throttling, y exportación a múltiples formatos. Ideal para proyectos serios de extracción de datos.
Instalación y proyecto inicial
pip install scrapy
# Crear nuevo proyecto
scrapy startproject mi_scraper
cd mi_scraper
# Estructura generada:
# mi_scraper/
# scrapy.cfg
# mi_scraper/
# __init__.py
# items.py ← definición de datos
# middlewares.py ← lógica intermedia
# pipelines.py ← procesamiento de items
# settings.py ← configuración global
# spiders/ ← aquí van los spiders
Tu primer spider
# mi_scraper/spiders/libros_spider.py
import scrapy
class LibrosSpider(scrapy.Spider):
name = "libros"
start_urls = ["https://books.toscrape.com"]
def parse(self, response):
# Extraer datos de cada libro en la página actual
for libro in response.css("article.product_pod"):
yield {
"titulo": libro.css("h3 a::attr(title)").get(),
"precio": libro.css("p.price_color::text").get(),
"disponible": libro.css("p.availability::text").get().strip(),
"valoracion": libro.css("p.star-rating::attr(class)").get().split()[-1],
"url_detalle": response.urljoin(libro.css("h3 a::attr(href)").get()),
}
# Seguir a la siguiente página (paginación)
siguiente = response.css("li.next a::attr(href)").get()
if siguiente:
yield response.follow(siguiente, self.parse)
Ejecutar el spider:
# Mostrar en pantalla
scrapy crawl libros
# Exportar a JSON
scrapy crawl libros -o libros.json
# Exportar a CSV
scrapy crawl libros -o libros.csv
# Exportar a JSONLines (un JSON por línea — mejor para archivos grandes)
scrapy crawl libros -o libros.jsonl
Selectores CSS y XPath
Scrapy ofrece dos motores de selección:
def parse(self, response):
# ── Selectores CSS ────────────────────────────────────
# Texto de un elemento
titulo = response.css("h1.product-title::text").get()
# Atributo
url_imagen = response.css("img.cover::attr(src)").get()
# Todos los elementos (lista)
todos_precios = response.css("span.price::text").getall()
# Texto limpio (sin espacios)
desc = response.css("div.description p::text").get("").strip()
# ── Selectores XPath ──────────────────────────────────
# Más potente para estructuras complejas
autor = response.xpath("//span[@itemprop='author']/text()").get()
# Texto que contiene una cadena
aviso = response.xpath("//*[contains(text(), 'Oferta')]//text()").getall()
# Navegar al padre
precio_padre = response.xpath("//span[@class='precio']/parent::div/text()").get()
# Combinar CSS y XPath en el mismo spider
for producto in response.css("div.product"):
yield {
"nombre": producto.css("h2::text").get(),
"precio": producto.xpath(".//span[@class='price']/text()").get(),
"en_stock": "sí" if producto.css(".in-stock") else "no",
}
Items: estructurar los datos extraídos
# mi_scraper/items.py
import scrapy
class LibroItem(scrapy.Item):
titulo = scrapy.Field()
autor = scrapy.Field()
precio = scrapy.Field(serializer=float)
isbn = scrapy.Field()
genero = scrapy.Field()
valoracion = scrapy.Field(serializer=int)
url = scrapy.Field()
fecha_scrap = scrapy.Field()
# En el spider — usar Items en lugar de diccionarios
from mi_scraper.items import LibroItem
from datetime import date
class LibrosSpider(scrapy.Spider):
name = "libros_items"
start_urls = ["https://books.toscrape.com"]
def parse(self, response):
for libro in response.css("article.product_pod"):
item = LibroItem()
item['titulo'] = libro.css("h3 a::attr(title)").get()
item['precio'] = libro.css("p.price_color::text").get("").replace("£", "")
item['valoracion'] = {"One":1,"Two":2,"Three":3,"Four":4,"Five":5}.get(
libro.css("p.star-rating::attr(class)").get("").split()[-1], 0
)
item['url'] = response.urljoin(libro.css("h3 a::attr(href)").get())
item['fecha_scrap'] = str(date.today())
yield item
Spider con múltiples páginas y detalle
import scrapy
from mi_scraper.items import LibroItem
class LibrosDetalleSpider(scrapy.Spider):
name = "libros_detalle"
start_urls = ["https://books.toscrape.com"]
def parse(self, response):
"""Página de listado — sigue a cada libro."""
for libro in response.css("article.product_pod"):
url = response.urljoin(libro.css("h3 a::attr(href)").get())
yield response.follow(url, self.parse_libro)
# Paginación
siguiente = response.css("li.next a::attr(href)").get()
if siguiente:
yield response.follow(siguiente, self.parse)
def parse_libro(self, response):
"""Página de detalle — extrae toda la información."""
item = LibroItem()
item['titulo'] = response.css("h1::text").get()
item['precio'] = response.css("p.price_color::text").get("").replace("£","")
item['url'] = response.url
# Tabla de información del libro
tabla = {}
for fila in response.css("table.table-striped tr"):
clave = fila.css("th::text").get("")
valor = fila.css("td::text").get("")
tabla[clave] = valor
item['isbn'] = tabla.get("UPC", "")
item['genero'] = response.css("ul.breadcrumb li:nth-child(3) a::text").get()
yield item
Pipelines de datos
Los pipelines procesan y limpian los items extraídos:
# mi_scraper/pipelines.py
import re
import sqlite3
from datetime import date
from itemadapter import ItemAdapter
class LimpiezaPipeline:
"""Limpia y normaliza los datos."""
def process_item(self, item, spider):
adapter = ItemAdapter(item)
# Limpiar precio
if adapter.get('precio'):
precio_str = adapter['precio']
precio_limpio = re.sub(r'[^\d.,]', '', precio_str).replace(',', '.')
adapter['precio'] = float(precio_limpio) if precio_limpio else 0.0
# Normalizar texto
if adapter.get('titulo'):
adapter['titulo'] = adapter['titulo'].strip()
return item
class FiltroPipeline:
"""Descarta items inválidos."""
def process_item(self, item, spider):
adapter = ItemAdapter(item)
if not adapter.get('titulo'):
raise DropItem(f"Sin título: {item}")
if adapter.get('precio', 0) <= 0:
raise DropItem(f"Precio inválido: {item}")
return item
class SQLitePipeline:
"""Guarda los items en SQLite."""
def open_spider(self, spider):
self.conn = sqlite3.connect('libros.db')
self.cursor = self.conn.cursor()
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS libros (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT, autor TEXT, precio REAL,
isbn TEXT UNIQUE, genero TEXT,
valoracion INTEGER, url TEXT,
fecha_scrap TEXT
)
""")
self.conn.commit()
def close_spider(self, spider):
self.conn.close()
def process_item(self, item, spider):
adapter = ItemAdapter(item)
try:
self.cursor.execute("""
INSERT OR REPLACE INTO libros
(titulo, autor, precio, isbn, genero, valoracion, url, fecha_scrap)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
adapter.get('titulo'), adapter.get('autor'),
adapter.get('precio'), adapter.get('isbn'),
adapter.get('genero'), adapter.get('valoracion'),
adapter.get('url'), adapter.get('fecha_scrap'),
))
self.conn.commit()
except Exception as e:
spider.logger.error(f"Error guardando en SQLite: {e}")
return item
Activar los pipelines en settings.py:
ITEM_PIPELINES = {
'mi_scraper.pipelines.LimpiezaPipeline': 100,
'mi_scraper.pipelines.FiltroPipeline': 200,
'mi_scraper.pipelines.SQLitePipeline': 300,
}
Configuración recomendada (settings.py)
# mi_scraper/settings.py
BOT_NAME = 'mi_scraper'
ROBOTSTXT_OBEY = True # respetar robots.txt
# Throttling (ser amable con el servidor)
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 10.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
# Cabeceras por defecto
DEFAULT_REQUEST_HEADERS = {
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
# Caché de respuestas (evita re-descargar páginas en desarrollo)
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 3600 # 1 hora
HTTPCACHE_DIR = '.scrapy_cache'
# Concurrencia
CONCURRENT_REQUESTS = 8
CONCURRENT_REQUESTS_PER_DOMAIN = 4
DOWNLOAD_DELAY = 0.5
# Pipelines
ITEM_PIPELINES = {
'mi_scraper.pipelines.LimpiezaPipeline': 100,
'mi_scraper.pipelines.SQLitePipeline': 300,
}
# Logging
LOG_LEVEL = 'INFO'
LOG_FILE = 'scrapy.log'
Spider programático (sin proyecto)
Para scripts rápidos sin toda la estructura de proyecto:
import scrapy
from scrapy.crawler import CrawlerProcess
class QuickSpider(scrapy.Spider):
name = "quick"
start_urls = ["https://example.com"]
custom_settings = {
'FEEDS': {'resultado.json': {'format': 'json'}},
'ROBOTSTXT_OBEY': True,
'LOG_LEVEL': 'WARNING',
}
def parse(self, response):
yield {
'titulo': response.css("title::text").get(),
'url': response.url,
'links': response.css("a::attr(href)").getall()[:10],
}
# Ejecutar sin CLI
process = CrawlerProcess()
process.crawl(QuickSpider)
process.start()
Recurso adicional
Para convertir los datos extraídos entre CSV, JSON, Excel y otros formatos sin código adicional, usa KaijuConverter — gratis y sin registro.
Conversiones relacionadas
Conversiones frecuentes del catálogo: