From d68f9f5659745c37f2b26203057dbf817cb4cfb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 11:33:37 +0000 Subject: [PATCH 1/3] Sync Google Ads campaigns to Airtable before analysis Before each run, fetch all non-removed campaigns from Google Ads and upsert them into the Airtable campaigns table: new campaigns are created (Activa=False, pending manual PPL/Cap/CPA setup) and existing ones get their name updated if it changed. https://claude.ai/code/session_01WEcdWPxGWZKk8FGwBRLGR2 --- airtable_client.py | 38 ++++++++++++++++++++++++++++++++++++++ google_ads_client.py | 26 ++++++++++++++++++++++++++ run.py | 16 ++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/airtable_client.py b/airtable_client.py index 6de3b95..d8ef43d 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -25,6 +25,44 @@ class AirtableClient: }) return [c for c in result if c["google_campaign_id"]] # descartar sin ID + def sync_campaigns_from_google_ads(self, google_campaigns: list[dict]) -> dict: + """ + Sincroniza campañas de Google Ads → Airtable. + - Crea las campañas nuevas (Activa=False, requiere configuración manual de PPL/Cap/CPA). + - Actualiza el nombre si ha cambiado. + Devuelve un resumen con listas de campañas creadas y actualizadas. + """ + all_records = self.campaigns.all() + at_by_gid = { + str(r["fields"].get("CampaignID", "")).strip(): r + for r in all_records + if r["fields"].get("CampaignID") + } + + created = [] + updated = [] + + for gc in google_campaigns: + gid = gc["id"] + at_record = at_by_gid.get(gid) + + if at_record is None: + self.campaigns.create({ + "Campaign Name": gc["name"], + "CampaignID": int(gid), + "Activa": False, + }) + created.append(gc["name"]) + else: + changes = {} + if at_record["fields"].get("Campaign Name") != gc["name"]: + changes["Campaign Name"] = gc["name"] + if changes: + self.campaigns.update(at_record["id"], changes) + updated.append(gc["name"]) + + return {"created": created, "updated": updated} + def get_leads_this_month(self, google_campaign_id: str) -> int: """ Cuenta todos los leads del mes actual para una campaña. diff --git a/google_ads_client.py b/google_ads_client.py index 1156cac..290c92e 100644 --- a/google_ads_client.py +++ b/google_ads_client.py @@ -15,6 +15,32 @@ class GoogleAdsClient: }) self.customer_id = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID + def get_all_campaigns(self) -> list[dict]: + """Obtiene todas las campañas no eliminadas de la cuenta.""" + ga_service = self.client.get_service("GoogleAdsService") + query = """ + SELECT + campaign.id, + campaign.name, + campaign.status + FROM campaign + WHERE campaign.status != 'REMOVED' + ORDER BY campaign.name + """ + try: + response = ga_service.search(customer_id=self.customer_id, query=query) + return [ + { + "id": str(row.campaign.id), + "name": row.campaign.name, + "status": row.campaign.status.name, + } + for row in response + ] + except GoogleAdsException as e: + print(f" ❌ Error obteniendo campañas de Google Ads: {e}") + return [] + def get_campaign_metrics(self, campaign_id: str) -> dict: """Métricas del mes en curso para una campaña concreta.""" ga_service = self.client.get_service("GoogleAdsService") diff --git a/run.py b/run.py index e1c6588..e04b2e2 100644 --- a/run.py +++ b/run.py @@ -25,6 +25,22 @@ def run(): 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() + sync_result = at.sync_campaigns_from_google_ads(google_campaigns) + if sync_result["created"]: + print(f" ✅ Campañas nuevas importadas ({len(sync_result['created'])}):") + for name in sync_result["created"]: + print(f" + {name}") + if sync_result["updated"]: + print(f" 🔄 Campañas con nombre actualizado ({len(sync_result['updated'])}):") + for name in sync_result["updated"]: + print(f" ~ {name}") + if not sync_result["created"] and not sync_result["updated"]: + print(" ✓ Sin cambios en el catálogo.") + print() + campaigns = at.get_active_campaigns() print(f"→ {len(campaigns)} campañas activas encontradas\n") From 905ad3bcd2f4e588e066c5f15ed477b277d732e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 11:48:52 +0000 Subject: [PATCH 2/3] Align field names with actual Airtable schema - Campaigns: Status='Activa' filter (was Activa=TRUE), CPAMax (was CPAMaximo), add MargenObjetivo and CursoID Text per campaign - Leads: filter by attr_cursoid + creado (was GoogleCampaignID + Fecha) - analyzer: expose margen_objetivo and margen_ok per campaign - config: remove global MARGEN_MINIMO, now per-campaign via Airtable - sync: new campaigns created with Status='Pausada' https://claude.ai/code/session_01WEcdWPxGWZKk8FGwBRLGR2 --- airtable_client.py | 18 ++++++++++-------- analyzer.py | 3 +++ config.py | 3 +-- run.py | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/airtable_client.py b/airtable_client.py index d8ef43d..d37f219 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -11,17 +11,19 @@ class AirtableClient: def get_active_campaigns(self) -> list[dict]: """Lee todas las campañas activas desde 'Google Ads Campaigns'.""" - records = self.campaigns.all(formula="{Activa}=TRUE()") + records = self.campaigns.all(formula="{Status}='Activa'") result = [] for r in records: f = r["fields"] result.append({ "airtable_id": r["id"], "curso": f.get("Campaign Name", "Sin nombre"), + "cursoid_text": str(f.get("CursoID Text", "")).strip(), "google_campaign_id": str(f.get("CampaignID", "")).strip(), "ppl": float(f.get("PPL", 0)), "capping_mensual": int(f.get("CapTotalMes", 0)), - "cpa_maximo": float(f.get("CPAMaximo", 0)), + "cpa_maximo": float(f.get("CPAMax", 0)), + "margen_objetivo": float(f.get("MargenObjetivo", 0)), }) return [c for c in result if c["google_campaign_id"]] # descartar sin ID @@ -50,7 +52,7 @@ class AirtableClient: self.campaigns.create({ "Campaign Name": gc["name"], "CampaignID": int(gid), - "Activa": False, + "Status": "Pausada", }) created.append(gc["name"]) else: @@ -63,18 +65,18 @@ class AirtableClient: return {"created": created, "updated": updated} - def get_leads_this_month(self, google_campaign_id: str) -> int: + def get_leads_this_month(self, cursoid_text: str) -> int: """ - Cuenta todos los leads del mes actual para una campaña. - Todos los leads en Airtable están validados, sin filtro de estado. + Cuenta todos los leads validados del mes actual para un curso. + Filtra por attr_cursoid (que coincide con CursoID Text de la campaña). """ now = datetime.now() mes_inicio = f"{now.year}-{now.month:02d}-01" formula = ( f"AND(" - f"{{GoogleCampaignID}}='{google_campaign_id}'," - f"{{Fecha}}>='{mes_inicio}'" + f"{{attr_cursoid}}='{cursoid_text}'," + f"{{creado}}>='{mes_inicio}'" f")" ) records = self.leads.all(formula=formula) diff --git a/analyzer.py b/analyzer.py index 32f036d..247d203 100644 --- a/analyzer.py +++ b/analyzer.py @@ -11,6 +11,7 @@ def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> capping = campaign_config["capping_mensual"] ppl = campaign_config["ppl"] cpa_max = campaign_config["cpa_maximo"] + margen_objetivo = campaign_config.get("margen_objetivo", 0) gasto = ads_metrics.get("cost", 0) conversiones_google = ads_metrics.get("conversions", 0) @@ -42,6 +43,8 @@ def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> "campaign_id": campaign_config["google_campaign_id"], "ppl": ppl, "cpa_maximo": cpa_max, + "margen_objetivo": margen_objetivo, + "margen_ok": margen >= margen_objetivo if margen_objetivo > 0 else True, "capping": capping, "leads_entregados": leads_entregados, "leads_restantes": leads_restantes, diff --git a/config.py b/config.py index c189aa4..3cd5d81 100644 --- a/config.py +++ b/config.py @@ -20,5 +20,4 @@ GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] # Operación -DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads -MARGEN_MINIMO = 0.40 # 40% mínimo de margen sobre PPL +DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads diff --git a/run.py b/run.py index e04b2e2..b135e78 100644 --- a/run.py +++ b/run.py @@ -53,7 +53,7 @@ def run(): print(f" Campaign ID: {cid} | PPL: {campaign['ppl']}€ | Cap: {campaign['capping_mensual']} leads") # 1. Leads reales desde Airtable - leads = at.get_leads_this_month(cid) + leads = at.get_leads_this_month(campaign["cursoid_text"]) # 2. Métricas de Google Ads metrics = gads.get_campaign_metrics(cid) From 67d5104dca3789c092f9edfdfbfef6ec1a24a5a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 11:52:38 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Sync=20campaign=20Status=20from=20Google=20?= =?UTF-8?q?Ads=20(ENABLED=E2=86=92Activa,=20PAUSED=E2=86=92Pausada)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both on create and update, mirror the real Google Ads status instead of defaulting to Pausada. https://claude.ai/code/session_01WEcdWPxGWZKk8FGwBRLGR2 --- airtable_client.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/airtable_client.py b/airtable_client.py index d37f219..255684e 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -30,10 +30,12 @@ class AirtableClient: def sync_campaigns_from_google_ads(self, google_campaigns: list[dict]) -> dict: """ Sincroniza campañas de Google Ads → Airtable. - - Crea las campañas nuevas (Activa=False, requiere configuración manual de PPL/Cap/CPA). - - Actualiza el nombre si ha cambiado. + - Crea las campañas nuevas con el status real de Google Ads. + - Actualiza nombre y/o status si han cambiado. Devuelve un resumen con listas de campañas creadas y actualizadas. """ + STATUS_MAP = {"ENABLED": "Activa", "PAUSED": "Pausada"} + all_records = self.campaigns.all() at_by_gid = { str(r["fields"].get("CampaignID", "")).strip(): r @@ -46,19 +48,22 @@ class AirtableClient: for gc in google_campaigns: gid = gc["id"] + at_status = STATUS_MAP.get(gc["status"], "Pausada") at_record = at_by_gid.get(gid) if at_record is None: self.campaigns.create({ "Campaign Name": gc["name"], "CampaignID": int(gid), - "Status": "Pausada", + "Status": at_status, }) created.append(gc["name"]) else: changes = {} if at_record["fields"].get("Campaign Name") != gc["name"]: changes["Campaign Name"] = gc["name"] + if at_record["fields"].get("Status") != at_status: + changes["Status"] = at_status if changes: self.campaigns.update(at_record["id"], changes) updated.append(gc["name"])