El reporting manual es una de las tareas más tediosas del SEO: exportar CSV desde Search Console, formatear datos en Excel, crear gráficos, enviar reporte por email. Repetir. Cada. Semana.
Google Search Console API resuelve este problema. Pero tiene una barrera de entrada: OAuth 2.0, autenticación, rate limits, tokens que expiran. Parece complicado.
No lo es. Vamos a construir un framework completo de reporting automatizado con Python que:
- Se conecta a GSC API con OAuth 2.0 (guía paso a paso incluida)
- Genera 4 tipos de reportes automáticamente
- Exporta en múltiples formatos (JSON, CSV, HTML, email)
- Guarda históricos y calcula comparativas MoM/YoY
- Envía alertas cuando hay cambios significativos
Por qué automatizar reporting con GSC API
Problemas del reporting manual:
- Perderás tiempo: 1-2 horas por reporte, cada semana
- Error humano: Copy-paste mistakes, fórmulas que se rompen
- No escalable: 1 sitio está bien, 10 clientes es un nightmare
- Reactividad vs proactividad: Ves el pasado, no detectas cambios en tiempo real
Ventajas del reporting automatizado:
- Ahorra 20+ horas/mes: Scripts que ejecutan en segundos
- 100% preciso: Sin error humano, datos consistentes
- Escalable: Añade sitios, clientes, keywords sin costo extra
- Proactivo: Alertas automáticas cuando algo cambia
Qué vas a construir
Vamos a crear un framework con 4 reportes modulares:
- Reporte Performance Diario — Métricas agregadas del sitio completo
- Rankings Tracker — Seguimiento de keywords específicas con históricos
- Landing Pages Analysis — Top páginas + oportunidades de optimización
- Comparativa MoM/YoY — Comparación automática con deltas calculados
Cada reporte es independiente pero comparte el mismo framework de autenticación y utilidades.
Prerrequisitos
- Cuenta de Google con acceso a Search Console
- Python 3.10+ instalado
- Propiedad verificada en GSC (mínimo 48h de datos)
- Ganas de automatizar tu trabajo SEO
OAuth 2.0: Guía Completa Paso a Paso
Antes de acceder a GSC API, necesitas autenticarte. Google usa OAuth 2.0, que parece intimidante pero sigue un proceso claro.
Paso 1: Crear proyecto en Google Cloud Console
- Navega a console.cloud.google.com
- Click en "Seleccionar un proyecto" → "NUEVO PROYECTO"
- Nombre: gsc-seo-reporting (o el que prefieras)
- Click "CREAR"
Paso 2: Habilitar Search Console API
- En el menú: "API y servicios" → "Biblioteca"
- Busca: "Google Search Console API"
- Click en la API → "HABILITAR"
Paso 3: Configurar OAuth 2.0 consent screen
- Nombre de la aplicación: GSC SEO Reporting
- Email de soporte técnico: tu email
• Click "GUARDAR Y CONTINUAR" (puedes omitir los demás campos)
Paso 4: Crear credenciales OAuth
- En "Pantalla de consentimiento" → "Credenciales"
- Tipo: "ID de cliente OAuth 2.0"
- Application type: "Aplicación de ordenador"
- Nombre: GSC Reporting Client
Paso 5: Descargar credentials JSON
- Click "CREAR" en el cliente OAuth
- Se descargará un archivo client_secret_XXX.apps.googleusercontent.com.json
- Importante: Renómbralo a credentials.json y guárdalo en tu proyecto
- ¡Nunca commits este archivo! Añádelo a .gitignore
Paso 6: Primer token de acceso
La primera vez necesitas autorizar manualmente. El script de Python hará esto por ti:
- Ejecutar el script (ver sección de autenticación)
- Se abrirá una ventana de navegador pidiendo permiso
- Click "Autorizar"
- Se generará un archivo token.json con tu access token
Nota: Los tokens expiran. El framework incluye autorenovación automática.
Troubleshooting OAuth común
Framework Base: Estructura Reutilizable
Antes de los reportes, establezcamos el framework que todos compartirán.
Estructura de proyecto
gsc-reporting/
├── credentials.json # OAuth credentials (NO commitear)
├── token.json # Access token (se genera automáticamente)
├── .env # Variables de entorno
├── requirements.txt # Dependencias Python
├── config.py # Configuración centralizada
├── auth.py # OAuth 2.0 handler
├── utils/
│ ├── exporter.py # Export JSON, CSV, HTML
│ ├── alerts.py # Alertas automáticas
│ └── database.py # SQLite handler
├── reports/
│ ├── performance.py # Reporte 1
│ ├── rankings.py # Reporte 2
│ ├── landing_pages.py # Reporte 3
│ └── comparative.py # Reporte 4
└── main.py # Entry pointrequirements.txt
google-api-python-client==2.100.0
google-auth-httplib2==0.1.1
google-auth-oauthlib==1.0.0
pandas==2.1.0
jinja2==3.1.2
python-dotenv==1.0.0config.py - Configuración centralizada
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
# GSC Property URL
PROPERTY_URL = os.getenv('PROPERTY_URL', 'https://www.example.com')
# Reportes configuration
START_DATE = os.getenv('START_DATE', '2023-01-01')
END_DATE = os.getenv('END_DATE', '2023-12-31')
# Alertas thresholds
ALERT_CLICKS_CHANGE = float(os.getenv('ALERT_CLICKS_CHANGE', '0.2')) # 20%
ALERT_POSITION_CHANGE = int(os.getenv('ALERT_POSITION_CHANGE', '3'))
# Database
DB_PATH = os.getenv('DB_PATH', 'gsc_data.db')
# Email alerts (opcional)
SMTP_SERVER = os.getenv('SMTP_SERVER', 'smtp.gmail.com')
SMTP_PORT = int(os.getenv('SMTP_PORT', '587'))
EMAIL_USER = os.getenv('EMAIL_USER')
EMAIL_PASSWORD = os.getenv('EMAIL_PASSWORD')
EMAIL_TO = os.getenv('EMAIL_TO', '').split(',')
config = Config()auth.py - OAuth 2.0 handler
import os
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
SCOPES = ['https://www.googleapis.com/auth/webmasters.readonly']
def authenticate():
"""
Autentica con GSC API usando OAuth 2.0
Genera token.json si no existe
Retorna cliente autenticado
"""
creds = None
# Token existe?
if os.path.exists('token.json'):
creds = Credentials.from_authorized_user_file('token.json', SCOPES)
# Si no hay token o está expirado, renovar
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
'credentials.json', scopes=SCOPES
)
flow.run_local_server(port=0)
creds = flow.credentials
# Guardar token para futuros usos
n with open('token.json', 'w') as token:
token.write(creds.to_json())
# Retornar cliente de GSC API
service = build('searchconsole', 'v1', credentials=creds)
return serviceReporte 1: Performance Diario
Propósito: Métricas agregadas del sitio completo (clicks, impressions, CTR, position) en formato daily summary.
Script completo
# reports/performance.py
from datetime import datetime, timedelta
import pandas as pd
from auth import authenticate
from config import config
from utils.exporter import export_json, export_csv, export_html
def get_performance_report(days=7):
"""
Obtiene reporte de performance de los últimos N días
Args:
days: Número de días a consultar
Returns:
DataFrame con datos de performance
"""
service = authenticate()
# Calcular rango de fechas
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
print(f"Consultando GSC API: {start_date} a {end_date}")
# Consultar GSC API
request = {
'startDate': start_date,
'endDate': end_date,
'dimensions': ['date'], # Agrupar por fecha
'type': 'web'
}
response = service.searchanalytics().query().get(body=request).execute()
# Parsear respuesta
rows = response.get('rows', [])
if not rows:
print("No data found para el período")
return None
# Crear DataFrame
data = []
for row in rows:
data.append({
'date': row['keys'][0],
'clicks': row['clicks'],
'impressions': row['impressions'],
'ctr': round(row['ctr'] * 100, 2),
'position': round(row['position'], 2)
})
df = pd.DataFrame(data)
df['date'] = pd.to_datetime(df['date'])
df = df.sort_values('date')
# Métricas agregadas
summary = {
'period': f"{start_date} to {end_date}",
'total_clicks': int(df['clicks'].sum()),
'total_impressions': int(df['impressions'].sum()),
'avg_ctr': round(df['clicks'].sum() / df['impressions'].sum() * 100, 2),
'avg_position': round(df['position'].mean(), 2),
'days_with_data': len(df)
}
print(f"\n📊 Resumen:")
print(f"Clicks: {summary['total_clicks']:,}")
print(f"Impressions: {summary['total_impressions']:,}")
print(f"CTR: {summary['avg_ctr']}%")
print(f"Avg Position: {summary['avg_position']}")
return df, summary
if __name__ == '__main__':
df, summary = get_performance_report(days=30)
if df is not None:
# Exportar en múltiples formatos
export_json(df, 'performance_report.json', summary)
export_csv(df, 'performance_report.csv')
export_html(df, 'performance_report.html', 'Performance Report', summary)
print("\n✅ Reportes generados:")
print(" - performance_report.json")
print(" - performance_report.csv")
print(" - performance_report.html")utils/exporter.py - Export multi-formato
import pandas as pd
import json
from jinja2 import Template
from datetime import datetime
def export_json(df, filename, summary=None):
"""Exporta DataFrame a JSON"""
output = {
'generated_at': datetime.now().isoformat(),
'data': df.to_dict('records'),
'summary': summary
}
with open(filename, 'w', encoding='utf-8') as f:
json.dump(output, f, indent=2, ensure_ascii=False)
def export_csv(df, filename):
"""Exporta DataFrame a CSV"""
df.to_csv(filename, index=False, encoding='utf-8-sig')
def export_html(df, filename, title, summary=None):
"""Exporta DataFrame a HTML con template"""
template = Template('''
<!DOCTYPE html>
<html>
<body>
<h1>{{ title }}</h1>
{% if summary %}
<div class="summary">
<h2>Resumen</h2>
<p><strong>Periodo:</strong> {{ summary.period }}</p>
<p><strong>Clicks:</strong> {{ "{:,.0f}".format(summary.total_clicks) }}</p>
<p><strong>Impressions:</strong> {{ "{:,.0f}".format(summary.total_impressions) }}</p>
<p><strong>CTR:</strong> {{ summary.avg_ctr }}%</p>
<p><strong>Avg Position:</strong> {{ summary.avg_position }}</p>
</div>
{% endif %}
<table>
<thead>
<tr>
{% for col in columns %}
<th>{{ col.upper() }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for index, row in data.iterrows() %}
<tr>
{% for col in columns %}
<td>{{ row[col] }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<p style="color: #666; font-size: 12px;">Generated: {{ generated_at }}</p>
</body>
</html>
''')
html = template.render(
title=title,
summary=summary,
columns=df.columns.tolist(),
data=df,
generated_at=datetime.now().strftime('%Y-%m-%d %H:%M')
)
with open(filename, 'w', encoding='utf-8') as f:
f.write(html)Cron job para ejecución diaria
# Ejecutar todos los días a las 9am
0 9 * * * cd /ruta/a/gsc-reporting && python -m reports.performance >> logs/cron.log 2>&1
# Ejecutar semanalmente los lunes a las 8am
0 8 * * 1 cd /ruta/a/gsc-reporting && python -m reports.performance >> logs/cron.log 2>&1Reporte 2: Rankings Tracker
Propósito: Seguimiento de keywords específicas con guardado de históricos en SQLite.
Script completo
# reports/rankings.py
from datetime import datetime, timedelta
import sqlite3
import pandas as pd
from auth import authenticate
from config import config
from utils.database import init_db, save_ranking_snapshot
def get_keyword_rankings(keywords, days=7):
"""
Obtiene rankings actuales para keywords específicas
Args:
keywords: Lista de keywords a trackear
days: Días de datos a consultar
Returns:
DataFrame con rankings por keyword
"""
service = authenticate()
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
print(f"Tracking {len(keywords)} keywords...")
# Consultar GSC API para cada keyword
all_data = []
for keyword in keywords:
request = {
'startDate': start_date,
'endDate': end_date,
'query': keyword,
'type': 'web'
}
try:
response = service.searchanalytics().query().get(body=request).execute()
rows = response.get('rows', [])
if rows:
# Agregar datos del último día disponible
row = rows[-1]
all_data.append({
'keyword': keyword,
'clicks': row['clicks'],
'impressions': row['impressions'],
'ctr': round(row['ctr'] * 100, 2),
'position': round(row['position'], 2),
'date': row['keys'][0] # Fecha del dato
})
except Exception as e:
print(f"Error consultando '{keyword}': {e}")
if not all_data:
print("No data encontrado")
return None
df = pd.DataFrame(all_data)
df = df.sort_values('impressions', ascending=False)
return df
def compare_with_previous(current_df, keyword):
"""
Compara ranking actual con snapshot anterior
Args:
current_df: DataFrame con rankings actuales
keyword: Keyword específica a comparar
Returns:
Dict con cambio de posición
"""
conn = sqlite3.connect(config.DB_PATH)
# Obtener último registro de esta keyword
query = '''
SELECT position, date FROM rankings_history
WHERE keyword = ?
ORDER BY date DESC
LIMIT 1
'''
previous = pd.read_sql_query(query, conn, params=(keyword,))
conn.close()
if previous.empty:
return {'keyword': keyword, 'change': 'NEW', 'previous_pos': None, 'current_pos': None}
previous_pos = previous.iloc[0]['position']
current_pos = current_df[current_df['keyword'] == keyword].iloc[0]['position']
position_change = previous_pos - current_pos
return {
'keyword': keyword,
'change': position_change,
'previous_pos': previous_pos,
'current_pos': current_pos,
'change_type': 'UP' if position_change > 0 else 'DOWN' if position_change < 0 else 'SAME'
}
if __name__ == '__main__':
# Inicializar database
init_db()
# Keywords a trackear
KEYWORDS = [
'neumaticos online',
'comprar neumaticos',
'neumaticos baratos',
'cambiar neumaticos',
'neumaticos coche'
]
# Obtener rankings actuales
df = get_keyword_rankings(KEYWORDS, days=30)
if df is not None:
print("\n📊 Rankings actuales:")
print(df[['keyword', 'position', 'clicks', 'impressions']].to_string(index=False))
# Guardar snapshot
save_ranking_snapshot(df)
print("\n✅ Snapshot guardado en database")
# Comparar con anterior
print("\n🔄 Cambios desde última medición:")
for kw in KEYWORDS:
comparison = compare_with_previous(df, kw)
if comparison['change'] != 'NEW':
arrow = "↑" if comparison['change_type'] == 'UP' else "↓" if comparison['change_type'] == 'DOWN' else "→"
print(f" {kw}: {comparison['previous_pos']:.1f} {arrow} {comparison['current_pos']:.1f}")utils/database.py - SQLite handler
import sqlite3
from datetime import datetime
from config import config
def init_db():
"""Inicializa database y crea tablas"""
conn = sqlite3.connect(config.DB_PATH)
c = conn.cursor()
# Tabla de rankings_history
c.execute('''
CREATE TABLE IF NOT EXISTS rankings_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
keyword TEXT,
clicks INTEGER,
impressions INTEGER,
ctr REAL,
position REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Índice para búsquedas rápidas
c.execute('''
CREATE INDEX IF NOT EXISTS idx_keyword_date
ON rankings_history(keyword, date)
''')
conn.commit()
conn.close()
def save_ranking_snapshot(df):
"""Guarda snapshot de rankings en database"""
conn = sqlite3.connect(config.DB_PATH)
c = conn.cursor()
today = datetime.now().strftime('%Y-%m-%d')
for _, row in df.iterrows():
c.execute('''
INSERT INTO rankings_history
(date, keyword, clicks, impressions, ctr, position)
VALUES (?, ?, ?, ?, ?, ?)
''', (
today,
row['keyword'],
row['clicks'],
row['impressions'],
row['ctr'],
row['position']
))
conn.commit()
conn.close()
def get_ranking_history(keyword, days=30):
"""Obtiene histórico de rankings para una keyword"""
conn = sqlite3.connect(config.DB_PATH)
query = '''
SELECT date, position, clicks, impressions
FROM rankings_history
WHERE keyword = ?
AND date >= date('now', '-' || ? || ' days')
ORDER BY date ASC
'''
df = pd.read_sql_query(query, conn, params=(keyword, days), parse_dates=['date'])
conn.close()
return dfReporte 3: Landing Pages Analysis
Propósito: Identificar top páginas por clicks/impressions y encontrar oportunidades de optimización.
# reports/landing_pages.py
from datetime import datetime, timedelta
import pandas as pd
from auth import authenticate
def get_landing_pages_report(days=30, rows_limit=20):
"""
Obtiene reporte de landing pages
Args:
days: Días de datos a consultar
rows_limit: Máximo de páginas a retornar
Returns:
DataFrame con landing pages ordenadas
"""
service = authenticate()
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
print(f"Consultando top {rows_limit} landing pages...")
request = {
'startDate': start_date,
'endDate': end_date,
'dimensions': ['page'],
'rowLimit': rows_limit,
'type': 'web'
}
response = service.searchanalytics().query().get(body=request).execute()
rows = response.get('rows', [])
if not rows:
print("No data encontrado")
return None
data = []
for row in rows:
page_url = row['keys'][0]
# Extraer path limpio
if page_url.startswith('https://'):
path = page_url.split('/', 3)[-1]
else:
path = page_url
data.append({
'page': page_url,
'path': path,
'clicks': row['clicks'],
'impressions': row['impressions'],
'ctr': round(row['ctr'] * 100, 2),
'position': round(row['position'], 2)
})
df = pd.DataFrame(data)
df = df.sort_values('impressions', ascending=False)
return df
def identify_opportunities(df):
"""
Identifica páginas con oportunidades de optimización
Criterios:
- Alta impressions + bajo CTR (<2%)
- Posición 11-20 (casi en top 10)
- Alta oportunidad de mejora
"""
if df is None:
return []
opportunities = []
for _, row in df.iterrows():
opp_score = 0
reasons = []
# Alta impressions, bajo CTR
if row['impressions'] > 100 and row['ctr'] < 2:
opp_score += 30
reasons.append('Alta impresión, bajo CTR')
# Posición 11-20
if 11 <= row['position'] <= 20:
opp_score += 20
reasons.append('Posición 11-20 (cerca top 10)')
# Buenas impresiones, posición mejorable
if row['impressions'] > 500 and row['position'] > 5:
opp_score += 15
reasons.append('Buen tráfico, posición >5')
if opp_score > 0:
opportunities.append({
'page': row['page'],
'path': row['path'],
'clicks': row['clicks'],
'impressions': row['impressions'],
'ctr': row['ctr'],
'position': row['position'],
'opportunity_score': opp_score,
'reasons': reasons
})
# Ordenar por oportunidad
opportunities.sort(key=lambda x: x['opportunity_score'], reverse=True)
return opportunities
if __name__ == '__main__':
df = get_landing_pages_report(days=90, rows_limit=50)
if df is not None:
print("\n📊 Top 10 Landing Pages (by impressions):")
print(df[['path', 'impressions', 'clicks', 'ctr', 'position']].head(10).to_string(index=False))
# Identificar oportunidades
opportunities = identify_opportunities(df)
if opportunities:
print("\n🎯 Páginas con Oportunidades de Optimización:")
for opp in opportunities[:10]:
print(f"\n {opp['path']}")
print(f" Score: {opp['opportunity_score']} | Pos: {opp['position']} | CTR: {opp['ctr']}%")
print(f" Razones: {', '.join(opp['reasons'])}")Reporte 4: Comparativa MoM/YoY
Propósito: Comparación automática Mes-a-Mes y Año-a-Año con deltas calculados.
# reports/comparative.py
from datetime import datetime, timedelta
import pandas as pd
from auth import authenticate
def get_period_data(days_ago):
"""
Obtiene datos de un período específico
Args:
days_ago: Días hacia atrás desde hoy
Returns:
DataFrame con datos agregados del período
"""
service = authenticate()
end_date = datetime.now() - timedelta(days=days_ago)
start_date = end_date - timedelta(days=30) # Período de 30 días
request = {
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'type': 'web'
}
response = service.searchanalytics().query().get(body=request).execute()
rows = response.get('rows', [])
if not rows:
return None
# Sumar todas las filas (agregado)
total = {
'clicks': sum(row['clicks'] for row in rows),
'impressions': sum(row['impressions'] for row in rows),
}
total['ctr'] = (total['clicks'] / total['impressions'] * 100) if total['impressions'] > 0 else 0
return total
def calculate_comparative():
"""
Calcula comparativa MoM y YoY
Returns:
Dict con comparativas y deltas
"""
# Período actual (últimos 30 días)
current = get_period_data(days_ago=30)
# Período anterior (hace 30-60 días)
previous = get_period_data(days_ago=60)
# YoY (hace 365 días)
last_year = get_period_data(days_ago=395)
if not current:
print("No data disponible")
return None
# Calcular deltas
results = {
'current': current,
'previous': previous,
'last_year': last_year,
'mom': {},
'yoy': {}
}
# Mes-a-Mes
if previous:
for metric in ['clicks', 'impressions', 'ctr']:
current_val = current[metric]
previous_val = previous[metric]
delta = current_val - previous_val
delta_pct = (delta / previous_val * 100) if previous_val > 0 else 0
results['mom'][metric] = {
'current': round(current_val, 2),
'previous': round(previous_val, 2),
'delta': round(delta, 2),
'delta_pct': round(delta_pct, 2)
}
# Año-a-Año
if last_year:
for metric in ['clicks', 'impressions', 'ctr']:
current_val = current[metric]
last_year_val = last_year[metric]
delta = current_val - last_year_val
delta_pct = (delta / last_year_val * 100) if last_year_val > 0 else 0
results['yoy'][metric] = {
'current': round(current_val, 2),
'previous': round(last_year_val, 2),
'delta': round(delta, 2),
'delta_pct': round(delta_pct, 2)
}
return results
def format_comparative_report(results):
"""Formatea reporte comparativo para visualización"""
lines = []
lines.append("="*50)
lines.append("COMPARATIVA MoM / YoY")
lines.append("="*50)
# MoM
if results.get('mom'):
lines.append("\n📊 MES-A-MES (últimos 30 días vs 30 días anteriores):")
lines.append("-")
for metric, data in results['mom'].items():
arrow = "↑" if data['delta'] > 0 else "↓" if data['delta'] < 0 else "→"
delta_str = f"{data['delta_pct']:+.1f}%"
lines.append(f" {metric.upper()}: {data['current']:,.0f} → {data['previous']:,.0f} ({arrow} {delta_str})")
# YoY
if results.get('yoy'):
lines.append("\n📈 AÑO-A-AÑO (vs hace 1 año):")
lines.append("-")
for metric, data in results['yoy'].items():
arrow = "↑" if data['delta'] > 0 else "↓" if data['delta'] < 0 else "→"
delta_str = f"{data['delta_pct']:+.1f}%"
lines.append(f" {metric.upper()}: {data['current']:,.0f} → {data['previous']:,.0f} ({arrow} {delta_str})")
lines.append("\n" + "="*50)
return "\n".join(lines)
if __name__ == '__main__':
results = calculate_comparative()
if results:
report = format_comparative_report(results)
print(report)
# Guardar en archivo
filename = f"comparative_{datetime.now().strftime('%Y%m%d')}.txt"
with open(filename, 'w', encoding='utf-8') as f:
f.write(report)
print(f"\n✅ Reporte guardado: {filename}")Alertas Automatizadas
Propósito: Notificar automáticamente cuando hay cambios significativos en los datos.
# utils/alerts.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from config import config
def check_significant_changes(current_df, previous_df):
"""
Compara dos períodos y detecta cambios significativos
Returns:
Lista de alertas
"""
alerts = []
# Comparar métricas agregadas
current_clicks = current_df['clicks'].sum()
previous_clicks = previous_df['clicks'].sum()
if previous_clicks > 0:
clicks_change_pct = (current_clicks - previous_clicks) / previous_clicks
if abs(clicks_change_pct) >= config.ALERT_CLICKS_CHANGE:
alerts.append({
'type': 'clicks_change',
'severity': 'high' if abs(clicks_change_pct) > 0.3 else 'medium',
'message': f"Clicks {'+' if clicks_change_pct > 0 else ''}{clicks_change_pct:.1%} vs período anterior",
'current_value': int(current_clicks),
'previous_value': int(previous_clicks)
})
# Detectar nuevas keywords en top 10
# (Esto requiere tracking histórico, pseudo-código)
# new_top_keywords = detect_new_top_positions(current_df, previous_df)
return alerts
def send_email_alert(alerts):
"""Envía alertas por email"""
if not config.EMAIL_TO:
print("⚠️ Email no configurado (set EMAIL_TO en .env)")
return
if not alerts:
print("No hay alertas para enviar")
return
# Crear mensaje
msg = MIMEMultipart()
msg['From'] = config.EMAIL_USER
msg['To'] = ', '.join(config.EMAIL_TO)
msg['Subject'] = f"🚨 Alertas SEO - {len(alerts)} cambios detectados"
# Cuerpo del email
body = "<h2>Alertas de Cambios Significativos</h2>\n"
body += "<ul>"
for alert in alerts:
severity_icon = "🔴" if alert['severity'] == 'high' else "⚠️"
body += f"<li>{severity_icon} {alert['message']}</li>"
body += "</ul>"
body += "<p><em>Automated by GSC Reporting Framework</em></p>"
msg.attach(MIMEText(body, 'html'))
# Enviar
try:
with smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) as server:
server.starttls()
server.login(config.EMAIL_USER, config.EMAIL_PASSWORD)
server.send_message(msg)
print(f"✅ Email enviado a {len(config.EMAIL_TO)} destinatarios")
except Exception as e:
print(f"❌ Error enviando email: {e}")
# Integración en reportes
# from utils.alerts import check_significant_changes, send_email_alert
# alerts = check_significant_changes(current_df, previous_df)
# if alerts:
# send_email_alert(alerts)Deployment en Producción
Opción 1: Cron Jobs (Linux/Mac)
# Editar crontab
crontab -e
# Añadir línea para ejecutar diariamente a las 9am
0 9 * * * cd /ruta/a/gsc-reporting && python -m reports.performance >> logs/cron.log 2>&1
# Verificar cron jobs
crontab -lOpción 2: GitHub Actions
# .github/workflows/gsc-report.yml
name: GSC Weekly Report
on:
schedule:
- cron: '0 9 * * 1' # Lunes a las 9am
workflow_dispatch: # Permitir ejecución manual
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.10'
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run performance report
env:
PROPERTY_URL: ${{ secrets.PROPERTY_URL }}
run: |
python -m reports.performance
- name: Upload reports
uses: actions/upload-artifact@v2
with:
name: gsc-reports
path: '*.json,*.csv,*.htmlOpción 3: Google Cloud Scheduler
# main.py - Modificado para Cloud Functions
def run_performance_report(request):
"""HTTP endpoint para Cloud Functions"""
from reports.performance import get_performance_report
df, summary = get_performance_report(days=7)
return {
'statusCode': 200,
'body': json.dumps({
'success': True,
'summary': summary
})
}
# Desplegar con:
# gcloud functions deploy gsc-reporting --runtime python39 --trigger-httpConclusión
Con este framework de reporting automatizado, tienes:
- 4 reportes modulares: Performance, Rankings, Landing Pages, Comparativas
- Multi-formato: JSON, CSV, HTML, email según necesites
- Históricos: Base de datos SQLite para tracking temporal
- Alertas: Notificaciones automáticas de cambios significativos
- Automatización: Cron, GitHub Actions, Cloud Scheduler
Casos de uso del framework
Próximos pasos
- Añade más dimensiones: Device (mobile/desktop), country, search appearance
- Integra con Analytics: Combina GSC + GA4 para reportes completos
- Dashboard visual: Conecta con Metabase/Grafana para visualización
- Predicción: Usa históricos para predecir tendencias con scikit-learn
Recursos recomendados
- GSC API Documentation
- Google API Python Client
- Cron Tutorial
- GitHub Actions Documentation
Sobre el Autor
Alfonso Calero Gijón es Editor de 4SEO.es, portal de noticias y comunidad SEO. Especialista en automatización de procesos SEO y arquitectura de información. Escribe análisis y guías sobre automatización SEO para la comunidad.
Conectar en LinkedIn →
¿Necesitas ayuda con automatización SEO?
Sigue en 4SEO → Más guías en el blog
Blog 4SEO → Más guías en el blog
Blog 4SEO → Más guías de SEO técnico
Artículos Relacionados
- Python para SEO: Automatiza tu Trabajo Diario
- API REST para SEO: Integraciones y Dashboards
- Auditoría SEO Técnica: Guía Completa 2026
