Integración con OpenCode

This commit is contained in:
Rompetechos cuenta de desarrollo 2026-07-09 16:49:00 +02:00
parent a7f8ddad08
commit 7228cf1b65
8 changed files with 910 additions and 808 deletions

18
.claude/settings.json Normal file
View File

@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(python backfill_games_mayo.py)",
"Bash(findstr /i \"GAMes Actualiz MetricasDiarias actualizado\" \"C:\\\\Users\\\\jmgom\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-jmgom-projects-leads-optimizer\\\\2e73bc62-0b35-4293-96e9-676bded17b5f\\\\tasks\\\\bf9cfc1r9.output\")",
"Bash(findstr /i /c:\"actualiz\" \"C:\\\\Users\\\\jmgom\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-jmgom-projects-leads-optimizer\\\\2e73bc62-0b35-4293-96e9-676bded17b5f\\\\tasks\\\\bf9cfc1r9.output\")",
"Bash(python -c ' *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(pkill -f \"streamlit run dashboard.py\")",
"Skill(run)",
"Skill(run:*)"
],
"additionalDirectories": [
"C:\\Users\\jmgom\\projects\\meta-optimizer"
]
}
}

View File

@ -1,14 +1,14 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(pip install *)", "Bash(pip install *)",
"Bash(python -m py_compile run.py airtable_client.py google_ads_client.py)", "Bash(python -m py_compile run.py airtable_client.py google_ads_client.py)",
"Bash(python -m py_compile run.py airtable_client.py slack_reporter.py)", "Bash(python -m py_compile run.py airtable_client.py slack_reporter.py)",
"Bash(python -m py_compile run.py)", "Bash(python -m py_compile run.py)",
"Bash(python run.py)", "Bash(python run.py)",
"Bash(git add *)", "Bash(git add *)",
"Bash(python migrate_leads_field.py)", "Bash(python migrate_leads_field.py)",
"Bash(python backfill_leads_google_mayo.py)" "Bash(python backfill_leads_google_mayo.py)"
] ]
} }
} }

84
AGENTS.md Normal file
View File

@ -0,0 +1,84 @@
# Leads Optimizer — AGENTS.md
## Quick start
```bash
pip install -r requirements.txt
python run.py # main optimizer run
python weekly_report.py # weekly strategic report
streamlit run dashboard.py # Streamlit dashboard (port 15002)
```
Python 3.12+. No build step, no linter, no test suite.
## Execution entry points
| File | Trigger | What it does |
|------|---------|-------------|
| `run.py` | daily.yml (00:00 UTC) or manual | Syncs Google Ads → Airtable, analyses campaigns, applies budget decisions, reports to Slack |
| `weekly_report.py` | weekly.yml (Mon 07:00 UTC) or manual | Deeper week-over-week analysis via Anthropic, sends Slack |
| `dashboard.py` | `streamlit run dashboard.py` | UI on port 15002, reads from Airtable directly |
## DRY_RUN mode
`config.py` has `DRY_RUN = True` by default. When `True`, decisions are printed but **no changes are applied to Google Ads**. Must be set to `False` to apply budget/pause changes. This is a module-level constant, not an env var.
## Required env vars (loaded via `python-dotenv` from `.env`)
All are mandatory except `SLACK_WEBHOOK_URL` (optional, has empty default):
- `AIRTABLE_TOKEN`, `AIRTABLE_BASE_ID`
- `Google Ads`: `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_CLIENT_ID`, `GOOGLE_ADS_CLIENT_SECRET`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_LOGIN_CUSTOMER_ID` (digits only, no dashes)
- `ANTHROPIC_API_KEY`
- `SLACK_WEBHOOK_URL`
The `.github/workflows/` files pull these from GitHub Secrets. `run.sh` has **hardcoded secrets** — never commit changes to it.
## Architecture
Flat structure, no packages. Flow:
1. **Sync**: `airtable_client.py` + `google_ads_client.py` pull campaign catalog and monthly metrics from Google Ads, write to Airtable tables `Google Ads Campaigns`, `GACampaignMes`, `Leads Lake`, `MetricasDiarias`, `GAMes`.
2. **Analyze**: `analyzer.py` computes per-campaign urgency (PAUSAR / SPRINT / ACELERAR / FRENAR / EN_RITMO).
3. **Decide**: `agent.py` calls **Anthropic Claude (claude-sonnet-4-6)** per campaign to get JSON decision with action, budget multiplier, justification, and advice. Also produces portfolio-level analysis.
4. **Apply**: `optimizer.py` mutates Google Ads campaigns (budget changes, pause/unpause) — only if `DRY_RUN = False`.
5. **Report**: `slack_reporter.py` sends formatted summary to Slack via webhook.
## Campaign naming conventions
Campaigns follow naming patterns that drive logic:
- `fco_search_<N>` — Search campaigns for formation courses
- `fco_pmx_<N>` — PMX (Performance Max) campaigns
- `fco_leadform_<N>` — Lead form campaigns (leads captured inside Google, never reach Airtable)
When a course has both Search and PMX campaigns (`_search_` + `_pmx_` with same `<N>`), Search conversions reflow to PMX. When PMX has a `_leadform` companion, leadform conversions are summed into the PMX campaign's `leads_grupo`. If a course has multiple PMX campaigns (excluding leadform), paths 4/5 are disabled to avoid double counting.
## Month-boundary metric handling
`run.py` has special logic (lines ~400-432) for when yesterday belongs to a different calendar month. It merges into the **previous month's** `GACampaignMes` record instead of overwriting the new month's (avoids a known bug that erased June history). Any edits to the metrics-writing section must preserve this redirect.
## Airtable tables
- `Google Ads Campaigns` — master campaign catalog (Curso, GoogleCampaignID, PPL, CapTotalMes, CPAMaximo, Activa)
- `Leads Lake` — individual lead records (GoogleCampaignID, FechaEntrada)
- `GACampaignMes` — per-campaign monthly snapshot; updated each run with leads, advice, criticidad, metricas_diarias
- `MetricasDiarias` — JSON field with per-day {coste, ingreso, margen, leads, leads_lake}
- `GAMes` — aggregated daily totals for all fco_ campaigns + monthly totals
## Backfill and migration scripts
Scripts prefixed `backfill_*` and `migrate_*` are one-off data scripts. Do not call them from normal flow; they are only for historical data repairs.
## CI/CD
- `daily.yml` — runs `python run.py` at 00:00 UTC (2 AM CEST / 1 AM CET)
- `weekly.yml` — runs `python weekly_report.py` Monday at 07:00 UTC (9 AM CEST)
- Both use `ubuntu-latest` + Python 3.12 + secrets from GitHub
- Logs uploaded as artifacts (30-day retention)
## Gotchas
- `run.py` wraps `sys.stdout` with a custom `Tee` class that writes to both console and `logs/<timestamp>.log`. Do not remove this.
- The Anthropic model is hardcoded to `claude-sonnet-4-6` in `agent.py`. If the model changes, update all three calls (`decide`, `portfolio_daily_analysis`, `weekly_strategic_analysis`).
- `run.sh` contains real API secrets — it should NOT be committed. Use `.env` + `python run.py` instead.

160
README.md
View File

@ -1,80 +1,80 @@
# Leads Optimizer — Formación # Leads Optimizer — Formación
Agente de optimización automática de campañas Google Ads para generación de leads de formación. Agente de optimización automática de campañas Google Ads para generación de leads de formación.
Cruza datos de Airtable (leads reales) con métricas de Google Ads y decide ajustes de presupuesto. Cruza datos de Airtable (leads reales) con métricas de Google Ads y decide ajustes de presupuesto.
--- ---
## Campos requeridos en Airtable ## Campos requeridos en Airtable
### Tabla: "Google Ads Campaigns" ### Tabla: "Google Ads Campaigns"
| Campo | Tipo | Descripción | | Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------| |-------------------|---------|--------------------------------------|
| Curso | Text | Nombre del curso | | Curso | Text | Nombre del curso |
| GoogleCampaignID | Number | ID de campaña en Google Ads | | GoogleCampaignID | Number | ID de campaña en Google Ads |
| PPL | Number | Precio por lead (€) | | PPL | Number | Precio por lead (€) |
| CapTotalMes | Number | Capping mensual de leads | | CapTotalMes | Number | Capping mensual de leads |
| CPAMaximo | Number | CPA máximo tolerable (€) | | CPAMaximo | Number | CPA máximo tolerable (€) |
| Activa | Boolean | TRUE para incluir en el análisis | | Activa | Boolean | TRUE para incluir en el análisis |
### Tabla: "Leads Lake" ### Tabla: "Leads Lake"
| Campo | Tipo | Descripción | | Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------| |-------------------|---------|--------------------------------------|
| GoogleCampaignID | Text | ID de campaña de origen | | GoogleCampaignID | Text | ID de campaña de origen |
| FechaEntrada | Date | Fecha del lead (formato YYYY-MM-DD) | | FechaEntrada | Date | Fecha del lead (formato YYYY-MM-DD) |
--- ---
## Variables de entorno ## Variables de entorno
```bash ```bash
export AIRTABLE_TOKEN=pat_xxxxxxxxxxxx export AIRTABLE_TOKEN=pat_xxxxxxxxxxxx
export AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX export AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
export GOOGLE_ADS_DEVELOPER_TOKEN=xxxx export GOOGLE_ADS_DEVELOPER_TOKEN=xxxx
export GOOGLE_ADS_CLIENT_ID=xxxx.apps.googleusercontent.com export GOOGLE_ADS_CLIENT_ID=xxxx.apps.googleusercontent.com
export GOOGLE_ADS_CLIENT_SECRET=xxxx export GOOGLE_ADS_CLIENT_SECRET=xxxx
export GOOGLE_ADS_REFRESH_TOKEN=xxxx export GOOGLE_ADS_REFRESH_TOKEN=xxxx
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
export ANTHROPIC_API_KEY=sk-ant-xxxx export ANTHROPIC_API_KEY=sk-ant-xxxx
export SLACK_WEBHOOK_URL=xxx export SLACK_WEBHOOK_URL=xxx
``` ```
--- ---
## Instalación y ejecución ## Instalación y ejecución
```bash ```bash
# Instalar dependencias # Instalar dependencias
pip install -r requirements.txt pip install -r requirements.txt
# Ejecutar en modo DRY RUN (recomendado para empezar) # Ejecutar en modo DRY RUN (recomendado para empezar)
# DRY_RUN = True en config.py → solo muestra decisiones, no aplica cambios # DRY_RUN = True en config.py → solo muestra decisiones, no aplica cambios
python run.py python run.py
# Cuando estés seguro, cambiar DRY_RUN = False en config.py # Cuando estés seguro, cambiar DRY_RUN = False en config.py
python run.py python run.py
``` ```
--- ---
## Lógica de urgencia ## Lógica de urgencia
| Urgencia | Condición | Acción típica | | Urgencia | Condición | Acción típica |
|-------------|--------------------------------------------------------|-----------------------| |-------------|--------------------------------------------------------|-----------------------|
| PAUSAR | leads >= capping | Pausa campaña | | PAUSAR | leads >= capping | Pausa campaña |
| SPRINT | ritmo muy atrasado + quedan ≤ 5 días | +30-50% presupuesto | | SPRINT | ritmo muy atrasado + quedan ≤ 5 días | +30-50% presupuesto |
| ACELERAR | ritmo atrasado > 15 puntos vs ratio del mes | +10-25% presupuesto | | ACELERAR | ritmo atrasado > 15 puntos vs ratio del mes | +10-25% presupuesto |
| FRENAR | ritmo adelantado > 15 puntos vs ratio del mes | -10-25% presupuesto | | FRENAR | ritmo adelantado > 15 puntos vs ratio del mes | -10-25% presupuesto |
| EN_RITMO | dentro del margen esperado | Mantener | | EN_RITMO | dentro del margen esperado | Mantener |
--- ---
## Automatización con cron ## Automatización con cron
```bash ```bash
# Ejecutar cada día a las 8:00 # Ejecutar cada día a las 8:00
0 8 * * * cd /ruta/leads-optimizer && python run.py >> logs/optimizer.log 2>&1 0 8 * * * cd /ruta/leads-optimizer && python run.py >> logs/optimizer.log 2>&1
``` ```

508
agent.py
View File

@ -1,254 +1,254 @@
import json import json
from datetime import datetime from datetime import datetime
import anthropic import anthropic
import config import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
PORTFOLIO_SYSTEM = """ PORTFOLIO_SYSTEM = """
Eres un experto en marketing de performance para una agencia de generación de leads en formación. Eres un experto en marketing de performance para una agencia de generación de leads en formación.
Recibes datos agregados del portfolio de campañas de Google Ads (solo campañas fco_). Recibes datos agregados del portfolio de campañas de Google Ads (solo campañas fco_).
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-). Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
""" """
WEEKLY_SYSTEM = """ WEEKLY_SYSTEM = """
Eres un consultor senior de marketing de performance especializado en generación de leads para formación. Eres un consultor senior de marketing de performance especializado en generación de leads para formación.
Recibes el análisis semanal del portfolio de campañas de Google Ads (solo campañas fco_). Recibes el análisis semanal del portfolio de campañas de Google Ads (solo campañas fco_).
Tu análisis debe ser estratégico, comparando la semana actual con la anterior, identificando tendencias y proponiendo acciones concretas. Tu análisis debe ser estratégico, comparando la semana actual con la anterior, identificando tendencias y proponiendo acciones concretas.
Responde siempre en español. Sin markdown, sin bullet symbols especiales, usa guiones simples (-). Responde siempre en español. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
""" """
SYSTEM_PROMPT = """ SYSTEM_PROMPT = """
Eres un agente experto en optimización de campañas de generación de leads para centros de formación. Eres un agente experto en optimización de campañas de generación de leads para centros de formación.
Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores. Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores.
MODELO DE NEGOCIO: MODELO DE NEGOCIO:
- Ingreso = leads_entregados × PPL - Ingreso = leads_entregados × PPL
- Margen = (Ingreso - Gasto Google Ads) / Ingreso - Margen = (Ingreso - Gasto Google Ads) / Ingreso
- El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo. - El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo.
- El CPA máximo ya refleja el margen mínimo aceptable. - El CPA máximo ya refleja el margen mínimo aceptable.
ESTADO DE LA CAMPAÑA: ESTADO DE LA CAMPAÑA:
- El campo status_google indica el estado actual en Google Ads: ENABLED (activa) o PAUSED (pausada). - El campo status_google indica el estado actual en Google Ads: ENABLED (activa) o PAUSED (pausada).
- Nunca recomiendes reactivar una campaña si status_google = ENABLED (ya está activa). - Nunca recomiendes reactivar una campaña si status_google = ENABLED (ya está activa).
- Si status_google = PAUSED y la acción es AUMENTAR_PRESUPUESTO, menciona en el consejo que primero hay que reactivarla. - Si status_google = PAUSED y la acción es AUMENTAR_PRESUPUESTO, menciona en el consejo que primero hay que reactivarla.
CAPPING: CAPPING:
- Si capping = 0, significa que no hay límite de leads configurado para este mes. No menciones el capping en el consejo ni en la justificación. Toma la decisión basándote únicamente en rentabilidad y ritmo. - Si capping = 0, significa que no hay límite de leads configurado para este mes. No menciones el capping en el consejo ni en la justificación. Toma la decisión basándote únicamente en rentabilidad y ritmo.
REGLAS DE DECISIÓN: REGLAS DE DECISIÓN:
1. urgencia=PAUSAR accion=PAUSAR siempre. El capping está lleno, seguir gastando destruye margen. 1. urgencia=PAUSAR accion=PAUSAR siempre. El capping está lleno, seguir gastando destruye margen.
2. urgencia=SPRINT accion=AUMENTAR_PRESUPUESTO con parametro entre 1.3 y 1.5. Quedan pocos días y leads por entregar. 2. urgencia=SPRINT accion=AUMENTAR_PRESUPUESTO con parametro entre 1.3 y 1.5. Quedan pocos días y leads por entregar.
3. urgencia=ACELERAR y campaña rentable accion=AUMENTAR_PRESUPUESTO con parametro entre 1.1 y 1.25. 3. urgencia=ACELERAR y campaña rentable accion=AUMENTAR_PRESUPUESTO con parametro entre 1.1 y 1.25.
4. urgencia=ACELERAR y campaña NO rentable accion=MANTENER o revisar keywords (no gastar más si no convierte). 4. urgencia=ACELERAR y campaña NO rentable accion=MANTENER o revisar keywords (no gastar más si no convierte).
5. urgencia=FRENAR accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9. 5. urgencia=FRENAR accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9.
6. urgencia=EN_RITMO y rentable accion=MANTENER. 6. urgencia=EN_RITMO y rentable accion=MANTENER.
7. urgencia=EN_RITMO y NO rentable accion=REDUCIR_PRESUPUESTO con parametro 0.85. 7. urgencia=EN_RITMO y NO rentable accion=REDUCIR_PRESUPUESTO con parametro 0.85.
8. alerta_tracking=true añadir alerta sobre discrepancia de tracking aunque la acción sea otra. 8. alerta_tracking=true añadir alerta sobre discrepancia de tracking aunque la acción sea otra.
Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown: Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown:
{ {
"accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER", "accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER",
"parametro": 1.0, "parametro": 1.0,
"nuevo_budget_diario": 0.0, "nuevo_budget_diario": 0.0,
"justificacion": "explicación breve del porqué de la decisión", "justificacion": "explicación breve del porqué de la decisión",
"consejo": "acción concreta y específica que debería tomar el gestor (keywords, pujas, anuncios, configuración, etc.)", "consejo": "acción concreta y específica que debería tomar el gestor (keywords, pujas, anuncios, configuración, etc.)",
"alerta": "texto si hay algo crítico, null si no hay", "alerta": "texto si hay algo crítico, null si no hay",
"confianza": 0.0 "confianza": 0.0
} }
El campo nuevo_budget_diario = budget_diario_actual × parametro (calcula el valor final). El campo nuevo_budget_diario = budget_diario_actual × parametro (calcula el valor final).
El campo consejo debe ser accionable y específico: qué revisar, qué cambiar, qué hacer a continuación. El campo consejo debe ser accionable y específico: qué revisar, qué cambiar, qué hacer a continuación.
""" """
def _classify_type(curso: str) -> str: def _classify_type(curso: str) -> str:
c = curso.lower() c = curso.lower()
if "_leadform" in c: if "_leadform" in c:
return "leadform" return "leadform"
if "_pmx" in c or "pmx_" in c: if "_pmx" in c or "pmx_" in c:
return "pmx" return "pmx"
if "search" in c: if "search" in c:
return "search" return "search"
return "otro" return "otro"
def portfolio_daily_analysis(collected: list) -> str: def portfolio_daily_analysis(collected: list) -> str:
"""Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack.""" """Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack."""
from datetime import datetime from datetime import datetime
now = datetime.now() now = datetime.now()
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")] fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
tipos: dict = {} tipos: dict = {}
leadforms_detail = [] leadforms_detail = []
alertas_tracking = 0 alertas_tracking = 0
campañas_perdida = 0 campañas_perdida = 0
for item in fco: for item in fco:
t = _classify_type(item["campaign"]["curso"]) t = _classify_type(item["campaign"]["curso"])
m = item["metrics"] m = item["metrics"]
a = item["analysis"] a = item["analysis"]
cost = m.get("cost", 0) cost = m.get("cost", 0)
conv = a["conversiones_google"] conv = a["conversiones_google"]
ppl = item["campaign"]["ppl"] ppl = item["campaign"]["ppl"]
rev = a["revenue_estimado"] rev = a["revenue_estimado"]
margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0 margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0
if t not in tipos: if t not in tipos:
tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0} tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0}
tipos[t]["campañas"] += 1 tipos[t]["campañas"] += 1
tipos[t]["inversion"] += cost tipos[t]["inversion"] += cost
tipos[t]["conversiones"] += conv tipos[t]["conversiones"] += conv
tipos[t]["ingreso"] += rev tipos[t]["ingreso"] += rev
if a.get("alerta_tracking"): if a.get("alerta_tracking"):
alertas_tracking += 1 alertas_tracking += 1
if rev > 0 and cost > rev: if rev > 0 and cost > rev:
campañas_perdida += 1 campañas_perdida += 1
if t == "leadform": if t == "leadform":
leadforms_detail.append({ leadforms_detail.append({
"curso": item["campaign"]["curso"][:40], "curso": item["campaign"]["curso"][:40],
"cpa_google": round(cost / conv, 2) if conv > 0 else None, "cpa_google": round(cost / conv, 2) if conv > 0 else None,
"conv_google": int(conv), "conv_google": int(conv),
"conv_airtable": item["leads"], "conv_airtable": item["leads"],
"margen_pct": margen_pct, "margen_pct": margen_pct,
}) })
resumen_tipos = {} resumen_tipos = {}
for t, d in tipos.items(): for t, d in tipos.items():
cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None
ing = d["ingreso"] ing = d["ingreso"]
margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0 margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0
resumen_tipos[t] = { resumen_tipos[t] = {
"campañas": d["campañas"], "campañas": d["campañas"],
"inversion": round(d["inversion"], 2), "inversion": round(d["inversion"], 2),
"conversiones": int(d["conversiones"]), "conversiones": int(d["conversiones"]),
"cpa_medio": cpa, "cpa_medio": cpa,
"margen_pct": margen, "margen_pct": margen,
} }
data = { data = {
"fecha": now.strftime("%d/%m/%Y"), "fecha": now.strftime("%d/%m/%Y"),
"dia_del_mes": now.day, "dia_del_mes": now.day,
"campañas_totales": len(fco), "campañas_totales": len(fco),
"campañas_en_perdida": campañas_perdida, "campañas_en_perdida": campañas_perdida,
"alertas_tracking": alertas_tracking, "alertas_tracking": alertas_tracking,
"rendimiento_por_tipo": resumen_tipos, "rendimiento_por_tipo": resumen_tipos,
"detalle_leadforms": leadforms_detail, "detalle_leadforms": leadforms_detail,
} }
try: try:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=800, max_tokens=800,
system=PORTFOLIO_SYSTEM, system=PORTFOLIO_SYSTEM,
messages=[{ messages=[{
"role": "user", "role": "user",
"content": ( "content": (
"Analiza estos datos del portfolio y proporciona:\n" "Analiza estos datos del portfolio y proporciona:\n"
"1. Diagnóstico en 2 frases\n" "1. Diagnóstico en 2 frases\n"
"2. Problemas principales (máx 3, con guión)\n" "2. Problemas principales (máx 3, con guión)\n"
"3. Acciones prioritarias (máx 3, muy concretas, con guión)\n" "3. Acciones prioritarias (máx 3, muy concretas, con guión)\n"
"Si hay campañas leadform, evalúa específicamente su situación.\n\n" "Si hay campañas leadform, evalúa específicamente su situación.\n\n"
f"{json.dumps(data, ensure_ascii=False, indent=2)}" f"{json.dumps(data, ensure_ascii=False, indent=2)}"
), ),
}], }],
) )
return response.content[0].text.strip() return response.content[0].text.strip()
except Exception as e: except Exception as e:
return f"Error generando análisis: {e}" return f"Error generando análisis: {e}"
def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict, def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict,
collected: list, mes_nombre: str) -> str: collected: list, mes_nombre: str) -> str:
""" """
Análisis estratégico semanal profundo. Análisis estratégico semanal profundo.
games_md_this: MetricasDiarias de GAMes de los últimos 7 días (esta semana). games_md_this: MetricasDiarias de GAMes de los últimos 7 días (esta semana).
games_md_prev_week: MetricasDiarias de GAMes de los 7 días anteriores. games_md_prev_week: MetricasDiarias de GAMes de los 7 días anteriores.
collected: lista de campañas del optimizer. collected: lista de campañas del optimizer.
""" """
def _week_summary(md: dict) -> dict: def _week_summary(md: dict) -> dict:
coste = ing = leads = leads_lake = 0.0 coste = ing = leads = leads_lake = 0.0
for v in md.values(): for v in md.values():
coste += v.get("coste", 0) coste += v.get("coste", 0)
ing += v.get("ingreso_sum", 0) ing += v.get("ingreso_sum", 0)
leads += v.get("leads", 0) leads += v.get("leads", 0)
leads_lake += v.get("leads_lake", 0) leads_lake += v.get("leads_lake", 0)
margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0 margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0
cpa = round(coste / leads, 2) if leads > 0 else None cpa = round(coste / leads, 2) if leads > 0 else None
return {"coste": round(coste, 2), "ingreso": round(ing, 2), return {"coste": round(coste, 2), "ingreso": round(ing, 2),
"leads_google": int(leads), "leads_airtable": int(leads_lake), "leads_google": int(leads), "leads_airtable": int(leads_lake),
"margen_pct": margen, "cpa": cpa} "margen_pct": margen, "cpa": cpa}
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")] fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
# Top 5 peores por CPA del mes # Top 5 peores por CPA del mes
peores = sorted( peores = sorted(
[{"curso": i["campaign"]["curso"][:40], [{"curso": i["campaign"]["curso"][:40],
"cpa": i["analysis"]["cpa_actual"], "cpa": i["analysis"]["cpa_actual"],
"conv": int(i["analysis"]["conversiones_google"]), "conv": int(i["analysis"]["conversiones_google"]),
"margen_pct": round(i["analysis"]["margen"] * 100, 1)} "margen_pct": round(i["analysis"]["margen"] * 100, 1)}
for i in fco if i["analysis"]["cpa_actual"] > 0], for i in fco if i["analysis"]["cpa_actual"] > 0],
key=lambda x: x["cpa"], reverse=True key=lambda x: x["cpa"], reverse=True
)[:5] )[:5]
data = { data = {
"mes": mes_nombre, "mes": mes_nombre,
"semana_actual": _week_summary(games_md_this), "semana_actual": _week_summary(games_md_this),
"semana_anterior": _week_summary(games_md_prev_week), "semana_anterior": _week_summary(games_md_prev_week),
"top5_peor_cpa_mes": peores, "top5_peor_cpa_mes": peores,
"leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()), "leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()),
"campañas_totales": len(fco), "campañas_totales": len(fco),
} }
try: try:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=900, max_tokens=900,
system=WEEKLY_SYSTEM, system=WEEKLY_SYSTEM,
messages=[{ messages=[{
"role": "user", "role": "user",
"content": ( "content": (
"Genera el informe estratégico semanal con:\n" "Genera el informe estratégico semanal con:\n"
"1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n" "1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n"
"2. Tendencias clave detectadas (máx 4, con guión)\n" "2. Tendencias clave detectadas (máx 4, con guión)\n"
"3. Situación campañas leadform y qué hacer con ellas\n" "3. Situación campañas leadform y qué hacer con ellas\n"
"4. Acciones estratégicas prioritarias para la próxima semana (máx 4, muy concretas, con guión)\n" "4. Acciones estratégicas prioritarias para la próxima semana (máx 4, muy concretas, con guión)\n"
"5. Una frase de conclusión sobre si el portfolio va en la dirección correcta\n\n" "5. Una frase de conclusión sobre si el portfolio va en la dirección correcta\n\n"
f"{json.dumps(data, ensure_ascii=False, indent=2)}" f"{json.dumps(data, ensure_ascii=False, indent=2)}"
), ),
}], }],
) )
return response.content[0].text.strip() return response.content[0].text.strip()
except Exception as e: except Exception as e:
return f"Error generando análisis semanal: {e}" return f"Error generando análisis semanal: {e}"
def decide(analysis: dict) -> dict: def decide(analysis: dict) -> dict:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=750, max_tokens=750,
system=SYSTEM_PROMPT, system=SYSTEM_PROMPT,
messages=[{ messages=[{
"role": "user", "role": "user",
"content": ( "content": (
f"Analiza esta campaña y devuelve la decisión en JSON:\n\n" f"Analiza esta campaña y devuelve la decisión en JSON:\n\n"
f"{json.dumps(analysis, ensure_ascii=False, indent=2)}" f"{json.dumps(analysis, ensure_ascii=False, indent=2)}"
) )
}] }]
) )
raw = response.content[0].text.strip() raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip() clean = raw.replace("```json", "").replace("```", "").strip()
try: try:
return json.loads(clean) return json.loads(clean)
except json.JSONDecodeError: except json.JSONDecodeError:
# Fallback seguro si el modelo no devuelve JSON limpio # Fallback seguro si el modelo no devuelve JSON limpio
return { return {
"accion": "MANTENER", "accion": "MANTENER",
"parametro": 1.0, "parametro": 1.0,
"nuevo_budget_diario": analysis.get("budget_diario_actual", 0), "nuevo_budget_diario": analysis.get("budget_diario_actual", 0),
"justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.", "justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.",
"alerta": f"JSON inválido recibido: {raw[:200]}", "alerta": f"JSON inválido recibido: {raw[:200]}",
"confianza": 0.0, "confianza": 0.0,
} }

View File

@ -1,69 +1,69 @@
from datetime import datetime from datetime import datetime
import calendar import calendar
def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> dict: def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> dict:
now = datetime.now() now = datetime.now()
dias_mes = calendar.monthrange(now.year, now.month)[1] dias_mes = calendar.monthrange(now.year, now.month)[1]
dia_actual = now.day dia_actual = now.day
ratio_mes = dia_actual / dias_mes ratio_mes = dia_actual / dias_mes
capping = campaign_config["capping_mensual"] capping = campaign_config["capping_mensual"]
ppl = campaign_config["ppl"] ppl = campaign_config["ppl"]
cpa_max = campaign_config["cpa_maximo"] cpa_max = campaign_config["cpa_maximo"]
margen_objetivo = campaign_config.get("margen_objetivo", 0) margen_objetivo = campaign_config.get("margen_objetivo", 0)
gasto = ads_metrics.get("cost", 0) gasto = ads_metrics.get("cost", 0)
conversiones_google = ads_metrics.get("conversions", 0) conversiones_google = ads_metrics.get("conversions", 0)
ratio_leads = leads_entregados / capping if capping > 0 else 0 ratio_leads = leads_entregados / capping if capping > 0 else 0
cpa_actual = gasto / leads_entregados if leads_entregados > 0 else 0 cpa_actual = gasto / leads_entregados if leads_entregados > 0 else 0
revenue = leads_entregados * ppl revenue = leads_entregados * ppl
margen = (revenue - gasto) / revenue if revenue > 0 else 0 margen = (revenue - gasto) / revenue if revenue > 0 else 0
leads_restantes = capping - leads_entregados leads_restantes = capping - leads_entregados
dias_restantes = dias_mes - dia_actual dias_restantes = dias_mes - dia_actual
ritmo = ratio_leads - ratio_mes # positivo = adelantado, negativo = atrasado ritmo = ratio_leads - ratio_mes # positivo = adelantado, negativo = atrasado
# Urgencia # Urgencia
if ratio_leads >= 1.0: if ratio_leads >= 1.0:
urgencia = "PAUSAR" urgencia = "PAUSAR"
elif capping > 0 and ratio_leads < ratio_mes - 0.15 and dias_restantes <= 5: elif capping > 0 and ratio_leads < ratio_mes - 0.15 and dias_restantes <= 5:
urgencia = "SPRINT" urgencia = "SPRINT"
elif ritmo < -0.15: elif ritmo < -0.15:
urgencia = "ACELERAR" urgencia = "ACELERAR"
elif ritmo > 0.15: elif ritmo > 0.15:
urgencia = "FRENAR" urgencia = "FRENAR"
else: else:
urgencia = "EN_RITMO" urgencia = "EN_RITMO"
# Discrepancia en ambas direcciones: Google > Airtable puede indicar conversiones falsas # Discrepancia en ambas direcciones: Google > Airtable puede indicar conversiones falsas
conv_leads_lake_mes = campaign_config.get("conv_leads_lake_mes", leads_entregados) conv_leads_lake_mes = campaign_config.get("conv_leads_lake_mes", leads_entregados)
discrepancia = abs(conversiones_google - conv_leads_lake_mes) discrepancia = abs(conversiones_google - conv_leads_lake_mes)
return { return {
"curso": campaign_config["curso"], "curso": campaign_config["curso"],
"campaign_id": campaign_config["google_campaign_id"], "campaign_id": campaign_config["google_campaign_id"],
"ppl": ppl, "ppl": ppl,
"cpa_maximo": cpa_max, "cpa_maximo": cpa_max,
"margen_objetivo": margen_objetivo, "margen_objetivo": margen_objetivo,
"margen_ok": margen >= margen_objetivo if margen_objetivo > 0 else True, "margen_ok": margen >= margen_objetivo if margen_objetivo > 0 else True,
"capping": capping, "capping": capping,
"leads_entregados": leads_entregados, "leads_entregados": leads_entregados,
"leads_restantes": leads_restantes, "leads_restantes": leads_restantes,
"dias_restantes": dias_restantes, "dias_restantes": dias_restantes,
"ratio_leads": round(ratio_leads, 3), "ratio_leads": round(ratio_leads, 3),
"ratio_mes": round(ratio_mes, 3), "ratio_mes": round(ratio_mes, 3),
"ritmo": round(ritmo, 3), "ritmo": round(ritmo, 3),
"urgencia": urgencia, "urgencia": urgencia,
"cpa_actual": round(cpa_actual, 2), "cpa_actual": round(cpa_actual, 2),
"rentable": cpa_actual <= cpa_max if cpa_actual > 0 else True, "rentable": cpa_actual <= cpa_max if cpa_actual > 0 else True,
"margen": round(margen, 3), "margen": round(margen, 3),
"revenue_estimado": round(revenue, 2), "revenue_estimado": round(revenue, 2),
"gasto_acumulado": round(gasto, 2), "gasto_acumulado": round(gasto, 2),
"budget_diario_actual": ads_metrics.get("budget_daily", 0), "budget_diario_actual": ads_metrics.get("budget_daily", 0),
"ctr": ads_metrics.get("ctr", 0), "ctr": ads_metrics.get("ctr", 0),
"clicks": ads_metrics.get("clicks", 0), "clicks": ads_metrics.get("clicks", 0),
"conversiones_google": conversiones_google, "conversiones_google": conversiones_google,
"discrepancia_tracking": discrepancia, "discrepancia_tracking": discrepancia,
"alerta_tracking": discrepancia > 10, "alerta_tracking": discrepancia > 10,
"status_google": ads_metrics.get("status", "UNKNOWN"), "status_google": ads_metrics.get("status", "UNKNOWN"),
} }

View File

@ -1,26 +1,26 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Airtable # Airtable
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"] AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"] AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"]
LEADS_TABLE = "Leads Lake" LEADS_TABLE = "Leads Lake"
CAMPAIGNS_TABLE = "Google Ads Campaigns" CAMPAIGNS_TABLE = "Google Ads Campaigns"
# Google Ads # Google Ads
GOOGLE_ADS_DEVELOPER_TOKEN = os.environ["GOOGLE_ADS_DEVELOPER_TOKEN"] GOOGLE_ADS_DEVELOPER_TOKEN = os.environ["GOOGLE_ADS_DEVELOPER_TOKEN"]
GOOGLE_ADS_CLIENT_ID = os.environ["GOOGLE_ADS_CLIENT_ID"] GOOGLE_ADS_CLIENT_ID = os.environ["GOOGLE_ADS_CLIENT_ID"]
GOOGLE_ADS_CLIENT_SECRET = os.environ["GOOGLE_ADS_CLIENT_SECRET"] GOOGLE_ADS_CLIENT_SECRET = os.environ["GOOGLE_ADS_CLIENT_SECRET"]
GOOGLE_ADS_REFRESH_TOKEN = os.environ["GOOGLE_ADS_REFRESH_TOKEN"] GOOGLE_ADS_REFRESH_TOKEN = os.environ["GOOGLE_ADS_REFRESH_TOKEN"]
GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ["GOOGLE_ADS_LOGIN_CUSTOMER_ID"]
# Anthropic # Anthropic
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
# Slack # Slack
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "") SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")
# Operación # Operación
DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads

View File

@ -1,365 +1,365 @@
from google.ads.googleads.client import GoogleAdsClient as GAdsClient from google.ads.googleads.client import GoogleAdsClient as GAdsClient
from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.errors import GoogleAdsException
from datetime import datetime from datetime import datetime
import config import config
class GoogleAdsClient: class GoogleAdsClient:
def __init__(self): def __init__(self):
self.client = GAdsClient.load_from_dict({ self.client = GAdsClient.load_from_dict({
"developer_token": config.GOOGLE_ADS_DEVELOPER_TOKEN, "developer_token": config.GOOGLE_ADS_DEVELOPER_TOKEN,
"client_id": config.GOOGLE_ADS_CLIENT_ID, "client_id": config.GOOGLE_ADS_CLIENT_ID,
"client_secret": config.GOOGLE_ADS_CLIENT_SECRET, "client_secret": config.GOOGLE_ADS_CLIENT_SECRET,
"refresh_token": config.GOOGLE_ADS_REFRESH_TOKEN, "refresh_token": config.GOOGLE_ADS_REFRESH_TOKEN,
"login_customer_id": config.GOOGLE_ADS_LOGIN_CUSTOMER_ID, "login_customer_id": config.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
"use_proto_plus": True, "use_proto_plus": True,
}) })
self.customer_id = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID self.customer_id = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID
def get_all_campaigns(self) -> list[dict]: def get_all_campaigns(self) -> list[dict]:
"""Obtiene todas las campañas no eliminadas de la cuenta.""" """Obtiene todas las campañas no eliminadas de la cuenta."""
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = """ query = """
SELECT SELECT
campaign.id, campaign.id,
campaign.name, campaign.name,
campaign.status campaign.status
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
ORDER BY campaign.name ORDER BY campaign.name
""" """
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
return [ return [
{ {
"id": str(row.campaign.id), "id": str(row.campaign.id),
"name": row.campaign.name, "name": row.campaign.name,
"status": row.campaign.status.name, "status": row.campaign.status.name,
} }
for row in response for row in response
] ]
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo campañas de Google Ads: {e}") print(f" ❌ Error obteniendo campañas de Google Ads: {e}")
return [] return []
def get_monthly_metrics_all(self) -> dict: def get_monthly_metrics_all(self) -> dict:
""" """
Devuelve métricas del mes actual para TODAS las campañas en una sola query. Devuelve métricas del mes actual para TODAS las campañas en una sola query.
Retorna dict {campaign_id: {conversions, cost}}. Retorna dict {campaign_id: {conversions, cost}}.
""" """
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = """ query = """
SELECT SELECT
campaign.id, campaign.id,
metrics.conversions, metrics.conversions,
metrics.cost_micros metrics.cost_micros
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date DURING THIS_MONTH AND segments.date DURING THIS_MONTH
""" """
result = {} result = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
cid = str(row.campaign.id) cid = str(row.campaign.id)
result[cid] = { result[cid] = {
"conversions": row.metrics.conversions, "conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000, "cost": row.metrics.cost_micros / 1_000_000,
} }
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas mensuales: {e}") print(f" ❌ Error obteniendo métricas mensuales: {e}")
return result return result
def get_yesterday_metrics_all(self) -> dict: def get_yesterday_metrics_all(self) -> dict:
""" """
Devuelve métricas del día anterior para TODAS las campañas en una sola query. Devuelve métricas del día anterior para TODAS las campañas en una sola query.
Retorna dict {campaign_id: {conversions, cost}}. Retorna dict {campaign_id: {conversions, cost}}.
""" """
from datetime import timedelta from datetime import timedelta
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = f""" query = f"""
SELECT SELECT
campaign.id, campaign.id,
metrics.conversions, metrics.conversions,
metrics.cost_micros metrics.cost_micros
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date = '{yesterday}' AND segments.date = '{yesterday}'
""" """
result = {} result = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
cid = str(row.campaign.id) cid = str(row.campaign.id)
result[cid] = { result[cid] = {
"conversions": row.metrics.conversions, "conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000, "cost": row.metrics.cost_micros / 1_000_000,
} }
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas de hoy: {e}") print(f" ❌ Error obteniendo métricas de hoy: {e}")
return result return result
def get_metrics_for_date(self, date_str: str) -> dict: def get_metrics_for_date(self, date_str: str) -> dict:
""" """
Devuelve métricas de una fecha concreta ('YYYY-MM-DD') para TODAS las Devuelve métricas de una fecha concreta ('YYYY-MM-DD') para TODAS las
campañas en una sola query. Retorna dict {campaign_id: {conversions, cost}}. campañas en una sola query. Retorna dict {campaign_id: {conversions, cost}}.
""" """
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = f""" query = f"""
SELECT SELECT
campaign.id, campaign.id,
metrics.conversions, metrics.conversions,
metrics.cost_micros metrics.cost_micros
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date = '{date_str}' AND segments.date = '{date_str}'
""" """
result = {} result = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
cid = str(row.campaign.id) cid = str(row.campaign.id)
result[cid] = { result[cid] = {
"conversions": row.metrics.conversions, "conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000, "cost": row.metrics.cost_micros / 1_000_000,
} }
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas del {date_str}: {e}") print(f" ❌ Error obteniendo métricas del {date_str}: {e}")
return result return result
def get_daily_metrics_for_month(self, year: int, month: int) -> dict: def get_daily_metrics_for_month(self, year: int, month: int) -> dict:
""" """
Devuelve coste y conversiones diarias de todas las campañas para un mes Devuelve coste y conversiones diarias de todas las campañas para un mes
completo. Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: {cost, conversions}}}. completo. Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: {cost, conversions}}}.
""" """
start = f"{year}-{month:02d}-01" start = f"{year}-{month:02d}-01"
if month == 12: if month == 12:
end = f"{year + 1}-01-01" end = f"{year + 1}-01-01"
else: else:
end = f"{year}-{month + 1:02d}-01" end = f"{year}-{month + 1:02d}-01"
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = f""" query = f"""
SELECT SELECT
campaign.id, campaign.id,
segments.date, segments.date,
metrics.conversions, metrics.conversions,
metrics.cost_micros metrics.cost_micros
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date >= '{start}' AND segments.date >= '{start}'
AND segments.date < '{end}' AND segments.date < '{end}'
""" """
result: dict = {} result: dict = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
date_str = row.segments.date date_str = row.segments.date
cid = str(row.campaign.id) cid = str(row.campaign.id)
result.setdefault(date_str, {})[cid] = { result.setdefault(date_str, {})[cid] = {
"conversions": row.metrics.conversions, "conversions": row.metrics.conversions,
"cost": row.metrics.cost_micros / 1_000_000, "cost": row.metrics.cost_micros / 1_000_000,
} }
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas diarias del mes: {e}") print(f" ❌ Error obteniendo métricas diarias del mes: {e}")
return result return result
def get_daily_conversions_for_month(self, year: int, month: int) -> dict: def get_daily_conversions_for_month(self, year: int, month: int) -> dict:
""" """
Devuelve conversiones diarias de todas las campañas para un mes completo. Devuelve conversiones diarias de todas las campañas para un mes completo.
Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: conversions}}. Retorna dict {date_str ('YYYY-MM-DD'): {campaign_id: conversions}}.
""" """
start = f"{year}-{month:02d}-01" start = f"{year}-{month:02d}-01"
# último día del mes # último día del mes
if month == 12: if month == 12:
end = f"{year + 1}-01-01" end = f"{year + 1}-01-01"
else: else:
end = f"{year}-{month + 1:02d}-01" end = f"{year}-{month + 1:02d}-01"
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = f""" query = f"""
SELECT SELECT
campaign.id, campaign.id,
segments.date, segments.date,
metrics.conversions metrics.conversions
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date >= '{start}' AND segments.date >= '{start}'
AND segments.date < '{end}' AND segments.date < '{end}'
""" """
result: dict = {} result: dict = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
date_str = row.segments.date date_str = row.segments.date
cid = str(row.campaign.id) cid = str(row.campaign.id)
result.setdefault(date_str, {})[cid] = row.metrics.conversions result.setdefault(date_str, {})[cid] = row.metrics.conversions
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas mensuales: {e}") print(f" ❌ Error obteniendo métricas mensuales: {e}")
return result return result
def get_monthly_metrics_all(self) -> dict: def get_monthly_metrics_all(self) -> dict:
""" """
Métricas del mes en curso para TODAS las campañas en una sola query. Métricas del mes en curso para TODAS las campañas en una sola query.
Retorna dict {campaign_id: {cost, conversions, clicks, impressions, ctr, Retorna dict {campaign_id: {cost, conversions, clicks, impressions, ctr,
status, budget_daily, budget_resource_name, name}}. status, budget_daily, budget_resource_name, name}}.
""" """
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = """ query = """
SELECT SELECT
campaign.id, campaign.id,
campaign.name, campaign.name,
campaign.status, campaign.status,
campaign_budget.amount_micros, campaign_budget.amount_micros,
campaign_budget.resource_name, campaign_budget.resource_name,
metrics.cost_micros, metrics.cost_micros,
metrics.conversions, metrics.conversions,
metrics.clicks, metrics.clicks,
metrics.impressions metrics.impressions
FROM campaign FROM campaign
WHERE campaign.status != 'REMOVED' WHERE campaign.status != 'REMOVED'
AND segments.date DURING THIS_MONTH AND segments.date DURING THIS_MONTH
""" """
raw: dict = {} raw: dict = {}
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response: for row in response:
cid = str(row.campaign.id) cid = str(row.campaign.id)
if cid not in raw: if cid not in raw:
raw[cid] = { raw[cid] = {
"name": row.campaign.name, "name": row.campaign.name,
"status": row.campaign.status.name, "status": row.campaign.status.name,
"budget_daily": row.campaign_budget.amount_micros / 1_000_000, "budget_daily": row.campaign_budget.amount_micros / 1_000_000,
"budget_resource_name": row.campaign_budget.resource_name, "budget_resource_name": row.campaign_budget.resource_name,
"cost": 0.0, "conversions": 0.0, "clicks": 0, "impressions": 0, "cost": 0.0, "conversions": 0.0, "clicks": 0, "impressions": 0,
} }
m = row.metrics m = row.metrics
raw[cid]["cost"] += m.cost_micros / 1_000_000 raw[cid]["cost"] += m.cost_micros / 1_000_000
raw[cid]["conversions"] += m.conversions raw[cid]["conversions"] += m.conversions
raw[cid]["clicks"] += m.clicks raw[cid]["clicks"] += m.clicks
raw[cid]["impressions"] += m.impressions raw[cid]["impressions"] += m.impressions
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas mensuales bulk: {e}") print(f" ❌ Error obteniendo métricas mensuales bulk: {e}")
result = {} result = {}
for cid, d in raw.items(): for cid, d in raw.items():
imp = d["impressions"] imp = d["impressions"]
result[cid] = { result[cid] = {
"campaign_id": cid, "campaign_id": cid,
"name": d["name"], "name": d["name"],
"status": d["status"], "status": d["status"],
"budget_daily": round(d["budget_daily"], 2), "budget_daily": round(d["budget_daily"], 2),
"budget_resource_name": d["budget_resource_name"], "budget_resource_name": d["budget_resource_name"],
"cost": round(d["cost"], 2), "cost": round(d["cost"], 2),
"conversions": d["conversions"], "conversions": d["conversions"],
"clicks": d["clicks"], "clicks": d["clicks"],
"impressions": imp, "impressions": imp,
"ctr": round(d["clicks"] / imp * 100, 2) if imp > 0 else 0.0, "ctr": round(d["clicks"] / imp * 100, 2) if imp > 0 else 0.0,
} }
return result return result
def get_campaign_metrics(self, campaign_id: str) -> dict: def get_campaign_metrics(self, campaign_id: str) -> dict:
"""Métricas del mes en curso para una campaña concreta (acumulado mensual).""" """Métricas del mes en curso para una campaña concreta (acumulado mensual)."""
ga_service = self.client.get_service("GoogleAdsService") ga_service = self.client.get_service("GoogleAdsService")
query = f""" query = f"""
SELECT SELECT
campaign.id, campaign.id,
campaign.name, campaign.name,
campaign.status, campaign.status,
campaign_budget.amount_micros, campaign_budget.amount_micros,
campaign_budget.resource_name, campaign_budget.resource_name,
metrics.cost_micros, metrics.cost_micros,
metrics.conversions, metrics.conversions,
metrics.clicks, metrics.clicks,
metrics.impressions metrics.impressions
FROM campaign FROM campaign
WHERE campaign.id = {campaign_id} WHERE campaign.id = {campaign_id}
AND segments.date DURING THIS_MONTH AND segments.date DURING THIS_MONTH
""" """
try: try:
response = ga_service.search(customer_id=self.customer_id, query=query) response = ga_service.search(customer_id=self.customer_id, query=query)
cost = conversions = clicks = impressions = 0 cost = conversions = clicks = impressions = 0
meta = {} meta = {}
for row in response: for row in response:
m = row.metrics m = row.metrics
cost += m.cost_micros / 1_000_000 cost += m.cost_micros / 1_000_000
conversions += m.conversions conversions += m.conversions
clicks += m.clicks clicks += m.clicks
impressions += m.impressions impressions += m.impressions
if not meta: if not meta:
meta = { meta = {
"name": row.campaign.name, "name": row.campaign.name,
"status": row.campaign.status.name, "status": row.campaign.status.name,
"budget_daily": row.campaign_budget.amount_micros / 1_000_000, "budget_daily": row.campaign_budget.amount_micros / 1_000_000,
"budget_resource_name": row.campaign_budget.resource_name, "budget_resource_name": row.campaign_budget.resource_name,
} }
if not meta: if not meta:
return {} return {}
ctr = round(clicks / impressions * 100, 2) if impressions > 0 else 0.0 ctr = round(clicks / impressions * 100, 2) if impressions > 0 else 0.0
return { return {
"campaign_id": campaign_id, "campaign_id": campaign_id,
"name": meta["name"], "name": meta["name"],
"status": meta["status"], "status": meta["status"],
"budget_daily": meta["budget_daily"], "budget_daily": meta["budget_daily"],
"budget_resource_name": meta["budget_resource_name"], "budget_resource_name": meta["budget_resource_name"],
"cost": round(cost, 2), "cost": round(cost, 2),
"conversions": conversions, "conversions": conversions,
"clicks": clicks, "clicks": clicks,
"impressions": impressions, "impressions": impressions,
"ctr": ctr, "ctr": ctr,
} }
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error Google Ads para campaña {campaign_id}: {e}") print(f" ❌ Error Google Ads para campaña {campaign_id}: {e}")
return {} return {}
return {} return {}
def set_campaign_budget(self, budget_resource_name: str, new_daily_budget: float): def set_campaign_budget(self, budget_resource_name: str, new_daily_budget: float):
"""Ajusta el presupuesto diario de una campaña.""" """Ajusta el presupuesto diario de una campaña."""
if config.DRY_RUN: if config.DRY_RUN:
print(f" [DRY RUN] Nuevo presupuesto diario → {new_daily_budget:.2f}") print(f" [DRY RUN] Nuevo presupuesto diario → {new_daily_budget:.2f}")
return True return True
try: try:
budget_service = self.client.get_service("CampaignBudgetService") budget_service = self.client.get_service("CampaignBudgetService")
campaign_budget = self.client.get_type("CampaignBudget") campaign_budget = self.client.get_type("CampaignBudget")
campaign_budget.resource_name = budget_resource_name campaign_budget.resource_name = budget_resource_name
campaign_budget.amount_micros = int(new_daily_budget * 1_000_000) campaign_budget.amount_micros = int(new_daily_budget * 1_000_000)
operation = self.client.get_type("CampaignBudgetOperation") operation = self.client.get_type("CampaignBudgetOperation")
operation.update = campaign_budget operation.update = campaign_budget
operation.update_mask.paths.append("amount_micros") operation.update_mask.paths.append("amount_micros")
budget_service.mutate_campaign_budgets( budget_service.mutate_campaign_budgets(
customer_id=self.customer_id, customer_id=self.customer_id,
operations=[operation] operations=[operation]
) )
return True return True
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error ajustando presupuesto: {e}") print(f" ❌ Error ajustando presupuesto: {e}")
return False return False
def pause_campaign(self, campaign_id: str): def pause_campaign(self, campaign_id: str):
"""Pausa una campaña.""" """Pausa una campaña."""
if config.DRY_RUN: if config.DRY_RUN:
print(f" [DRY RUN] Pausar campaña {campaign_id}") print(f" [DRY RUN] Pausar campaña {campaign_id}")
return True return True
try: try:
campaign_service = self.client.get_service("CampaignService") campaign_service = self.client.get_service("CampaignService")
campaign = self.client.get_type("Campaign") campaign = self.client.get_type("Campaign")
campaign.resource_name = campaign_service.campaign_path( campaign.resource_name = campaign_service.campaign_path(
self.customer_id, campaign_id self.customer_id, campaign_id
) )
campaign.status = self.client.enums.CampaignStatusEnum.PAUSED campaign.status = self.client.enums.CampaignStatusEnum.PAUSED
operation = self.client.get_type("CampaignOperation") operation = self.client.get_type("CampaignOperation")
operation.update = campaign operation.update = campaign
operation.update_mask.paths.append("status") operation.update_mask.paths.append("status")
campaign_service.mutate_campaigns( campaign_service.mutate_campaigns(
customer_id=self.customer_id, customer_id=self.customer_id,
operations=[operation] operations=[operation]
) )
return True return True
except GoogleAdsException as e: except GoogleAdsException as e:
print(f" ❌ Error pausando campaña: {e}") print(f" ❌ Error pausando campaña: {e}")
return False return False