Auditoría SEO con Python

auditoria seo python

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

  1. Enumerar las URLs: lo más simple es parsear el sitemap.xml y filtrar la subcarpeta que nos interese, por ejemplo /blog/  .
  2. Descargar cada página: requests mantiene la petición ligera y permite añadir cabeceras que simulen un navegador real para evitar bloqueos.
  3. Parsear contenido: BeautifulSoup recupera encabezados y metas, mientras que extruct devuelve un diccionario de schema listo para serializar.
  4. 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 / HerramientaTipoComplejidadCoste aprox.Puntos fuertesLimitaciones
Screaming Frog CLIDesktop + CLIBajaGratis ≤ 500 URL, £199/año licenciaRender JS, configuraciones avanzadas, export masivoRequiere GUI o scripting, consumo de RAM
Sitebulb Desktop/CloudSaaS/desktopBaja13–35 €/mes según plan“Hints” priorizados, crawl JS, visualizacionesSolo Windows/macOS; licencias por usuario
Google URL Inspection APIAPI oficialMediaGratis (2 000 req/día)Estado indexación y rich results directos de GoogleLímite duro; solo URLs verificadas
BrightEdge ContentIQEnterprise SaaSBajaConsultar ventasIntegra KPIs y ranking, crawler a gran escalaPrecio enterprise, contrato anual
Scrapy + ProxiesFramework OSSAltaProxy ≈ 1 $/1 k reqControl completo, pipelines, auto-throttleCurva de aprendizaje Python/Twisted
Playwright HeadlessNavegador realMediaLibre + proxyRenderiza JS, evita detección, asyncMá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.

Daniel Pajuelo
Daniel Pajuelo es ingeniero informático y SEO Senior, actualmente trabajando en Guruwalk. En su blog personal escribe sobre Inteligencia Artificial, SEO, Vibe Coding, Blockchain... Ver más

Continua leyendo

Leer más sobre: SEO, Programación