Compare commits
10 Commits
523e04d61f
...
c025a5f828
| Author | SHA1 | Date | |
|---|---|---|---|
| c025a5f828 | |||
| 3028123c81 | |||
|
|
bf945f2d75 | ||
| d82e604a15 | |||
| b30a075c59 | |||
| a94c18c13c | |||
| aa9225d338 | |||
| a489a08785 | |||
| 624f5e484d | |||
| bdc0d5ede3 |
35
.github/workflows/weekly.yml
vendored
Normal file
35
.github/workflows/weekly.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
name: Weekly Strategic Report
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 7 * * 1' # Lunes 9:00 AM hora española (CEST/UTC+2)
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run weekly report
|
||||||
|
env:
|
||||||
|
AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }}
|
||||||
|
AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }}
|
||||||
|
GOOGLE_ADS_DEVELOPER_TOKEN: ${{ secrets.GOOGLE_ADS_DEVELOPER_TOKEN }}
|
||||||
|
GOOGLE_ADS_CLIENT_ID: ${{ secrets.GOOGLE_ADS_CLIENT_ID }}
|
||||||
|
GOOGLE_ADS_CLIENT_SECRET: ${{ secrets.GOOGLE_ADS_CLIENT_SECRET }}
|
||||||
|
GOOGLE_ADS_REFRESH_TOKEN: ${{ secrets.GOOGLE_ADS_REFRESH_TOKEN }}
|
||||||
|
GOOGLE_ADS_LOGIN_CUSTOMER_ID: ${{ secrets.GOOGLE_ADS_LOGIN_CUSTOMER_ID }}
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||||
|
run: python weekly_report.py
|
||||||
@ -38,6 +38,8 @@ export GOOGLE_ADS_REFRESH_TOKEN=xxxx
|
|||||||
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
|
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
|
||||||
|
|
||||||
export ANTHROPIC_API_KEY=sk-ant-xxxx
|
export ANTHROPIC_API_KEY=sk-ant-xxxx
|
||||||
|
|
||||||
|
export SLACK_WEBHOOK_URL=xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
179
agent.py
179
agent.py
@ -1,9 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
import anthropic
|
import anthropic
|
||||||
import config
|
import config
|
||||||
|
|
||||||
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
|
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
|
PORTFOLIO_SYSTEM = """
|
||||||
|
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 = """
|
SYSTEM_PROMPT = """
|
||||||
Eres un agente experto en optimización de campañas de generación de leads para centros de formación.
|
Eres un agente experto en optimización de campañas de generación de leads para centros de formación.
|
||||||
Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores.
|
Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores.
|
||||||
@ -48,9 +62,172 @@ El campo consejo debe ser accionable y específico: qué revisar, qué cambiar,
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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=500,
|
||||||
|
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:
|
def decide(analysis: dict) -> dict:
|
||||||
response = client.messages.create(
|
response = client.messages.create(
|
||||||
model="claude-sonnet-4-20250514",
|
model="claude-sonnet-4-6",
|
||||||
max_tokens=400,
|
max_tokens=400,
|
||||||
system=SYSTEM_PROMPT,
|
system=SYSTEM_PROMPT,
|
||||||
messages=[{
|
messages=[{
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class AirtableClient:
|
|||||||
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
|
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
|
||||||
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
|
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
|
||||||
self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes")
|
self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes")
|
||||||
|
self.games = self.api.table(config.AIRTABLE_BASE_ID, "GAMes")
|
||||||
|
|
||||||
MESES_ES = {
|
MESES_ES = {
|
||||||
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
|
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
|
||||||
@ -338,24 +339,45 @@ class AirtableClient:
|
|||||||
def get_leads_this_month_gads(self, campaign_id: str, campaign_name: str = "") -> tuple[int, list[str]]:
|
def get_leads_this_month_gads(self, campaign_id: str, campaign_name: str = "") -> tuple[int, list[str]]:
|
||||||
"""
|
"""
|
||||||
Leads del mes actual para una campaña de Google Ads.
|
Leads del mes actual para una campaña de Google Ads.
|
||||||
Cubre tres vías de atribución:
|
Cubre cuatro vías de atribución:
|
||||||
1. GACampaignID / GoogleCampaignID (leads web normales)
|
1. GACampaignID / GoogleCampaignID (leads web normales)
|
||||||
2. gad_campaignid en attr_referer (UTM web)
|
2. gad_campaignid en attr_referer (UTM web)
|
||||||
3. attr_referer = campaign_name con UserAgent Google-Ads-Notifications (Lead Form)
|
3. attr_referer = campaign_name con UserAgent Google-Ads-Notifications (Lead Form)
|
||||||
|
4. attr_cursoid = course_num AND attr_utm_source = 'pmx'|'google' (atribución por curso)
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
mes_inicio = f"{now.year}-{now.month:02d}-01"
|
mes_inicio = f"{now.year}-{now.month:02d}-01"
|
||||||
|
|
||||||
leadform_clause = (
|
leadform_clause = (
|
||||||
f"AND({{attr_referer}}='{campaign_name}',{{UserAgent del visitante}}='Google-Ads-Notifications')"
|
f"AND({{attr_referer}}='{campaign_name}',{{UserAgent del visitante}}='Google-Ads-Notifications')"
|
||||||
if campaign_name else "FALSE()"
|
if campaign_name else "FALSE()"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extraer número de curso y tipo de fuente del nombre de campaña
|
||||||
|
curso_clause = "FALSE()"
|
||||||
|
m = re.search(r'fco_(?:search|pmx)_(\d+)', campaign_name, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
course_num = m.group(1)
|
||||||
|
if "pmx" in campaign_name.lower() and "_leadform" not in campaign_name.lower():
|
||||||
|
utm_source = "pmx"
|
||||||
|
elif "search" in campaign_name.lower():
|
||||||
|
utm_source = "google"
|
||||||
|
else:
|
||||||
|
utm_source = None
|
||||||
|
if utm_source:
|
||||||
|
curso_clause = (
|
||||||
|
f"AND({{attr_cursoid}}='{course_num}',"
|
||||||
|
f"{{attr_utm_source}}='{utm_source}')"
|
||||||
|
)
|
||||||
|
|
||||||
formula = (
|
formula = (
|
||||||
f"AND("
|
f"AND("
|
||||||
f"OR("
|
f"OR("
|
||||||
f"{{GACampaignID}}='{campaign_id}',"
|
f"{{GACampaignID}}='{campaign_id}',"
|
||||||
f"FIND(',{campaign_id},',',' & {{GoogleCampaignID}} & ','),"
|
f"FIND(',{campaign_id},',',' & {{GoogleCampaignID}} & ','),"
|
||||||
f"FIND('gad_campaignid={campaign_id}',{{attr_referer}}),"
|
f"FIND('gad_campaignid={campaign_id}',{{attr_referer}}),"
|
||||||
f"{leadform_clause}"
|
f"{leadform_clause},"
|
||||||
|
f"{curso_clause}"
|
||||||
f"),"
|
f"),"
|
||||||
f"{{creado}}>='{mes_inicio}'"
|
f"{{creado}}>='{mes_inicio}'"
|
||||||
f")"
|
f")"
|
||||||
@ -478,6 +500,28 @@ class AirtableClient:
|
|||||||
for i in range(0, len(batch), 10):
|
for i in range(0, len(batch), 10):
|
||||||
self.gacampaignmes.batch_update(batch[i:i+10])
|
self.gacampaignmes.batch_update(batch[i:i+10])
|
||||||
|
|
||||||
|
# ── GAMes ──────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
def get_or_create_games_record(self, mes: int, anio: int) -> str:
|
||||||
|
records = self.games.all(formula=f"AND({{Mes}}='{mes}',{{Año}}='{anio}')")
|
||||||
|
if records:
|
||||||
|
return records[0]["id"]
|
||||||
|
r = self.games.create({"Mes": str(mes), "Año": str(anio)})
|
||||||
|
return r["id"]
|
||||||
|
|
||||||
|
def update_games_metricas(self, record_id: str, metricas_json: str, totales: dict = None) -> None:
|
||||||
|
fields = {"MetricasDiarias": metricas_json}
|
||||||
|
if totales:
|
||||||
|
fields.update(totales)
|
||||||
|
self.games.update(record_id, fields)
|
||||||
|
|
||||||
|
def get_games_metricas(self, record_id: str) -> dict:
|
||||||
|
r = self.games.get(record_id)
|
||||||
|
try:
|
||||||
|
return json.loads(r["fields"].get("MetricasDiarias") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
def batch_update_gacampaignmes_advice(self, updates: list[tuple]) -> None:
|
def batch_update_gacampaignmes_advice(self, updates: list[tuple]) -> None:
|
||||||
"""
|
"""
|
||||||
Actualiza en lote los campos 'Consejo', 'Criticidad' y 'Log' de GACampaignMes.
|
Actualiza en lote los campos 'Consejo', 'Criticidad' y 'Log' de GACampaignMes.
|
||||||
|
|||||||
73
backfill_games_mayo.py
Normal file
73
backfill_games_mayo.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Script one-off: crea el registro de mayo 2026 en GAMes y rellena MetricasDiarias
|
||||||
|
con el agregado diario de todas las campañas fco_ a partir de GACampaignMes.
|
||||||
|
|
||||||
|
Ejecutar una sola vez:
|
||||||
|
python backfill_games_mayo.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from airtable_client import AirtableClient
|
||||||
|
|
||||||
|
at = AirtableClient()
|
||||||
|
|
||||||
|
MES = 5
|
||||||
|
ANIO = 2026
|
||||||
|
|
||||||
|
print("Cargando registros GACampaignMes de mayo (campañas fco_)...")
|
||||||
|
records = at.gacampaignmes.all(
|
||||||
|
formula="AND({Mes}='5',{Año}='2026')",
|
||||||
|
fields=["CampaignID", "MetricasDiarias", "Campaign Name (from CampaignID)"],
|
||||||
|
)
|
||||||
|
|
||||||
|
campaigns_records = at.campaigns.all(fields=["CampaignID", "PPL"])
|
||||||
|
at_id_to_info = {
|
||||||
|
r["id"]: {
|
||||||
|
"gid": str(r["fields"].get("CampaignID", "")).strip(),
|
||||||
|
"ppl": float(r["fields"].get("PPL", 0) or 0),
|
||||||
|
}
|
||||||
|
for r in campaigns_records
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar métricas por día para campañas fco_
|
||||||
|
daily_agg: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
at_cids = r["fields"].get("CampaignID", [])
|
||||||
|
if not at_cids:
|
||||||
|
continue
|
||||||
|
info = at_id_to_info.get(at_cids[0], {})
|
||||||
|
ppl = info.get("ppl", 0)
|
||||||
|
|
||||||
|
campaign_names = r["fields"].get("Campaign Name (from CampaignID)", [])
|
||||||
|
campaign_name = (campaign_names[0] if campaign_names else "").lower()
|
||||||
|
if not campaign_name.startswith("fco_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
md = json.loads(r["fields"].get("MetricasDiarias") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
md = {}
|
||||||
|
|
||||||
|
for day_str, vals in md.items():
|
||||||
|
if day_str not in daily_agg:
|
||||||
|
daily_agg[day_str] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0}
|
||||||
|
daily_agg[day_str]["coste"] += vals.get("coste", 0)
|
||||||
|
daily_agg[day_str]["ingreso_sum"] += vals.get("ingreso", 0)
|
||||||
|
daily_agg[day_str]["ingreso_lxp"] += vals.get("leads_lake", 0) * ppl
|
||||||
|
daily_agg[day_str]["leads"] += int(vals.get("leads", 0))
|
||||||
|
daily_agg[day_str]["leads_lake"] += int(vals.get("leads_lake", 0))
|
||||||
|
|
||||||
|
print(f" > Dias agregados: {sorted(daily_agg.keys())}")
|
||||||
|
|
||||||
|
# Redondear
|
||||||
|
for d in daily_agg:
|
||||||
|
daily_agg[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in daily_agg[d].items()}
|
||||||
|
|
||||||
|
print("Creando/obteniendo registro GAMes de mayo 2026...")
|
||||||
|
games_rid = at.get_or_create_games_record(MES, ANIO)
|
||||||
|
print(f" > Record ID: {games_rid}")
|
||||||
|
|
||||||
|
print("Guardando MetricasDiarias en GAMes...")
|
||||||
|
at.update_games_metricas(games_rid, json.dumps(daily_agg, ensure_ascii=False))
|
||||||
|
|
||||||
|
print(f"OK Backfill GAMes completado: {len(daily_agg)} dias guardados.")
|
||||||
@ -133,6 +133,66 @@ class GoogleAdsClient:
|
|||||||
print(f" ❌ Error obteniendo métricas mensuales: {e}")
|
print(f" ❌ Error obteniendo métricas mensuales: {e}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_monthly_metrics_all(self) -> dict:
|
||||||
|
"""
|
||||||
|
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:
|
def get_campaign_metrics(self, campaign_id: str) -> dict:
|
||||||
"""Métricas del mes en curso para una campaña concreta (acumulado mensual)."""
|
"""Métricas del mes en curso para una campaña concreta (acumulado mensual)."""
|
||||||
ga_service = self.client.get_service("GoogleAdsService")
|
ga_service = self.client.get_service("GoogleAdsService")
|
||||||
|
|||||||
79
run.py
79
run.py
@ -8,7 +8,7 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_bufferin
|
|||||||
from airtable_client import AirtableClient
|
from airtable_client import AirtableClient
|
||||||
from google_ads_client import GoogleAdsClient
|
from google_ads_client import GoogleAdsClient
|
||||||
from analyzer import analyze
|
from analyzer import analyze
|
||||||
from agent import decide
|
from agent import decide, portfolio_daily_analysis
|
||||||
from optimizer import apply_decision
|
from optimizer import apply_decision
|
||||||
from slack_reporter import build_and_send
|
from slack_reporter import build_and_send
|
||||||
import config
|
import config
|
||||||
@ -156,6 +156,15 @@ def run():
|
|||||||
if "_leadform" in c["curso"].lower() and _course_num(c["curso"])
|
if "_leadform" in c["curso"].lower() and _course_num(c["curso"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Precomputar conversiones Google de cada campaña leadform por número de curso
|
||||||
|
leadform_conv_by_course: dict[str, int] = {}
|
||||||
|
for c in campaigns:
|
||||||
|
if "_leadform" in c["curso"].lower():
|
||||||
|
num = _course_num(c["curso"])
|
||||||
|
if num:
|
||||||
|
lf_conv = int(monthly_metrics.get(c["google_campaign_id"], {}).get("conversions", 0))
|
||||||
|
leadform_conv_by_course[num] = leadform_conv_by_course.get(num, 0) + lf_conv
|
||||||
|
|
||||||
# === PRIMERA PASADA: recopilar datos de todas las campañas ===
|
# === PRIMERA PASADA: recopilar datos de todas las campañas ===
|
||||||
collected = []
|
collected = []
|
||||||
skipped = []
|
skipped = []
|
||||||
@ -167,7 +176,7 @@ def run():
|
|||||||
leads, lead_ids = at.get_leads_this_month_gads(cid, campaign["curso"])
|
leads, lead_ids = at.get_leads_this_month_gads(cid, campaign["curso"])
|
||||||
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
|
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
|
||||||
|
|
||||||
metrics = gads.get_campaign_metrics(cid)
|
metrics = monthly_metrics.get(cid)
|
||||||
if not metrics:
|
if not metrics:
|
||||||
skipped.append(f"[{cid}] {campaign['curso']}")
|
skipped.append(f"[{cid}] {campaign['curso']}")
|
||||||
continue
|
continue
|
||||||
@ -181,13 +190,20 @@ def run():
|
|||||||
google_status,
|
google_status,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Para PMX con companion Search: usar conversiones Google como leads de análisis
|
|
||||||
course_num = _course_num(campaign["curso"])
|
course_num = _course_num(campaign["curso"])
|
||||||
is_pmx_with_companion = (
|
is_pmx = "pmx" in campaign["curso"].lower() and "_leadform" not in campaign["curso"].lower()
|
||||||
"pmx" in campaign["curso"].lower()
|
|
||||||
and course_num in courses_with_both
|
# PMX con companion Search: conversiones Google del PMX (search fluye hacia PMX)
|
||||||
)
|
is_pmx_with_companion = is_pmx and course_num in courses_with_both
|
||||||
leads_grupo = int(metrics.get("conversions", 0)) if is_pmx_with_companion else leads
|
# PMX con companion Leadform: conv PMX + conv leadform (leads no llegan a Airtable)
|
||||||
|
is_pmx_with_leadform = is_pmx and course_num in courses_with_leadform
|
||||||
|
|
||||||
|
if is_pmx_with_leadform:
|
||||||
|
leads_grupo = int(metrics.get("conversions", 0)) + leadform_conv_by_course.get(course_num, 0)
|
||||||
|
elif is_pmx_with_companion:
|
||||||
|
leads_grupo = int(metrics.get("conversions", 0))
|
||||||
|
else:
|
||||||
|
leads_grupo = leads
|
||||||
|
|
||||||
analysis = analyze(campaign, leads_grupo, metrics)
|
analysis = analyze(campaign, leads_grupo, metrics)
|
||||||
decision = decide(analysis)
|
decision = decide(analysis)
|
||||||
@ -200,6 +216,7 @@ def run():
|
|||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"today_metrics": today_metrics.get(cid, {}),
|
"today_metrics": today_metrics.get(cid, {}),
|
||||||
|
"is_pmx_with_leadform": is_pmx_with_leadform,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Actualizar status en GACampaignMes y Google Ads Campaigns
|
# Actualizar status en GACampaignMes y Google Ads Campaigns
|
||||||
@ -219,6 +236,8 @@ def run():
|
|||||||
resumen = []
|
resumen = []
|
||||||
advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final
|
advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final
|
||||||
metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias
|
metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias
|
||||||
|
games_agg: dict = {} # dia_hoy → {coste, ingreso_sum, ingreso_lxp, leads, leads_lake}
|
||||||
|
games_mes = {"coste_mes": 0.0, "conv_mes": 0, "ppl_leads": 0.0, "leads_total": 0}
|
||||||
ayer = datetime.now() - timedelta(days=1)
|
ayer = datetime.now() - timedelta(days=1)
|
||||||
dia_hoy = ayer.strftime("%d")
|
dia_hoy = ayer.strftime("%d")
|
||||||
cambio_mes = ayer.month != datetime.now().month
|
cambio_mes = ayer.month != datetime.now().month
|
||||||
@ -291,8 +310,9 @@ def run():
|
|||||||
is_leadform = "_leadform" in campaign["curso"].lower()
|
is_leadform = "_leadform" in campaign["curso"].lower()
|
||||||
if is_leadform:
|
if is_leadform:
|
||||||
log_text += " | ℹ️ LEADFORM: leads capturados directamente en Google (sin visitar la web) — no llegan a Airtable"
|
log_text += " | ℹ️ LEADFORM: leads capturados directamente en Google (sin visitar la web) — no llegan a Airtable"
|
||||||
elif course_num in courses_with_leadform:
|
elif item.get("is_pmx_with_leadform"):
|
||||||
log_text += " | ⚠️ LEADFORM COMPANION: existe una campaña _leadform activa para este curso — parte de las conversiones de Google pueden provenir de leads capturados directamente en Google"
|
lf_conv = leadform_conv_by_course.get(course_num, 0)
|
||||||
|
log_text += f" | ℹ️ LEADFORM COMPANION: {lf_conv} conv leadform sumadas a leads_grupo para este curso"
|
||||||
|
|
||||||
# Métricas diarias: coste hoy, ingreso (conversiones × PPL) y margen
|
# Métricas diarias: coste hoy, ingreso (conversiones × PPL) y margen
|
||||||
coste_hoy = round(today_m.get("cost", 0), 2)
|
coste_hoy = round(today_m.get("cost", 0), 2)
|
||||||
@ -306,6 +326,21 @@ def run():
|
|||||||
leads_lake_hoy = leads_yesterday.get(cid, 0)
|
leads_lake_hoy = leads_yesterday.get(cid, 0)
|
||||||
md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy, "leads": int(conv_hoy), "leads_lake": leads_lake_hoy}
|
md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy, "leads": int(conv_hoy), "leads_lake": leads_lake_hoy}
|
||||||
campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False)
|
campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False)
|
||||||
|
|
||||||
|
if campaign["curso"].lower().startswith("fco_"):
|
||||||
|
if dia_hoy not in games_agg:
|
||||||
|
games_agg[dia_hoy] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0}
|
||||||
|
games_agg[dia_hoy]["coste"] += coste_hoy
|
||||||
|
games_agg[dia_hoy]["ingreso_sum"] += ingreso_hoy
|
||||||
|
games_agg[dia_hoy]["ingreso_lxp"] += leads_lake_hoy * campaign["ppl"]
|
||||||
|
games_agg[dia_hoy]["leads"] += int(conv_hoy)
|
||||||
|
games_agg[dia_hoy]["leads_lake"] += leads_lake_hoy
|
||||||
|
# Totales mes (fuente: Google Ads acumulado mensual)
|
||||||
|
games_mes["coste_mes"] += metrics.get("cost", 0)
|
||||||
|
games_mes["conv_mes"] += int(analysis["conversiones_google"])
|
||||||
|
# PPLMedio ponderado por leads Airtable
|
||||||
|
games_mes["ppl_leads"] += campaign["ppl"] * leads
|
||||||
|
games_mes["leads_total"] += leads
|
||||||
metricas_updates.append({
|
metricas_updates.append({
|
||||||
"airtable_id": campaign["airtable_id"],
|
"airtable_id": campaign["airtable_id"],
|
||||||
"metricas_json": json.dumps(md, ensure_ascii=False),
|
"metricas_json": json.dumps(md, ensure_ascii=False),
|
||||||
@ -355,6 +390,26 @@ def run():
|
|||||||
at.batch_update_metricas_diarias(metricas_updates)
|
at.batch_update_metricas_diarias(metricas_updates)
|
||||||
print(" ✓ MetricasDiarias actualizado.")
|
print(" ✓ MetricasDiarias actualizado.")
|
||||||
|
|
||||||
|
# Actualizar GAMes con el agregado diario fco_
|
||||||
|
if games_agg:
|
||||||
|
print("→ Actualizando GAMes...")
|
||||||
|
games_rid = at.get_or_create_games_record(ayer.month, ayer.year)
|
||||||
|
games_md = at.get_games_metricas(games_rid)
|
||||||
|
for d, vals in games_agg.items():
|
||||||
|
games_md[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in vals.items()}
|
||||||
|
coste_mes = round(games_mes["coste_mes"], 2)
|
||||||
|
conv_mes = games_mes["conv_mes"]
|
||||||
|
ppl_medio = round(games_mes["ppl_leads"] / games_mes["leads_total"], 2) if games_mes["leads_total"] > 0 else 0.0
|
||||||
|
cpa_medio = round(coste_mes / conv_mes, 2) if conv_mes > 0 else 0.0
|
||||||
|
totales = {
|
||||||
|
"CosteMes": coste_mes,
|
||||||
|
"ConvMes": conv_mes,
|
||||||
|
"PPLMedio": ppl_medio,
|
||||||
|
"CPAMedio": cpa_medio,
|
||||||
|
}
|
||||||
|
at.update_games_metricas(games_rid, json.dumps(games_md, ensure_ascii=False), totales)
|
||||||
|
print(" ✓ GAMes actualizado.")
|
||||||
|
|
||||||
# Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo
|
# Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo
|
||||||
# PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo)
|
# PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo)
|
||||||
final_leads_data = [
|
final_leads_data = [
|
||||||
@ -386,9 +441,11 @@ def run():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
# Enviar resumen a Slack
|
# Enviar resumen a Slack
|
||||||
|
print("→ Generando análisis estratégico del portfolio...")
|
||||||
|
portfolio_text = portfolio_daily_analysis(collected)
|
||||||
print("→ Enviando resumen a Slack...")
|
print("→ Enviando resumen a Slack...")
|
||||||
prev_month_metricas = at.get_metricas_diarias_prev_month() if (cambio_mes or datetime.now().day <= 5) else {}
|
prev_month_metricas = at.get_metricas_diarias_prev_month() if (cambio_mes or datetime.now().day <= 5) else {}
|
||||||
build_and_send(collected, config.DRY_RUN, prev_month_metricas)
|
build_and_send(collected, config.DRY_RUN, prev_month_metricas, portfolio_text)
|
||||||
print(" ✓ Resumen enviado a Slack.")
|
print(" ✓ Resumen enviado a Slack.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,8 @@ def _curso(name: str, max_len: int = 40) -> str:
|
|||||||
return name[:max_len] + ("…" if len(name) > max_len else "")
|
return name[:max_len] + ("…" if len(name) > max_len else "")
|
||||||
|
|
||||||
|
|
||||||
def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None) -> None:
|
def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None,
|
||||||
|
portfolio_analysis_text: str = None) -> None:
|
||||||
if not config.SLACK_WEBHOOK_URL:
|
if not config.SLACK_WEBHOOK_URL:
|
||||||
print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.")
|
print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.")
|
||||||
return
|
return
|
||||||
@ -91,11 +92,15 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
|||||||
margen_leads_ppl = 0.0
|
margen_leads_ppl = 0.0
|
||||||
pct_leads_ppl = 0.0
|
pct_leads_ppl = 0.0
|
||||||
else:
|
else:
|
||||||
ing_sumatorio = round(sum(
|
ing_sumatorio = 0.0
|
||||||
sum(d.get("ingreso", 0) for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values())
|
coste_sumatorio = 0.0
|
||||||
for item in fco
|
for item in fco:
|
||||||
), 2)
|
for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values():
|
||||||
margen_sumatorio = round(ing_sumatorio - inv_total, 2)
|
ing_sumatorio += d.get("ingreso", 0)
|
||||||
|
coste_sumatorio += d.get("coste", 0)
|
||||||
|
ing_sumatorio = round(ing_sumatorio, 2)
|
||||||
|
coste_sumatorio = round(coste_sumatorio, 2)
|
||||||
|
margen_sumatorio = round(ing_sumatorio - coste_sumatorio, 2)
|
||||||
margen_leads_ppl = round(ing_leads_ppl - inv_total, 2)
|
margen_leads_ppl = round(ing_leads_ppl - inv_total, 2)
|
||||||
pct_sumatorio = round(margen_sumatorio / ing_sumatorio * 100, 1) if ing_sumatorio > 0 else 0.0
|
pct_sumatorio = round(margen_sumatorio / ing_sumatorio * 100, 1) if ing_sumatorio > 0 else 0.0
|
||||||
pct_leads_ppl = round(margen_leads_ppl / ing_leads_ppl * 100, 1) if ing_leads_ppl > 0 else 0.0
|
pct_leads_ppl = round(margen_leads_ppl / ing_leads_ppl * 100, 1) if ing_leads_ppl > 0 else 0.0
|
||||||
@ -151,6 +156,49 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
|||||||
worst_month = month_rows[:TOP_N]
|
worst_month = month_rows[:TOP_N]
|
||||||
best_month = list(reversed(month_rows[-TOP_N:]))
|
best_month = list(reversed(month_rows[-TOP_N:]))
|
||||||
|
|
||||||
|
# ── Tabla de márgenes diarios ─────────────────────────────────────────────
|
||||||
|
daily_totals: dict[int, dict] = {}
|
||||||
|
for item in fco:
|
||||||
|
ppl = item["campaign"].get("ppl", 0)
|
||||||
|
md = _parse_metricas(item["campaign"].get("metricas_diarias", "{}"))
|
||||||
|
for day_str, vals in md.items():
|
||||||
|
try:
|
||||||
|
d = int(day_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if d not in daily_totals:
|
||||||
|
daily_totals[d] = {"coste": 0.0, "ingreso_sum": 0.0}
|
||||||
|
daily_totals[d]["coste"] += vals.get("coste", 0)
|
||||||
|
daily_totals[d]["ingreso_sum"] += vals.get("ingreso", 0)
|
||||||
|
|
||||||
|
margin_table_block = None
|
||||||
|
if daily_totals and not primer_dia_mes:
|
||||||
|
rows = ["Día Margen € %"]
|
||||||
|
total_coste = total_ing = 0.0
|
||||||
|
for d in sorted(daily_totals):
|
||||||
|
coste = daily_totals[d]["coste"]
|
||||||
|
ing_sum = daily_totals[d]["ingreso_sum"]
|
||||||
|
margen = ing_sum - coste
|
||||||
|
pct = round(margen / ing_sum * 100, 1) if ing_sum > 0 else 0.0
|
||||||
|
total_coste += coste
|
||||||
|
total_ing += ing_sum
|
||||||
|
s_eur = ("+" if margen >= 0 else "") + f"{margen:,.0f}€".replace(",", ".")
|
||||||
|
s_pct = ("+" if pct >= 0 else "") + f"{pct:.1f}%"
|
||||||
|
rows.append(f"{d:02d} {s_eur:>9} {s_pct:>7}")
|
||||||
|
total_margen = total_ing - total_coste
|
||||||
|
total_pct = round(total_margen / total_ing * 100, 1) if total_ing > 0 else 0.0
|
||||||
|
s_tot_eur = ("+" if total_margen >= 0 else "") + f"{total_margen:,.0f}€".replace(",", ".")
|
||||||
|
s_tot_pct = ("+" if total_pct >= 0 else "") + f"{total_pct:.1f}%"
|
||||||
|
rows.append("─" * 24)
|
||||||
|
rows.append(f"TOT {s_tot_eur:>9} {s_tot_pct:>7}")
|
||||||
|
margin_table_block = {
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"📅 *MÁRGENES POR DÍA — {MESES_ES[now.month].upper()}*\n```\n" + "\n".join(rows) + "\n```",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# ── Alertas ──────────────────────────────────────────────────────────────
|
# ── Alertas ──────────────────────────────────────────────────────────────
|
||||||
alerts = [
|
alerts = [
|
||||||
r for r in month_rows
|
r for r in month_rows
|
||||||
@ -232,6 +280,10 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
if margin_table_block:
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
blocks.append(margin_table_block)
|
||||||
|
|
||||||
blocks.append({"type": "divider"})
|
blocks.append({"type": "divider"})
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "section",
|
"type": "section",
|
||||||
@ -264,6 +316,16 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if portfolio_analysis_text:
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"🤖 *DIAGNÓSTICO ESTRATÉGICO*\n{portfolio_analysis_text}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
payload = {"blocks": blocks}
|
payload = {"blocks": blocks}
|
||||||
try:
|
try:
|
||||||
resp = requests.post(config.SLACK_WEBHOOK_URL, json=payload, timeout=10)
|
resp = requests.post(config.SLACK_WEBHOOK_URL, json=payload, timeout=10)
|
||||||
|
|||||||
134
weekly_report.py
Normal file
134
weekly_report.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Informe estratégico semanal de Leads Optimizer.
|
||||||
|
Se ejecuta los lunes via GitHub Actions y envía un análisis profundo a Slack.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import config
|
||||||
|
from airtable_client import AirtableClient
|
||||||
|
from google_ads_client import GoogleAdsClient
|
||||||
|
from agent import weekly_strategic_analysis
|
||||||
|
from analyzer import analyze
|
||||||
|
from slack_reporter import MESES_ES
|
||||||
|
|
||||||
|
MESES_ES_LOCAL = MESES_ES
|
||||||
|
|
||||||
|
|
||||||
|
def _get_week_days(offset_weeks: int = 0) -> list[str]:
|
||||||
|
"""Devuelve los 7 días YYYY-MM-DD de la semana offset_weeks atrás (0=esta, 1=anterior)."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
monday = today - timedelta(days=today.weekday()) - timedelta(weeks=offset_weeks)
|
||||||
|
return [(monday + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_games_md_by_days(games_md: dict, days: list[str]) -> dict:
|
||||||
|
"""Filtra MetricasDiarias de GAMes a los días indicados (formato 'DD')."""
|
||||||
|
day_keys = {d[8:10] for d in days}
|
||||||
|
return {k: v for k, v in games_md.items() if k in day_keys}
|
||||||
|
|
||||||
|
|
||||||
|
def run_weekly():
|
||||||
|
now = datetime.now()
|
||||||
|
print(f"\n{'='*55}")
|
||||||
|
print(f" INFORME SEMANAL — {now.strftime('%d/%m/%Y %H:%M')}")
|
||||||
|
print(f"{'='*55}\n")
|
||||||
|
|
||||||
|
at = AirtableClient()
|
||||||
|
gads = GoogleAdsClient()
|
||||||
|
|
||||||
|
# Obtener MetricasDiarias de GAMes del mes en curso
|
||||||
|
mes, anio = now.month, now.year
|
||||||
|
print("Cargando GAMes...")
|
||||||
|
games_rid = at.get_or_create_games_record(mes, anio)
|
||||||
|
games_md = at.get_games_metricas(games_rid)
|
||||||
|
|
||||||
|
# Calcular semanas
|
||||||
|
this_week_days = _get_week_days(0)
|
||||||
|
prev_week_days = _get_week_days(1)
|
||||||
|
|
||||||
|
games_this = _filter_games_md_by_days(games_md, this_week_days)
|
||||||
|
games_prev = _filter_games_md_by_days(games_md, prev_week_days)
|
||||||
|
|
||||||
|
# Si la semana anterior cruza con el mes pasado, intentar cargar ese GAMes también
|
||||||
|
prev_month_days = [d for d in prev_week_days if d[:7] != now.strftime("%Y-%m")]
|
||||||
|
if prev_month_days:
|
||||||
|
prev_mes = mes - 1 if mes > 1 else 12
|
||||||
|
prev_anio = anio if mes > 1 else anio - 1
|
||||||
|
prev_records = at.games.all(formula=f"AND({{Mes}}='{prev_mes}',{{Año}}='{prev_anio}')")
|
||||||
|
if prev_records:
|
||||||
|
try:
|
||||||
|
prev_md = json.loads(prev_records[0]["fields"].get("MetricasDiarias") or "{}")
|
||||||
|
except Exception:
|
||||||
|
prev_md = {}
|
||||||
|
extra = _filter_games_md_by_days(prev_md, prev_month_days)
|
||||||
|
games_prev.update(extra)
|
||||||
|
|
||||||
|
# Cargar métricas de campañas activas para el análisis de portfolio
|
||||||
|
print("Cargando campañas activas...")
|
||||||
|
campaigns = at.get_active_gacampaignmes()
|
||||||
|
monthly_metrics = gads.get_monthly_metrics_all()
|
||||||
|
|
||||||
|
collected = []
|
||||||
|
for campaign in campaigns:
|
||||||
|
cid = campaign["google_campaign_id"]
|
||||||
|
metrics = monthly_metrics.get(cid, {"cost": 0, "conversions": 0, "clicks": 0,
|
||||||
|
"impressions": 0, "ctr": 0, "budget_daily": 0,
|
||||||
|
"status": "UNKNOWN"})
|
||||||
|
leads = campaign.get("conv_leads_lake_mes", 0)
|
||||||
|
analysis = analyze(campaign, leads, metrics)
|
||||||
|
collected.append({"campaign": campaign, "metrics": metrics,
|
||||||
|
"analysis": analysis, "leads": leads})
|
||||||
|
|
||||||
|
print("Generando análisis estratégico semanal con IA...")
|
||||||
|
mes_nombre = MESES_ES_LOCAL.get(mes, str(mes))
|
||||||
|
analysis_text = weekly_strategic_analysis(games_this, games_prev, collected, mes_nombre)
|
||||||
|
|
||||||
|
# Construir mensaje Slack
|
||||||
|
if not config.SLACK_WEBHOOK_URL:
|
||||||
|
print("SLACK_WEBHOOK_URL no configurada.")
|
||||||
|
print("\n--- ANÁLISIS ---\n")
|
||||||
|
print(analysis_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
this_range = f"{this_week_days[0][8:10]}/{this_week_days[0][5:7]}–{this_week_days[-1][8:10]}/{this_week_days[-1][5:7]}"
|
||||||
|
prev_range = f"{prev_week_days[0][8:10]}/{prev_week_days[0][5:7]}–{prev_week_days[-1][8:10]}/{prev_week_days[-1][5:7]}"
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text",
|
||||||
|
"text": f"INFORME SEMANAL — {now.strftime('%d/%m/%Y')}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"_Semana actual: {this_range} | Semana anterior: {prev_range}_",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "divider"},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"🤖 *ANÁLISIS ESTRATÉGICO SEMANAL*\n{analysis_text}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(config.SLACK_WEBHOOK_URL, json={"blocks": blocks}, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"⚠️ Slack respondió {resp.status_code}: {resp.text[:200]}")
|
||||||
|
else:
|
||||||
|
print("✓ Informe semanal enviado a Slack.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error enviando a Slack: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_weekly()
|
||||||
Loading…
x
Reference in New Issue
Block a user