- Broaden Airtable lead counting to attr_utm_source IN ('Lead ads','landingpage')
— the 'landingpage' leads (100% fbclid, 0% gclid) were being missed entirely,
undercounting real leads for '_web' suffixed campaigns and skewing
capping/pacing decisions since yesterday's first production run.
- Add airtable_client.get_meta_leads_bulk() for day/curso-level aggregation.
- Drop per-familia Slack sectioning in favor of a single Formación block,
chunked by campaign batches instead.
- Add daily AT-vs-Meta table, per-curso PPL/CPL contrast table (leadform vs
landing breakdown), and a Claude-generated portfolio strategic diagnosis
(ported from leads-optimizer's portfolio_daily_analysis).
- Persist daily aggregate totals to a new Baserow table (daily_metrics) so
the dashboard and future reports don't depend on Meta's historical API
access remaining available indefinitely.
- Surface adset/ad-level recommendations in the campaign cards instead of
only numeric tables.
194 lines
7.7 KiB
Python
194 lines
7.7 KiB
Python
"""Re-send a day's Slack report (tabla diaria/resumen por curso frescos de
|
|
Meta+Airtable; tarjetas por campaña reconstruidas desde snapshots de Baserow)."""
|
|
import sys
|
|
import io
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
import config
|
|
from meta_ads_client import MetaAdsClient
|
|
from airtable_client import AirtableClient, extract_cursoid
|
|
from baserow_client import BaserowClient
|
|
import slack_notifier
|
|
|
|
|
|
def main():
|
|
run_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
|
|
print(f"Reenviando informe para {run_date}...")
|
|
|
|
meta = MetaAdsClient()
|
|
baserow = BaserowClient()
|
|
airtable = AirtableClient()
|
|
|
|
ppl_lookup, cap_lookup, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
|
|
|
|
# ── Monthly daily totals: Leads Meta vs Leads Airtable (fresco, no se persiste) ─
|
|
print("Obteniendo datos mensuales de Meta y Airtable...")
|
|
month_start = f"{run_date[:7]}-01"
|
|
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
|
|
daily_at_leads = airtable.get_meta_leads_bulk(month_start, run_date)
|
|
|
|
_daily: dict = {}
|
|
for row in daily_rows:
|
|
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
|
ppl = ppl_lookup.get(cursoid, 0)
|
|
d = _daily.setdefault(row["date"], {
|
|
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
|
|
})
|
|
d["spend"] += row["spend"]
|
|
d["leads_meta"] += row["leads"]
|
|
d["ing_meta"] += row["leads"] * ppl
|
|
for lead in daily_at_leads:
|
|
ppl = ppl_lookup.get(lead["cursoid"], 0)
|
|
d = _daily.setdefault(lead["date"], {
|
|
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
|
|
})
|
|
d["leads_at"] += 1
|
|
d["ing_at"] += ppl
|
|
daily_totals = [
|
|
{
|
|
"date": date,
|
|
"spend": round(d["spend"], 2),
|
|
"leads_meta": int(d["leads_meta"]),
|
|
"leads_at": int(d["leads_at"]),
|
|
"ing_meta": round(d["ing_meta"], 2),
|
|
"ing_at": round(d["ing_at"], 2),
|
|
"margin": round(d["ing_meta"] - d["spend"], 2),
|
|
"margin_pct": round((d["ing_meta"] - d["spend"]) / d["ing_meta"] * 100, 1) if d["ing_meta"] > 0 else 0.0,
|
|
}
|
|
for date, d in sorted(_daily.items())
|
|
]
|
|
print(f" ✓ {len(daily_totals)} días con datos")
|
|
|
|
# ── Resumen y contraste por curso (mismo cálculo que run.py) ────────────────
|
|
monthly_metrics_meta = meta.get_campaign_metrics(month_start, run_date)
|
|
name_by_cid = {}
|
|
for row in meta.get_all_campaigns():
|
|
name_by_cid[row["id"]] = row["name"]
|
|
|
|
def _new_curso_entry(cid_: str) -> dict:
|
|
return {
|
|
"campaigns": [], "familia": familia_lookup.get(cid_, "Sin familia"),
|
|
"ppl": ppl_lookup.get(cid_, 0), "spend": 0.0, "leads_meta": 0,
|
|
"leads_at_leadform": 0, "leads_at_landing": 0,
|
|
}
|
|
|
|
curso_summary: dict = {}
|
|
for mcid, m in monthly_metrics_meta.items():
|
|
name = name_by_cid.get(mcid, mcid)
|
|
cursoid = extract_cursoid(name) or ""
|
|
if not cursoid:
|
|
continue
|
|
cs = curso_summary.setdefault(cursoid, _new_curso_entry(cursoid))
|
|
cs["campaigns"].append(name)
|
|
cs["spend"] += m.get("spend", 0.0)
|
|
cs["leads_meta"] += m.get("leads", 0)
|
|
for lead in daily_at_leads:
|
|
cs = curso_summary.setdefault(lead["cursoid"], _new_curso_entry(lead["cursoid"]))
|
|
if lead["utm_source"] == "Lead ads":
|
|
cs["leads_at_leadform"] += 1
|
|
else:
|
|
cs["leads_at_landing"] += 1
|
|
for cursoid, cs in curso_summary.items():
|
|
leads_at_total = cs["leads_at_leadform"] + cs["leads_at_landing"]
|
|
cs["leads_at_total"] = leads_at_total
|
|
cs["cpl_meta"] = round(cs["spend"] / cs["leads_meta"], 2) if cs["leads_meta"] > 0 else 0.0
|
|
cs["cpl_at"] = round(cs["spend"] / leads_at_total, 2) if leads_at_total > 0 else 0.0
|
|
cs["discrepancia"] = cs["leads_meta"] - leads_at_total
|
|
|
|
# ── Load proposed actions (to get parameter values) ──────────────────────
|
|
action_params: dict = {} # campaign_name → parameter
|
|
try:
|
|
all_actions = baserow._get_rows(config.BASEROW_TABLE_ACTIONS, {
|
|
"filter__proposed_at__equal": run_date,
|
|
})
|
|
for a in all_actions:
|
|
cname = a.get("campaign_name", "")
|
|
param = a.get("parameter")
|
|
if cname and param:
|
|
action_params[cname] = float(param)
|
|
print(f" ✓ {len(action_params)} parámetros de acción cargados")
|
|
except Exception as e:
|
|
print(f" Aviso: no se pudieron cargar parámetros de acción: {e}")
|
|
|
|
# ── Load snapshots from Baserow ───────────────────────────────────────────
|
|
print(f"Cargando snapshots de Baserow para {run_date}...")
|
|
snapshots = baserow.get_snapshots_for_date(run_date)
|
|
print(f" ✓ {len(snapshots)} snapshots encontrados")
|
|
|
|
if not snapshots:
|
|
print("ERROR: No hay snapshots en Baserow para esta fecha. Ejecuta run.py primero.")
|
|
return
|
|
|
|
# ── Reconstruct data structures ───────────────────────────────────────────
|
|
# Nota: urgencia/leads_mes/capping no se persisten en daily_snapshots, así
|
|
# que al reenviar desde snapshots esos campos salen con su valor por
|
|
# defecto (slack_notifier ya los trata con .get(...)).
|
|
campaign_details: dict = {}
|
|
actions: list = []
|
|
|
|
for snap in snapshots:
|
|
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
|
|
name = snap["campaign_name"]
|
|
familia = snap.get("familia") or familia_lookup.get(extract_cursoid(name) or "", "Sin familia")
|
|
margin = float(snap.get("margin") or 0)
|
|
spend = float(snap.get("spend") or 0)
|
|
leads = int(snap.get("leads") or 0)
|
|
action_type = snap.get("action_type") or "MAINTAIN"
|
|
|
|
try:
|
|
adsets = json.loads(snap.get("adsets_json") or "[]")
|
|
except Exception:
|
|
adsets = []
|
|
try:
|
|
ads = json.loads(snap.get("ads_json") or "[]")
|
|
except Exception:
|
|
ads = []
|
|
|
|
campaign_details[cid] = {
|
|
"name": name,
|
|
"familia": familia,
|
|
"margin": margin,
|
|
"spend_1d": spend,
|
|
"leads_1d": leads,
|
|
"adsets": adsets,
|
|
"ads": ads,
|
|
"bid_config": {},
|
|
}
|
|
|
|
if action_type != "MAINTAIN":
|
|
actions.append({
|
|
"campaign_name": name,
|
|
"action_type": action_type,
|
|
"justification": snap.get("justification") or "",
|
|
"advice": "",
|
|
"alert": "",
|
|
"confidence": 0.8,
|
|
"parameter": action_params.get(name, 1.0),
|
|
"row_id": snap["id"],
|
|
})
|
|
|
|
# ── Send (sin diagnóstico estratégico: reenviar no vuelve a llamar a Claude) ─
|
|
print("Enviando a Slack...")
|
|
ts = slack_notifier.send_daily_report(
|
|
daily_totals=daily_totals,
|
|
best_campaigns=[],
|
|
worst_campaigns=[],
|
|
actions=actions,
|
|
campaigns_analyzed=len(snapshots),
|
|
mode="DRY_RUN",
|
|
campaign_details=campaign_details,
|
|
curso_summary=curso_summary,
|
|
portfolio_analysis_text=None,
|
|
)
|
|
if ts:
|
|
print(f" ✓ Mensaje enviado (ts={ts})")
|
|
else:
|
|
print(" ✗ Error al enviar (revisa token y canal)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|