@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 +17 -0
- package/app/api/ads.py +3 -5
- package/app/api/fraude.py +35 -0
- package/app/api/tiktok.py +16 -0
- package/app/main.py +21 -3
- package/app/services/database.py +27 -9
- package/app/services/fraude.py +174 -0
- package/app/services/publish_ads.py +3 -2
- package/app/services/publish_tiktok.py +155 -0
- package/frontend/dist/assets/{index-tkOFAfR-.js → index-rK4wl-bk.js} +19 -18
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
335
|
+
extra_params,
|
|
318
336
|
now_str(),
|
|
319
337
|
),
|
|
320
338
|
)
|
package/app/services/database.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
"""Banco de dados —
|
|
1
|
+
"""Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
|
|
2
2
|
|
|
3
|
-
Esquema
|
|
4
|
-
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de
|
|
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
|
|
10
|
-
row_to_dict, # noqa: F401 (reexportado para 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
|
|
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
|
-
#
|
|
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)
|
|
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.
|
|
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
|
+
}
|