@danhachuel/prodmaker 0.1.13 → 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 +37 -0
- package/app/api/ads.py +3 -5
- package/app/api/builder.py +49 -3
- package/app/api/fraude.py +35 -0
- package/app/api/tiktok.py +16 -0
- package/app/main.py +21 -3
- package/app/services/builder.py +127 -11
- 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-rK4wl-bk.js +78 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/seed/prompts/builder-anuncios.md +10 -5
- package/seed/prompts/builder-assets.md +23 -0
- package/seed/prompts/builder-produto.md +19 -0
- package/frontend/dist/assets/index-dz1c2OPb.js +0 -75
|
@@ -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
|
+
}
|