@danhachuel/prodmaker 0.1.8 → 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,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
+ }