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 ANTHROPIC_API_KEY=sk-ant-xxxx
|
||||
|
||||
export SLACK_WEBHOOK_URL=xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
179
agent.py
179
agent.py
@ -1,9 +1,23 @@
|
||||
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.
|
||||
@ -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:
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=400,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{
|
||||
|
||||
@ -14,6 +14,7 @@ class AirtableClient:
|
||||
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
|
||||
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
|
||||
self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes")
|
||||
self.games = self.api.table(config.AIRTABLE_BASE_ID, "GAMes")
|
||||
|
||||
MESES_ES = {
|
||||
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]]:
|
||||
"""
|
||||
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)
|
||||
2. gad_campaignid en attr_referer (UTM web)
|
||||
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()
|
||||
mes_inicio = f"{now.year}-{now.month:02d}-01"
|
||||
|
||||
leadform_clause = (
|
||||
f"AND({{attr_referer}}='{campaign_name}',{{UserAgent del visitante}}='Google-Ads-Notifications')"
|
||||
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 = (
|
||||
f"AND("
|
||||
f"OR("
|
||||
f"{{GACampaignID}}='{campaign_id}',"
|
||||
f"FIND(',{campaign_id},',',' & {{GoogleCampaignID}} & ','),"
|
||||
f"FIND('gad_campaignid={campaign_id}',{{attr_referer}}),"
|
||||
f"{leadform_clause}"
|
||||
f"{leadform_clause},"
|
||||
f"{curso_clause}"
|
||||
f"),"
|
||||
f"{{creado}}>='{mes_inicio}'"
|
||||
f")"
|
||||
@ -478,6 +500,28 @@ class AirtableClient:
|
||||
for i in range(0, len(batch), 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:
|
||||
"""
|
||||
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}")
|
||||
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")
|
||||
|
||||
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 google_ads_client import GoogleAdsClient
|
||||
from analyzer import analyze
|
||||
from agent import decide
|
||||
from agent import decide, portfolio_daily_analysis
|
||||
from optimizer import apply_decision
|
||||
from slack_reporter import build_and_send
|
||||
import config
|
||||
@ -156,6 +156,15 @@ def run():
|
||||
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 ===
|
||||
collected = []
|
||||
skipped = []
|
||||
@ -167,7 +176,7 @@ def run():
|
||||
leads, lead_ids = at.get_leads_this_month_gads(cid, campaign["curso"])
|
||||
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
|
||||
|
||||
metrics = gads.get_campaign_metrics(cid)
|
||||
metrics = monthly_metrics.get(cid)
|
||||
if not metrics:
|
||||
skipped.append(f"[{cid}] {campaign['curso']}")
|
||||
continue
|
||||
@ -181,13 +190,20 @@ def run():
|
||||
google_status,
|
||||
))
|
||||
|
||||
# Para PMX con companion Search: usar conversiones Google como leads de análisis
|
||||
course_num = _course_num(campaign["curso"])
|
||||
is_pmx_with_companion = (
|
||||
"pmx" in campaign["curso"].lower()
|
||||
and course_num in courses_with_both
|
||||
)
|
||||
leads_grupo = int(metrics.get("conversions", 0)) if is_pmx_with_companion else leads
|
||||
is_pmx = "pmx" in campaign["curso"].lower() and "_leadform" not in campaign["curso"].lower()
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
decision = decide(analysis)
|
||||
@ -200,6 +216,7 @@ def run():
|
||||
"analysis": analysis,
|
||||
"decision": decision,
|
||||
"today_metrics": today_metrics.get(cid, {}),
|
||||
"is_pmx_with_leadform": is_pmx_with_leadform,
|
||||
})
|
||||
|
||||
# Actualizar status en GACampaignMes y Google Ads Campaigns
|
||||
@ -219,6 +236,8 @@ def run():
|
||||
resumen = []
|
||||
advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final
|
||||
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)
|
||||
dia_hoy = ayer.strftime("%d")
|
||||
cambio_mes = ayer.month != datetime.now().month
|
||||
@ -291,8 +310,9 @@ def run():
|
||||
is_leadform = "_leadform" in campaign["curso"].lower()
|
||||
if is_leadform:
|
||||
log_text += " | ℹ️ LEADFORM: leads capturados directamente en Google (sin visitar la web) — no llegan a Airtable"
|
||||
elif course_num in courses_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"
|
||||
elif item.get("is_pmx_with_leadform"):
|
||||
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
|
||||
coste_hoy = round(today_m.get("cost", 0), 2)
|
||||
@ -306,6 +326,21 @@ def run():
|
||||
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}
|
||||
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({
|
||||
"airtable_id": campaign["airtable_id"],
|
||||
"metricas_json": json.dumps(md, ensure_ascii=False),
|
||||
@ -355,6 +390,26 @@ def run():
|
||||
at.batch_update_metricas_diarias(metricas_updates)
|
||||
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
|
||||
# PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo)
|
||||
final_leads_data = [
|
||||
@ -386,9 +441,11 @@ def run():
|
||||
print()
|
||||
|
||||
# Enviar resumen a Slack
|
||||
print("→ Generando análisis estratégico del portfolio...")
|
||||
portfolio_text = portfolio_daily_analysis(collected)
|
||||
print("→ Enviando resumen a Slack...")
|
||||
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.")
|
||||
|
||||
|
||||
|
||||
@ -63,7 +63,8 @@ def _curso(name: str, max_len: int = 40) -> str:
|
||||
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:
|
||||
print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.")
|
||||
return
|
||||
@ -91,11 +92,15 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
||||
margen_leads_ppl = 0.0
|
||||
pct_leads_ppl = 0.0
|
||||
else:
|
||||
ing_sumatorio = round(sum(
|
||||
sum(d.get("ingreso", 0) for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values())
|
||||
for item in fco
|
||||
), 2)
|
||||
margen_sumatorio = round(ing_sumatorio - inv_total, 2)
|
||||
ing_sumatorio = 0.0
|
||||
coste_sumatorio = 0.0
|
||||
for item in fco:
|
||||
for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values():
|
||||
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)
|
||||
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
|
||||
@ -151,6 +156,49 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
|
||||
worst_month = 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 ──────────────────────────────────────────────────────────────
|
||||
alerts = [
|
||||
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)
|
||||
|
||||
if margin_table_block:
|
||||
blocks.append({"type": "divider"})
|
||||
blocks.append(margin_table_block)
|
||||
|
||||
blocks.append({"type": "divider"})
|
||||
blocks.append({
|
||||
"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}
|
||||
try:
|
||||
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