commit 92786e94a854320ff219b888647f19604a1999a0 Author: José Manuel Gómez Date: Fri May 22 12:22:11 2026 +0200 Initial structure: Meta Optimizer - meta_ads_client.py: Meta Marketing API client (facebook-business SDK) - agent.py: Claude-powered campaign decision engine - run.py: main orchestration script - config.py: environment variables - .github/workflows/daily.yml: GitHub Actions cron (8am CEST) Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f1cbd0 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +AIRTABLE_TOKEN=your_airtable_token +AIRTABLE_BASE_ID=your_base_id + +META_APP_ID=your_app_id +META_APP_SECRET=your_app_secret +META_ACCESS_TOKEN=your_long_lived_access_token +META_AD_ACCOUNT_ID=act_XXXXXXXXXX + +ANTHROPIC_API_KEY=your_anthropic_key +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml new file mode 100644 index 0000000..35a0cd9 --- /dev/null +++ b/.github/workflows/daily.yml @@ -0,0 +1,42 @@ +name: Daily Meta Optimizer + +on: + schedule: + - cron: '0 6 * * *' # 8: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 optimizer + env: + AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }} + AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }} + META_APP_ID: ${{ secrets.META_APP_ID }} + META_APP_SECRET: ${{ secrets.META_APP_SECRET }} + META_ACCESS_TOKEN: ${{ secrets.META_ACCESS_TOKEN }} + META_AD_ACCOUNT_ID: ${{ secrets.META_AD_ACCOUNT_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: python run.py + + - name: Upload log + if: always() + uses: actions/upload-artifact@v4 + with: + name: meta-optimizer-log-${{ github.run_id }} + path: logs/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..029e910 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +logs/ +__pycache__/ +*.pyc +.venv/ diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..64169c1 --- /dev/null +++ b/agent.py @@ -0,0 +1,59 @@ +import json +import anthropic +import config + +client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + +SYSTEM_PROMPT = """ +Eres un experto en optimización de campañas de Meta Ads (Facebook/Instagram) para generación de leads. +Cada campaña corresponde a un producto o curso con un CPL objetivo (coste por lead) acordado. + +MODELO DE NEGOCIO: +- El objetivo es maximizar el volumen de leads por debajo del CPL máximo rentable. +- La frecuencia alta puede indicar saturación de audiencia. +- El CTR y CPM son indicadores clave de relevancia creativa y competencia en subasta. + +REGLAS DE DECISIÓN: +1. CPL > CPL_máximo → REDUCIR_PRESUPUESTO o revisar creatividades/audiencias. +2. CPL <= CPL_máximo y volumen bajo → AUMENTAR_PRESUPUESTO si hay margen. +3. Frecuencia > 3.0 → considerar rotar creatividades o ampliar audiencia. +4. CTR < 1% → problema creativo, revisar anuncios. +5. Sin leads tras 3+ días de gasto → revisar configuración de conversión. + +Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown: +{ + "accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER | REVISAR_CREATIVIDADES", + "parametro": 1.0, + "justificacion": "explicación breve", + "consejo": "acción concreta y específica", + "alerta": "texto si hay algo crítico, null si no", + "confianza": 0.0 +} +""" + + +def decide(analysis: dict) -> dict: + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=400, + system=SYSTEM_PROMPT, + messages=[{ + "role": "user", + "content": ( + f"Analiza esta campaña de Meta Ads y devuelve la decisión en JSON:\n\n" + f"{json.dumps(analysis, ensure_ascii=False, indent=2)}" + ), + }], + ) + raw = response.content[0].text.strip() + clean = raw.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except json.JSONDecodeError: + return { + "accion": "MANTENER", + "parametro": 1.0, + "justificacion": "Error parseando respuesta del agente.", + "alerta": f"JSON inválido: {raw[:200]}", + "confianza": 0.0, + } diff --git a/config.py b/config.py new file mode 100644 index 0000000..64c72a6 --- /dev/null +++ b/config.py @@ -0,0 +1,23 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# Airtable +AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"] +AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"] + +# Meta Ads +META_APP_ID = os.environ["META_APP_ID"] +META_APP_SECRET = os.environ["META_APP_SECRET"] +META_ACCESS_TOKEN = os.environ["META_ACCESS_TOKEN"] +META_AD_ACCOUNT_ID = os.environ["META_AD_ACCOUNT_ID"] # formato: act_XXXXXXXX + +# Anthropic +ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] + +# Slack +SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "") + +# Operación +DRY_RUN = True # True = solo sugiere, no aplica cambios en Meta Ads diff --git a/meta_ads_client.py b/meta_ads_client.py new file mode 100644 index 0000000..7651072 --- /dev/null +++ b/meta_ads_client.py @@ -0,0 +1,78 @@ +""" +Cliente para Meta Marketing API. +Documentación: https://developers.facebook.com/docs/marketing-api +SDK: facebook-business +""" +from facebook_business.api import FacebookAdsApi +from facebook_business.adobjects.adaccount import AdAccount +from facebook_business.adobjects.campaign import Campaign +import config +from datetime import datetime + + +class MetaAdsClient: + def __init__(self): + FacebookAdsApi.init( + app_id=config.META_APP_ID, + app_secret=config.META_APP_SECRET, + access_token=config.META_ACCESS_TOKEN, + ) + self.account = AdAccount(config.META_AD_ACCOUNT_ID) + + def get_monthly_metrics_all(self) -> dict: + """ + Métricas del mes en curso para todas las campañas activas. + Retorna dict {campaign_id: {spend, impressions, clicks, ctr, cpm, leads, cpl, status, name}}. + """ + now = datetime.now() + date_start = f"{now.year}-{now.month:02d}-01" + date_end = now.strftime("%Y-%m-%d") + + campaigns = self.account.get_campaigns(fields=[ + Campaign.Field.id, + Campaign.Field.name, + Campaign.Field.status, + Campaign.Field.effective_status, + ], params={"effective_status": ["ACTIVE", "PAUSED"]}) + + result = {} + for c in campaigns: + cid = c["id"] + name = c["name"] + status = c.get("effective_status", "UNKNOWN") + + insights = c.get_insights(fields=[ + "spend", "impressions", "clicks", "ctr", "cpm", + "actions", # conversiones por tipo (lead, purchase, etc.) + "cost_per_action_type", + ], params={ + "time_range": {"since": date_start, "until": date_end}, + "level": "campaign", + }) + + spend = impressions = clicks = ctr = cpm = leads = 0.0 + if insights: + row = insights[0] + spend = float(row.get("spend", 0)) + impressions = int(row.get("impressions", 0)) + clicks = int(row.get("clicks", 0)) + ctr = float(row.get("ctr", 0)) + cpm = float(row.get("cpm", 0)) + for action in row.get("actions", []): + if action["action_type"] in ("lead", "onsite_conversion.lead_grouped"): + leads += float(action["value"]) + + cpl = round(spend / leads, 2) if leads > 0 else 0.0 + result[cid] = { + "campaign_id": cid, + "name": name, + "status": status, + "spend": round(spend, 2), + "impressions": impressions, + "clicks": clicks, + "ctr": round(ctr, 4), + "cpm": round(cpm, 2), + "leads": int(leads), + "cpl": cpl, + } + return result diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ef8532 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +anthropic==0.95.0 +pyairtable==3.3.0 +facebook-business>=19.0.0 +python-dotenv==1.2.2 +requests>=2.32.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..ff065f6 --- /dev/null +++ b/run.py @@ -0,0 +1,86 @@ +""" +Meta Optimizer — punto de entrada principal. +Analiza campañas de Meta Ads y publica resumen en Slack. +""" +import sys +import io +import os +import json +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True) + +from meta_ads_client import MetaAdsClient +from agent import decide +import config +from datetime import datetime + + +class Tee: + 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() + + +def run(): + now = datetime.now() + print(f"\n{'='*55}") + print(f" META OPTIMIZER — {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") + + meta = MetaAdsClient() + + print("→ Obteniendo métricas del mes desde Meta Ads...") + metrics_all = meta.get_monthly_metrics_all() + print(f" ✓ {len(metrics_all)} campañas encontradas.\n") + + results = [] + for cid, metrics in metrics_all.items(): + analysis = { + "campaign_id": cid, + "name": metrics["name"], + "status": metrics["status"], + "spend": metrics["spend"], + "leads": metrics["leads"], + "cpl": metrics["cpl"], + "cpl_maximo": 0, # TODO: cargar desde Airtable o config por campaña + "ctr": metrics["ctr"], + "cpm": metrics["cpm"], + "impressions": metrics["impressions"], + "clicks": metrics["clicks"], + } + decision = decide(analysis) + results.append({"metrics": metrics, "analysis": analysis, "decision": decision}) + + print(f"📢 {metrics['name'][:50]}") + print(f" Gasto: {metrics['spend']}€ | Leads: {metrics['leads']} | CPL: {metrics['cpl']}€") + print(f" Decisión: {decision['accion']} — {decision['justificacion'][:80]}") + if decision.get("alerta"): + print(f" 🚨 {decision['alerta']}") + print() + + print(f"Log guardado en: logs/{now.strftime('%Y%m%d_%H%M%S')}.log") + + +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() + sys.stdout = tee._stdout