
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 / API | Tipo | Complejidad | Coste aprox.* | Fortalezas | Limitaciones |
spaCy v3.7 | OSS (MIT) | Media | Gratis (local) | Rápido, NER multilingüe, embeddings integrados | Consume RAM, requiere GPU para entrenar |
NLTK | OSS (Apache) | Baja | Gratis | Corpora exhaustivos, didáctico | Lento en grandes volúmenes |
TextBlob | OSS | Muy baja | Gratis | API sencilla, sentiment listo | No apto para producción pesada |
Gensim LDA | OSS | Media | Gratis | Topic-modeling escalable | Requiere tuning, sin POS |
OpenAI embeddings | SaaS | Baja | 0,00013 $ / 1 k tokens (text-embedding-3-large) | Precisión puntera, soporte multilingüe | Datos viajan a la nube |
Google Cloud NL API | SaaS | Baja | 1$ / 1 k registros (entidad/sentiment) + $300 free credit | Integrado con Vertex AI, SLA de Google | Límite 1 000 chars/registro |
Hugging Face Inference API | SaaS | Baja | 0,06 $ / 1 k tokens (modelos base) | Model zoo enorme, versión gratuita 30 req/min | Latencia, dependes de modelo público |
Keyword extractor SpotIntelligence | Tutorial | Muy baja | OSS + spaCy | Ejemplo listo, POS filtrado | No cubre español por defecto |
Análisis Tokenización Medium | Tutorial | Muy baja | Gratis (NLTK) | Explica paso a paso | No contempla lematización |
*Precios y licencias de mayo 2025.
En resumen
- spaCy para profundidad: lematiza, etiqueta y vectoriza en milisegundos, ideal para dashboards de canibalizaciones o clustering de intenciones.
- NLTK para volumen ligero: si sólo quieres quitar stop-words y contar tokens, su simplicidad gana.
- 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.
- 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)!