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