@danhachuel/prodmaker 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ """AdsCreator — construtor de planos de campanha (defaults do seed + produto vinculado)."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from .database import get_db, now_str, row_to_dict
6
+ from .llm import LLMError
7
+
8
+ SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
9
+ PLATAFORMAS = ("google", "meta", "tiktok")
10
+
11
+
12
+ def _defaults(plataforma: str) -> dict:
13
+ aliases = {"google": "google", "meta": "meta", "tiktok": "tiktok"}
14
+ path = SEED_DIR / "campaign-defaults" / f"{aliases[plataforma]}.json"
15
+ if path.exists():
16
+ return json.loads(path.read_text(encoding="utf-8"))
17
+ return {}
18
+
19
+
20
+ def construir(produto: dict, plataforma: str, form: dict) -> dict:
21
+ """Monta o plano da campanha com defaults do seed + dados do produto."""
22
+ if plataforma not in PLATAFORMAS:
23
+ raise LLMError(f"Plataforma inválida: {plataforma}. Use: google, meta, tiktok.")
24
+ comissao = float(produto.get("comissao") or 0)
25
+ preco = float(produto.get("preco") or 0)
26
+ receita = comissao or preco * 0.5
27
+ defaults = _defaults(plataforma)
28
+
29
+ if plataforma == "google":
30
+ cpa_alvo = round(receita * 0.7, 2) if receita else 120.0
31
+ orcamento = float(form.get("orcamento_diario") or 110)
32
+ plano = {
33
+ "rede": "Pesquisa (Search)",
34
+ "objetivo": "Vendas — conversões",
35
+ "lance": f"Manual CPC com lance = CPA alvo (R$ {cpa_alvo}) — troque para CPA automático quando a conversão estiver ativa",
36
+ "cpa_alvo": cpa_alvo,
37
+ "orcamento_diario": orcamento,
38
+ "localizacao": "Presença (apenas): Estados Unidos, Canadá, Austrália, Reino Unido",
39
+ "idiomas": "Inglês e Francês",
40
+ "correspondencia": "frase",
41
+ "anuncio": defaults.get("anuncio", {}),
42
+ }
43
+ elif plataforma == "meta":
44
+ ticket = preco or (receita / 0.5 if receita else 0)
45
+ criativos = max(5, int(form.get("quantidade_criativos") or 5))
46
+ orcamento = round(criativos * (ticket / 2), 2) if ticket else 300.0
47
+ plano = {
48
+ "estrutura": f"1 campanha → 1 conjunto → {criativos} criativos",
49
+ "objetivo": "Vendas (Conversões)",
50
+ "publico": "Aberto + Advantage+",
51
+ "orcamento_diario": orcamento,
52
+ "regra_budget": "criativos × metade do ticket",
53
+ "criativos": criativos,
54
+ }
55
+ else:
56
+ plano = {
57
+ "formato": "Vídeo vertical 9:16, 15–30s, estilo UGC",
58
+ "objetivo": "Conversões",
59
+ "publico": "Idade 18–45 (EUA/UK/CA/AU)",
60
+ "orcamento_diario": float(form.get("orcamento_diario") or 15),
61
+ "moeda": "USD",
62
+ "teste": "US$10–20/dia por criativo; escala US$50–100/dia no vencedor",
63
+ }
64
+
65
+ positivas = form.get("keywords") or []
66
+ negativas = form.get("negativas") or []
67
+ criativos = form.get("criativos") or []
68
+ return {
69
+ "plataforma": plataforma,
70
+ "nome": form.get("nome") or f"{produto.get('nome') or 'Produto'} — {plataforma.title()}",
71
+ "produto_id": produto.get("id"),
72
+ "cpa_alvo": float(plano.get("cpa_alvo") or 0),
73
+ "orcamento_diario": float(plano.get("orcamento_diario") or 0),
74
+ "keywords": positivas,
75
+ "negativas": negativas,
76
+ "criativos": criativos,
77
+ "plano": plano,
78
+ "manual": plataforma == "tiktok",
79
+ }
80
+
81
+
82
+ def salvar(campanha: dict) -> dict:
83
+ timestamp = now_str()
84
+ with get_db() as conn:
85
+ cursor = conn.execute(
86
+ "INSERT INTO campanhas (produto_id, plataforma, nome, objetivo, cpa_alvo, orcamento_diario, keywords_json, negativas_json, criativos_json, plano_json, status, created_at, updated_at) "
87
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'rascunho', ?, ?)",
88
+ (
89
+ campanha.get("produto_id"),
90
+ campanha["plataforma"],
91
+ campanha["nome"],
92
+ json.dumps(campanha["plano"].get("objetivo", ""), ensure_ascii=False),
93
+ campanha.get("cpa_alvo") or 0,
94
+ campanha.get("orcamento_diario") or 0,
95
+ json.dumps(campanha["keywords"], ensure_ascii=False),
96
+ json.dumps(campanha["negativas"], ensure_ascii=False),
97
+ json.dumps(campanha["criativos"], ensure_ascii=False),
98
+ json.dumps(campanha["plano"], ensure_ascii=False),
99
+ timestamp,
100
+ timestamp,
101
+ ),
102
+ )
103
+ row = conn.execute("SELECT * FROM campanhas WHERE id = ?", (cursor.lastrowid,)).fetchone()
104
+ return row_to_dict(row)
105
+
106
+
107
+ def listar(produto_id: int | None = None) -> list[dict]:
108
+ query = "SELECT * FROM campanhas"
109
+ params: tuple = ()
110
+ if produto_id:
111
+ query += " WHERE produto_id = ?"
112
+ params = (produto_id,)
113
+ query += " ORDER BY id DESC"
114
+ with get_db() as conn:
115
+ rows = conn.execute(query, params).fetchall()
116
+ return [row_to_dict(row) for row in rows]
117
+
118
+
119
+ def get(campanha_id: int) -> dict:
120
+ with get_db() as conn:
121
+ row = conn.execute("SELECT * FROM campanhas WHERE id = ?", (campanha_id,)).fetchone()
122
+ if not row:
123
+ raise LLMError("Campanha não encontrada.")
124
+ record = row_to_dict(row)
125
+ for field in ("keywords_json", "negativas_json", "criativos_json", "plano_json", "resultado_json"):
126
+ if record.get(field):
127
+ try:
128
+ record[field.replace("_json", "")] = json.loads(record[field])
129
+ except Exception: # noqa: BLE001
130
+ record[field.replace("_json", "")] = record[field]
131
+ return record
132
+
133
+
134
+ def remover(campanha_id: int) -> None:
135
+ with get_db() as conn:
136
+ cursor = conn.execute("DELETE FROM campanhas WHERE id = ?", (campanha_id,))
137
+ if cursor.rowcount == 0:
138
+ raise LLMError("Campanha não encontrada.")
139
+
140
+
141
+ def marcar_publicada(campanha_id: int, resultado: dict, status: str = "publicada", erro: str = "") -> dict:
142
+ with get_db() as conn:
143
+ conn.execute(
144
+ "UPDATE campanhas SET status = ?, resultado_json = ?, erro = ?, updated_at = ? WHERE id = ?",
145
+ (status, json.dumps(resultado, ensure_ascii=False), erro[:2000], now_str(), campanha_id),
146
+ )
147
+ return get(campanha_id)
148
+
149
+
150
+ def atualizar(campanha_id: int, campanha: dict) -> dict:
151
+ with get_db() as conn:
152
+ conn.execute(
153
+ "UPDATE campanhas SET nome = ?, cpa_alvo = ?, orcamento_diario = ?, keywords_json = ?, negativas_json = ?, criativos_json = ?, plano_json = ?, updated_at = ? WHERE id = ?",
154
+ (
155
+ campanha["nome"],
156
+ campanha.get("cpa_alvo") or 0,
157
+ campanha.get("orcamento_diario") or 0,
158
+ json.dumps(campanha["keywords"], ensure_ascii=False),
159
+ json.dumps(campanha["negativas"], ensure_ascii=False),
160
+ json.dumps(campanha["criativos"], ensure_ascii=False),
161
+ json.dumps(campanha["plano"], ensure_ascii=False),
162
+ now_str(),
163
+ campanha_id,
164
+ ),
165
+ )
166
+ return get(campanha_id)
@@ -97,6 +97,69 @@ def ensure_db() -> None:
97
97
  )
98
98
  """
99
99
  )
100
+ conn.execute(
101
+ """
102
+ CREATE TABLE IF NOT EXISTS vendas (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ fonte TEXT NOT NULL DEFAULT 'manual',
105
+ produto_id INTEGER,
106
+ produto_nome TEXT NOT NULL DEFAULT '',
107
+ transacao_id TEXT NOT NULL DEFAULT '',
108
+ comprador_email TEXT NOT NULL DEFAULT '',
109
+ valor REAL NOT NULL DEFAULT 0,
110
+ comissao REAL NOT NULL DEFAULT 0,
111
+ gclid TEXT NOT NULL DEFAULT '',
112
+ utm_source TEXT NOT NULL DEFAULT '',
113
+ utm_medium TEXT NOT NULL DEFAULT '',
114
+ utm_campaign TEXT NOT NULL DEFAULT '',
115
+ status TEXT NOT NULL DEFAULT 'aprovada',
116
+ conversao_status TEXT NOT NULL DEFAULT 'pendente',
117
+ conversao_erro TEXT NOT NULL DEFAULT '',
118
+ gclid_match TEXT NOT NULL DEFAULT '',
119
+ raw TEXT NOT NULL DEFAULT '',
120
+ created_at TEXT NOT NULL
121
+ )
122
+ """
123
+ )
124
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
125
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_created ON vendas (created_at)")
126
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
127
+ conn.execute(
128
+ """
129
+ CREATE TABLE IF NOT EXISTS palavras (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ produto_id INTEGER,
132
+ produto_nome TEXT NOT NULL DEFAULT '',
133
+ tipo TEXT NOT NULL DEFAULT 'positiva',
134
+ texto TEXT NOT NULL,
135
+ correspondencia TEXT NOT NULL DEFAULT 'frase',
136
+ origem TEXT NOT NULL DEFAULT 'ia',
137
+ created_at TEXT NOT NULL
138
+ )
139
+ """
140
+ )
141
+ conn.execute(
142
+ """
143
+ CREATE TABLE IF NOT EXISTS campanhas (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ produto_id INTEGER,
146
+ plataforma TEXT NOT NULL DEFAULT 'google',
147
+ nome TEXT NOT NULL,
148
+ objetivo TEXT NOT NULL DEFAULT '',
149
+ cpa_alvo REAL NOT NULL DEFAULT 0,
150
+ orcamento_diario REAL NOT NULL DEFAULT 0,
151
+ keywords_json TEXT NOT NULL DEFAULT '[]',
152
+ negativas_json TEXT NOT NULL DEFAULT '[]',
153
+ criativos_json TEXT NOT NULL DEFAULT '[]',
154
+ plano_json TEXT NOT NULL DEFAULT '',
155
+ status TEXT NOT NULL DEFAULT 'rascunho',
156
+ resultado_json TEXT NOT NULL DEFAULT '',
157
+ erro TEXT NOT NULL DEFAULT '',
158
+ created_at TEXT NOT NULL,
159
+ updated_at TEXT NOT NULL
160
+ )
161
+ """
162
+ )
100
163
  columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
101
164
  if "analysis" not in columns:
102
165
  conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
@@ -0,0 +1,109 @@
1
+ """Google Ads — upload de conversões offline (click conversions via GCLID).
2
+
3
+ Implementação enxuta via REST (httpx), sem SDK pesado:
4
+ 1. refresh_token -> access_token (OAuth2)
5
+ 2. POST /{version}/customers/{customer_id}:uploadClickConversions
6
+
7
+ Best-effort: a versão da API é configurável (settings.api_keys['google-ads'].api_version,
8
+ default v22) e erros da API são devolvidos com attribution clara.
9
+ """
10
+ import time
11
+
12
+ import httpx
13
+
14
+ from .llm import LLMError
15
+ from .store import get_settings
16
+
17
+ DEFAULT_API_VERSION = "v22"
18
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
19
+ ADS_BASE = "https://googleads.googleapis.com"
20
+
21
+
22
+ def _google_ads_config() -> dict:
23
+ keys = (get_settings().get("api_keys") or {}).get("google-ads") or {}
24
+ return {
25
+ "developer_token": (keys.get("developer_token") or "").strip(),
26
+ "client_id": (keys.get("client_id") or "").strip(),
27
+ "client_secret": (keys.get("client_secret") or "").strip(),
28
+ "refresh_token": (keys.get("refresh_token") or "").strip(),
29
+ "customer_id": (keys.get("customer_id") or "").strip().replace("-", ""),
30
+ "conversion_action": (keys.get("conversion_action") or "").strip(),
31
+ "api_version": (keys.get("api_version") or "").strip() or DEFAULT_API_VERSION,
32
+ }
33
+
34
+
35
+ def _access_token(client_id: str, client_secret: str, refresh_token: str) -> str:
36
+ try:
37
+ response = httpx.post(
38
+ TOKEN_URL,
39
+ data={
40
+ "client_id": client_id,
41
+ "client_secret": client_secret,
42
+ "refresh_token": refresh_token,
43
+ "grant_type": "refresh_token",
44
+ },
45
+ timeout=30.0,
46
+ )
47
+ except httpx.HTTPError as exc:
48
+ raise LLMError(f"Falha de conexão com o OAuth do Google: {exc}", provider="google-ads", transient=True) from exc
49
+ if response.status_code != 200:
50
+ raise LLMError(
51
+ f"Google OAuth rejeitou as credenciais ({response.status_code}): {response.text[:200]} — confira client_id/client_secret/refresh_token em Chaves de API.",
52
+ provider="google-ads",
53
+ )
54
+ return (response.json() or {}).get("access_token", "")
55
+
56
+
57
+ def upload_conversao(gclid: str, valor: float, ocorrido_em: str | None = None, currency: str = "BRL") -> dict:
58
+ """Envia uma conversão offline (GCLID) ao Google Ads. Retorna dict de resultado."""
59
+ config = _google_ads_config()
60
+ missing = [field for field in ("developer_token", "client_id", "client_secret", "refresh_token", "customer_id", "conversion_action") if not config[field]]
61
+ if missing:
62
+ raise LLMError(
63
+ "Conversões GCLID requerem Google Ads configurado em Chaves de API: " + ", ".join(missing) + ".",
64
+ provider="google-ads",
65
+ )
66
+ if not gclid:
67
+ raise LLMError("Venda sem GCLID — não é possível enviar conversão offline.", provider="google-ads")
68
+
69
+ try:
70
+ access_token = _access_token(config["client_id"], config["client_secret"], config["refresh_token"])
71
+ except LLMError:
72
+ raise
73
+ if not access_token:
74
+ raise LLMError("OAuth do Google não devolveu access_token.", provider="google-ads")
75
+
76
+ ocorrido_em = ocorrido_em or time.strftime("%Y-%m-%d %H:%M:%S%z")
77
+ if "%z" in ocorrido_em:
78
+ # Formato esperado pelo Google Ads: yyyy-mm-dd hh:mm:ss+|-hh:mm
79
+ ocorrido_em = ocorrido_em.replace("+0000", "+00:00")
80
+ if len(ocorrido_em) >= 5 and ocorrido_em[-5] in "+-":
81
+ ocorrido_em = ocorrido_em[:-2] + ":" + ocorrido_em[-2:]
82
+ url = f"{ADS_BASE}/{config['api_version']}/customers/{config['customer_id']}:uploadClickConversions"
83
+ body = {
84
+ "conversions": [
85
+ {
86
+ "gclid": gclid,
87
+ "conversionAction": config["conversion_action"],
88
+ "conversionDateTime": ocorrido_em,
89
+ "conversionValue": valor,
90
+ "currencyCode": currency,
91
+ }
92
+ ],
93
+ "partialFailure": True,
94
+ }
95
+ headers = {
96
+ "Authorization": f"Bearer {access_token}",
97
+ "developer-token": config["developer_token"],
98
+ "Content-Type": "application/json",
99
+ }
100
+ try:
101
+ response = httpx.post(url, headers=headers, json=body, timeout=60.0)
102
+ except httpx.HTTPError as exc:
103
+ raise LLMError(f"Falha de conexão com a Google Ads API: {exc}", provider="google-ads", transient=True) from exc
104
+ if response.status_code == 200:
105
+ return {"ok": True, "resultado": response.json()}
106
+ raise LLMError(
107
+ f"Google Ads API respondeu {response.status_code}: {response.text[:300]} (API {config['api_version']}).",
108
+ provider="google-ads",
109
+ )
@@ -0,0 +1,273 @@
1
+ """Engines de publicação de campanhas — Google Ads (REST mutates) e Meta Ads (Marketing API).
2
+
3
+ Best-effort com erros atribuídos por etapa. TikTok permanece manual (guia no plano).
4
+ """
5
+ import httpx
6
+
7
+ from .llm import LLMError
8
+ from .store import get_settings
9
+
10
+ ADS_BASE = "https://googleads.googleapis.com"
11
+ GRAPH_BASE = "https://graph.facebook.com/v19.0"
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Google Ads — cadeia de mutates
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def _google_config() -> dict:
20
+ keys = (get_settings().get("api_keys") or {}).get("google-ads") or {}
21
+ return {
22
+ "developer_token": (keys.get("developer_token") or "").strip(),
23
+ "client_id": (keys.get("client_id") or "").strip(),
24
+ "client_secret": (keys.get("client_secret") or "").strip(),
25
+ "refresh_token": (keys.get("refresh_token") or "").strip(),
26
+ "customer_id": (keys.get("customer_id") or "").strip().replace("-", ""),
27
+ "api_version": (keys.get("api_version") or "").strip() or "v22",
28
+ }
29
+
30
+
31
+ def _access_token() -> str:
32
+ from .gads import _access_token as token
33
+
34
+ config = _google_config()
35
+ missing = [field for field in ("client_id", "client_secret", "refresh_token") if not config[field]]
36
+ if missing:
37
+ raise LLMError("Google Ads: faltam " + ", ".join(missing) + " em Chaves de API.", provider="google")
38
+ return token(config["client_id"], config["client_secret"], config["refresh_token"])
39
+
40
+
41
+ def _mutate(client, config, headers, operations: list, label: str) -> dict:
42
+ url = f"{ADS_BASE}/{config['api_version']}/customers/{config['customer_id']}/googleAds:mutate"
43
+ response = client.post(url, headers=headers, json={"operations": operations, "partialFailure": True})
44
+ if response.status_code != 200:
45
+ raise LLMError(f"[Google:{label}] HTTP {response.status_code}: {response.text[:300]}", provider="google")
46
+ payload = response.json()
47
+ errors = []
48
+ for detail in (payload.get("partialFailureError") or {}).get("details", []):
49
+ errors.append(detail.get("message") or str(detail)[:200])
50
+ names = []
51
+ for result in payload.get("results", []):
52
+ names.append(result.get("resourceName", ""))
53
+ return {"names": names, "errors": errors}
54
+
55
+
56
+ def publicar_google(campanha: dict) -> dict:
57
+ config = _google_config()
58
+ missing = [field for field in ("developer_token", "client_id", "client_secret", "refresh_token", "customer_id") if not config[field]]
59
+ if missing:
60
+ raise LLMError(
61
+ "Publicação Google Ads requer: " + ", ".join(missing) + " (em Chaves de API).",
62
+ provider="google",
63
+ )
64
+ keywords = [item.get("texto") or item.get("palavra") or "" for item in campanha.get("keywords", [])]
65
+ negativas = [item.get("texto") or item.get("palavra") or "" for item in campanha.get("negativas", [])]
66
+ keywords = [text for text in keywords if text.strip()]
67
+ negativas = [text for text in negativas if text.strip()]
68
+
69
+ cpa_micro = int((campanha.get("cpa_alvo") or 0) * 1_000_000)
70
+ budget_micro = int((campanha.get("orcamento_diario") or 0) * 1_000_000)
71
+ nome = campanha["nome"]
72
+
73
+ access = _access_token()
74
+ headers = {
75
+ "Authorization": f"Bearer {access}",
76
+ "developer-token": config["developer_token"],
77
+ "Content-Type": "application/json",
78
+ }
79
+ etapas = []
80
+ try:
81
+ with httpx.Client(timeout=60.0) as client:
82
+ # 1) Orçamento
83
+ budget = _mutate(
84
+ client,
85
+ config,
86
+ headers,
87
+ [{"create": {"name": f"{nome} — Orçamento", "amountMicros": budget_micro, "deliveryMethod": "STANDARD"}}],
88
+ "orçamento",
89
+ )
90
+ etapas.append({"etapa": "orçamento", **budget})
91
+ if budget["errors"]:
92
+ raise LLMError("[Google:orçamento] " + "; ".join(budget["errors"]), provider="google")
93
+ budget_name = budget["names"][0]
94
+
95
+ # 2) Campanha (pausada para revisão segura)
96
+ campaign = _mutate(
97
+ client,
98
+ config,
99
+ headers,
100
+ [{
101
+ "create": {
102
+ "name": nome,
103
+ "status": "PAUSED",
104
+ "advertisingChannelType": "SEARCH",
105
+ "campaignBudget": budget_name,
106
+ "manualCpc": {},
107
+ "networkSettings": {"targetGoogleSearch": True, "targetSearchNetwork": True},
108
+ "startDate": None,
109
+ "endDate": None,
110
+ }
111
+ }],
112
+ "campanha",
113
+ )
114
+ etapas.append({"etapa": "campanha", **campaign})
115
+ if campaign["errors"]:
116
+ raise LLMError("[Google:campanha] " + "; ".join(campaign["errors"]), provider="google")
117
+ campaign_name = campaign["names"][0]
118
+
119
+ # 3) Grupo de anúncios
120
+ ad_group = _mutate(
121
+ client,
122
+ config,
123
+ headers,
124
+ [{"create": {"name": f"{nome} — Grupo 1", "campaign": campaign_name, "status": "PAUSED", "type": "SEARCH_STANDARD", "cpcBidMicros": cpa_micro or 1_000_000}}],
125
+ "grupo de anúncios",
126
+ )
127
+ etapas.append({"etapa": "grupo de anúncios", **ad_group})
128
+ if ad_group["errors"]:
129
+ raise LLMError("[Google:grupo de anúncios] " + "; ".join(ad_group["errors"]), provider="google")
130
+ ad_group_name = ad_group["names"][0]
131
+
132
+ # 4) Palavras-chave positivas (frase) + negativas (campanha)
133
+ criterion_ops = []
134
+ for text in keywords:
135
+ criterion_ops.append({
136
+ "create": {
137
+ "adGroup": ad_group_name,
138
+ "keyword": {"text": text, "matchType": "PHRASE"},
139
+ "status": "PAUSED",
140
+ }
141
+ })
142
+ for text in negativas:
143
+ criterion_ops.append({
144
+ "create": {
145
+ "campaign": campaign_name,
146
+ "keyword": {"text": text, "matchType": "PHRASE"},
147
+ "negative": True,
148
+ }
149
+ })
150
+ if criterion_ops:
151
+ criteria = _mutate(client, config, headers, criterion_ops, "palavras-chave")
152
+ etapas.append({"etapa": "palavras-chave", **criteria})
153
+ if criteria["errors"]:
154
+ raise LLMError("[Google:palavras-chave] " + "; ".join(criteria["errors"]), provider="google")
155
+
156
+ # 5) Anúncio RSA responsivo
157
+ headlines = [
158
+ {"text": f"{campanha['nome'].split(' — ')[0]}"},
159
+ {"text": "Compre Agora com Desconto"},
160
+ {"text": "Oferta por Tempo Limitado"},
161
+ ]
162
+ descriptions = [
163
+ {"text": f"Garantia e entrega rápida. Lance R$ {campanha.get('cpa_alvo') or 0} alvo."},
164
+ {"text": "Acesse o site oficial e aproveite."},
165
+ ]
166
+ ad = _mutate(
167
+ client,
168
+ config,
169
+ headers,
170
+ [{
171
+ "create": {
172
+ "adGroup": ad_group_name,
173
+ "status": "PAUSED",
174
+ "ad": {
175
+ "responsiveSearchAd": {
176
+ "headlines": headlines,
177
+ "descriptions": descriptions,
178
+ "finalUrls": (campanha.get("plano") or {}).get("final_urls", []) or [],
179
+ }
180
+ },
181
+ }
182
+ }],
183
+ "anúncio",
184
+ )
185
+ etapas.append({"etapa": "anúncio", **ad})
186
+ if ad["errors"]:
187
+ raise LLMError("[Google:anúncio] " + "; ".join(ad["errors"]), provider="google")
188
+ except httpx.HTTPError as exc:
189
+ raise LLMError(f"Falha de conexão com a Google Ads API: {exc}", provider="google", transient=True) from exc
190
+
191
+ return {
192
+ "plataforma": "google",
193
+ "etapas": etapas,
194
+ "campaign": campaign_name,
195
+ "observacao": "Campanha e recursos criados PAUSADOS para revisão — ative manualmente quando aprovar.",
196
+ }
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Meta Ads — 4 POSTs simples (campanha -> conjunto -> criativo -> anúncio)
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def publicar_meta(campanha: dict) -> dict:
205
+ keys = (get_settings().get("api_keys") or {}).get("meta") or {}
206
+ access_token = (keys.get("access_token") or "").strip()
207
+ ad_account_id = (keys.get("ad_account_id") or "").strip()
208
+ missing = [name for name, value in (("access_token", access_token), ("ad_account_id", ad_account_id)) if not value]
209
+ if missing:
210
+ raise LLMError("Publicação Meta Ads requer: " + ", ".join(missing) + " (em Chaves de API).", provider="meta")
211
+ account = ad_account_id if ad_account_id.startswith("act_") else f"act_{ad_account_id}"
212
+ plano = campanha.get("plano") or {}
213
+ budget_cents = int((campanha.get("orcamento_diario") or 0) * 100)
214
+ criativos = campanha.get("criativos") or []
215
+ roteiro_base = (criativos[0].get("roteiro") if criativos else "") or f"Conheça {campanha['nome']}!"
216
+ link = plano.get("final_urls", [""])[0] if plano.get("final_urls") else ""
217
+
218
+ etapas = []
219
+ try:
220
+ with httpx.Client(timeout=60.0) as client:
221
+ response = client.post(
222
+ f"{GRAPH_BASE}/{account}/campaigns",
223
+ data={"name": campanha["nome"], "objective": "OUTCOME_SALES", "status": "PAUSED", "special_ad_categories": "[]", "access_token": access_token},
224
+ )
225
+ campaign_id = (response.json() or {}).get("id")
226
+ if not campaign_id:
227
+ raise LLMError(f"[Meta:campanha] {response.status_code}: {response.text[:300]}", provider="meta")
228
+ etapas.append({"etapa": "campanha", "id": campaign_id})
229
+
230
+ response = client.post(
231
+ f"{GRAPH_BASE}/{account}/adsets",
232
+ data={
233
+ "name": f"{campanha['nome']} — Conjunto",
234
+ "campaign_id": campaign_id,
235
+ "daily_budget": budget_cents,
236
+ "billing_event": "IMPRESSIONS",
237
+ "optimization_goal": "CONVERSIONS",
238
+ "bid_strategy": "LOWEST_COST_WITHOUT_CAP",
239
+ "status": "PAUSED",
240
+ "targeting": "{}",
241
+ "access_token": access_token,
242
+ },
243
+ )
244
+ adset_id = (response.json() or {}).get("id")
245
+ if not adset_id:
246
+ raise LLMError(f"[Meta:conjunto] {response.status_code}: {response.text[:300]}", provider="meta")
247
+ etapas.append({"etapa": "conjunto", "id": adset_id})
248
+
249
+ response = client.post(
250
+ f"{GRAPH_BASE}/{account}/adcreatives",
251
+ data={"name": f"{campanha['nome']} — Criativo", "message": roteiro_base[:2000], "link": link, "access_token": access_token},
252
+ )
253
+ creative_id = (response.json() or {}).get("id")
254
+ if not creative_id:
255
+ raise LLMError(f"[Meta:criativo] {response.status_code}: {response.text[:300]}", provider="meta")
256
+ etapas.append({"etapa": "criativo", "id": creative_id})
257
+
258
+ response = client.post(
259
+ f"{GRAPH_BASE}/{account}/ads",
260
+ data={"name": campanha["nome"], "adset_id": adset_id, "creative": {"creative_id": creative_id}, "status": "PAUSED", "access_token": access_token},
261
+ )
262
+ ad_id = (response.json() or {}).get("id")
263
+ if not ad_id:
264
+ raise LLMError(f"[Meta:anúncio] {response.status_code}: {response.text[:300]}", provider="meta")
265
+ etapas.append({"etapa": "anúncio", "id": ad_id})
266
+ except httpx.HTTPError as exc:
267
+ raise LLMError(f"Falha de conexão com a Meta API: {exc}", provider="meta", transient=True) from exc
268
+
269
+ return {
270
+ "plataforma": "meta",
271
+ "etapas": etapas,
272
+ "observacao": "Criado PAUSADO para revisão — ative manualmente quando aprovar.",
273
+ }