Initial commit
This commit is contained in:
commit
f0d7f65c1a
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pip install *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.env.example
Normal file
13
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
78
README.md
Normal file
78
README.md
Normal 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
67
agent.py
Normal 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 tú 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
43
airtable_client.py
Normal 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
64
analyzer.py
Normal 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
24
config.py
Normal 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
36
get_refresh_token.py
Normal 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
108
google_ads_client.py
Normal 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
33
optimizer.py
Normal 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
4
requirements.txt
Normal 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
98
run.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user