Detección automática de Entidades SEO con Python

image 4

Uno de los atajos más eficaces para pasar del texto bruto al valor semántico consiste en detectar automáticamente las entidades (personas, lugares, organizaciones, productos…) que contiene cada página. Google alimenta su Knowledge Graph con esos mismos nodos y recompensa los contenidos que los mencionan de forma coherente; por eso un auditor SEO que ignore las entidades vuela a ciegas.

¿Por qué las entidades son gasolina para el SEO semántico?

Google declaró hace años que sus sistemas de ranking están basados en entender entidades y sus relaciones, más que en simples palabras clave  .  Estudios prácticos muestran que conectar tu contenido con el Knowledge Graph aumenta la visibilidad de marca y la relevancia temática  .  Además, aprovechar esas entidades para crear enlaces internos refuerza la autoridad de cada clúster de contenido  .

En términos operativos, un flujo de auditoría típico incluye:

  1. Rastrear o cargar las URLs objeto de estudio.
  2. Aplicar NER (Reconocimiento de Entidades Nombradas) con un modelo estadístico o reglas.
  3. Comparar listas de entidades propias con las de competidores o con un “golden set” sectorial.
  4. Detectar huecos y proponer mejoras de copy o de enlazado interno.

Prompt #1 — Auditor SEO de entidades con spaCy

Prompt:

Eres un Ingeniero de Contenidos especializado en procesamiento de texto y NLP en español. Genera un único script Python que cumpla los siguientes requisitos:

1. **Entrada de datos**  
   - Reciba como argumento de línea de comandos la ruta a un fichero de texto (`urls.txt`) que contenga una URL por línea.
   - Compruebe que el fichero existe y sea legible; en caso contrario, muestre un mensaje de error claro y termine la ejecución.

2. **Descarga y extracción de texto**  
   - Para cada URL, haga una petición HTTP segura (con tiempo de espera configurable y gestión de errores de conexión o código de estado HTTP distinto de 200).  
   - Analice el HTML con **BeautifulSoup** y extraiga todo el texto de los elementos `<p>`.  
   - Ignore contenidos duplicados y vacíos.

3. **Análisis con spaCy**  
   - Cargue el modelo `es_core_news_md`.  
   - Procese en batch los textos extraídos para mejorar rendimiento.  
   - Detecte entidades de los tipos **PERSON**, **ORG**, **GPE**, **PRODUCT** y **DATE**.

4. **Cálculo de frecuencias**  
   - Cuente cuántas veces aparece cada entidad (texto normalizado, sin distinguir mayúsculas/minúsculas).  
   - Genere una lista ordenada (de mayor a menor frecuencia) agrupada por tipo de entidad.

5. **Exportación de resultados**  
   - Cree un fichero `entities.csv` con las columnas:  
     ```
     entity,type,frequency
     ```  
   - Asegúrese de que el CSV sea compatible con Excel/Udemy (codificación UTF-8, sin índice).

6. **Calidad de código**  
   - Incluya comentarios y docstrings en formato numpydoc para funciones y clases.  
   - Use manejo de excepciones para todos los puntos de fallo (I/O, red, NLP).  
   - Aplique buenas prácticas de rendimiento (uso de sesiones HTTP de `requests`, procesamiento en lote de spaCy).  
   - Añada un bloque `if __name__ == "__main__":` para la ejecución como script.  
   - Incluya al inicio un bloque de importaciones claro y, opcionalmente, un fichero `requirements.txt` comentado.

Entrega solo el contenido del script Python (un único archivo) listo para ejecutarse en un entorno con Python 3.8+.

Script:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de línea de comandos para extraer entidades nombradas de textos en URLs.

Requisitos:
1. Recibe como argumento la ruta a un fichero de texto con una URL por línea.
2. Comprueba existencia y legibilidad del fichero.
3. Descarga seguro de cada URL y extracción de texto de <p> con BeautifulSoup.
4. Procesamiento en batch con spaCy (es_core_news_md) para detectar PERSON, ORG, GPE, PRODUCT, DATE.
5. Conteo de frecuencias, normalización a minúsculas.
6. Exportación a entidades.csv (UTF-8, sin índice).
7. Buenas prácticas: sesiones HTTP, manejo de excepciones, comentarios y docstrings en numpydoc.
"""

import sys
import os
import logging
import csv
from pathlib import Path
from typing import List, Dict, Tuple

import requests
from bs4 import BeautifulSoup
import spacy
from spacy.tokens import Doc

# Configuración de logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)


def load_urls(file_path: str) -> List[str]:
    """
    Carga las URLs desde un fichero de texto.

    Parameters
    ----------
    file_path : str
        Ruta al fichero de texto que contiene una URL por línea.

    Returns
    -------
    List[str]
        Lista de URLs limpias.

    Raises
    ------
    FileNotFoundError
        Si el fichero no existe.
    PermissionError
        Si el fichero no es legible.
    """
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(f"El fichero no existe: {file_path}")
    if not os.access(path, os.R_OK):
        raise PermissionError(f"No se puede leer el fichero: {file_path}")

    with path.open("r", encoding="utf-8") as f:
        urls = [line.strip() for line in f if line.strip()]
    return urls


def fetch_text_from_url(
    url: str,
    session: requests.Session,
    timeout: Tuple[int, int] = (5, 10)
) -> str:
    """
    Descarga el contenido de una URL y extrae el texto de los elementos <p>.

    Parameters
    ----------
    url : str
        URL a procesar.
    session : requests.Session
        Sesión HTTP para mejorar rendimiento.
    timeout : Tuple[int, int]
        Timeout para la conexión y la lectura en segundos.

    Returns
    -------
    str
        Texto concatenado de todos los párrafos.

    Raises
    ------
    requests.RequestException
        En caso de errores de conexión o HTTP status != 200.
    """
    try:
        response = session.get(url, timeout=timeout)
        response.raise_for_status()
    except requests.RequestException as e:
        logging.error(f"Error al descargar {url}: {e}")
        return ""

    soup = BeautifulSoup(response.text, "html.parser")
    paragraphs = soup.find_all("p")
    texts = [p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)]
    # Eliminar duplicados manteniendo orden
    seen = set()
    unique_texts = []
    for txt in texts:
        if txt not in seen:
            seen.add(txt)
            unique_texts.append(txt)
    return "\n".join(unique_texts)


def extract_entities(
    texts: List[str],
    nlp_model: spacy.language.Language,
    batch_size: int = 20
) -> List[spacy.tokens.Span]:
    """
    Procesa en batch los textos y extrae entidades nombradas del tipo
    PERSON, ORG, GPE, PRODUCT y DATE.

    Parameters
    ----------
    texts : List[str]
        Lista de textos a procesar.
    nlp_model : spacy.language.Language
        Modelo spaCy cargado.
    batch_size : int
        Tamaño de lote para procesar.

    Returns
    -------
    List[spacy.tokens.Span]
        Lista de spans con las entidades encontradas.
    """
    entities = []
    for doc in nlp_model.pipe(texts, batch_size=batch_size):
        for ent in doc.ents:
            if ent.label_ in {"PERSON", "ORG", "GPE", "PRODUCT", "DATE"}:
                entities.append(ent)
    return entities


def count_entity_frequencies(
    entities: List[spacy.tokens.Span]
) -> List[Tuple[str, str, int]]:
    """
    Cuenta la frecuencia de cada entidad, normalizando a minúsculas.

    Parameters
    ----------
    entities : List[spacy.tokens.Span]
        Lista de spans con entidades.

    Returns
    -------
    List[Tuple[str, str, int]]
        Lista de tuplas (entidad, tipo, frecuencia) ordenada por frecuencia descendente.
    """
    freq: Dict[Tuple[str, str], int] = {}
    for ent in entities:
        key = (ent.text.lower(), ent.label_)
        freq[key] = freq.get(key, 0) + 1

    # Ordenar por frecuencia descendente
    sorted_items = sorted(
        [(text, label, count) for (text, label), count in freq.items()],
        key=lambda x: x[2],
        reverse=True
    )
    return sorted_items


def save_to_csv(
    data: List[Tuple[str, str, int]],
    output_file: str = "entities.csv"
) -> None:
    """
    Guarda las entidades y sus frecuencias en un CSV compatible con Excel/Udemy.

    Parameters
    ----------
    data : List[Tuple[str, str, int]]
        Lista de tuplas (entidad, tipo, frecuencia).
    output_file : str
        Nombre del fichero de salida CSV.
    """
    try:
        with open(output_file, "w", encoding="utf-8", newline="") as csvfile:
            writer = csv.writer(csvfile)
            writer.writerow(["entity", "type", "frequency"])
            for entity, ent_type, freq in data:
                writer.writerow([entity, ent_type, freq])
        logging.info(f"Resultados guardados en {output_file}")
    except Exception as e:
        logging.error(f"No se pudo escribir el fichero CSV: {e}")


def main():
    """
    Función principal del script.
    """
    if len(sys.argv) != 2:
        logging.error("Uso: python script.py <ruta_urls.txt>")
        sys.exit(1)

    urls_file = sys.argv[1]

    try:
        urls = load_urls(urls_file)
    except (FileNotFoundError, PermissionError) as e:
        logging.error(e)
        sys.exit(1)

    # Inicializar sesión HTTP y modelo spaCy
    session = requests.Session()
    try:
        nlp = spacy.load("es_core_news_md")
    except Exception as e:
        logging.error(f"Error cargando modelo spaCy: {e}")
        sys.exit(1)

    # Descargar y extraer textos
    all_texts = []
    for url in urls:
        text = fetch_text_from_url(url, session)
        if text:
            all_texts.append(text)

    if not all_texts:
        logging.warning("No se extrajo texto de ninguna URL válida.")
        sys.exit(0)

    # Extraer entidades y contar frecuencias
    entities = extract_entities(all_texts, nlp, batch_size=20)
    freq_data = count_entity_frequencies(entities)

    # Guardar resultados
    save_to_csv(freq_data, output_file="entities.csv")


if __name__ == "__main__":
    main()

# requirements.txt (opcional):
# ----------------------------
# beautifulsoup4>=4.9.3
# requests>=2.24.0
# spacy>=3.0.0
# es-core-news-md @ https://github.com/explosion/spacy-models/releases/download/es_core_news_md-3.3.0/es_core_news_md-3.3.0-py3-none-any.whl

Cómo usarlo

  • Instala dependencias: pip install spacy beautifulsoup4 requests && python -m spacy download es_core_news_md.
  • El modelo español de spaCy alcanza ≈ 88 F-score en CoNLL-03 tras su última revisión de 2024  .
  • El CSV resultante permite filtrar rápidamente páginas que no mencionan entidades clave de tu topic cluster.

Prompt #2 — Auditor comparativo con Transformers (Hugging Face)

Prompt:

Eres un analista de competencia y tu tarea es generar un script Python de un solo fichero, llamado `analisis_competencia.py`, que cumpla estos requisitos:

1. **Entorno y dependencias**  
   - Python 3.8+  
   - Paquetes: `transformers`, `torch` (o `tensorflow`), `tqdm`, `click`, `logging`, `pandas`  
   - Incluye instrucciones de instalación en el encabezado o en un bloque `requirements.txt`

2. **Carga del modelo**  
   - Utiliza `transformers.pipeline` para cargar `Babelscape/wikineural-multilingual-ner`  
   - Carga el modelo una sola vez al inicio y reutilízalo

3. **Interfaz de línea de comandos**  
   - Emplea `click` para aceptar dos parámetros obligatorios:  
     - `--mios`: ruta al directorio de tus textos  
     - `--competencia`: ruta al directorio de los textos de la competencia  
   - Opción opcional `--salida`: ruta de un fichero CSV/JSON de resultados (por defecto `brecha_entidades.csv`)

4. **Procesamiento de archivos**  
   - Recorre recursivamente ambos directorios buscando archivos de texto plano (`.txt`)  
   - Lee cada archivo con control de codificación y captura errores de lectura

5. **Extracción y conteo de entidades**  
   - Para cada conjunto (`mios` y `competencia`) extrae entidades con el pipeline NER  
   - Normaliza nombres de entidades (por ejemplo, convertir a minúsculas y strip)  
   - Cuenta frecuencias por entidad usando `collections.Counter` o `pandas.Series`

6. **Cálculo de la brecha**  
   - Identifica entidades que aparecen **al menos 3 veces** en `competencia` y **0 veces** en `mios`  
   - Guarda la lista de estas entidades junto con sus frecuencias en competencia

7. **Salida de resultados**  
   - Genera un fichero CSV (o JSON si se solicita) con columnas: `entidad`, `freq_competencia`  
   - Imprime por pantalla un resumen: total de entidades en cada grupo y número de brechas encontradas

8. **Calidad del código**  
   - Añade docstrings (funciones y módulo) y typing (`def foo(...) -> dict:`)  
   - Estructura modular: funciones como `cargar_modelo()`, `procesar_directorio()`, `calcular_brecha()`  
   - Usa `if __name__ == "__main__": main()`  
   - Implementa logging con distintos niveles (`INFO`, `ERROR`) y barra de progreso con `tqdm`

9. **Robustez y buenas prácticas**  
   - Validación de rutas de directorio y existencia de archivos  
   - Manejo de excepciones para fallos en el modelo y en lectura de ficheros  
   - Cierre seguro de recursos  
   - Comentarios breves donde aporte claridad a secciones críticas

Devuelve **solo el código** completo de `analisis_competencia.py`, sin explicaciones adicionales.

Script:

#!/usr/bin/env python3
"""
analisis_competencia.py

Requisitos de instalación:

# Crear virtualenv e instalar paquetes:
pip install transformers torch tqdm click pandas

O usar un fichero requirements.txt:
transformers
torch
tqdm
click
pandas
"""

import logging
from pathlib import Path
from typing import List, Tuple
import click
from transformers import pipeline, Pipeline
from tqdm import tqdm
import pandas as pd
from collections import Counter

def cargar_modelo() -> Pipeline:
    """Carga el modelo NER Babelscape/wikineural-multilingual-ner."""
    try:
        ner_pipeline = pipeline(
            "ner",
            model="Babelscape/wikineural-multilingual-ner",
            tokenizer="Babelscape/wikineural-multilingual-ner",
            grouped_entities=True
        )
        logging.info("Modelo NER cargado correctamente")
        return ner_pipeline
    except Exception as e:
        logging.error(f"Error al cargar el modelo: {e}")
        raise

def procesar_directorio(dir_path: Path, ner_pipeline: Pipeline) -> Counter:
    """Procesa archivos .txt en un directorio y retorna contador de entidades."""
    entity_counter: Counter = Counter()
    if not dir_path.is_dir():
        logging.error(f"Directorio no existe: {dir_path}")
        return entity_counter
    txt_files = list(dir_path.rglob("*.txt"))
    for file in tqdm(txt_files, desc=f"Procesando {dir_path}"):
        try:
            text = file.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            try:
                text = file.read_text(encoding="latin-1")
            except Exception as e:
                logging.error(f"No se pudo leer {file}: {e}")
                continue
        try:
            ner_results = ner_pipeline(text)
            for ent in ner_results:
                name = ent["word"].lower().strip()
                entity_counter[name] += 1
        except Exception as e:
            logging.error(f"Error NER en {file}: {e}")
            continue
    return entity_counter

def calcular_brecha(
    mios: Counter,
    competencia: Counter,
    umbral: int = 3
) -> List[Tuple[str, int]]:
    """Calcula brecha: freq>=umbral en competencia y 0 en mios."""
    brechas: List[Tuple[str, int]] = []
    for ent, freq in competencia.items():
        if freq >= umbral and mios.get(ent, 0) == 0:
            brechas.append((ent, freq))
    return brechas

@click.command()
@click.option(
    "--mios",
    required=True,
    type=click.Path(exists=True, file_okay=False),
    help="Directorio de mis textos"
)
@click.option(
    "--competencia",
    required=True,
    type=click.Path(exists=True, file_okay=False),
    help="Directorio de textos de la competencia"
)
@click.option(
    "--salida",
    default="brecha_entidades.csv",
    help="Fichero de salida CSV o JSON"
)
def main(mios: str, competencia: str, salida: str) -> None:
    """Punto de entrada del script."""
    logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
    logging.info("Iniciando análisis de competencia")
    ner = cargar_modelo()
    mios_counter = procesar_directorio(Path(mios), ner)
    comp_counter = procesar_directorio(Path(competencia), ner)
    brechas = calcular_brecha(mios_counter, comp_counter)

    df = pd.DataFrame(brechas, columns=["entidad", "freq_competencia"])
    try:
        if salida.lower().endswith(".json"):
            df.to_json(salida, orient="records", force_ascii=False, indent=2)
        else:
            df.to_csv(salida, index=False)
        logging.info(f"Resultados guardados en {salida}")
    except Exception as e:
        logging.error(f"Error al guardar resultados: {e}")

    # Resumen por pantalla
    print(f"Total entidades únicas en mis textos: {len(mios_counter)}")
    print(f"Total entidades únicas en competencia: {len(comp_counter)}")
    print(f"Entidades brecha encontradas: {len(brechas)}")

if __name__ == "__main__":
    main()

Claves técnicas

  • El modelo WikiNeural combina datos automáticos + humanos y soporta 9 idiomas, incluido español  .
  • La tubería aggregation_strategy=»simple» une sub-tokens y devuelve spans listos para contar  .
  • El archivo gap.json revela entidades que tus rivales cubren y tú no, orientación directa para nuevos artículos o enlaces internos.

Otras opciones

SoluciónTipoComplejidadCoste aprox.*VentajasLimitaciones
spaCy NEROSS localMediaGratisRápido, personalizable; EntityRuler combina reglas + modeloRAM >500 MB en modelos md/lg
StanzaOSS localMediaGratis80 idiomas; buenos resultados en españolModelos pesados, +CPU
FlairOSS localMediaGratisSOTA, dominio biomédico opcionalDocumentación dispersa
Transformers pipelineOSS + cloud opcionalMediaGratis local / 0,06 $·1k tok cloudModel zoo enormeRequiere GPU para lotes grandes
Google Cloud NL APISaaSBaja1 $·1k chars entidad → 0,0005 $ escaladoSLA, 30 k chars gratis/mesLímite 1 000 chars/llamada
AWS ComprehendSaaSBaja1 $·10k chars (aprox.)Modelos médicos y personalizadosPre-procesado 100 chars mín.
OpenAI GPT NER (func-call)SaaSBaja0,0003 $·1k tok (gpt-4o)Alta precisión, multilingüeDatos viajan a la nube
EntityRuler (spaCy)ReglasBajaGratisCobertura exacta de listas de productos o marcasNo generaliza
Relik + Neo4j (LlamaIndex)OSSMedia-AltaGratis localUne NER y entity linking para grafosStack más complejo

*Precios consultados en mayo 2025; pueden variar.

Las Entidades son pieza clave del SEO Semántico

Las entidades son la moneda semántica que Google entiende y premia. Con spaCy obtienes un auditor local rápido; con Transformers añades modelos multilingües de última generación. Las APIs cloud (Google, AWS, OpenAI) simplifican el despliegue en producción sin mantener infraestructura, pero implican coste y envío de datos.  Combina NER con enlaces internos por entidad para reforzar tu topical authority —práctica respaldada por estudios recientes  — y vigila las brechas frente a la competencia para decidir tu próximo artículo o actualización.

Con los prompts y la comparativa anterior, tienes todo para convertir tus textos (y los ajenos) en insights accionables basados en entidades. ¡A mapear tu propio Knowledge Graph!

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