@danhachuel/prodmaker 0.1.14 → 0.1.15

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
4
4
  (o registry NPM normaliza `0.1.00` → `0.1.0`).
5
5
 
6
+ ## 0.1.15 — 2026-10-31
7
+
8
+ - **Fase 11 — TikTok Ads real + Anti-Fraude AdsPixel (Redis).**
9
+ - **TikTok Ads (publicação automática):** `publish_tiktok.py` — troca do auth_code por
10
+ access_token/refresh_token (`POST /api/tiktok/token`) e criação de campanha + grupo
11
+ PAUSADOS via Marketing API v1.3 (objective CONVERSIONS, budget diário US$,
12
+ placement automático); passo final do criativo com instrução honesta quando não há
13
+ mídia. Erros da API atribuídos. Card TikTok em Chaves de API ganhou auth_code +
14
+ botão "Trocar auth_code por token". AdsCreator passa a publicar TikTok de verdade.
15
+ - **Anti-Fraude (AdsPixel):** `fraude.py` seguindo o padrão do redis-datasets/fraud-detection —
16
+ **IP blacklist (Redis Cuckoo Filter)** + **click spamming (Sorted Set com janela de 60s e
17
+ limite de 10 cliques)**; **fallback SQLite automático sem Redis**; página **Anti-Fraude**
18
+ (/tracking/fraude) com status do motor, bloqueio/desbloqueio manual, cópia da lista e
19
+ métricas de spam 24h. O `/track` agora avalia IP bloqueado antes de gravar e o pixel
20
+ nunca quebra a página do visitante. Card **Redis** em Chaves de API (REDIS_URL).
21
+ - OAuth Google Ads (item 1 da Fase 11) já estava entregue na 0.1.12.
22
+
6
23
  ## 0.1.14 — 2026-10-31
7
24
 
8
25
  - **Fase 10 — REVISÃO (fechamento das lacunas apontadas na criação de produto/PDF).**
package/app/api/ads.py CHANGED
@@ -143,11 +143,9 @@ def campanhas_publicar(campanha_id: int):
143
143
  elif plataforma == "meta":
144
144
  resultado = publish_ads.publicar_meta(campanha)
145
145
  else:
146
- resultado = {
147
- "plataforma": "tiktok",
148
- "etapas": [{"etapa": "manual", "observacao": "TikTok: siga o checklist do plano (formato 9:16 UGC) e crie manualmente no TikTok Ads Manager."}],
149
- "observacao": "Plano manual — crie manualmente no TikTok Ads Manager.",
150
- }
146
+ from ..services import publish_tiktok
147
+
148
+ resultado = publish_tiktok.publicar_campanha(campanha)
151
149
  except LLMError as exc:
152
150
  campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
153
151
  raise HTTPException(status_code=400, detail=str(exc))
@@ -0,0 +1,35 @@
1
+ """Anti-Fraude (AdsPixel) — painel de IPs bloqueados, bloqueio/desbloqueio e resumo."""
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+
5
+ from ..services import fraude
6
+ from ..services.llm import LLMError
7
+
8
+ router = APIRouter(prefix="/api/fraude", tags=["anti-fraude"])
9
+
10
+
11
+ class BloquearBody(BaseModel):
12
+ ip: str
13
+ motivo: str = "manual"
14
+
15
+
16
+ @router.get("/resumo")
17
+ def resumo():
18
+ return fraude.resumo()
19
+
20
+
21
+ @router.post("/bloquear")
22
+ def bloquear(body: BloquearBody):
23
+ try:
24
+ return fraude.bloquear_ip(body.ip, body.motivo)
25
+ except LLMError as exc:
26
+ raise HTTPException(status_code=400, detail=str(exc))
27
+
28
+
29
+ @router.delete("/ips/{ip}")
30
+ def desbloquear(ip: str):
31
+ try:
32
+ fraude.desbloquear_ip(ip)
33
+ except LLMError as exc:
34
+ raise HTTPException(status_code=404, detail=str(exc))
35
+ return {"ok": True}
@@ -0,0 +1,16 @@
1
+ """TikTok Ads — troca do auth_code por access_token (1 clique)."""
2
+ from fastapi import APIRouter, HTTPException
3
+
4
+ from ..services.llm import LLMError
5
+ from ..services.publish_tiktok import trocar_auth_code
6
+
7
+ router = APIRouter(prefix="/api/tiktok", tags=["tiktok"])
8
+
9
+
10
+ @router.post("/token")
11
+ def token():
12
+ """Troca o auth_code salvo em Chaves de API por access_token + refresh_token."""
13
+ try:
14
+ return trocar_auth_code()
15
+ except LLMError as exc:
16
+ raise HTTPException(status_code=400, detail=str(exc))
package/app/main.py CHANGED
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
15
15
  from .services.llm import LLMError, analyze_product, list_providers
16
16
  from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
17
17
 
18
- APP_VERSION = "0.1.14"
18
+ APP_VERSION = "0.1.15"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
  MASTER_ALIAS = "admin"
@@ -41,6 +41,8 @@ from .api import guias as guias_router # noqa: E402
41
41
  from .api import contas as contas_router # noqa: E402
42
42
  from .api import invest as invest_router # noqa: E402
43
43
  from .api import builder as builder_router # noqa: E402
44
+ from .api import fraude as fraude_router # noqa: E402
45
+ from .api import tiktok as tiktok_router # noqa: E402
44
46
 
45
47
  app.include_router(llm_router.router)
46
48
  app.include_router(infoprodutos_router.router)
@@ -58,6 +60,8 @@ app.include_router(guias_router.router)
58
60
  app.include_router(contas_router.router)
59
61
  app.include_router(invest_router.router)
60
62
  app.include_router(builder_router.router)
63
+ app.include_router(fraude_router.router)
64
+ app.include_router(tiktok_router.router)
61
65
 
62
66
  ensure_db()
63
67
 
@@ -264,7 +268,7 @@ def health():
264
268
 
265
269
  @app.get("/api/version")
266
270
  def version():
267
- return {"version": APP_VERSION, "canonical": "0.1.14"}
271
+ return {"version": APP_VERSION, "canonical": "0.1.15"}
268
272
 
269
273
 
270
274
  @app.get("/api/settings")
@@ -300,6 +304,20 @@ def track(request: Request):
300
304
  forwarded = request.headers.get("x-forwarded-for", "")
301
305
  ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
302
306
  known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
307
+
308
+ # Anti-fraude (AdsPixel): IP bloqueado nao entra no tracking; spam e sinalizado
309
+ from .services import fraude
310
+
311
+ try:
312
+ avaliacao = fraude.avaliar_visita(ip)
313
+ if avaliacao.get("bloqueado"):
314
+ # IP na blacklist: nao grava a visita (o pixel segue respondendo 204)
315
+ return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
316
+ if params.get("event") == "cta_click":
317
+ fraude.registrar_clique(ip, params.get("page", ""))
318
+ except Exception:
319
+ pass # anti-fraude nunca quebra o pixel
320
+ extra_params = json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False)
303
321
  try:
304
322
  with get_db() as conn:
305
323
  conn.execute(
@@ -314,7 +332,7 @@ def track(request: Request):
314
332
  params.get("ref", ""),
315
333
  ip,
316
334
  request.headers.get("user-agent", ""),
317
- json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False),
335
+ extra_params,
318
336
  now_str(),
319
337
  ),
320
338
  )
@@ -1,17 +1,17 @@
1
- """Banco de dados — façade sobre dbengine (SQLite padrão, PostgreSQL/Supabase via DATABASE_URL).
1
+ """Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
2
2
 
3
- Esquema único para os dois engines. Todas as tabelas carregam `user_id` (preparação
4
- multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migração.
3
+ Esquema unico para os dois engines. Todas as tabelas carregam `user_id` (preparacao
4
+ multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migracao.
5
5
  """
6
6
  from .dbengine import (
7
7
  add_column_if_missing,
8
8
  is_postgres,
9
- now_str, # noqa: F401 (reexportado para os serviços)
10
- row_to_dict, # noqa: F401 (reexportado para os serviços)
9
+ now_str, # noqa: F401 (reexportado para os servicos)
10
+ row_to_dict, # noqa: F401 (reexportado para os servicos)
11
11
  )
12
12
 
13
13
  # ---------------------------------------------------------------------------
14
- # Esquema — SQLite DDL; para PostgreSQL as mesmas definições são traduzidas.
14
+ # Esquema — SQLite DDL; para PostgreSQL as mesmas definicoes sao traduzidas.
15
15
  # ---------------------------------------------------------------------------
16
16
 
17
17
  TABLES = {
@@ -201,6 +201,26 @@ TABLES = {
201
201
  created_at TEXT NOT NULL
202
202
  )
203
203
  """,
204
+ "ips_bloqueados": """
205
+ CREATE TABLE IF NOT EXISTS ips_bloqueados (
206
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
207
+ user_id TEXT NOT NULL DEFAULT '',
208
+ ip TEXT NOT NULL,
209
+ motivo TEXT NOT NULL DEFAULT 'manual',
210
+ origem TEXT NOT NULL DEFAULT 'sqlite',
211
+ created_at TEXT NOT NULL
212
+ )
213
+ """,
214
+ "fraude_eventos": """
215
+ CREATE TABLE IF NOT EXISTS fraude_eventos (
216
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
217
+ user_id TEXT NOT NULL DEFAULT '',
218
+ ip TEXT NOT NULL,
219
+ page TEXT NOT NULL DEFAULT '',
220
+ created_epoch REAL NOT NULL DEFAULT 0,
221
+ created_at TEXT NOT NULL
222
+ )
223
+ """,
204
224
  }
205
225
 
206
226
  ALL_TABLES = list(TABLES.keys())
@@ -225,11 +245,9 @@ def ensure_db() -> None:
225
245
  else:
226
246
  for ddl in TABLES.values():
227
247
  conn.execute(ddl)
228
- # Migração: bancos criados antes da coluna user_id / da tabela investimentos
248
+ # Migracao: bancos criados antes da coluna user_id / da tabela investimentos
229
249
  for table in ALL_TABLES:
230
250
  add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
231
- if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
232
- conn.execute(TABLES["investimentos"])
233
251
  conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
234
252
  conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
235
253
 
@@ -0,0 +1,174 @@
1
+ """Anti-Fraude do AdsPixel — padrão do redis-datasets/fraud-detection:
2
+ IP blacklisting (Redis Cuckoo Filter) + click spamming (Redis Sorted Set).
3
+
4
+ - Redis é OPCIONAL: sem Redis, tudo cai em SQLite local (tabelas ips_bloqueados /
5
+ fraude_eventos) — bloqueio e detecção continuam funcionando.
6
+ - O pixel NUNCA quebra a página do visitante: avaliação em falha = segue normalmente.
7
+ """
8
+ import json
9
+ import os
10
+ import time
11
+
12
+ from .database import get_db, now_str, row_to_dict
13
+ from .llm import LLMError
14
+ from .store import get_settings
15
+
16
+ CUCKOO_KEY = "prodmaker:ip_blacklist_cf" # Redis Cuckoo Filter (IP_BLOCKED)
17
+ SPAM_KEY = "prodmaker:click_spamming" # Redis Sorted Set (IP -> timestamp)
18
+ SPAM_JANELA_SEGUNDOS = int(os.environ.get("PRODMAKER_SPAM_JANELA") or "60")
19
+ SPAM_LIMITE = int(os.environ.get("PRODMAKER_SPAM_LIMITE") or "10")
20
+
21
+
22
+ def _redis_url() -> str | None:
23
+ env = (os.environ.get("REDIS_URL") or "").strip()
24
+ if env:
25
+ return env
26
+ keys = (get_settings().get("api_keys") or {}).get("redis") or {}
27
+ return (keys.get("url") or "").strip() or None
28
+
29
+
30
+ def _redis():
31
+ url = _redis_url()
32
+ if not url:
33
+ return None
34
+ try:
35
+ import redis
36
+
37
+ client = redis.Redis.from_url(url, decode_responses=True, socket_timeout=2)
38
+ client.ping()
39
+ return client
40
+ except Exception:
41
+ return None # sem Redis: fallback SQLite
42
+
43
+
44
+ def redis_ativo() -> bool:
45
+ return _redis() is not None
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Blacklist (Cuckoo Filter quando há Redis; tabela local sempre)
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _bloqueados_locais() -> dict[str, str]:
54
+ with get_db() as conn:
55
+ rows = conn.execute("SELECT ip, motivo FROM ips_bloqueados ORDER BY id DESC").fetchall()
56
+ return {row["ip"]: row["motivo"] for row in rows}
57
+
58
+
59
+ def bloquear_ip(ip: str, motivo: str = "manual") -> dict:
60
+ ip = (ip or "").strip()
61
+ if not ip:
62
+ raise LLMError("Informe o IP a bloquear.")
63
+ client = _redis()
64
+ if client:
65
+ try:
66
+ client.execute_command("CF.ADDR", CUCKOO_KEY, ip)
67
+ except Exception:
68
+ pass # filtro ausente no servidor: fica só local
69
+ with get_db() as conn:
70
+ existe = conn.execute("SELECT id FROM ips_bloqueados WHERE ip = ?", (ip,)).fetchone()
71
+ if not existe:
72
+ conn.execute(
73
+ "INSERT INTO ips_bloqueados (ip, motivo, origem, created_at) VALUES (?, ?, ?, ?)",
74
+ (ip, motivo, "redis" if client else "sqlite", now_str()),
75
+ )
76
+ return {"ok": True, "ip": ip, "engine": "redis" if client else "sqlite"}
77
+
78
+
79
+ def desbloquear_ip(ip: str) -> None:
80
+ with get_db() as conn:
81
+ cursor = conn.execute("DELETE FROM ips_bloqueados WHERE ip = ?", (ip.strip(),))
82
+ if cursor.rowcount == 0:
83
+ raise LLMError("IP não encontrado na lista de bloqueio.")
84
+
85
+
86
+ def ip_bloqueado(ip: str) -> bool:
87
+ if not ip:
88
+ return False
89
+ client = _redis()
90
+ if client:
91
+ try:
92
+ resposta = client.execute_command("CF.EXISTS", CUCKOO_KEY, ip)
93
+ if isinstance(resposta, list):
94
+ resposta = resposta[0] if resposta else 0
95
+ if int(resposta):
96
+ return True
97
+ except Exception:
98
+ pass # redis sem o módulo RedisBloom: cai no local
99
+ return (ip or "").strip() in _bloqueados_locais()
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Click spamming (Sorted Set quando há Redis)
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ def registrar_clique(ip: str, page: str = "") -> dict:
108
+ """Registra um clique e devolve {spam: bool, cliques_janela: N}."""
109
+ ip = (ip or "").strip()
110
+ if not ip:
111
+ return {"spam": False, "cliques_janela": 0, "bloqueado": False}
112
+ agora = time.time()
113
+ cliques = 0
114
+ client = _redis()
115
+ if client:
116
+ try:
117
+ member = f"{int(agora * 1000)}:{ip}"
118
+ pipeline = client.pipeline()
119
+ pipeline.execute_command("ZADD", SPAM_KEY, agora, member)
120
+ pipeline.execute_command("ZREMRANGEBYSCORE", SPAM_KEY, 0, agora - SPAM_JANELA_SEGUNDOS)
121
+ pipeline.execute_command(
122
+ "ZCOUNT", SPAM_KEY, (int((agora - SPAM_JANELA_SEGUNDOS) * 1000)), int(agora * 1000)
123
+ )
124
+ cliques = int(pipeline.execute()[-1])
125
+ except Exception:
126
+ cliques = _cliques_local(ip, agora, page)
127
+ else:
128
+ cliques = _cliques_local(ip, agora, page)
129
+ spam = cliques >= SPAM_LIMITE
130
+ return {"spam": spam, "cliques_janela": cliques, "bloqueado": ip_bloqueado(ip)}
131
+
132
+
133
+ def _cliques_local(ip: str, agora: float, page: str = "") -> int:
134
+ with get_db() as conn:
135
+ conn.execute("DELETE FROM fraude_eventos WHERE created_epoch < ?", (agora - SPAM_JANELA_SEGUNDOS * 2,))
136
+ conn.execute("INSERT INTO fraude_eventos (ip, page, created_epoch, created_at) VALUES (?, ?, ?, ?)", (ip, page or "", agora, now_str()))
137
+ row = conn.execute(
138
+ "SELECT COUNT(*) AS n FROM fraude_eventos WHERE ip = ? AND created_epoch > ?",
139
+ (ip, agora - SPAM_JANELA_SEGUNDOS),
140
+ ).fetchone()
141
+ return int(row["n"])
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Painel / API
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def resumo() -> dict:
150
+ bloqueados = _bloqueados_locais()
151
+ spam_24h = 0
152
+ with get_db() as conn:
153
+ row = conn.execute(
154
+ "SELECT COUNT(*) AS n FROM fraude_eventos WHERE created_at >= datetime('now', '-1 day') GROUP BY ip HAVING COUNT(*) >= ?",
155
+ (SPAM_LIMITE,),
156
+ ).fetchone()
157
+ spam_24h = row["n"] if row else 0
158
+ return {
159
+ "redis_ativo": redis_ativo(),
160
+ "ips_bloqueados": [
161
+ {"ip": ip, "motivo": motivo} for ip, motivo in bloqueados.items()
162
+ ],
163
+ "total_bloqueados": len(bloqueados),
164
+ "spam_ultimas_24h": spam_24h,
165
+ "janela_segundos": SPAM_JANELA_SEGUNDOS,
166
+ "limite_cliques": SPAM_LIMITE,
167
+ }
168
+
169
+
170
+ def avaliar_visita(ip: str) -> dict:
171
+ """Chamado pelo /track: decide se a visita entra no tracking."""
172
+ if ip_bloqueado(ip):
173
+ return {"bloqueado": True, "motivo": _bloqueados_locais().get((ip or "").strip(), "blacklist")}
174
+ return {"bloqueado": False}
@@ -1,6 +1,7 @@
1
- """Engines de publicação de campanhas — Google Ads (REST mutates) e Meta Ads (Marketing API).
1
+ """Engines de publicação de campanhas — Google Ads (REST mutates), Meta Ads
2
+ (Marketing API) e TikTok Ads (Marketing API OAuth).
2
3
 
3
- Best-effort com erros atribuídos por etapa. TikTok permanece manual (guia no plano).
4
+ Best-effort com erros atribuídos por etapa. Tudo é criado PAUSADO para revisão.
4
5
  """
5
6
  import httpx
6
7
 
@@ -0,0 +1,155 @@
1
+ """TikTok Ads — publicação automática via Marketing API (OAuth + campaign/adgroup).
2
+
3
+ Fluxo: auth_code (do portal TikTok) -> access_token (24h) -> refresh_token ->
4
+ campanha PAUSADA -> grupo de anúncios. O criativo (vídeo/imagem) exige upload de
5
+ arquivo local: sem vídeo informado, a campanha e o grupo são criados e o passo
6
+ final (anúncio) fica como instrução honesta — nenhum hack é inventado.
7
+ """
8
+ import time
9
+
10
+ import httpx
11
+
12
+ from .llm import LLMError
13
+ from .store import get_settings, save_settings
14
+
15
+ TIKTOK_API = "https://business-api.tiktok.com/open_api/v1.3"
16
+
17
+
18
+ def _keys() -> dict:
19
+ keys = (get_settings().get("api_keys") or {}).get("tiktok") or {}
20
+ return {
21
+ "app_id": (keys.get("app_id") or "").strip(),
22
+ "app_secret": (keys.get("app_secret") or "").strip(),
23
+ "auth_code": (keys.get("auth_code") or "").strip(),
24
+ "access_token": (keys.get("access_token") or "").strip(),
25
+ "refresh_token": (keys.get("refresh_token") or "").strip(),
26
+ "advertiser_id": (keys.get("advertiser_id") or "").strip(),
27
+ }
28
+
29
+
30
+ def _check(required: list[str]) -> None:
31
+ config = _keys()
32
+ missing = [field for field in required if not config[field]]
33
+ if missing:
34
+ raise LLMError(
35
+ "TikTok Ads requer em Chaves de API: " + ", ".join(missing) + ".",
36
+ provider="tiktok",
37
+ )
38
+
39
+
40
+ def trocar_auth_code() -> dict:
41
+ """Troca o auth_code (portal TikTok) por access_token + refresh_token."""
42
+ config = _keys()
43
+ _check(["app_id", "app_secret", "auth_code"])
44
+ try:
45
+ response = httpx.post(
46
+ f"{TIKTOK_API}/oauth2/access_token/",
47
+ json={"app_id": config["app_id"], "secret": config["app_secret"], "auth_code": config["auth_code"]},
48
+ timeout=30.0,
49
+ )
50
+ except httpx.HTTPError as exc:
51
+ raise LLMError(f"Falha de conexão com o TikTok: {exc}", provider="tiktok", transient=True) from exc
52
+ payload = response.json() or {}
53
+ if payload.get("code") != 0:
54
+ raise LLMError(
55
+ f"TikTok rejeitou o auth_code ({payload.get('code')}): {payload.get('message')} — gere um novo código no portal.",
56
+ provider="tiktok",
57
+ )
58
+ data = payload.get("data") or {}
59
+ access_token = data.get("access_token")
60
+ refresh_token = data.get("refresh_token")
61
+ advertiser_ids = data.get("advertiser_ids") or []
62
+ if not access_token:
63
+ raise LLMError("TikTok não devolveu access_token.", provider="tiktok")
64
+
65
+ settings = get_settings()
66
+ tiktok = settings.setdefault("api_keys", {}).setdefault("tiktok", {})
67
+ tiktok["access_token"] = access_token
68
+ tiktok["refresh_token"] = refresh_token
69
+ tiktok["connected_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
70
+ if advertiser_ids and not (tiktok.get("advertiser_id") or "").strip():
71
+ tiktok["advertiser_id"] = str(advertiser_ids[0])
72
+ save_settings(settings)
73
+ return {
74
+ "ok": True,
75
+ "connected_at": tiktok["connected_at"],
76
+ "advertiser_ids": advertiser_ids,
77
+ "advertiser_id": tiktok.get("advertiser_id"),
78
+ }
79
+
80
+
81
+ def _post(path: str, body: dict, token: str) -> dict:
82
+ try:
83
+ response = httpx.post(
84
+ f"{TIKTOK_API}{path}",
85
+ headers={"Access-Token": token, "Content-Type": "application/json"},
86
+ json=body,
87
+ timeout=60.0,
88
+ )
89
+ except httpx.HTTPError as exc:
90
+ raise LLMError(f"Falha de conexão com o TikTok: {exc}", provider="tiktok", transient=True) from exc
91
+ payload = response.json() or {}
92
+ if payload.get("code") != 0:
93
+ raise LLMError(
94
+ f"TikTok API ({path}) {payload.get('code')}: {payload.get('message')}",
95
+ provider="tiktok",
96
+ )
97
+ return payload.get("data") or {}
98
+
99
+
100
+ def publicar_campanha(campanha: dict) -> dict:
101
+ """Cria campanha (PAUSADA) + grupo de anúncios. Criativo final = instrução honesta."""
102
+ config = _keys()
103
+ _check(["app_id", "app_secret", "advertiser_id", "access_token"])
104
+ plano = campanha.get("plano") or {}
105
+ nome = campanha["nome"]
106
+ orcamento_dia = float(campanha.get("orcamento_diario") or 0)
107
+ if orcamento_dia <= 0:
108
+ orcamento_dia = 15.0
109
+ budget_cents = int(orcamento_dia * 100)
110
+
111
+ etapas = []
112
+ campaign = _post(
113
+ "/campaign/create/",
114
+ {
115
+ "advertiser_id": config["advertiser_id"],
116
+ "campaign_name": nome,
117
+ "objective_type": "CONVERSIONS",
118
+ "budget_mode": "BUDGET_MODE_DAY",
119
+ "budget": budget_cents,
120
+ "operation_status": "DISABLE",
121
+ },
122
+ config["access_token"],
123
+ )
124
+ campaign_id = campaign.get("campaign_id")
125
+ etapas.append({"etapa": "campanha", "id": campaign_id, "status": "PAUSED"})
126
+
127
+ adgroup = _post(
128
+ "/adgroup/create/",
129
+ {
130
+ "advertiser_id": config["advertiser_id"],
131
+ "campaign_id": campaign_id,
132
+ "adgroup_name": f"{nome} — Grupo 1",
133
+ "placement_type": "PLACEMENT_TYPE_AUTOMATIC",
134
+ "budget_mode": "BUDGET_MODE_DAY",
135
+ "budget": budget_cents,
136
+ "schedule_type": "SCHEDULE_START_END",
137
+ "optimization_goal": "CONVERT",
138
+ "billing_event": "OCPM",
139
+ "operation_status": "DISABLE",
140
+ },
141
+ config["access_token"],
142
+ )
143
+ adgroup_id = adgroup.get("adgroup_id")
144
+ etapas.append({"etapa": "grupo", "id": adgroup_id, "status": "PAUSED"})
145
+
146
+ return {
147
+ "plataforma": "tiktok",
148
+ "etapas": etapas,
149
+ "campaign": campaign_id,
150
+ "adgroup": adgroup_id,
151
+ "observacao": (
152
+ "Campanha e grupo criados PAUSADOS. Passo final (anúncio): suba o vídeo vertical 9:16 "
153
+ "em TikTok Ads Manager ou use o upload de criativo — o ProdMaker avisa quando não há mídia informada."
154
+ ),
155
+ }