
Python y pandas convierten el «caos» de las hojas CSV de keywords, métricas de Search Console y volúmenes de búsqueda en un tablero claro y accionable: en una sola línea puedes fusionar tablas, agrupar por URL o fecha y dibujar un gráfico interactivo que muestre si tu optimización semántica realmente disparó los clics.
1. Por qué pandas es la navaja suiza del SEO de datos
La propia documentación define pandas como «rápido, potente y flexible» para manipular datos tabulares y su API merge() es el estándar de facto para unir datasets heterogéneos .
En SEO semántico esto se traduce en fusionar la exportación de Search Console (impresiones, clics, CTR) con volúmenes de búsqueda y taxonomías internas, algo vital para descubrir oportunidades y canibalizaciones. La Performance API ofrece hasta 50 000 filas/día, suficientes para la mayoría de proyectos .
Una vez procesados, librerías de visualización como matplotlib o Plotly (interactiva) permiten plasmar tendencias y comparar periodos; tutoriales de la comunidad muestran incluso cómo seguir tendencias de keywords semana a semana .
Cuando el volumen crece, programar una tarea recurrente (cron, Cloud Scheduler o paquete schedule) descarga el informe de la API, actualiza el dashboard y avisa por correo .
2. Prompt #1 — Wrangling y tablero exprés con pandas + Plotly
Prompt:
**Rol**: Analista SEO
**Objetivo**: Escribir un script Python de un único fichero (`seo_analysis.py`) que, de forma eficiente y robusta, realice todo el flujo de análisis y visualización descrito a continuación.
---
### Requisitos funcionales
1. **Lectura de datos**
- Cargar tres archivos CSV: `gsc.csv`, `volumen.csv` y `taxonomia.csv`.
- Validar que cada fichero exista y tenga las columnas esperadas (p. ej. `keyword`, `url`, `clics`, `impresiones`, `volumen`, `topic`).
2. **Unión y procesado**
- Hacer _merge_ de los tres DataFrames usando `keyword` como clave.
- Agrupar por `url` y para cada grupo calcular:
- Suma de `clics`
- Suma de `impresiones`
- CTR global (`clics / impresiones`)
- Volumen total (`sum(volumen)`)
3. **Selección de “tópicos con potencial”**
- Definir “potencial” como combinación de **alto volumen** y **bajo CTR**.
- Ordenar los grupos según mayor volumen y menor CTR, y seleccionar los top 10.
4. **Visualización**
- Generar una gráfica interactiva de barras con Plotly:
- Eje X: volumen total
- Eje Y: URL o topic
- Color o anotaciones: CTR para cada barra
- Título y etiquetas claras
---
### Requisitos no funcionales
- **Único fichero**: todo el código debe residir en `seo_analysis.py`.
- **Documentación**:
- Docstrings en funciones (`"""Descripción…"""`).
- Comentarios puntuales donde la lógica sea compleja.
- **Robustez**:
- Comprobación de errores al leer ficheros o columnas faltantes.
- Captura de excepciones con mensajes claros.
- **Eficiencia**:
- Uso de operaciones vectorizadas de `pandas`.
- Evitar bucles innecesarios.
- **CLI parametrizable**:
- Usar `argparse` para permitir:
- Rutas de entrada (`--gsc`, `--volumen`, `--taxonomia`)
- Nombre o ruta del fichero de salida de la gráfica (`--output`)
- **Logging**:
- Informativo (nivel INFO) para pasos principales.
- Nivel DEBUG opcional para diagnósticos.
- **Dependencias**:
- `pandas`, `plotly`, `argparse`, `logging`, más las que sean estrictamente necesarias.
- Incluir en cabecera un bloque `# Requirements:`.
---
### Entrega esperada
El asistente debe devolver directamente el contenido completo de `seo_analysis.py`, listo para usar, que cumpla todos los requisitos anteriores.
Script:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SEO Analysis Script
Reads Google Search Console data, keyword volume, and taxonomy mappings,
then identifies top topics/URLs with high search volume and low CTR,
and generates an interactive bar chart.
Requirements:
pandas
plotly
argparse
logging
"""
import os
import sys
import argparse
import logging
import pandas as pd
import plotly.express as px
def setup_logging(level: str):
"""
Configures the root logger.
Args:
level (str): Logging level (e.g., 'INFO', 'DEBUG').
"""
numeric_level = getattr(logging, level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError(f'Invalid log level: {level}')
logging.basicConfig(
level=numeric_level,
format='[%(asctime)s] %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def load_csv(path: str, required_columns: list) -> pd.DataFrame:
"""
Loads a CSV file and validates required columns.
Args:
path (str): Path to the CSV file.
required_columns (list): List of column names that must be present.
Returns:
pd.DataFrame: Loaded DataFrame.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If required columns are missing.
"""
logging.info(f'Loading CSV file: {path}')
if not os.path.isfile(path):
logging.error(f'File not found: {path}')
raise FileNotFoundError(f'File not found: {path}')
try:
df = pd.read_csv(path)
except Exception as e:
logging.error(f'Error reading {path}: {e}')
raise
missing = set(required_columns) - set(df.columns)
if missing:
logging.error(f'Missing columns in {path}: {missing}')
raise ValueError(f'Missing columns in {path}: {missing}')
logging.debug(f'Loaded {len(df)} rows from {path}')
return df
def merge_data(gsc: pd.DataFrame, volumen: pd.DataFrame, taxo: pd.DataFrame) -> pd.DataFrame:
"""
Merges GSC, volume, and taxonomy DataFrames on 'keyword'.
Args:
gsc (pd.DataFrame): GSC data.
volumen (pd.DataFrame): Volume data.
taxo (pd.DataFrame): Taxonomy data.
Returns:
pd.DataFrame: Merged DataFrame.
"""
logging.info('Merging data on "keyword"')
df = gsc.merge(volumen, on='keyword', how='inner')
df = df.merge(taxo, on='keyword', how='inner')
logging.debug(f'Merged DataFrame shape: {df.shape}')
return df
def process_data(df: pd.DataFrame) -> pd.DataFrame:
"""
Aggregates metrics by URL and topic:
- Sum of clicks
- Sum of impressions
- CTR = clicks / impressions
- Total volume
Args:
df (pd.DataFrame): Merged DataFrame.
Returns:
pd.DataFrame: Aggregated DataFrame.
"""
logging.info('Processing data: aggregating by URL and topic')
agg = df.groupby(['url', 'topic'], as_index=False).agg({
'clics': 'sum',
'impresiones': 'sum',
'volumen': 'sum',
})
agg['ctr_global'] = agg['clics'] / agg['impresiones']
logging.debug('Aggregation complete')
return agg
def select_potential(df: pd.DataFrame, top_n: int = 10) -> pd.DataFrame:
"""
Selects top topics/URLs with high volume and low CTR.
Sorting by:
1. volumen descending
2. ctr_global ascending
Args:
df (pd.DataFrame): Aggregated DataFrame.
top_n (int): Number of top items to select.
Returns:
pd.DataFrame: Filtered top_n DataFrame.
"""
logging.info(f'Selecting top {top_n} items by volume and CTR')
df_sorted = df.sort_values(
by=['volumen', 'ctr_global'],
ascending=[False, True]
)
top = df_sorted.head(top_n).copy()
logging.debug(f'Selected items:\n{top}')
return top
def generate_plot(df: pd.DataFrame, output_path: str):
"""
Generates an interactive horizontal bar chart of volume vs. URL,
colored by CTR, with CTR annotations.
Args:
df (pd.DataFrame): DataFrame containing 'url', 'volumen', 'ctr_global'.
output_path (str): File path to write the HTML plot.
"""
logging.info(f'Generating interactive plot: {output_path}')
# Format CTR as percentage for display
df['ctr_pct'] = (df['ctr_global'] * 100).round(2).astype(str) + '%'
fig = px.bar(
df,
x='volumen',
y='url',
orientation='h',
color='ctr_global',
text='ctr_pct',
labels={
'volumen': 'Volumen Total',
'url': 'URL',
'ctr_global': 'CTR Global'
},
title='Top 10 URLs con Potencial (Alto Volumen, Bajo CTR)'
)
fig.update_layout(
yaxis={'categoryorder': 'total ascending'},
coloraxis_colorbar=dict(title='CTR'),
margin=dict(l=200, r=50, t=50, b=50)
)
fig.update_traces(textposition='outside')
try:
fig.write_html(output_path, include_plotlyjs='cdn')
logging.info(f'Plot saved to {output_path}')
except Exception as e:
logging.error(f'Failed to save plot: {e}')
raise
def parse_args():
"""
Parses command-line arguments.
Returns:
argparse.Namespace: Parsed arguments.
"""
parser = argparse.ArgumentParser(
description='SEO analysis: identify high-volume, low-CTR topics/URLs'
)
parser.add_argument('--gsc', required=True, help='Path to gsc.csv')
parser.add_argument('--volumen', required=True, help='Path to volumen.csv')
parser.add_argument('--taxonomia', required=True, help='Path to taxonomia.csv')
parser.add_argument('--output', required=True, help='Output HTML file for the plot')
parser.add_argument(
'--log-level',
default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
help='Logging level (default: INFO)'
)
return parser.parse_args()
def main():
"""
Main function to orchestrate SEO analysis workflow.
"""
args = parse_args()
setup_logging(args.log_level)
try:
gsc_df = load_csv(args.gsc, ['keyword', 'url', 'clics', 'impresiones'])
vol_df = load_csv(args.volumen, ['keyword', 'volumen'])
tax_df = load_csv(args.taxonomia, ['keyword', 'topic'])
except Exception as e:
logging.critical(f'Error loading data: {e}')
sys.exit(1)
try:
merged_df = merge_data(gsc_df, vol_df, tax_df)
agg_df = process_data(merged_df)
top_df = select_potential(agg_df, top_n=10)
except Exception as e:
logging.critical(f'Error processing data: {e}')
sys.exit(1)
try:
generate_plot(top_df, args.output)
except Exception as e:
logging.critical(f'Error generating plot: {e}')
sys.exit(1)
if __name__ == '__main__':
main()
Qué aporta
- merge() combina sin esfuerzo métricas de fuentes distintas .
- Plotly genera HTML interactivo ideal para compartir con stakeholders .
- El filtro ctr < 0.02 señala temas donde el contenido no engancha pese al interés de búsqueda.
3. Prompt #2 — Reporte semanal automático con la Search Console API
Prompt:
**Rol:** Ingeniero de Datos SEO
**Objetivo:**
Crear un único script Python que, de forma automatizada y robusta, realice cada lunes a las 09:00 CET las siguientes tareas:
1. **Descarga** de los últimos 180 días de datos desde una URL configurable.
2. **Actualización** de un fichero `historico.parquet`, añadiendo únicamente las filas nuevas.
3. **Generación** o actualización de un gráfico de tendencia a partir de `historico.parquet`.
**Requisitos y criterios de calidad:**
- **Un solo fichero**: todo el código debe residir en `update_trends.py`.
- **Documentación**:
- Docstrings para módulos, funciones y clases.
- Comentarios claros que expliquen pasos clave.
- **Eficiencia**:
- Uso de pandas para operaciones en bloque.
- Lectura/escritura de Parquet con `pyarrow`.
- **Robustez**:
- Manejo de excepciones para peticiones HTTP (reintentos exponenciales con `requests` + `urllib3.util.retry`).
- Validación de esquemas de datos con `pandera` o comprobaciones manuales.
- Logs estructurados con `logging`, incluyendo nivel, timestamp y detalles de errores.
- **Programación de la tarea**:
- Uso de la librería `schedule` o integración con Cron (documentar cómo ejecutar en background).
- La zona horaria debe ser CET (considerar GMT+1 y hora de verano).
- **Configurabilidad**:
- Parámetros (URL de datos, ruta de `historico.parquet`, configuración de logging) deben leerse desde un archivo `config.yaml` o variables de entorno.
- **Salida gráfica**:
- Gráfico de tendencia con `matplotlib` o `plotly`, guardado como `trend.png`.
- Ejes y leyenda correctamente etiquetados.
- **Pruebas básicas**:
- Incluir un bloque `if __name__ == "__main__":` que permita ejecutar el flujo completo.
- Al menos una prueba mínima de integridad de datos (por ejemplo, comprobación de no duplicados).
Script:
#!/usr/bin/env python3
"""
update_trends.py
Script único para:
1. Descargar los últimos 180 días de datos.
2. Actualizar historico.parquet añadiendo sólo filas nuevas.
3. Generar/actualizar gráfico de tendencia.
Uso:
python update_trends.py
"""
import os
import sys
import logging
import yaml
import pandas as pd
import requests
from urllib3.util import Retry
from requests.adapters import HTTPAdapter
import pandera as pa
from pandera import Column, DataFrameSchema, Check
import pyarrow.parquet as pq
import matplotlib.pyplot as plt
from datetime import datetime
# ----------------------------------------------------------
# Config y Logging
# ----------------------------------------------------------
def load_config(path: str = "config.yaml") -> dict:
"""Carga configuración desde un fichero YAML."""
with open(path, "r") as f:
return yaml.safe_load(f)
def setup_logging(cfg: dict):
"""Configura logging estructurado."""
level = getattr(logging, cfg["logging"]["level"].upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler(cfg["logging"]["file"]),
logging.StreamHandler(sys.stdout)
]
)
# ----------------------------------------------------------
# Descarga de datos con retries
# ----------------------------------------------------------
def get_session_with_retries(retries: int = 5, backoff: float = 1.0) -> requests.Session:
"""
Retorna una sesión de requests con estrategia de retry exponencial.
"""
session = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff,
status_forcelist=[429, 500, 502, 503, 504]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def download_data(url: str) -> pd.DataFrame:
"""
Descarga datos desde la URL (CSV o JSON) y los devuelve como DataFrame.
Lanza excepción si falla.
"""
logging.info(f"Descargando datos de {url}")
sess = get_session_with_retries()
resp = sess.get(url, timeout=30)
resp.raise_for_status()
# Detectar formato
if "application/json" in resp.headers.get("Content-Type", ""):
df = pd.DataFrame(resp.json())
else:
df = pd.read_csv(pd.compat.StringIO(resp.text), parse_dates=True)
logging.info(f"Descargados {len(df)} registros")
return df
# ----------------------------------------------------------
# Validación de esquema
# ----------------------------------------------------------
schema = DataFrameSchema({
"date": Column(pa.DateTime, nullable=False),
"value": Column(pa.Float, Check.ge(0), nullable=False),
# Añadir más columnas según sea necesario...
})
def validate_df(df: pd.DataFrame) -> pd.DataFrame:
"""
Valida el DataFrame con pandera. Lanza excepción si hay errores de esquema.
"""
logging.info("Validando esquema de datos descargados")
return schema.validate(df)
# ----------------------------------------------------------
# Actualización de historico.parquet
# ----------------------------------------------------------
def load_historic(path: str) -> pd.DataFrame:
"""
Carga el DataFrame histórico. Si no existe, devuelve DataFrame vacío.
"""
if os.path.exists(path):
logging.info(f"Cargando histórico de {path}")
return pd.read_parquet(path)
else:
logging.info("No existe historico.parquet, se creará uno nuevo")
return pd.DataFrame(columns=schema.columns.keys())
def update_historic(historic: pd.DataFrame, new: pd.DataFrame) -> pd.DataFrame:
"""
Añade sólo filas nuevas (por fecha) al histórico y elimina duplicados.
"""
combined = pd.concat([historic, new], ignore_index=True)
# Suponemos que 'date' es clave única
before = len(combined)
combined = combined.drop_duplicates(subset=["date"], keep="last")
after = len(combined)
logging.info(f"Añadidas {after - len(historic)} filas nuevas; duplicados eliminados: {before - after}")
return combined.sort_values("date")
def save_historic(df: pd.DataFrame, path: str):
"""Guarda el DataFrame como Parquet con pyarrow."""
logging.info(f"Guardando histórico actualizado en {path}")
df.to_parquet(path, engine="pyarrow", index=False)
# ----------------------------------------------------------
# Generación de gráfica
# ----------------------------------------------------------
def plot_trend(df: pd.DataFrame, output_path: str):
"""
Genera y guarda un gráfico de línea de 'value' vs 'date'.
"""
logging.info(f"Generando gráfica de tendencia en {output_path}")
plt.figure(figsize=(10, 6))
plt.plot(df["date"], df["value"])
plt.title("Tendencia últimos 180 días")
plt.xlabel("Fecha")
plt.ylabel("Valor")
plt.grid(True)
plt.tight_layout()
plt.savefig(output_path)
plt.close()
# ----------------------------------------------------------
# Chequeo de integridad mínima
# ----------------------------------------------------------
def check_no_duplicates(df: pd.DataFrame):
"""
Verifica que no haya fechas duplicadas en el histórico final.
Lanza AssertionError si encuentra duplicados.
"""
dup = df["date"].duplicated().sum()
assert dup == 0, f"Se encontraron {dup} fechas duplicadas en histórico"
# ----------------------------------------------------------
# Flujo principal
# ----------------------------------------------------------
def main():
cfg = load_config()
setup_logging(cfg)
# 1. Descargar
df_new = download_data(cfg["data_url"])
# 2. Validar
df_new = validate_df(df_new)
# 3. Cargar histórico y actualizar
hist = load_historic(cfg["historic_path"])
hist_updated = update_historic(hist, df_new)
check_no_duplicates(hist_updated)
save_historic(hist_updated, cfg["historic_path"])
# 4. Generar gráfica
plot_trend(hist_updated, cfg["output_plot"])
logging.info("Proceso completado con éxito.")
if __name__ == "__main__":
main()
Programación automática
Opción A: Con la librería schedule
Si prefieres que el propio script se quede en ejecución:
import schedule
import time
def job():
from update_trends import main
main()
# Ejecutar cada lunes a las 09:00 CET (asegúrate de que la zona horaria del servidor esté en CET)
schedule.every().monday.at("09:00").do(job)
if __name__ == "__main__":
while True:
schedule.run_pending()
time.sleep(60)
Nota: Para usar este enfoque, lanza en background (screen, tmux o systemd).
Opción B: Cron (recomendado en producción)
- Edita tu crontab con
crontab -e
. - Añade esta línea (ajusta rutas absolutas y asegúrate de que el servidor esté en UTC+1/CEST según corresponda): swiftCopiarEditar
0 9 * * MON TZ=Europe/Madrid /usr/bin/env python3 /ruta/a/update_trends.py >> /ruta/a/update_trends.log 2>&1
0 9 * * MON
→ cada lunes a las 09:00.TZ=Europe/Madrid
para garantizar horario CET/CEST.- La salida se redirige al mismo log configurado.
Puntos clave
- La librería searchconsole devuelve DataFrames listos para pandas .
- Search Console limita a 50 k filas/día y requiere paginación si superas ese umbral .
- schedule o un cron en Cloud Scheduler ejecutan el script sin servidor propio .
- Guardar en Parquet mantiene el histórico comprimido y listo para consultas.
4. Tabla comparativa de alternativas
Herramienta / Enfoque | Tipo | Complejidad | Coste estimado* | Pros | Contras |
---|---|---|---|---|---|
pandas + Plotly | OSS local | Media | Gratis | Unión flexible, gráficos interactivos [oai_citation:19‡Data Apps for Production | Plotly](https://plotly.com/python/?utm_source=chatgpt.com) |
Looker Studio (ant. Data Studio) | SaaS | Baja | Gratis | Conector nativo GSC | Pocas transformaciones |
BigQuery + SQL | SaaS | Media | 5 $/TB consultado | Escala masivo, SQL | Curva SQL, coste por TB |
Power BI + Conector GSC | Desktop/SaaS | Media | 10 €/mes Pro | Dashboards empresariales | Instalación local |
Excel Power Query | Desktop | Baja | Licencia Office | Fácil para analistas | Lento > 100 k filas |
R (tidyverse) | OSS | Media | Gratis | Elegante para EDA | Menor adopción SEO |
AWS Athena + Glue | SaaS | Alta | 5 $/TB | Serverless SQL | Set-up S3, IAM |
Apache Superset | OSS | Alta | Gratis | BI open-source | Deploy complejo |
Tableau + Conector | Desktop/SaaS | Media | 70 $/mes | Visualización top | Licencia cara |
Airflow + Python | OSS | Alta | Gratis | Orquestación tareas | Sobrecarga infra |
*Precios de referencia mayo 2025.
5. Resumiendo
- pandas domina el wrangling: merge, groupby y formatos como Parquet te dan agilidad y eficiencia sin salir de Python .
- Visualizar es convencer: matplotlib cubre lo esencial, Plotly ofrece interacción web —ambas son gratuitas y documentadas a fondo .
- Automatiza o muere: programar descargas periódicas con schedule, cron o Cloud Scheduler libera al analista y mantiene el histórico íntegro .
- Mide el impacto semántico: al combinar datos antes/después, el gráfico revela si añadir términos o entidades elevó clics y CTR —y si no, qué páginas necesitan una segunda pasada.
Con estos prompts, scripts y panorama de herramientas tendrás un laboratorio de datos SEO siempre fresco, listo para validar (o refutar) tus hipótesis semánticas. ¡Hora de convertir números en decisiones de contenido!