Initial commit

This commit is contained in:
Jose Manuel 2026-04-17 12:53:25 +02:00
commit f0d7f65c1a
13 changed files with 578 additions and 0 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pip install *)"
]
}
}

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Airtable
AIRTABLE_TOKEN=your_airtable_personal_access_token
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
# Google Ads
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token
GOOGLE_ADS_CLIENT_ID=your_oauth_client_id
GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret
GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token
GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890
# Anthropic
ANTHROPIC_API_KEY=sk-ant-XXXXXXXXXXXXXXXXXXXX

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc

78
README.md Normal file
View File

@ -0,0 +1,78 @@
# 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
```
---
## 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
```

67
agent.py Normal file
View File

@ -0,0 +1,67 @@
import json
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
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.
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 en español",
"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).
"""
def decide(analysis: dict) -> dict:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=400,
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,
}

43
airtable_client.py Normal file
View File

@ -0,0 +1,43 @@
from pyairtable import Api
from datetime import datetime
import config
class AirtableClient:
def __init__(self):
self.api = Api(config.AIRTABLE_TOKEN)
self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE)
self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.CAMPAIGNS_TABLE)
def get_active_campaigns(self) -> list[dict]:
"""Lee todas las campañas activas desde 'Google Ads Campaigns'."""
records = self.campaigns.all(formula="{Activa}=TRUE()")
result = []
for r in records:
f = r["fields"]
result.append({
"airtable_id": r["id"],
"curso": f.get("Campaign Name", "Sin nombre"),
"google_campaign_id": str(f.get("CampaignID", "")).strip(),
"ppl": float(f.get("PPL", 0)),
"capping_mensual": int(f.get("CapTotalMes", 0)),
"cpa_maximo": float(f.get("CPAMaximo", 0)),
})
return [c for c in result if c["google_campaign_id"]] # descartar sin ID
def get_leads_this_month(self, google_campaign_id: str) -> int:
"""
Cuenta todos los leads del mes actual para una campaña.
Todos los leads en Airtable están validados, sin filtro de estado.
"""
now = datetime.now()
mes_inicio = f"{now.year}-{now.month:02d}-01"
formula = (
f"AND("
f"{{GoogleCampaignID}}='{google_campaign_id}',"
f"{{Fecha}}>='{mes_inicio}'"
f")"
)
records = self.leads.all(formula=formula)
return len(records)

64
analyzer.py Normal file
View File

@ -0,0 +1,64 @@
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"]
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 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 entre leads Airtable y conversiones Google
discrepancia = abs(conversiones_google - leads_entregados)
return {
"curso": campaign_config["curso"],
"campaign_id": campaign_config["google_campaign_id"],
"ppl": ppl,
"cpa_maximo": cpa_max,
"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,
}

24
config.py Normal file
View File

@ -0,0 +1,24 @@
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"]
# Operación
DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads
MARGEN_MINIMO = 0.40 # 40% mínimo de margen sobre PPL

36
get_refresh_token.py Normal file
View File

@ -0,0 +1,36 @@
"""
Ejecuta este script una sola vez para obtener el Refresh Token de Google Ads.
Requiere tener CLIENT_ID y CLIENT_SECRET en el .env.
Uso:
python get_refresh_token.py
"""
from google_auth_oauthlib.flow import InstalledAppFlow
from dotenv import load_dotenv
import os
load_dotenv()
CLIENT_ID = os.environ["GOOGLE_ADS_CLIENT_ID"]
CLIENT_SECRET = os.environ["GOOGLE_ADS_CLIENT_SECRET"]
SCOPES = ["https://www.googleapis.com/auth/adwords"]
client_config = {
"installed": {
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
}
flow = InstalledAppFlow.from_client_config(client_config, scopes=SCOPES)
credentials = flow.run_local_server(port=0)
print("\n✓ Refresh Token obtenido:")
print(credentials.refresh_token)
print("\nPégalo en tu .env como:")
print(f"GOOGLE_ADS_REFRESH_TOKEN={credentials.refresh_token}")

108
google_ads_client.py Normal file
View File

@ -0,0 +1,108 @@
from google.ads.googleads.client import GoogleAdsClient as GAdsClient
from google.ads.googleads.errors import GoogleAdsException
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_campaign_metrics(self, campaign_id: str) -> dict:
"""Métricas del mes en curso para una campaña concreta."""
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,
metrics.ctr
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)
for row in response:
m = row.metrics
return {
"campaign_id": campaign_id,
"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": m.cost_micros / 1_000_000,
"conversions": m.conversions,
"clicks": m.clicks,
"impressions": m.impressions,
"ctr": round(m.ctr * 100, 2),
}
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

33
optimizer.py Normal file
View File

@ -0,0 +1,33 @@
import config
def apply_decision(campaign: dict, decision: dict, metrics: dict, gads_client) -> bool:
"""
Aplica la decisión del agente sobre Google Ads.
En DRY_RUN solo imprime lo que haría.
"""
accion = decision.get("accion", "MANTENER")
nuevo_budget = decision.get("nuevo_budget_diario", 0)
budget_resource = metrics.get("budget_resource_name", "")
if accion == "PAUSAR":
print(f" ⛔ ACCIÓN: Pausar campaña")
return gads_client.pause_campaign(campaign["google_campaign_id"])
elif accion in ("AUMENTAR_PRESUPUESTO", "REDUCIR_PRESUPUESTO"):
if nuevo_budget <= 0:
print(f" ⚠️ Presupuesto calculado inválido ({nuevo_budget}), omitiendo.")
return False
print(f" 💰 ACCIÓN: {accion}{nuevo_budget:.2f}€/día")
if not budget_resource:
print(f" ⚠️ Sin budget_resource_name, no se puede aplicar.")
return False
return gads_client.set_campaign_budget(budget_resource, nuevo_budget)
elif accion == "MANTENER":
print(f" ✅ ACCIÓN: Mantener sin cambios")
return True
else:
print(f" ❓ Acción desconocida: {accion}")
return False

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
anthropic==0.95.0
pyairtable==3.3.0
google-ads==30.0.0
python-dotenv==1.2.2

98
run.py Normal file
View File

@ -0,0 +1,98 @@
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
from analyzer import analyze
from agent import decide
from optimizer import apply_decision
import config
from datetime import datetime
ICONOS = {
"PAUSAR": "",
"SPRINT": "🚀",
"ACELERAR": "📈",
"FRENAR": "📉",
"EN_RITMO": "",
}
def run():
print(f"\n{'='*55}")
print(f" LEADS OPTIMIZER — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f" Modo: {'DRY RUN (sin cambios)' if config.DRY_RUN else '⚡ PRODUCCIÓN'}")
print(f"{'='*55}\n")
at = AirtableClient()
gads = GoogleAdsClient()
campaigns = at.get_active_campaigns()
print(f"{len(campaigns)} campañas activas encontradas\n")
resumen = []
for campaign in campaigns:
cid = campaign["google_campaign_id"]
print(f"{''*55}")
print(f"📚 {campaign['curso']}")
print(f" Campaign ID: {cid} | PPL: {campaign['ppl']}€ | Cap: {campaign['capping_mensual']} leads")
# 1. Leads reales desde Airtable
leads = at.get_leads_this_month(cid)
# 2. Métricas de Google Ads
metrics = gads.get_campaign_metrics(cid)
if not metrics:
print(f" ⚠️ Sin métricas en Google Ads, omitiendo.\n")
continue
# 3. Análisis
analysis = analyze(campaign, leads, metrics)
icono = ICONOS.get(analysis["urgencia"], "")
print(f" Leads mes: {leads}/{campaign['capping_mensual']} "
f"({analysis['ratio_leads']*100:.0f}% cap) | "
f"Ratio mes: {analysis['ratio_mes']*100:.0f}%")
print(f" CPA actual: {analysis['cpa_actual']}€ | "
f"CPA máximo: {analysis['cpa_maximo']}€ | "
f"Margen: {analysis['margen']*100:.0f}%")
print(f" Urgencia: {icono} {analysis['urgencia']} | "
f"Rentable: {'' if analysis['rentable'] else ''}")
if analysis["alerta_tracking"]:
print(f" 🚨 ALERTA TRACKING: {analysis['discrepancia_tracking']} leads de diferencia "
f"entre Airtable ({leads}) y Google Ads ({int(analysis['conversiones_google'])})")
# 4. Decisión del agente
decision = decide(analysis)
print(f" Decisión: {decision['accion']} "
f"(confianza: {decision['confianza']*100:.0f}%)")
print(f" Justificación: {decision['justificacion']}")
if decision.get("alerta"):
print(f" 🚨 {decision['alerta']}")
# 5. Aplicar
apply_decision(campaign, decision, metrics, gads)
resumen.append({
"curso": campaign["curso"],
"urgencia": analysis["urgencia"],
"accion": decision["accion"],
"leads": f"{leads}/{campaign['capping_mensual']}",
"cpa": analysis["cpa_actual"],
"margen": f"{analysis['margen']*100:.0f}%",
})
print()
# Resumen final
print(f"{'='*55}")
print("RESUMEN FINAL")
print(f"{'='*55}")
for r in resumen:
print(f" {r['curso'][:35]:<35} | {r['urgencia']:<12} | {r['accion']}")
print()
if __name__ == "__main__":
run()