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 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-05-22 12:22:11 +02:00
commit 92786e94a8
8 changed files with 308 additions and 0 deletions

10
.env.example Normal file
View File

@ -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/...

42
.github/workflows/daily.yml vendored Normal file
View File

@ -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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
logs/
__pycache__/
*.pyc
.venv/

59
agent.py Normal file
View File

@ -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,
}

23
config.py Normal file
View File

@ -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

78
meta_ads_client.py Normal file
View File

@ -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

5
requirements.txt Normal file
View File

@ -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

86
run.py Normal file
View File

@ -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