Análisis SEO Semántico NLP con Python

seo python

Con los datos crudos ya en disco, el paso decisivo para un SEO semántico de primer nivel es transformar esas cadenas de texto en significado. Python facilita la tarea con librerías NLP maduras (spaCy, NLTK, TextBlob) y con servicios externos –de Google a OpenAI– que hoy ofrecen embeddings multilingües por céntimos.

1. De texto plano a datos semánticos

Los modelos de spaCy traen tokenizador, lematizador y NER listos para español e inglés, y se actualizan a ritmo de comunidad open-source  .

NLTK sigue reinando cuando basta con tokenizar y filtrar stop-words, gracias a sus corpora integrados para más de 20 idiomas, español incluido  .

TextBlob envuelve NLTK y Pattern en una API “baterías incluidas” (sentiment, n-grams) ideal para prototipos  .

Combinados, permiten escanear cientos de URLs y destilar solo los términos informativos que Google realmente valora  .

2. Prompt #1 — Pipeline semántico con spaCy

Te comparto un prompt posible para construir esta aplicación:

Rol:  
Ingeniero de Datos SEO y Desarrollador Python.

Objetivo:  
Generar un único fichero Python (<nombre_script>.py) que, al ejecutarse, lea una lista de URLs, procese cada página usando spaCy (modelo es_core_news_md) y produzca un CSV con, por cada URL:

  - **keywords**: lista de lemas de sustantivos y adjetivos (sin stop-words)  
  - **entities**: lista de entidades nombradas de tipo ORG, PRODUCT y PERSON  
  - **vector**: embedding completo devuelto por `doc.vector`

Requisitos técnicos:

1. **Versión de Python**: ≥ 3.8  
2. **Dependencias** (instalables con pip):  
   - `spacy` (y `es_core_news_md`)  
   - `requests`  
   - `beautifulsoup4`  
   - `pandas`  
   - `tqdm`  
3. **Interfaz de línea de comandos** (usar `argparse`):  
   - `--input` (archivo de texto o CSV con una URL por línea)  
   - `--output` (ruta del CSV resultante)  
   - Opcionales: `--timeout`, `--retries`, `--batch-size`, `--log-level`

Esquema interno:

1. **Carga de dependencias** y configuración de logging.  
2. **Función** `fetch_html(url: str) -> str`:  
   - Descarga con `requests` (timeout, reintentos configurables).  
   - Excepciones capturadas y registradas; en fallo, devuelve cadena vacía.  
3. **Función** `extract_text(html: str) -> str`:  
   - Limpia y extrae texto visible con BeautifulSoup.  
4. **Inicialización** de spaCy con `nlp = spacy.load("es_core_news_md")`.  
5. **Procesado en batch**: usar `nlp.pipe(texts, batch_size=..., n_process=...)` para eficiencia.  
6. **Para cada `doc`**:  
   - `keywords`: filtrar `token.pos_` en {“NOUN”, “ADJ”}, `not token.is_stop`, extraer `token.lemma_`.  
   - `entities`: recorrer `doc.ents` y quedarnos solo con `ent.label_` en {“ORG”, “PRODUCT”, “PERSON”}.  
   - `vector`: `doc.vector.tolist()` para serializar.  
7. **Construcción del DataFrame** con columnas `url`, `keywords` (JSON/string serializada), `entities`, `vector`.  
8. **Escritura** a CSV con `DataFrame.to_csv`.

Detalles de robustez y eficiencia:

- Barra de progreso con `tqdm` mostrando URLs procesadas / totales.  
- Manejo de errores: si un paso falla, registrar WARNING y continuar.  
- Configuración de número de procesos (`n_process`) y tamaño de lote (`batch_size`) como parámetros ajustables por el usuario.  
- Docstrings completos para cada función y comentarios donde sea necesario.  
- Bloque `if __name__ == "__main__":` con parsing de argumentos y llamada al flujo principal.

Ejemplo de uso:

```bash
python seo_extractor.py \
  --input urls.txt \
  --output resultados.csv \
  --timeout 5 \
  --retries 3 \
  --batch-size 32 \
  --log-level INFO

Script resultante (puedes iterar mejoras sobre el)

#!/usr/bin/env python3
"""
SEO Extractor:
Reads URLs, processes with spaCy es_core_news_md to extract keywords, entities, and embeddings.
Outputs CSV with columns: url, keywords, entities, vector.
"""
import sys
assert sys.version_info >= (3, 8), "Python 3.8 or higher required."

import argparse
import logging
import json
import time

import requests
from bs4 import BeautifulSoup
import spacy
import pandas as pd
from tqdm import tqdm


def fetch_html(url: str, timeout: float, retries: int) -> str:
    """Fetch HTML content from a URL with retries."""
    for attempt in range(1, retries + 1):
        try:
            response = requests.get(url, timeout=timeout)
            response.raise_for_status()
            return response.text
        except Exception as e:
            logging.warning(f"Attempt {attempt}/{retries} failed for {url}: {e}")
            if attempt < retries:
                time.sleep(1)
    return ""


def extract_text(html: str) -> str:
    """Extract and clean visible text from HTML."""
    soup = BeautifulSoup(html, "html.parser")
    for script in soup(["script", "style"]):
        script.extract()
    text = soup.get_text(separator=" ")
    # collapse whitespace
    return " ".join(text.split())


def process_urls(urls, nlp, batch_size, n_process, timeout, retries):
    """Process a list of URLs and return list of result dicts."""
    texts = []
    for url in urls:
        html = fetch_html(url, timeout, retries)
        text = extract_text(html) if html else ""
        texts.append(text)
    results = []
    for i, doc in enumerate(tqdm(
            nlp.pipe(texts, batch_size=batch_size, n_process=n_process),
            total=len(texts),
            desc="Procesando documentos")):
        url = urls[i]
        try:
            # keywords: lemmas of NOUN and ADJ, excluding stop words
            keywords = [token.lemma_ for token in doc
                        if token.pos_ in {"NOUN", "ADJ"} and not token.is_stop]
            # entities: ORG, PRODUCT, PERSON
            entities = [ent.text for ent in doc.ents
                        if ent.label_ in {"ORG", "PRODUCT", "PERSON"}]
            vector = doc.vector.tolist()
        except Exception as e:
            logging.warning(f"Error processing document for {url}: {e}")
            keywords, entities, vector = [], [], []
        results.append({
            "url": url,
            "keywords": json.dumps(keywords, ensure_ascii=False),
            "entities": json.dumps(entities, ensure_ascii=False),
            "vector": json.dumps(vector),
        })
    return results


def load_urls(input_path: str):
    """Load URLs from a text or CSV file."""
    try:
        if input_path.lower().endswith(".csv"):
            df = pd.read_csv(input_path, header=None)
            urls = df.iloc[:, 0].dropna().astype(str).tolist()
        else:
            with open(input_path, "r", encoding="utf-8") as f:
                urls = [line.strip() for line in f if line.strip()]
        return urls
    except Exception as e:
        logging.error(f"Error loading URLs from {input_path}: {e}")
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(description="SEO extractor using spaCy")
    parser.add_argument("--input", "-i", required=True,
                        help="Input file (text or CSV) with one URL per line")
    parser.add_argument("--output", "-o", required=True,
                        help="Output CSV file path")
    parser.add_argument("--timeout", type=float, default=5.0,
                        help="Timeout for HTTP requests in seconds")
    parser.add_argument("--retries", type=int, default=3,
                        help="Number of retries for HTTP requests")
    parser.add_argument("--batch-size", type=int, default=32,
                        help="Batch size for spaCy processing")
    parser.add_argument("--n-process", type=int, default=1,
                        help="Number of processes for spaCy processing")
    parser.add_argument("--log-level", default="WARNING",
                        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
                        help="Logging level")
    args = parser.parse_args()

    logging.basicConfig(
        level=getattr(logging, args.log_level),
        format="%(asctime)s - %(levelname)s - %(message)s"
    )

    logging.info("Cargando spaCy modelo es_core_news_md")
    nlp = spacy.load("es_core_news_md")

    urls = load_urls(args.input)
    if not urls:
        logging.error("No URLs found. Exiting.")
        sys.exit(1)

    logging.info(f"Procesando {len(urls)} URLs")
    results = process_urls(urls, nlp, args.batch_size, args.n_process,
                           args.timeout, args.retries)

    df = pd.DataFrame(results)
    df.to_csv(args.output, index=False)
    logging.info(f"Resultados guardados en {args.output}")


if __name__ == "__main__":
    main()

3. Prompt #2 — Tokenización ligera con NLTK

Prompt posible:

**Rol**: Analista de contenidos y desarrollador de Python.

**Objetivo**: Generar un único fichero `script.py` que al ejecutarse desde la línea de comandos:

1. Acepte como argumento la ruta de un fichero CSV de entrada (por ejemplo: `--input datos.csv`).
2. Valide que el archivo existe y es legible; en caso contrario, muestre un mensaje de error y salga con código distinto de cero.
3. Lea el CSV con `pandas`, asumiendo que tiene una columna llamada `"texto"`.
4. Para cada fila de la columna `"texto"`:
   - Elimine todos los signos de puntuación.
   - Convierta el texto a minúsculas.
   - Tokenice el texto en palabras.
   - Elimine las _stop-words_ en español (usando, por ejemplo, `nltk` o `spaCy`).
   - Calcule las 15 palabras más frecuentes.
5. Guarde los resultados en un nuevo fichero CSV de salida (por ejemplo: `--output resultados.csv`), con columnas:
   - `fila`: índice original.
   - `palabras`: lista de las 15 palabras separadas por comas.
   - `frecuencias`: lista de frecuencias correspondientes.
6. ModeVerbose opcional (`--verbose`) que muestre por consola el progreso con `logging`.
7. Incluya:
   - Una función `main()` y un bloque `if __name__ == "__main__":`.
   - Tipos estáticos (`type hints`) y docstrings en formato Google o NumPy.
   - Gestión de excepciones para errores de E/S y dependencias faltantes.
   - Dependencias declaradas en un bloque `requirements.txt` comentado al inicio.

**Requisitos**:
- Un único archivo `script.py`.
- Claro, modular y preparado para producción ligera.
- Sin dependencias innecesarias; si usa `nltk`, que descargue recursos con control de errores.

Script resultado

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# requirements.txt
# pandas>=1.0.0
# nltk>=3.5

import sys
import os
import re
import logging
import argparse
from collections import Counter
from typing import List, Tuple, Set

# Dependencias externas
try:
    import pandas as pd
except ModuleNotFoundError:
    logging.error("Dependencia faltante: pandas. Instálalo con `pip install pandas`.")
    sys.exit(1)

try:
    import nltk
    from nltk.corpus import stopwords as nltk_stopwords
except ModuleNotFoundError:
    logging.error("Dependencia faltante: nltk. Instálalo con `pip install nltk`.")
    sys.exit(1)


def load_stopwords() -> Set[str]:
    """Carga las stopwords en español desde NLTK, descargando si es necesario.

    Returns:
        Set[str]: Conjunto de stopwords en español.
    """
    try:
        stop_words = set(nltk_stopwords.words('spanish'))
    except LookupError:
        logging.info("Descargando recursos de NLTK: stopwords")
        try:
            nltk.download('stopwords', quiet=True)
            stop_words = set(nltk_stopwords.words('spanish'))
        except Exception as e:
            logging.error(f"No se pudieron descargar las stopwords de NLTK: {e}")
            sys.exit(1)
    return stop_words


def process_text(text: str, stop_words: Set[str]) -> Tuple[List[str], List[int]]:
    """Procesa un texto eliminando puntuación, normalizando, tokenizando y filtrando stopwords.

    Args:
        text (str): Texto a procesar.
        stop_words (Set[str]): Conjunto de stopwords a eliminar.

    Returns:
        Tuple[List[str], List[int]]:
            - palabras: Lista de hasta 15 palabras más frecuentes.
            - frecuencias: Lista de frecuencias correspondientes.
    """
    # Eliminar puntuación
    text_clean = re.sub(r'[^\w\s]', '', text, flags=re.UNICODE)
    # Convertir a minúsculas
    text_clean = text_clean.lower()
    # Tokenizar
    tokens = text_clean.split()
    # Eliminar stopwords
    tokens = [tok for tok in tokens if tok not in stop_words]
    # Contar frecuencias
    counter = Counter(tokens)
    most_common = counter.most_common(15)
    palabras = [word for word, _ in most_common]
    frecuencias = [freq for _, freq in most_common]
    return palabras, frecuencias


def main() -> None:
    """Punto de entrada principal: parsea argumentos, procesa el CSV y genera el resultado."""
    parser = argparse.ArgumentParser(
        description="Procesa un CSV con columna 'texto' y genera las 15 palabras más frecuentes por fila."
    )
    parser.add_argument(
        "--input", "-i", required=True, help="Ruta al fichero CSV de entrada."
    )
    parser.add_argument(
        "--output", "-o", required=True, help="Ruta al fichero CSV de salida."
    )
    parser.add_argument(
        "--verbose", "-v", action="store_true", help="Modo verboso; muestra progreso detallado."
    )
    args = parser.parse_args()

    # Configuración de logging
    level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(
        format="%(asctime)s - %(levelname)s - %(message)s", level=level
    )

    input_path = args.input
    output_path = args.output

    # Validar fichero de entrada
    if not os.path.isfile(input_path):
        logging.error(f"El fichero de entrada no existe: {input_path}")
        sys.exit(1)
    if not os.access(input_path, os.R_OK):
        logging.error(f"No se puede leer el fichero de entrada: {input_path}")
        sys.exit(1)

    logging.info(f"Leyendo datos desde '{input_path}'")
    try:
        df = pd.read_csv(input_path)
    except Exception as e:
        logging.error(f"Error leyendo el CSV: {e}")
        sys.exit(1)

    if "texto" not in df.columns:
        logging.error("La columna 'texto' no existe en el CSV de entrada.")
        sys.exit(1)

    stop_words = load_stopwords()
    results = []

    logging.info("Procesando cada fila de texto...")
    for idx, row in df.iterrows():
        text = str(row["texto"])
        palabras, frecuencias = process_text(text, stop_words)
        results.append({
            "fila": idx,
            "palabras": ",".join(palabras),
            "frecuencias": ",".join(map(str, frecuencias))
        })
        logging.debug(f"Fila {idx}: procesadas {len(palabras)} palabras")

    logging.info(f"Guardando resultados en '{output_path}'")
    try:
        output_df = pd.DataFrame(results)
        output_df.to_csv(output_path, index=False)
    except Exception as e:
        logging.error(f"Error escribiendo el CSV de salida: {e}")
        sys.exit(1)

    logging.info("Proceso completado con éxito.")


if __name__ == "__main__":
    main()

En apenas una docena de líneas generas listas de términos “de contenido” listas para comparar contra tu mapa de intenciones de búsqueda  .

4. Otras vías: comparativa rápida

Herramienta / APITipoComplejidadCoste aprox.*FortalezasLimitaciones
spaCy v3.7OSS (MIT)MediaGratis (local) Rápido, NER multilingüe, embeddings integradosConsume RAM, requiere GPU para entrenar
NLTKOSS (Apache)BajaGratis Corpora exhaustivos, didácticoLento en grandes volúmenes
TextBlobOSSMuy bajaGratis API sencilla, sentiment listoNo apto para producción pesada
Gensim LDAOSSMediaGratis Topic-modeling escalableRequiere tuning, sin POS
OpenAI embeddingsSaaSBaja0,00013 $ / 1 k tokens (text-embedding-3-large) Precisión puntera, soporte multilingüeDatos viajan a la nube
Google Cloud NL APISaaSBaja1$ / 1 k registros (entidad/sentiment) + $300 free credit Integrado con Vertex AI, SLA de GoogleLímite 1 000 chars/registro
Hugging Face Inference APISaaSBaja0,06 $ / 1 k tokens (modelos base) Model zoo enorme, versión gratuita 30 req/minLatencia, dependes de modelo público
Keyword extractor SpotIntelligenceTutorialMuy bajaOSS + spaCy Ejemplo listo, POS filtradoNo cubre español por defecto
Análisis Tokenización MediumTutorialMuy bajaGratis (NLTK) Explica paso a pasoNo contempla lematización

*Precios y licencias de mayo 2025.

En resumen

  1. spaCy para profundidad: lematiza, etiqueta y vectoriza en milisegundos, ideal para dashboards de canibalizaciones o clustering de intenciones.
  2. NLTK para volumen ligero: si sólo quieres quitar stop-words y contar tokens, su simplicidad gana.
  3. Servicios externos para escala: embeddings de OpenAI o la API de Google Cloud ofrecen riqueza semántica y mínimos tiempos de arranque, pagando sólo por uso.
  4. Experimenta combinaciones: extrae keywords con spaCy, agrupa temas con Gensim y genera embeddings con OpenAI; son tres líneas más y multiplicas insights.

Con estos prompts, scripts y panorámica de herramientas puedes pasar de HTML a insights semánticos accionables en menos de una hora. ¡Que tus próximos clusters de contenidos hablen el idioma de tus usuarios (y de los motores de búsqueda)!

Aprende más sobre SEO Semántico

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