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:
commit
92786e94a8
10
.env.example
Normal file
10
.env.example
Normal 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
42
.github/workflows/daily.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
59
agent.py
Normal file
59
agent.py
Normal 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
23
config.py
Normal 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
78
meta_ads_client.py
Normal 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
5
requirements.txt
Normal 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
86
run.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user