import sys import io import os import re import json sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True) from airtable_client import AirtableClient from google_ads_client import GoogleAdsClient from analyzer import analyze from agent import decide from optimizer import apply_decision from slack_reporter import build_and_send import config from datetime import datetime, timedelta class Tee: """Escribe simultáneamente en consola y en archivo de log.""" def __init__(self, filepath): os.makedirs(os.path.dirname(filepath), exist_ok=True) self._file = open(filepath, "w", encoding="utf-8") self._stdout = sys.stdout def write(self, data): self._stdout.write(data) self._file.write(data) def flush(self): self._stdout.flush() if not self._file.closed: self._file.flush() def close(self): self._file.close() ICONOS = { "PAUSAR": "⛔", "SPRINT": "🚀", "ACELERAR": "📈", "FRENAR": "📉", "EN_RITMO": "✅", } ACCION_ICONOS = { "PAUSAR": "⛔", "AUMENTAR_PRESUPUESTO": "📈", "REDUCIR_PRESUPUESTO": "📉", "MANTENER": "✅", } # Google Ads customer ID (sin guiones) para construir enlaces directos _CUSTOMER_ID = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID.replace("-", "") def _ads_link(campaign_id: str) -> str: return f"https://ads.google.com/aw/campaigns?campaignId={campaign_id}&__c={_CUSTOMER_ID}" def _priority(item: dict) -> int: """ 0 — Crítica: urgencia PAUSAR o SPRINT 1 — No crítica: accion cambia presupuesto pero urgencia no es crítica 2 — Mantener: accion MANTENER """ urgencia = item["analysis"]["urgencia"] accion = item["decision"]["accion"] if urgencia in ("PAUSAR", "SPRINT"): return 0 if accion != "MANTENER": return 1 return 2 SECCION_LABELS = { 0: "ACCIONES CRÍTICAS (PAUSAR / AUMENTAR)", 1: "ACCIONES NO CRÍTICAS", 2: "SIN CAMBIOS (MANTENER)", } CRITICIDAD_MAP = { 0: "Crítico", 1: "Peligro", 2: "Mantener", } 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() # Sincronizar catálogo de campañas desde Google Ads → Airtable print("→ Sincronizando campañas desde Google Ads...") google_campaigns = gads.get_all_campaigns() monthly_metrics = gads.get_monthly_metrics_all() today_metrics = gads.get_yesterday_metrics_all() _ayer_date = (datetime.now() - timedelta(days=1)) leads_yesterday = at.get_leads_by_campaign_on_date(_ayer_date.strftime("%Y-%m-%d")) print(" Calculando PPL y CapTotalMes...") ppl_lookup, cap_lookup = at.build_campaign_lookups() sync_result = at.sync_campaigns_from_google_ads(google_campaigns, monthly_metrics, ppl_lookup, cap_lookup) if sync_result["created"]: print(f" ✅ Campañas nuevas importadas ({len(sync_result['created'])}):") for c in sync_result["created"]: print(f" + [{c['id']}] {c['name']} → {c['status']}") if sync_result["updated"]: print(f" 🔄 Campañas actualizadas ({len(sync_result['updated'])}):") for c in sync_result["updated"]: for field, val in c["changes"].items(): print(f" ~ [{c['id']}] {c['name']} | {field}: '{val['antes']}' → '{val['ahora']}'") if not sync_result["created"] and not sync_result["updated"]: print(" ✓ Sin cambios en el catálogo.") # Sincronizar GACampaignMes (campañas con actividad este mes) print(" Sincronizando GACampaignMes...") gcm_result = at.sync_gacampaignmes( google_campaigns, monthly_metrics, ppl_lookup, cap_lookup, sync_result["at_by_gid"] ) print(f" ✓ GACampaignMes: {gcm_result['created']} nuevas, {gcm_result['updated']} actualizadas.") print() campaigns = at.get_active_gacampaignmes() print(f"→ {len(campaigns)} campañas con actividad este mes") print("→ Analizando...\n") # Detección anticipada de cursos con Search Y PMX simultáneos def _course_num(name: str) -> str | None: m = re.search(r'fco_(?:search|pmx)_(\d+)', name, re.IGNORECASE) return m.group(1) if m else None course_types_pre: dict[str, set] = {} for c in campaigns: num = _course_num(c["curso"]) if num: course_types_pre.setdefault(num, set()) if "pmx" in c["curso"].lower(): course_types_pre[num].add("pmx") elif "search" in c["curso"].lower(): course_types_pre[num].add("search") courses_with_both = { num for num, types in course_types_pre.items() if "pmx" in types and "search" in types } # Detección anticipada de cursos con campaña _leadform activa courses_with_leadform = { _course_num(c["curso"]) for c in campaigns if "_leadform" in c["curso"].lower() and _course_num(c["curso"]) } # === PRIMERA PASADA: recopilar datos de todas las campañas === collected = [] skipped = [] status_updates = [] # (gcm_id, campaign_at_id, google_status) para batch update for campaign in campaigns: cid = campaign["google_campaign_id"] 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) if not metrics: skipped.append(f"[{cid}] {campaign['curso']}") continue # Recopilar status para actualizar ambas tablas al final del pase google_status = metrics.get("status", "") if google_status: status_updates.append(( campaign["airtable_id"], campaign.get("campaign_at_id", ""), 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 analysis = analyze(campaign, leads_grupo, metrics) decision = decide(analysis) collected.append({ "campaign": campaign, "leads": leads, "leads_grupo": leads_grupo, "metrics": metrics, "analysis": analysis, "decision": decision, "today_metrics": today_metrics.get(cid, {}), }) # Actualizar status en GACampaignMes y Google Ads Campaigns if status_updates: at.batch_update_status(status_updates) if skipped: print(f" ⚠️ {len(skipped)} campañas sin métricas omitidas:") for s in skipped: print(f" · {s}") print() # Ordenar: 0=críticas → 1=no críticas → 2=mantener collected.sort(key=_priority) # === SEGUNDA PASADA: imprimir en orden + aplicar decisiones === resumen = [] advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias ayer = datetime.now() - timedelta(days=1) dia_hoy = ayer.strftime("%d") cambio_mes = ayer.month != datetime.now().month last_priority = -1 for item in collected: campaign = item["campaign"] leads = item["leads"] metrics = item["metrics"] analysis = item["analysis"] decision = item["decision"] today_m = item["today_metrics"] cid = campaign["google_campaign_id"] p = _priority(item) if p != last_priority: print(f"\n{'━'*55}") print(f" {SECCION_LABELS[p]}") print(f"{'━'*55}") last_priority = p icono = ICONOS.get(analysis["urgencia"], "❓") accion_icono = ACCION_ICONOS.get(decision["accion"], "") print(f"{'─'*55}") print(f"📚 {campaign['curso']}") print(f" ID: {cid} | PPL: {campaign['ppl']}€ | Cap: {campaign['capping_mensual']} leads") print(f" 🔗 {_ads_link(cid)}") 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 ({campaign['conv_leads_lake_mes']}) y Google Ads ({int(analysis['conversiones_google'])})") print(f" Decisión: {accion_icono} {decision['accion']} " f"(confianza: {decision['confianza']*100:.0f}%)") print(f" Justificación: {decision['justificacion']}") if decision.get("consejo"): print(f" 💡 Consejo: {decision['consejo']}") if decision.get("alerta"): print(f" 🚨 {decision['alerta']}") apply_decision(campaign, decision, metrics, gads) conv_google = int(analysis["conversiones_google"]) discrepancia_fresh = abs(conv_google - leads) if discrepancia_fresh > 10: log_text = ( f"⚠️ DISCREPANCIA: {leads} leads Airtable vs {conv_google} conv Google Ads " f"(Δ {discrepancia_fresh})" ) else: log_text = f"OK: {leads} leads Airtable | {conv_google} conv Google Ads" # Nota de reatribución PMX para campañas Search con companion PMX del mismo curso course_num = _course_num(campaign["curso"]) if "search" in campaign["curso"].lower() and course_num in courses_with_both: log_text += " | ⚠️ PMX ATTRIBUTION: campaña Search con companion PMX activo — parte de las conversiones de Google pueden estar reatribuidas a la campaña PMX" # Nota leadform: los leads se capturan en Google sin pasar por la web 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" # Métricas diarias: coste hoy, ingreso (conversiones × PPL) y margen coste_hoy = round(today_m.get("cost", 0), 2) conv_hoy = today_m.get("conversions", 0) ingreso_hoy = round(conv_hoy * campaign["ppl"], 2) margen_hoy = round(ingreso_hoy - coste_hoy, 2) try: md = json.loads(campaign["metricas_diarias"]) except (json.JSONDecodeError, TypeError): md = {} 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) metricas_updates.append({ "airtable_id": campaign["airtable_id"], "metricas_json": json.dumps(md, ensure_ascii=False), }) advice_updates.append(( campaign["airtable_id"], decision.get("consejo", ""), CRITICIDAD_MAP[p], log_text, )) 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}%", "consejo": decision.get("consejo", ""), "link": _ads_link(cid), "prioridad": p, }) print() # Guardar consejos y criticidad en GACampaignMes if advice_updates: print(f"→ Guardando consejos y criticidad en GACampaignMes ({len(advice_updates)} registros)...") at.batch_update_gacampaignmes_advice(advice_updates) print(" ✓ Consejos y criticidad guardados.") # Guardar métricas diarias en MetricasDiarias if metricas_updates: print(f"→ Actualizando MetricasDiarias ({len(metricas_updates)} registros)...") if cambio_mes: # Ayer pertenece al mes anterior: redirigir escritura al GACampaignMes correcto prev_map = at.get_gcm_id_map_for_month(ayer.month, ayer.year) for u in metricas_updates: gid = next( (item["campaign"]["google_campaign_id"] for item in collected if item["campaign"]["airtable_id"] == u["airtable_id"]), None, ) if gid and gid in prev_map: u["airtable_id"] = prev_map[gid] at.batch_update_metricas_diarias(metricas_updates) print(" ✓ MetricasDiarias actualizado.") # Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo # PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo) final_leads_data = [ { "airtable_id": item["campaign"]["airtable_id"], "conv_leads_lake_mes": item["leads"], "conv_leads_lake_mes_grupo": item["leads_grupo"], } for item in collected ] if final_leads_data: print(f"→ Actualizando ConvLeadsLakeMesFinal ({len(final_leads_data)} registros)...") at.batch_update_gacampaignmes_final_leads(final_leads_data) print(" ✓ ConvLeadsLakeMesFinal actualizado.") # Resumen final ordenado por prioridad (ya está ordenado) print(f"{'='*55}") print("RESUMEN FINAL") print(f"{'='*55}") last_p = -1 for r in resumen: if r["prioridad"] != last_p: print(f"\n --- {SECCION_LABELS[r['prioridad']]} ---") last_p = r["prioridad"] print(f" {r['curso'][:35]:<35} | {r['urgencia']:<12} | {r['accion']:<25} | {r['leads']} leads | {r['margen']} margen") print(f" {'':35} {r['link']}") if r["consejo"]: print(f" {'':35} 💡 {r['consejo']}") print() # Enviar 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 {} build_and_send(collected, config.DRY_RUN, prev_month_metricas) print(" ✓ Resumen enviado a Slack.") if __name__ == "__main__": timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_path = os.path.join("logs", f"{timestamp}.log") tee = Tee(log_path) sys.stdout = tee try: run() finally: tee.close() print(f"\nLog guardado en: {log_path}", file=tee._stdout)