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": {
"allow": [
"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 slack_reporter.py)",
"Bash(python -m py_compile run.py)",
"Bash(python run.py)",
"Bash(git add *)",
"Bash(python migrate_leads_field.py)",
"Bash(python backfill_leads_google_mayo.py)"
]
}
}
{
"permissions": {
"allow": [
"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 slack_reporter.py)",
"Bash(python -m py_compile run.py)",
"Bash(python run.py)",
"Bash(git add *)",
"Bash(python migrate_leads_field.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
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.
---
## Campos requeridos en Airtable
### Tabla: "Google Ads Campaigns"
| Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------|
| Curso | Text | Nombre del curso |
| GoogleCampaignID | Number | ID de campaña en Google Ads |
| PPL | Number | Precio por lead (€) |
| CapTotalMes | Number | Capping mensual de leads |
| CPAMaximo | Number | CPA máximo tolerable (€) |
| Activa | Boolean | TRUE para incluir en el análisis |
### Tabla: "Leads Lake"
| Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------|
| GoogleCampaignID | Text | ID de campaña de origen |
| FechaEntrada | Date | Fecha del lead (formato YYYY-MM-DD) |
---
## Variables de entorno
```bash
export AIRTABLE_TOKEN=pat_xxxxxxxxxxxx
export AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
export GOOGLE_ADS_DEVELOPER_TOKEN=xxxx
export GOOGLE_ADS_CLIENT_ID=xxxx.apps.googleusercontent.com
export GOOGLE_ADS_CLIENT_SECRET=xxxx
export GOOGLE_ADS_REFRESH_TOKEN=xxxx
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
export ANTHROPIC_API_KEY=sk-ant-xxxx
export SLACK_WEBHOOK_URL=xxx
```
---
## Instalación y ejecución
```bash
# Instalar dependencias
pip install -r requirements.txt
# Ejecutar en modo DRY RUN (recomendado para empezar)
# DRY_RUN = True en config.py → solo muestra decisiones, no aplica cambios
python run.py
# Cuando estés seguro, cambiar DRY_RUN = False en config.py
python run.py
```
---
## Lógica de urgencia
| Urgencia | Condición | Acción típica |
|-------------|--------------------------------------------------------|-----------------------|
| PAUSAR | leads >= capping | Pausa campaña |
| SPRINT | ritmo muy atrasado + quedan ≤ 5 días | +30-50% 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 |
| EN_RITMO | dentro del margen esperado | Mantener |
---
## Automatización con cron
```bash
# Ejecutar cada día a las 8:00
0 8 * * * cd /ruta/leads-optimizer && python run.py >> logs/optimizer.log 2>&1
```
# Leads Optimizer — 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.
---
## Campos requeridos en Airtable
### Tabla: "Google Ads Campaigns"
| Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------|
| Curso | Text | Nombre del curso |
| GoogleCampaignID | Number | ID de campaña en Google Ads |
| PPL | Number | Precio por lead (€) |
| CapTotalMes | Number | Capping mensual de leads |
| CPAMaximo | Number | CPA máximo tolerable (€) |
| Activa | Boolean | TRUE para incluir en el análisis |
### Tabla: "Leads Lake"
| Campo | Tipo | Descripción |
|-------------------|---------|--------------------------------------|
| GoogleCampaignID | Text | ID de campaña de origen |
| FechaEntrada | Date | Fecha del lead (formato YYYY-MM-DD) |
---
## Variables de entorno
```bash
export AIRTABLE_TOKEN=pat_xxxxxxxxxxxx
export AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
export GOOGLE_ADS_DEVELOPER_TOKEN=xxxx
export GOOGLE_ADS_CLIENT_ID=xxxx.apps.googleusercontent.com
export GOOGLE_ADS_CLIENT_SECRET=xxxx
export GOOGLE_ADS_REFRESH_TOKEN=xxxx
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
export ANTHROPIC_API_KEY=sk-ant-xxxx
export SLACK_WEBHOOK_URL=xxx
```
---
## Instalación y ejecución
```bash
# Instalar dependencias
pip install -r requirements.txt
# Ejecutar en modo DRY RUN (recomendado para empezar)
# DRY_RUN = True en config.py → solo muestra decisiones, no aplica cambios
python run.py
# Cuando estés seguro, cambiar DRY_RUN = False en config.py
python run.py
```
---
## Lógica de urgencia
| Urgencia | Condición | Acción típica |
|-------------|--------------------------------------------------------|-----------------------|
| PAUSAR | leads >= capping | Pausa campaña |
| SPRINT | ritmo muy atrasado + quedan ≤ 5 días | +30-50% 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 |
| EN_RITMO | dentro del margen esperado | Mantener |
---
## Automatización con cron
```bash
# Ejecutar cada día a las 8:00
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
from datetime import datetime
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
PORTFOLIO_SYSTEM = """
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_).
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
"""
WEEKLY_SYSTEM = """
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_).
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 (-).
"""
SYSTEM_PROMPT = """
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.
MODELO DE NEGOCIO:
- Ingreso = leads_entregados × PPL
- Margen = (Ingreso - Gasto Google Ads) / Ingreso
- El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo.
- El CPA máximo ya refleja el margen mínimo aceptable.
ESTADO DE LA CAMPAÑA:
- 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).
- Si status_google = PAUSED y la acción es AUMENTAR_PRESUPUESTO, menciona en el consejo que primero hay que reactivarla.
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.
REGLAS DE DECISIÓN:
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.
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).
5. urgencia=FRENAR accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9.
6. urgencia=EN_RITMO y rentable accion=MANTENER.
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.
Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown:
{
"accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER",
"parametro": 1.0,
"nuevo_budget_diario": 0.0,
"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.)",
"alerta": "texto si hay algo crítico, null si no hay",
"confianza": 0.0
}
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.
"""
def _classify_type(curso: str) -> str:
c = curso.lower()
if "_leadform" in c:
return "leadform"
if "_pmx" in c or "pmx_" in c:
return "pmx"
if "search" in c:
return "search"
return "otro"
def portfolio_daily_analysis(collected: list) -> str:
"""Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack."""
from datetime import datetime
now = datetime.now()
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
tipos: dict = {}
leadforms_detail = []
alertas_tracking = 0
campañas_perdida = 0
for item in fco:
t = _classify_type(item["campaign"]["curso"])
m = item["metrics"]
a = item["analysis"]
cost = m.get("cost", 0)
conv = a["conversiones_google"]
ppl = item["campaign"]["ppl"]
rev = a["revenue_estimado"]
margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0
if t not in tipos:
tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0}
tipos[t]["campañas"] += 1
tipos[t]["inversion"] += cost
tipos[t]["conversiones"] += conv
tipos[t]["ingreso"] += rev
if a.get("alerta_tracking"):
alertas_tracking += 1
if rev > 0 and cost > rev:
campañas_perdida += 1
if t == "leadform":
leadforms_detail.append({
"curso": item["campaign"]["curso"][:40],
"cpa_google": round(cost / conv, 2) if conv > 0 else None,
"conv_google": int(conv),
"conv_airtable": item["leads"],
"margen_pct": margen_pct,
})
resumen_tipos = {}
for t, d in tipos.items():
cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None
ing = d["ingreso"]
margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0
resumen_tipos[t] = {
"campañas": d["campañas"],
"inversion": round(d["inversion"], 2),
"conversiones": int(d["conversiones"]),
"cpa_medio": cpa,
"margen_pct": margen,
}
data = {
"fecha": now.strftime("%d/%m/%Y"),
"dia_del_mes": now.day,
"campañas_totales": len(fco),
"campañas_en_perdida": campañas_perdida,
"alertas_tracking": alertas_tracking,
"rendimiento_por_tipo": resumen_tipos,
"detalle_leadforms": leadforms_detail,
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
system=PORTFOLIO_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analiza estos datos del portfolio y proporciona:\n"
"1. Diagnóstico en 2 frases\n"
"2. Problemas principales (máx 3, 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"
f"{json.dumps(data, ensure_ascii=False, indent=2)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis: {e}"
def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict,
collected: list, mes_nombre: str) -> str:
"""
Análisis estratégico semanal profundo.
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.
collected: lista de campañas del optimizer.
"""
def _week_summary(md: dict) -> dict:
coste = ing = leads = leads_lake = 0.0
for v in md.values():
coste += v.get("coste", 0)
ing += v.get("ingreso_sum", 0)
leads += v.get("leads", 0)
leads_lake += v.get("leads_lake", 0)
margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0
cpa = round(coste / leads, 2) if leads > 0 else None
return {"coste": round(coste, 2), "ingreso": round(ing, 2),
"leads_google": int(leads), "leads_airtable": int(leads_lake),
"margen_pct": margen, "cpa": cpa}
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
# Top 5 peores por CPA del mes
peores = sorted(
[{"curso": i["campaign"]["curso"][:40],
"cpa": i["analysis"]["cpa_actual"],
"conv": int(i["analysis"]["conversiones_google"]),
"margen_pct": round(i["analysis"]["margen"] * 100, 1)}
for i in fco if i["analysis"]["cpa_actual"] > 0],
key=lambda x: x["cpa"], reverse=True
)[:5]
data = {
"mes": mes_nombre,
"semana_actual": _week_summary(games_md_this),
"semana_anterior": _week_summary(games_md_prev_week),
"top5_peor_cpa_mes": peores,
"leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()),
"campañas_totales": len(fco),
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=900,
system=WEEKLY_SYSTEM,
messages=[{
"role": "user",
"content": (
"Genera el informe estratégico semanal con:\n"
"1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n"
"2. Tendencias clave detectadas (máx 4, con guión)\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"
"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)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis semanal: {e}"
def decide(analysis: dict) -> dict:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=750,
system=SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": (
f"Analiza esta campaña y devuelve la decisión en JSON:\n\n"
f"{json.dumps(analysis, ensure_ascii=False, indent=2)}"
)
}]
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
# Fallback seguro si el modelo no devuelve JSON limpio
return {
"accion": "MANTENER",
"parametro": 1.0,
"nuevo_budget_diario": analysis.get("budget_diario_actual", 0),
"justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.",
"alerta": f"JSON inválido recibido: {raw[:200]}",
"confianza": 0.0,
}
import json
from datetime import datetime
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
PORTFOLIO_SYSTEM = """
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_).
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
"""
WEEKLY_SYSTEM = """
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_).
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 (-).
"""
SYSTEM_PROMPT = """
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.
MODELO DE NEGOCIO:
- Ingreso = leads_entregados × PPL
- Margen = (Ingreso - Gasto Google Ads) / Ingreso
- El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo.
- El CPA máximo ya refleja el margen mínimo aceptable.
ESTADO DE LA CAMPAÑA:
- 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).
- Si status_google = PAUSED y la acción es AUMENTAR_PRESUPUESTO, menciona en el consejo que primero hay que reactivarla.
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.
REGLAS DE DECISIÓN:
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.
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).
5. urgencia=FRENAR accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9.
6. urgencia=EN_RITMO y rentable accion=MANTENER.
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.
Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown:
{
"accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER",
"parametro": 1.0,
"nuevo_budget_diario": 0.0,
"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.)",
"alerta": "texto si hay algo crítico, null si no hay",
"confianza": 0.0
}
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.
"""
def _classify_type(curso: str) -> str:
c = curso.lower()
if "_leadform" in c:
return "leadform"
if "_pmx" in c or "pmx_" in c:
return "pmx"
if "search" in c:
return "search"
return "otro"
def portfolio_daily_analysis(collected: list) -> str:
"""Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack."""
from datetime import datetime
now = datetime.now()
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
tipos: dict = {}
leadforms_detail = []
alertas_tracking = 0
campañas_perdida = 0
for item in fco:
t = _classify_type(item["campaign"]["curso"])
m = item["metrics"]
a = item["analysis"]
cost = m.get("cost", 0)
conv = a["conversiones_google"]
ppl = item["campaign"]["ppl"]
rev = a["revenue_estimado"]
margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0
if t not in tipos:
tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0}
tipos[t]["campañas"] += 1
tipos[t]["inversion"] += cost
tipos[t]["conversiones"] += conv
tipos[t]["ingreso"] += rev
if a.get("alerta_tracking"):
alertas_tracking += 1
if rev > 0 and cost > rev:
campañas_perdida += 1
if t == "leadform":
leadforms_detail.append({
"curso": item["campaign"]["curso"][:40],
"cpa_google": round(cost / conv, 2) if conv > 0 else None,
"conv_google": int(conv),
"conv_airtable": item["leads"],
"margen_pct": margen_pct,
})
resumen_tipos = {}
for t, d in tipos.items():
cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None
ing = d["ingreso"]
margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0
resumen_tipos[t] = {
"campañas": d["campañas"],
"inversion": round(d["inversion"], 2),
"conversiones": int(d["conversiones"]),
"cpa_medio": cpa,
"margen_pct": margen,
}
data = {
"fecha": now.strftime("%d/%m/%Y"),
"dia_del_mes": now.day,
"campañas_totales": len(fco),
"campañas_en_perdida": campañas_perdida,
"alertas_tracking": alertas_tracking,
"rendimiento_por_tipo": resumen_tipos,
"detalle_leadforms": leadforms_detail,
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
system=PORTFOLIO_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analiza estos datos del portfolio y proporciona:\n"
"1. Diagnóstico en 2 frases\n"
"2. Problemas principales (máx 3, 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"
f"{json.dumps(data, ensure_ascii=False, indent=2)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis: {e}"
def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict,
collected: list, mes_nombre: str) -> str:
"""
Análisis estratégico semanal profundo.
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.
collected: lista de campañas del optimizer.
"""
def _week_summary(md: dict) -> dict:
coste = ing = leads = leads_lake = 0.0
for v in md.values():
coste += v.get("coste", 0)
ing += v.get("ingreso_sum", 0)
leads += v.get("leads", 0)
leads_lake += v.get("leads_lake", 0)
margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0
cpa = round(coste / leads, 2) if leads > 0 else None
return {"coste": round(coste, 2), "ingreso": round(ing, 2),
"leads_google": int(leads), "leads_airtable": int(leads_lake),
"margen_pct": margen, "cpa": cpa}
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
# Top 5 peores por CPA del mes
peores = sorted(
[{"curso": i["campaign"]["curso"][:40],
"cpa": i["analysis"]["cpa_actual"],
"conv": int(i["analysis"]["conversiones_google"]),
"margen_pct": round(i["analysis"]["margen"] * 100, 1)}
for i in fco if i["analysis"]["cpa_actual"] > 0],
key=lambda x: x["cpa"], reverse=True
)[:5]
data = {
"mes": mes_nombre,
"semana_actual": _week_summary(games_md_this),
"semana_anterior": _week_summary(games_md_prev_week),
"top5_peor_cpa_mes": peores,
"leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()),
"campañas_totales": len(fco),
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=900,
system=WEEKLY_SYSTEM,
messages=[{
"role": "user",
"content": (
"Genera el informe estratégico semanal con:\n"
"1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n"
"2. Tendencias clave detectadas (máx 4, con guión)\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"
"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)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis semanal: {e}"
def decide(analysis: dict) -> dict:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=750,
system=SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": (
f"Analiza esta campaña y devuelve la decisión en JSON:\n\n"
f"{json.dumps(analysis, ensure_ascii=False, indent=2)}"
)
}]
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
# Fallback seguro si el modelo no devuelve JSON limpio
return {
"accion": "MANTENER",
"parametro": 1.0,
"nuevo_budget_diario": analysis.get("budget_diario_actual", 0),
"justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.",
"alerta": f"JSON inválido recibido: {raw[:200]}",
"confianza": 0.0,
}

View File

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

View File

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