
Python vuelve a demostrar que es el cuchillo suizo del SEO: con un puñado de librerías gratuitas puedes recorrer todas las URLs de una sección, capturar sus <h1>, sus metas y hasta el JSON-LD que alimenta los rich results. En minutos tu propio sitio se convierte en una hoja de cálculo viva, capaz de revelar incoherencias semánticas o marcar duplicidades antes de que Google las penalice.
Por qué auditar tu casa primero
Los auditores técnicos solemos fijarnos antes en la competencia que en nosotros mismos, pero revisar títulos, descripciones y schema propios es la forma más barata de mejorar CTR y prevenir canibalizaciones. Con BeautifulSoup podemos leer cualquier etiqueta meta y atributos content en una línea de código, algo que la comunidad demuestra desde hace años en ejemplos concretos . Para el marcado estructurado basta añadir extruct, un extractor que soporta JSON-LD, Microdata y Open Graph con una sola llamada .
Flujo de trabajo en cuatro pasos
- Enumerar las URLs: lo más simple es parsear el sitemap.xml y filtrar la subcarpeta que nos interese, por ejemplo /blog/ .
- Descargar cada página: requests mantiene la petición ligera y permite añadir cabeceras que simulen un navegador real para evitar bloqueos.
- Parsear contenido: BeautifulSoup recupera encabezados y metas, mientras que extruct devuelve un diccionario de schema listo para serializar.
- Persistir y analizar: con csv o pandas podrás detectar títulos duplicados o páginas sin description, y cruzarlo con Search Console o GA.
Auditoría vía Sitemap XML
Puedes generar la aplicación con un prompt similar a este:
**Rol:** Ingeniero SEO y desarrollador Python
**Objetivo:** Generar **un único fichero** `audit_sitemap.py` que, al ejecutarse, realice el siguiente flujo:
1. **Entrada**
- Reciba como parámetro (o mediante `input()`) la URL de un sitemap (puede ser un `sitemap.xml` o un `sitemap-index.xml`) de un dominio.
2. **Descarga y parseo**
- Descargue el sitemap usando `requests` con gestión de reintentos y timeouts.
- Detecte si es un índice de sitemaps (`<sitemapindex>`) y, en ese caso, recorra recursivamente cada `<loc>`.
- Analice cada sitemap secundario hasta obtener la lista completa de URLs.
3. **Filtrado**
- Filtre solo las URLs que empiecen por `/blog/`.
4. **Extracción de datos SEO**
Para cada URL filtrada:
- Haga una petición HTTP segura (HTTPS, manejo de códigos de estado y excepciones).
- Con `BeautifulSoup` extraiga:
- El contenido de la etiqueta `<title>`.
- La metaetiqueta `<meta name="description">`.
- Todos los `<h1>`, `<h2>` y `<h3>`.
- Busque cualquier bloque de **JSON-LD** dentro de `<script type="application/ld+json">` y extraiga solo los **tipos** definidos en `@type` (por ejemplo, `Article`, `BreadcrumbList`, etc.).
5. **Salida**
- Genere un CSV llamado `audit.csv` con columnas:
```
url,title,meta_desc,h1s,h2s,h3s,jsonld_types
```
- Donde `h1s`, `h2s` y `h3s` sean listas separadas por `|` en una sola celda, y `jsonld_types` contenga también los tipos separados por `|`.
- Incluya logging (`logging`), con nivel configurable (INFO/WARNING/ERROR) y mensajes claros.
6. **Calidad de código**
- Estructura modular: funciones bien definidas y reutilizables.
- Docstrings en formato Google o NumPy para cada función y descripción general del módulo.
- Tipado con **type hints** en funciones.
- Manejo de errores robusto (HTTP errors, parse errors, I/O errors).
- Dependencias gestionadas en `requirements.txt` (listar `requests`, `beautifulsoup4`, etc.) o usando `try/except` para sugerir su instalación si faltan.
7. **Extras (opcional)**
- Barra de progreso con `tqdm` para URLs.
- Parámetros CLI con `argparse` (dominio, fichero de salida, nivel de logging).
Esta puede ser una primera versión que puedes iterar y adaptar a tu gusto:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
audit_sitemap.py
Recorre un sitemap (o índice de sitemaps) de un dominio, extrae solo las URLs que empiecen por /blog/,
visita cada página, extrae datos SEO (<title>, meta description, h1-h3 y tipos JSON-LD) y genera un CSV.
Dependencias:
- requests
- beautifulsoup4
- tqdm (opcional para progresos)
"""
import argparse
import csv
import json
import logging
import sys
from typing import List, Set, Optional, Dict
from urllib.parse import urlparse
# Comprobación de dependencias externas
try:
import requests
except ImportError:
print("Falta 'requests'. Instálala con: pip install requests")
sys.exit(1)
try:
from bs4 import BeautifulSoup
except ImportError:
print("Falta 'beautifulsoup4'. Instálala con: pip install beautifulsoup4")
sys.exit(1)
# tqdm es opcional
try:
from tqdm import tqdm
_HAS_TQDM = True
except ImportError:
_HAS_TQDM = False
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def setup_logging(level: str) -> None:
"""
Configura el logging básico.
Args:
level (str): Nivel de logging ("INFO", "WARNING", "ERROR").
"""
numeric_level = getattr(logging, level.upper(), None)
if not isinstance(numeric_level, int):
print(f"Nivel de logging inválido: {level}")
sys.exit(1)
logging.basicConfig(
level=numeric_level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def get_retry_session(
retries: int = 3,
backoff_factor: float = 0.3,
status_forcelist: List[int] = [500, 502, 503, 504],
) -> requests.Session:
"""
Crea una sesión de requests con reintentos configurados.
Args:
retries (int): Número máximo de reintentos.
backoff_factor (float): Factor exponencial de espera.
status_forcelist (List[int]): Códigos HTTP que disparan reintento.
Returns:
requests.Session: Sesión configurada.
"""
session = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist,
allowed_methods=["GET"],
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def download_sitemap(session: requests.Session, url: str, timeout: int) -> str:
"""
Descarga el contenido de un sitemap XML.
Args:
session (requests.Session): Sesión con retries.
url (str): URL del sitemap.
timeout (int): Timeout en segundos.
Returns:
str: Texto XML del sitemap.
Raises:
HTTPError: Si el servidor responde con error HTTP.
"""
logging.info(f"Descargando sitemap: {url}")
resp = session.get(url, timeout=timeout)
resp.raise_for_status()
return resp.text
def parse_sitemap(xml_text: str) -> BeautifulSoup:
"""
Parsea un XML de sitemap con BeautifulSoup.
Args:
xml_text (str): Contenido XML.
Returns:
BeautifulSoup: Objeto de parseo XML.
"""
return BeautifulSoup(xml_text, "xml")
def extract_sitemap_locs(soup: BeautifulSoup) -> List[str]:
"""
Obtiene las URLs de <loc> de un sitemap o sitemap-index.
Args:
soup (BeautifulSoup): XML parseado.
Returns:
List[str]: Lista de URLs encontradas.
"""
if soup.find("sitemapindex"):
tags = soup.find_all("sitemap")
else:
tags = soup.find_all("url")
locs = []
for tag in tags:
loc = tag.find("loc")
if loc and loc.text:
locs.append(loc.text.strip())
return locs
def gather_all_urls(
session: requests.Session,
sitemap_url: str,
timeout: int,
visited: Optional[Set[str]] = None,
) -> Set[str]:
"""
Recorre recursivamente un sitemap-index y reúne todas las URLs finales.
Args:
session (requests.Session): Sesión HTTP.
sitemap_url (str): URL inicial de sitemap.
timeout (int): Timeout para peticiones.
visited (Set[str], optional): Conjunto de URLs ya visitadas.
Returns:
Set[str]: URLs de páginas finales.
"""
if visited is None:
visited = set()
try:
xml = download_sitemap(session, sitemap_url, timeout)
except Exception as e:
logging.error(f"Error descargando {sitemap_url}: {e}")
return visited
soup = parse_sitemap(xml)
locs = extract_sitemap_locs(soup)
# Si es sitemap-index, recursión
if soup.find("sitemapindex"):
for loc in locs:
if loc not in visited:
visited.add(loc)
gather_all_urls(session, loc, timeout, visited)
else:
# sitemap normal: estas son URLs de contenido
visited.update(locs)
return visited
def fetch_page(session: requests.Session, url: str, timeout: int) -> Optional[str]:
"""
Obtiene el HTML de una página web.
Args:
session (requests.Session): Sesión HTTP.
url (str): URL de la página.
timeout (int): Timeout en segundos.
Returns:
Optional[str]: HTML si OK, None si error.
"""
try:
resp = session.get(url, timeout=timeout)
resp.raise_for_status()
return resp.text
except Exception as e:
logging.warning(f"Error al obtener {url}: {e}")
return None
def extract_seo_data(html: str) -> Dict[str, List[str]]:
"""
Extrae datos SEO de un HTML: title, meta description, h1-h3 y tipos JSON-LD.
Args:
html (str): Texto HTML de la página.
Returns:
Dict[str, List[str]]: Diccionario con claves 'title','meta_desc','h1s','h2s','h3s','jsonld_types'.
"""
soup = BeautifulSoup(html, "html.parser")
title = soup.title.string.strip() if soup.title and soup.title.string else ""
meta = soup.find("meta", attrs={"name": "description"})
meta_desc = meta.get("content", "").strip() if meta else ""
h1s = [h.get_text(strip=True) for h in soup.find_all("h1")]
h2s = [h.get_text(strip=True) for h in soup.find_all("h2")]
h3s = [h.get_text(strip=True) for h in soup.find_all("h3")]
types: List[str] = []
for script in soup.find_all("script", attrs={"type": "application/ld+json"}):
try:
data = json.loads(script.string or "{}")
entries = data if isinstance(data, list) else [data]
for entry in entries:
t = entry.get("@type")
if isinstance(t, list):
types.extend(t)
elif isinstance(t, str):
types.append(t)
except json.JSONDecodeError as e:
logging.warning(f"JSON-LD inválido: {e}")
# Unificar y preservar orden
seen = set()
jsonld_types = [t for t in types if not (t in seen or seen.add(t))]
return {
"title": [title],
"meta_desc": [meta_desc],
"h1s": h1s,
"h2s": h2s,
"h3s": h3s,
"jsonld_types": jsonld_types,
}
def write_csv(rows: List[Dict], output: str) -> None:
"""
Escribe los resultados en un CSV.
Args:
rows (List[Dict]): Lista de diccionarios con datos por URL.
output (str): Ruta del fichero de salida.
"""
campo = ["url", "title", "meta_desc", "h1s", "h2s", "h3s", "jsonld_types"]
try:
with open(output, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(campo)
for row in rows:
writer.writerow([
row["url"],
row["title"][0] if row["title"] else "",
row["meta_desc"][0] if row["meta_desc"] else "",
"|".join(row["h1s"]),
"|".join(row["h2s"]),
"|".join(row["h3s"]),
"|".join(row["jsonld_types"]),
])
logging.info(f"CSV generado en {output}")
except Exception as e:
logging.error(f"Error escribiendo CSV: {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Audita un sitemap y extrae datos SEO de las URLs en /blog/"
)
parser.add_argument("sitemap_url", help="URL del sitemap o sitemap-index")
parser.add_argument(
"-o", "--output", default="audit.csv", help="Nombre del fichero CSV de salida"
)
parser.add_argument(
"-l", "--log", default="INFO", help="Nivel de logging: INFO, WARNING, ERROR"
)
parser.add_argument(
"--timeout", type=int, default=10, help="Timeout en segundos para HTTP"
)
parser.add_argument(
"--no-progress",
action="store_true",
help="Deshabilitar barra de progreso (si tqdm está instalado)",
)
args = parser.parse_args()
setup_logging(args.log)
session = get_retry_session()
# 1) Recolectar todas las URLs del sitemap
all_urls = gather_all_urls(session, args.sitemap_url, args.timeout)
# 2) Filtrar solo las rutas que empiecen por /blog/
filtered = []
for url in all_urls:
if urlparse(url).path.startswith("/blog/"):
filtered.append(url)
# 3) Extraer SEO data
rows = []
iterator = filtered
if _HAS_TQDM and not args.no_progress:
iterator = tqdm(filtered, desc="Procesando URLs")
for url in iterator:
html = fetch_page(session, url, args.timeout)
if not html:
continue
data = extract_seo_data(html)
data["url"] = url
rows.append(data)
# 4) Escribir CSV
write_csv(rows, args.output)
if __name__ == "__main__":
main()
Otra versión: Auditoría con Scrapy +CrawlSpider
Rol: Desarrollador Python experto en Scrapy
Objetivo: Escribe un único fichero Python (`blog_spider.py`) que contenga todo lo necesario para ejecutar un CrawlSpider capaz de:
1. Arrancar en `https://midominio.com/blog/`
2. Respetar `robots.txt` y usar retraso de descargas y concurrencia controlada
3. Seguir únicamente enlaces internos (mismo dominio) hasta una profundidad máxima de 3 niveles
4. Extraer en cada página los mismos campos que el “script anterior”:
- Título (`title`)
- URL (`url`)
- Fecha de publicación (`date`)
- Autor (`author`)
- Contenido principal de texto (`body`)
5. Manejar errores de red y páginas sin los campos esperados de forma limpia (logs y continuaciones)
6. Exportar todos los items en formato JSON, con codificación UTF-8 y sin buffering excesivo
7. Incluir:
- Docstrings en módulos, clases y funciones
- Tipado estático con type hints
- Configuración de Spider, Settings y pipelines dentro del mismo fichero
- Uso de `logging` para información de progreso y depuración
- Comentarios que expliquen las decisiones de diseño y optimización
Formato de entrega:
- Un solo archivo `blog_spider.py` que, al ejecutarse con `scrapy runspider blog_spider.py -o output.json`, realice el crawling y genere `output.json`.
Código de partida:
#!/usr/bin/env python3
# blog_spider.py
"""
BlogSpider module: Crawl blog posts from midominio.com/blog using Scrapy’s CrawlSpider.
This single-file spider includes:
- Spider, settings, pipelines
- Robots.txt compliance, download delay, concurrency control
- Depth limit (3), internal link following
- Extraction of title, url, date, author, body
- Network-error handling and missing-field logging
- JSON UTF-8 export with no buffering
- Docstrings, type hints, and logging
"""
import logging
from typing import Iterator, List, Optional
import scrapy
from scrapy import Item, Field, Spider, Request, Response
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapy.spidermiddlewares.httperror import HttpError
from twisted.internet.error import DNSLookupError, TimeoutError, TCPTimedOutError
class BlogItem(Item):
"""Scrapy Item for storing blog post data."""
title: str = Field()
url: str = Field()
date: str = Field()
author: str = Field()
body: str = Field()
class CleanPipeline:
"""
Pipeline to clean and validate BlogItem fields:
- Strips leading/trailing whitespace
- Logs missing/invalid fields
"""
def process_item(self, item: BlogItem, spider: Spider) -> BlogItem:
for field in ('title', 'date', 'author', 'body'):
value = item.get(field)
if isinstance(value, str):
item[field] = value.strip()
else:
spider.logger.warning(f"Missing or invalid '{field}' in {item.get('url')}")
item[field] = ''
return item
class BlogSpider(CrawlSpider):
"""
CrawlSpider to traverse internal blog links up to 3 levels deep,
extract post data, and handle errors gracefully.
"""
name: str = 'blog_spider'
allowed_domains: List[str] = ['midominio.com']
start_urls: List[str] = ['https://midominio.com/blog/']
# All settings, pipelines, and feed export configured here
custom_settings = {
# Politeness
'ROBOTSTXT_OBEY': True,
'DOWNLOAD_DELAY': 1.0, # seconds
'CONCURRENT_REQUESTS_PER_DOMAIN': 8,
# Crawl depth
'DEPTH_LIMIT': 3,
# Retry network errors
'RETRY_ENABLED': True,
'RETRY_TIMES': 2,
# Pipelines
'ITEM_PIPELINES': {
'blog_spider.CleanPipeline': 300,
},
# JSON feed export, UTF-8, no buffering
'FEEDS': {
'output.json': {
'format': 'json',
'encoding': 'utf8',
'indent': 4,
'item_export_kwargs': {
'ensure_ascii': False,
},
'buffer_item': 0,
},
},
# Logging
'LOG_LEVEL': 'INFO',
}
# Rule: follow only internal links, parse each page, handle errors
rules = (
Rule(
LinkExtractor(allow_domains=allowed_domains),
callback='parse_item',
errback='errback_http',
follow=True
),
)
def parse_item(self, response: Response) -> Iterator[BlogItem]:
"""
Parse a blog post page and extract fields.
Handles missing selectors by logging warnings and continuing.
"""
self.logger.debug(f"Parsing URL: {response.url}")
item = BlogItem()
# Title
title = response.css('article h1::text').get() or response.css('h1::text').get()
if title:
item['title'] = title.strip()
else:
self.logger.warning(f"Title not found at {response.url}")
item['title'] = ''
# URL
item['url'] = response.url
# Publication date
date = response.css('article time::attr(datetime)').get() \
or response.css('time::attr(datetime)').get() \
or response.css('span.date::text').get()
if date:
item['date'] = date.strip()
else:
self.logger.warning(f"Date not found at {response.url}")
item['date'] = ''
# Author
author = response.css('article [rel=author]::text').get() \
or response.css('span.author::text').get()
if author:
item['author'] = author.strip()
else:
self.logger.warning(f"Author not found at {response.url}")
item['author'] = ''
# Body text
paragraphs: List[str] = (
response.css('article p::text').getall() or
response.css('div.entry-content p::text').getall()
)
if paragraphs:
body = ' '.join(p.strip() for p in paragraphs if p.strip())
item['body'] = body
else:
self.logger.warning(f"Body content not found at {response.url}")
item['body'] = ''
yield item
def errback_http(self, failure):
"""
Errback for HTTP and network errors.
Logs failures without stopping the crawl.
"""
self.logger.error(f"Request failed: {failure!r}")
if failure.check(HttpError):
response = failure.value.response # type: ignore
self.logger.error(f"HTTP error on {response.url}")
elif failure.check(DNSLookupError):
request = failure.request # type: ignore
self.logger.error(f"DNS lookup failed for {request.url}")
elif failure.check(TimeoutError, TCPTimedOutError):
request = failure.request # type: ignore
self.logger.error(f"Timeout on {request.url}")
# Other errors are logged generically.
# To run:
# scrapy runspider blog_spider.py -o output.json
Tabla de alternativas y costes (≈ mayo 2025)
Enfoque / Herramienta | Tipo | Complejidad | Coste aprox. | Puntos fuertes | Limitaciones |
---|---|---|---|---|---|
Screaming Frog CLI | Desktop + CLI | Baja | Gratis ≤ 500 URL, £199/año licencia | Render JS, configuraciones avanzadas, export masivo | Requiere GUI o scripting, consumo de RAM |
Sitebulb Desktop/Cloud | SaaS/desktop | Baja | 13–35 €/mes según plan | “Hints” priorizados, crawl JS, visualizaciones | Solo Windows/macOS; licencias por usuario |
Google URL Inspection API | API oficial | Media | Gratis (2 000 req/día) | Estado indexación y rich results directos de Google | Límite duro; solo URLs verificadas |
BrightEdge ContentIQ | Enterprise SaaS | Baja | Consultar ventas | Integra KPIs y ranking, crawler a gran escala | Precio enterprise, contrato anual |
Scrapy + Proxies | Framework OSS | Alta | Proxy ≈ 1 $/1 k req | Control completo, pipelines, auto-throttle | Curva de aprendizaje Python/Twisted |
Playwright Headless | Navegador real | Media | Libre + proxy | Renderiza JS, evita detección, async | Más CPU / RAM, scripting complejo |
No hay excusas para no auditar tu web
Auditar tu propio contenido con Python es rápido, barato y te da una mirada de rayos X sobre la coherencia semántica de cada URL. Si buscas velocidad y cero mantenimiento, usa el prompt de sitemap; si necesitas profundidad y control de reglas, apuesta por Scrapy.
Herramientas comerciales como Screaming Frog o Sitebulb siguen siendo valiosas, pero saber programar tu propio crawler te da independencia y métricas a medida. La web es tu base de datos: sólo hace falta una sesión de terminal y unas cuantas líneas de código para ponerla a trabajar a tu favor.