Visualización avanzada de datos SEO con Python

image 6

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)

  1. Edita tu crontab con crontab -e.
  2. 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 / EnfoqueTipoComplejidadCoste estimado*ProsContras
pandas + PlotlyOSS localMediaGratisUnión flexible, gráficos interactivos [oai_citation:19‡Data Apps for ProductionPlotly](https://plotly.com/python/?utm_source=chatgpt.com)
Looker Studio (ant. Data Studio)SaaSBajaGratisConector nativo GSCPocas transformaciones
BigQuery + SQLSaaSMedia5 $/TB consultadoEscala masivo, SQLCurva SQL, coste por TB
Power BI + Conector GSCDesktop/SaaSMedia10 €/mes ProDashboards empresarialesInstalación local
Excel Power QueryDesktopBajaLicencia OfficeFácil para analistasLento > 100 k filas
R (tidyverse)OSSMediaGratisElegante para EDAMenor adopción SEO
AWS Athena + GlueSaaSAlta5 $/TBServerless SQLSet-up S3, IAM
Apache SupersetOSSAltaGratisBI open-sourceDeploy complejo
Tableau + ConectorDesktop/SaaSMedia70 $/mesVisualización topLicencia cara
Airflow + PythonOSSAltaGratisOrquestación tareasSobrecarga 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!

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