Screaming Frog es el crawler preferido para la mayoría de los SEOs, pero probablemente ya has encontrado sus límites: el tope de 500 URLs en la versión gratuita, la RAM al máximo en sitios grandes, o querer automatizar rastreos sin vigilar una GUI. Scrapy es el framework de Python de código abierto que elimina esos límites.
Si puedes ejecutar npm install o git clone, puedes ejecutar Scrapy. La curva de aprendizaje es real pero manejable, especialmente si ya te estás familiarizando con herramientas CLI a través de flujos de trabajo de codificación agéntica.
¿Por qué Scrapy?
Screaming Frog funciona bien para auditorías rápidas. Pero tiene límites:
| Limitación | Impacto |
|---|---|
| Límite gratuito de 500 URLs | Requiere licencia de $259/año para sitios más grandes |
| Consume mucha memoria | Rastreos grandes pueden consumir 8GB+ de RAM |
| Dependiente de GUI | Difícil de automatizar o programar |
| Personalización limitada | Las opciones de configuración son fijas |
Scrapy resuelve estos problemas:
| Scrapy | Lo que obtienes |
|---|---|
| Gratuito y de código abierto | Sin límites de URLs, sin tarifas de licencia |
| Menor huella de memoria | Colas respaldadas en disco mantienen la RAM controlada |
| Nativo de CLI | Scripteable, programable con cron, listo para CI/CD |
| Personalización completa con Python | Extrae lo que necesitas, filtra como quieras |
| Pausar/Reanudar | Detén y continúa rastreos grandes en cualquier momento |
Instalación
Scrapy funciona con Python. Usa un entorno virtual para mantener todo limpio:
Debian/Ubuntu:
sudo apt install python3.11-venv
python3 -m venv venv
source venv/bin/activate
pip install scrapy
macOS:
python3 -m venv venv
source venv/bin/activate
pip install scrapy
Windows:
python -m venv venv
venv\Scripts\activate
pip install scrapy
Crear un Proyecto
Con Scrapy instalado:
scrapy startproject myproject
cd myproject
scrapy genspider sitename example.com
Esto crea:
myproject/
scrapy.cfg
myproject/
__init__.py
items.py
middlewares.py
pipelines.py
settings.py
spiders/
__init__.py
sitename.py
El código del spider va en spiders/sitename.py. La configuración está en settings.py.
Configuración para Rastreo Educado
Configura settings.py antes de ejecutar cualquier cosa. Ser bloqueado desperdicia más tiempo que rastrear lentamente.
# Rastreo educado
CONCURRENT_REQUESTS_PER_DOMAIN = 5
DOWNLOAD_DELAY = 1
ROBOTSTXT_OBEY = True
# AutoThrottle - ajusta velocidad según respuesta del servidor
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 5
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
AUTOTHROTTLE_DEBUG = True
# Límites de seguridad
CLOSESPIDER_PAGECOUNT = 10000
# Salida
FEED_EXPORT_ENCODING = "utf-8"
AutoThrottle
AutoThrottle monitorea los tiempos de respuesta del servidor y ajusta la velocidad de rastreo automáticamente:
- Respuestas rápidas → acelera
- Respuestas lentas → reduce velocidad
- Errores/timeouts → reduce significativamente
A diferencia de los retrasos fijos de Screaming Frog, se adapta a las condiciones reales del servidor.
Manejo de Códigos de Estado
Por defecto, el HttpErrorMiddleware de Scrapy descarta silenciosamente las respuestas no-2xx. Esto significa que 404s, 301s, 500s son descartados antes de llegar a tu callback. Tu rastreo podría mostrar 100% códigos de estado 200, no porque el sitio sea perfecto, sino porque los errores están siendo filtrados.
Añade esto a tu clase spider para capturar todos los códigos de estado:
handle_httpstatus_list = [200, 301, 302, 403, 404, 500, 502, 503]
Screaming Frog captura todos los códigos de estado por defecto. Esta configuración alinea Scrapy con ese comportamiento.
Rendimiento en el Mundo Real
Números reales de un rastreo de prueba con 5 solicitudes concurrentes y AutoThrottle habilitado:
| Progreso del Rastreo | Páginas/Minuto | Notas |
|---|---|---|
| 0-200 páginas | 14-22 | Arranque |
| 200-500 páginas | 10-12 | Estabilizando |
| 500-1,000 páginas | 7-10 | AutoThrottle ajustando |
| 1,000+ páginas | 5-7 | Estado estable |
Comparación de Características
| Característica | Screaming Frog | Scrapy |
|---|---|---|
| Costo | Gratis <500 URLs, ~$259/año | Gratis, código abierto |
| Tamaño máx. de rastreo | Limitado por memoria | Colas respaldadas en disco |
| Personalización | Opciones de config limitadas | Código Python completo |
| Programación | Manual o terceros | CLI nativo, programable con cron |
| Pausar/Reanudar | Sí | Sí (con JOBDIR) |
| Curva de aprendizaje | Baja (GUI) | Media (código) |
| Limitación de velocidad | Retrasos fijos básicos | AutoThrottle (adaptativo) |
| Renderizado JavaScript | Opcional (Chrome) | Opcional (playwright/splash) |
| Códigos de estado | Todos por defecto | Requiere configuración |
| Filtrado de subdominios | Casillas GUI | Código (regex flexible) |
| Formatos de exportación | CSV, Excel, etc. | JSON, CSV, XML, personalizado |
| Integración CI/CD | Difícil | Nativo |
Filtrado de URLs
Screaming Frog usa casillas. Scrapy usa código. El intercambio es curva de aprendizaje por precisión.
Excluyendo rutas internacionales:
import re
from urllib.parse import urlparse
class MySiteSpider(scrapy.Spider):
name = "mysite"
allowed_domains = ["example.com", "www.example.com"]
start_urls = ["https://www.example.com/"]
# Omitir rutas internacionales como /uk/, /fr/, /de/
EXCLUDED_PATTERNS = re.compile(
r"/(in|au|th|es|hk|sg|ph|my|ca|cn|uk|kr|id|fr|vn|de|jp|nl|it|tw)/"
)
def filter_links(self, links):
filtered = []
for link in links:
hostname = urlparse(link.url).hostname or ""
if hostname not in ("example.com", "www.example.com"):
continue
if self.EXCLUDED_PATTERNS.search(link.url):
continue
filtered.append(link)
return filtered
Puedes filtrar por patrones de URL, parámetros de consulta, encabezados de respuesta, contenido de página o cualquier combinación.
Pausar y Reanudar
Para rastreos de más de 1,000 páginas, habilita pausar/reanudar con JOBDIR:
scrapy crawl myspider -o output.json -s JOBDIR=crawl_state
Scrapy guarda el estado en crawl_state/. Presiona Ctrl+C para pausar. Ejecuta el mismo comando para reanudar.
El estado incluye URLs pendientes, URLs vistas y la cola de solicitudes. Esto es más robusto que la función de guardar/cargar de Screaming Frog porque está basado en archivos y sobrevive a reinicios del sistema.
Renderizado JavaScript
Scrapy obtiene solo HTML crudo. No renderiza JavaScript. Esto es lo mismo que devuelve curl.
Para la mayoría de los rastreos SEO, esto está bien:
- Las meta tags, canonicals y h1s suelen estar en el HTML inicial
- Los motores de búsqueda indexan principalmente contenido renderizado del servidor
- La mayoría de los sitios de e-commerce y contenido son renderizados del servidor
Si tu sitio objetivo renderiza contenido del lado del cliente, tienes opciones:
| Paquete | Notas |
|---|---|
| scrapy-playwright | Usa Chromium/Firefox/WebKit. Recomendado para sitios JS modernos |
| scrapy-splash | Ligero, renderizador basado en Docker |
| scrapy-selenium | Enfoque antiguo, aún funciona |
El renderizado JS es significativamente más lento y consume más recursos. Solo añádelo si el sitio lo requiere.
Screaming Frog tiene un compromiso similar. Habilitar el renderizado JavaScript usa Chrome bajo el capó y ralentiza considerablemente los rastreos.
Gestión de Memoria
A ~1,300 páginas con extracción completa de campos:
- Memoria: ~265 MB
- CPU: ~4%
Usar JOBDIR mueve las colas de solicitudes a disco, manteniendo la memoria baja. Para rastreos muy grandes (100k+ URLs), añade estas configuraciones:
MEMUSAGE_LIMIT_MB = 1024
MEMUSAGE_WARNING_MB = 800
SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleFifoDiskQueue'
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.FifoMemoryQueue'
Esto limita el uso de memoria y fuerza colas respaldadas en disco para el scheduler.
Datos de Salida
Salida básica del spider:
{
"url": "https://www.example.com/page/",
"title": "Título de la Página Aquí",
"status": 200
}
Para rastreos SEO, querrás campos similares a lo que exporta Screaming Frog:
def parse_page(self, response):
yield {
"url": response.url,
"status": response.status,
"title": response.css("title::text").get(),
"meta_description": response.css("meta[name='description']::attr(content)").get(),
"meta_robots": response.css("meta[name='robots']::attr(content)").get(),
"h1": response.css("h1::text").get(),
"canonical": response.css("link[rel='canonical']::attr(href)").get(),
"og_title": response.css("meta[property='og:title']::attr(content)").get(),
"og_description": response.css("meta[property='og:description']::attr(content)").get(),
"word_count": len(response.text.split()) if response.status == 200 else None,
"content_type": response.headers.get("Content-Type", b"").decode("utf-8", errors="ignore"),
}
Añade o elimina campos según lo que necesites. Los selectores CSS funcionan para cualquier elemento en la página.
Formatos de exportación: JSON (-o output.json), JSON Lines (-o output.jsonl), CSV (-o output.csv), XML (-o output.xml).
JSON Lines es mejor para rastreos grandes. Los archivos son válidos línea por línea durante el rastreo, así que puedes monitorear con tail -f. El JSON estándar no es válido hasta que el rastreo se completa.
Screaming Frog → Scrapy
Mapeo de flujos de trabajo de SF a Scrapy:
| Acción de Screaming Frog | Equivalente en Scrapy |
|---|---|
| Iniciar nuevo rastreo | scrapy crawl spidername |
| Establecer retraso de rastreo | DOWNLOAD_DELAY en configuración |
| Limitar hilos concurrentes | CONCURRENT_REQUESTS_PER_DOMAIN |
| Respetar robots.txt | ROBOTSTXT_OBEY = True |
| Exportar a CSV | -o output.csv |
| Guardar/Cargar rastreo | -s JOBDIR=crawl_state |
| Filtrar subdominios | Código en spider (regex) |
| Extracción personalizada | Selectores CSS/XPath en parse() |
Cambios de mentalidad:
- La configuración es código. Edita
settings.pyen lugar de hacer clic en casillas. - La extracción es explícita. Tú escribes qué datos capturar.
- La programación es nativa. Añade comandos a cron o CI/CD.
- El debugging son logs. Habilita
AUTOTHROTTLE_DEBUGpara ver qué está pasando.
Flujo de Trabajo Completo
Con la configuración estándar anterior, puedes tener Scrapy instalado y rastreando en menos de 15 minutos:
python3 -m venv venv
source venv/bin/activate # venv\Scripts\activate en Windows
pip install scrapy
scrapy startproject urlcrawler
cd urlcrawler
scrapy genspider mysite example.com
# Edita settings.py con configuración de rastreo educado
# Edita spiders/mysite.py con tu lógica de parse
scrapy crawl mysite -o urls.jsonl -s JOBDIR=crawl_state
Scrapy Shell
Mientras construyes configuraciones personalizadas, usa Scrapy Shell para probar tus selectores y configuraciones interactivamente:
scrapy shell "https://example.com"
Esto abre una consola interactiva de Python con la respuesta ya cargada. Prueba selectores CSS y XPath en tiempo real antes de añadirlos a tu spider:
>>> response.css('title::text').get()
'Example Domain'
>>> response.xpath('//h1/text()').get()
'Example Domain'
Scrapy Shell reduce significativamente el tiempo de iteración. Valida la lógica de extracción sin ejecutar rastreos completos.
Plantilla Completa de Spider
Un spider listo para producción con filtrado de URLs, manejo de códigos de estado y extracción completa de campos SEO:
import re
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from urllib.parse import urlparse
class SEOSpider(CrawlSpider):
name = "seospider"
allowed_domains = ["example.com"]
start_urls = ["https://www.example.com"]
# Capturar todos los códigos de estado HTTP, no solo 2xx
handle_httpstatus_list = [200, 301, 302, 403, 404, 500, 502, 503]
# Patrones de URL a excluir
EXCLUDED_PATTERNS = re.compile(
r"/(in|au|th|es|hk|sg|ph|my|ca|cn|uk|kr|id|fr|vn|de|jp|nl|it|tw)/"
)
rules = (
Rule(
LinkExtractor(allow=()),
callback="parse_page",
follow=True,
process_links="filter_links",
),
)
def filter_links(self, links):
filtered = []
for link in links:
parsed = urlparse(link.url)
hostname = parsed.hostname or ""
if hostname not in ("example.com", "www.example.com"):
continue
if self.EXCLUDED_PATTERNS.search(link.url):
continue
filtered.append(link)
return filtered
def parse_page(self, response):
yield {
"url": response.url,
"status": response.status,
"title": response.css("title::text").get(),
"meta_description": response.css("meta[name='description']::attr(content)").get(),
"meta_robots": response.css("meta[name='robots']::attr(content)").get(),
"h1": response.css("h1::text").get(),
"canonical": response.css("link[rel='canonical']::attr(href)").get(),
"og_title": response.css("meta[property='og:title']::attr(content)").get(),
"og_description": response.css("meta[property='og:description']::attr(content)").get(),
"word_count": len(response.text.split()) if response.status == 200 else None,
"content_type": response.headers.get("Content-Type", b"").decode("utf-8", errors="ignore"),
}
Reemplaza example.com con tu dominio objetivo. Ajusta EXCLUDED_PATTERNS para la estructura de URLs de tu sitio.
Cuándo Usar Cuál
Screaming Frog:
- Auditorías rápidas bajo 500 URLs
- Resultados necesarios en minutos
- Exploración visual del sitio
- No cómodo con CLI
- Usar datos de Screaming Frog con Redirects.net
Scrapy:
- Sitios de más de 10,000 URLs
- Rastreos automatizados y programados
- Necesidades de extracción personalizada
- Integración CI/CD
- Restricciones de memoria
- Configuraciones con control de versiones
La Conclusión
Scrapy tiene una curva de configuración más pronunciada que Screaming Frog, pero elimina los límites prácticos que imponen los crawlers GUI. Sin límites de URLs, sin tarifas de licencia, menor uso de memoria y automatización nativa.
Empieza pequeño. Rastrea un sitio que conozcas. Usa configuraciones conservadoras. Compara la salida con Screaming Frog. Los datos coincidirán, pero tendrás una herramienta que escala.