@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.
- package/CHANGELOG.md +39 -0
- package/app/api/ads.py +155 -0
- package/app/api/vendas.py +167 -0
- package/app/main.py +6 -2
- package/app/services/adswords.py +158 -0
- package/app/services/campaigns.py +166 -0
- package/app/services/database.py +63 -0
- package/app/services/gads.py +109 -0
- package/app/services/publish_ads.py +273 -0
- package/app/services/vendas.py +324 -0
- package/frontend/dist/assets/index-CKt-bO8f.js +70 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-83L_ji8k.js +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.09 — 2026-10-30
|
|
7
|
+
|
|
8
|
+
- **Parte 7 — AdsWords & AdsCreator (palavras-chave e campanhas).**
|
|
9
|
+
- **AdsWords funcional:** geração de palavras-chave + negativas por IA (prompt do seed,
|
|
10
|
+
negativas padrão aplicadas automaticamente), listas por produto (positivas/negativas com
|
|
11
|
+
origem IA/manual), adição manual, remoção e cópia.
|
|
12
|
+
- **Gerador de criativos por IA** (fórmula gancho 3s + promessa + prova + CTA) reutilizado
|
|
13
|
+
pelo AdsCreator (Google/Meta/TikTok).
|
|
14
|
+
- **AdsCreator — construtor de campanhas:** cards por plataforma, formulário que puxa
|
|
15
|
+
produto vinculado (comissão → **CPA alvo 70%** no Google; regra criativos × ½ ticket no
|
|
16
|
+
Meta; teste US$10–20 no TikTok), defaults do seed versionados, salva **rascunho** com
|
|
17
|
+
plano completo (review modal com JSON do plano + palavras + criativos).
|
|
18
|
+
- **Publicação real (best-effort):** Google Ads via cadeia de mutates REST (orçamento →
|
|
19
|
+
campanha PAUSADA → grupo → keywords frase + negativas → anúncio RSA), Meta Ads via
|
|
20
|
+
Marketing API (campanha → conjunto → criativo → anúncio, POSTs simples) e TikTok como
|
|
21
|
+
plano manual guiado. Tudo criado **PAUSADO para revisão**; erros por etapa com attribution;
|
|
22
|
+
status por campanha (rascunho/publicada/erro).
|
|
23
|
+
- Novas tabelas: `palavras`, `campanhas`.
|
|
24
|
+
|
|
25
|
+
## 0.1.08 — 2026-10-30
|
|
26
|
+
|
|
27
|
+
- **Parte 6 — Minhas Vendas & Conversões GCLID.**
|
|
28
|
+
- **Vendas funcionais:** tabela com cards (vendas aprovadas, receita, comissão, conversões
|
|
29
|
+
pendentes), adição manual, **importação CSV** (colunas em ordem livre + upload de arquivo),
|
|
30
|
+
exclusão e filtro por status de conversão.
|
|
31
|
+
- **Webhooks de venda públicos:** `POST /api/webhook/sale/{hotmart|clickbank|digistore24|
|
|
32
|
+
kiify|cakto}` com parser **tolerante** (percorre o JSON procurando valor, comissão, e-mail,
|
|
33
|
+
transação, GCLID, produto e status — cada plataforma usa nomes diferentes; aceita JSON e form).
|
|
34
|
+
Página lista as URLs prontas para copiar no postback de cada plataforma.
|
|
35
|
+
- **Matching por GCLID:** ao registrar venda com GCLID, o sistema cruza com a visita
|
|
36
|
+
correspondente na tabela `visitas` (página, IP, origem) e marca 🎯 no GCLID rastreado.
|
|
37
|
+
- **Conversões offline (GCLID):** enxuta implementação REST `uploadClickConversions`
|
|
38
|
+
(`services/gads.py`): refresh token OAuth → POST Google Ads API (versão configurável,
|
|
39
|
+
default v22); upload individual ou **em lote** das pendentes; status por venda
|
|
40
|
+
(pendente/enviada/erro) com erro atribuído; exige Google Ads completo em Chaves de API
|
|
41
|
+
(developer token, client, refresh token, customer_id e **conversion_action**).
|
|
42
|
+
- Nova tabela `vendas` (com índices em gclid/created_at). Sem novas dependências Python
|
|
43
|
+
(upload via httpx já incluso).
|
|
44
|
+
|
|
6
45
|
## 0.1.07 — 2026-10-27
|
|
7
46
|
|
|
8
47
|
- **Código de Ativação Mestre:** o login local agora valida o código de verdade.
|
package/app/api/ads.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import adswords, campaigns, publish_ads
|
|
5
|
+
from ..services.database import get_db, row_to_dict
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api", tags=["ads"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _produto_or_none(produto_id: int | None) -> dict:
|
|
12
|
+
if not produto_id:
|
|
13
|
+
return {"id": None, "nome": "Produto", "nicho": "", "plataforma": ""}
|
|
14
|
+
with get_db() as conn:
|
|
15
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
16
|
+
return row_to_dict(row) if row else {"id": produto_id, "nome": "Produto", "nicho": "", "plataforma": ""}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KeywordsBody(BaseModel):
|
|
20
|
+
produto_id: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PalavraBody(BaseModel):
|
|
24
|
+
produto_id: int | None = None
|
|
25
|
+
tipo: str = "positiva"
|
|
26
|
+
texto: str
|
|
27
|
+
correspondencia: str = "frase"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CriativosBody(BaseModel):
|
|
31
|
+
produto_id: int | None = None
|
|
32
|
+
quantidade: int = 3
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CampanhaBody(BaseModel):
|
|
36
|
+
produto_id: int | None = None
|
|
37
|
+
plataforma: str = "google"
|
|
38
|
+
nome: str = ""
|
|
39
|
+
orcamento_diario: float | None = None
|
|
40
|
+
quantidade_criativos: int | None = None
|
|
41
|
+
keywords: list = []
|
|
42
|
+
negativas: list = []
|
|
43
|
+
criativos: list = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# AdsWords
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/adswords/gerar")
|
|
52
|
+
def adswords_gerar(body: KeywordsBody):
|
|
53
|
+
produto = _produto_or_none(body.produto_id)
|
|
54
|
+
try:
|
|
55
|
+
return adswords.gerar(produto)
|
|
56
|
+
except LLMError as exc:
|
|
57
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/adswords")
|
|
61
|
+
def adswords_listar(produto_id: int | None = None):
|
|
62
|
+
return adswords.listar(produto_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/adswords")
|
|
66
|
+
def adswords_adicionar(body: PalavraBody):
|
|
67
|
+
if not body.texto.strip():
|
|
68
|
+
raise HTTPException(status_code=400, detail="Informe a palavra.")
|
|
69
|
+
if body.tipo not in ("positiva", "negativa"):
|
|
70
|
+
raise HTTPException(status_code=400, detail="Tipo inválido.")
|
|
71
|
+
return adswords.adicionar_manual(_produto_or_none(body.produto_id), body.tipo, body.texto, body.correspondencia)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.delete("/adswords/{palavra_id}")
|
|
75
|
+
def adswords_remover(palavra_id: int):
|
|
76
|
+
try:
|
|
77
|
+
adswords.remover(palavra_id)
|
|
78
|
+
except LLMError as exc:
|
|
79
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
80
|
+
return {"ok": True}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.post("/criativos/gerar")
|
|
84
|
+
def criativos_gerar(body: CriativosBody):
|
|
85
|
+
produto = _produto_or_none(body.produto_id)
|
|
86
|
+
try:
|
|
87
|
+
return adswords.gerar_criativos(produto, body.quantidade)
|
|
88
|
+
except LLMError as exc:
|
|
89
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Campanhas (AdsCreator)
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/campanhas")
|
|
98
|
+
def campanhas_listar(produto_id: int | None = None):
|
|
99
|
+
return campaigns.listar(produto_id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.post("/campanhas")
|
|
103
|
+
def campanhas_criar(body: CampanhaBody):
|
|
104
|
+
produto = _produto_or_none(body.produto_id)
|
|
105
|
+
try:
|
|
106
|
+
plano = campaigns.construir(produto, body.plataforma, body.model_dump())
|
|
107
|
+
if body.nome.strip():
|
|
108
|
+
plano["nome"] = body.nome.strip()
|
|
109
|
+
except LLMError as exc:
|
|
110
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
111
|
+
return campaigns.salvar(plano)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/campanhas/{campanha_id}")
|
|
115
|
+
def campanhas_get(campanha_id: int):
|
|
116
|
+
try:
|
|
117
|
+
return campaigns.get(campanha_id)
|
|
118
|
+
except LLMError as exc:
|
|
119
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.delete("/campanhas/{campanha_id}")
|
|
123
|
+
def campanhas_remover(campanha_id: int):
|
|
124
|
+
try:
|
|
125
|
+
campaigns.remover(campanha_id)
|
|
126
|
+
except LLMError as exc:
|
|
127
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
128
|
+
return {"ok": True}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.post("/campanhas/{campanha_id}/publicar")
|
|
132
|
+
def campanhas_publicar(campanha_id: int):
|
|
133
|
+
try:
|
|
134
|
+
campanha = campaigns.get(campanha_id)
|
|
135
|
+
except LLMError as exc:
|
|
136
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
137
|
+
if campanha["status"] == "publicada":
|
|
138
|
+
raise HTTPException(status_code=400, detail="Campanha já publicada.")
|
|
139
|
+
plataforma = campanha["plataforma"]
|
|
140
|
+
try:
|
|
141
|
+
if plataforma == "google":
|
|
142
|
+
resultado = publish_ads.publicar_google(campanha)
|
|
143
|
+
elif plataforma == "meta":
|
|
144
|
+
resultado = publish_ads.publicar_meta(campanha)
|
|
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
|
+
}
|
|
151
|
+
except LLMError as exc:
|
|
152
|
+
campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
|
|
153
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
154
|
+
campanha = campaigns.marcar_publicada(campanha_id, resultado)
|
|
155
|
+
return campanha
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
4
|
+
from fastapi.responses import Response
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..services import gads, vendas
|
|
8
|
+
from ..services.database import get_db, now_str, row_to_dict
|
|
9
|
+
from ..services.llm import LLMError
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api", tags=["vendas"])
|
|
12
|
+
|
|
13
|
+
WEBHOOK_FONTES = ("hotmart", "clickbank", "digistore24", "kiify", "cakto")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VendaBody(BaseModel):
|
|
17
|
+
produto_nome: str = ""
|
|
18
|
+
produto_id: int | None = None
|
|
19
|
+
valor: float = 0
|
|
20
|
+
comissao: float = 0
|
|
21
|
+
gclid: str = ""
|
|
22
|
+
comprador_email: str = ""
|
|
23
|
+
transacao_id: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CsvBody(BaseModel):
|
|
27
|
+
csv: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Vendas
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/vendas")
|
|
36
|
+
def list_vendas():
|
|
37
|
+
return vendas.list_vendas()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/vendas/resumo")
|
|
41
|
+
def resumo_vendas():
|
|
42
|
+
return vendas.resumo()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/vendas")
|
|
46
|
+
def create_venda(body: VendaBody):
|
|
47
|
+
if not body.produto_nome.strip():
|
|
48
|
+
raise HTTPException(status_code=400, detail="Informe o nome do produto.")
|
|
49
|
+
return vendas.registrar_venda(
|
|
50
|
+
{
|
|
51
|
+
"fonte": "manual",
|
|
52
|
+
"produto_id": body.produto_id,
|
|
53
|
+
"produto_nome": body.produto_nome.strip(),
|
|
54
|
+
"valor": body.valor,
|
|
55
|
+
"comissao": body.comissao or round(body.valor * 0.5, 2),
|
|
56
|
+
"gclid": body.gclid.strip(),
|
|
57
|
+
"comprador_email": body.comprador_email.strip(),
|
|
58
|
+
"transacao_id": body.transacao_id.strip(),
|
|
59
|
+
"status": "aprovada",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.delete("/vendas/{venda_id}")
|
|
65
|
+
def delete_venda(venda_id: int):
|
|
66
|
+
vendas.delete_venda(venda_id)
|
|
67
|
+
return {"ok": True}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/vendas/csv")
|
|
71
|
+
def importar_csv(body: CsvBody):
|
|
72
|
+
if not body.csv.strip():
|
|
73
|
+
raise HTTPException(status_code=400, detail="CSV vazio.")
|
|
74
|
+
try:
|
|
75
|
+
return vendas.importar_csv(body.csv)
|
|
76
|
+
except LLMError as exc:
|
|
77
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Conversões GCLID (upload offline ao Google Ads)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/vendas/{venda_id}/conversao")
|
|
86
|
+
def upload_conversao(venda_id: int):
|
|
87
|
+
venda = vendas.get_venda(venda_id)
|
|
88
|
+
if venda["conversao_status"] == "enviada":
|
|
89
|
+
raise HTTPException(status_code=400, detail="Conversão desta venda já foi enviada.")
|
|
90
|
+
try:
|
|
91
|
+
resultado = gads.upload_conversao(venda["gclid"], venda["valor"], venda["created_at"])
|
|
92
|
+
except LLMError as exc:
|
|
93
|
+
vendas.marcar_conversao(venda_id, "erro", str(exc))
|
|
94
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
95
|
+
vendas.marcar_conversao(venda_id, "enviada")
|
|
96
|
+
return {"ok": True, "venda": vendas.get_venda(venda_id), "resultado": resultado}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.post("/vendas/conversoes/upload")
|
|
100
|
+
def upload_conversoes_pendentes():
|
|
101
|
+
"""Envia todas as vendas aprovadas com GCLID cuja conversão ainda está pendente."""
|
|
102
|
+
enviadas = []
|
|
103
|
+
erros = []
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
rows = conn.execute(
|
|
106
|
+
"SELECT * FROM vendas WHERE status = 'aprovada' AND gclid != '' AND conversao_status != 'enviada' ORDER BY id"
|
|
107
|
+
).fetchall()
|
|
108
|
+
for row in rows:
|
|
109
|
+
venda = row_to_dict(row)
|
|
110
|
+
try:
|
|
111
|
+
gads.upload_conversao(venda["gclid"], venda["valor"], venda["created_at"])
|
|
112
|
+
vendas.marcar_conversao(venda["id"], "enviada")
|
|
113
|
+
enviadas.append(venda["id"])
|
|
114
|
+
except LLMError as exc:
|
|
115
|
+
vendas.marcar_conversao(venda["id"], "erro", str(exc))
|
|
116
|
+
erros.append(f"#{venda['id']}: {exc}")
|
|
117
|
+
return {"enviadas": enviadas, "erros": erros}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Webhooks de vendas (públicos — POST das plataformas de afiliados)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.options("/webhook/sale/{fonte}")
|
|
126
|
+
def webhook_options(fonte: str):
|
|
127
|
+
return Response(
|
|
128
|
+
status_code=204,
|
|
129
|
+
headers={
|
|
130
|
+
"Access-Control-Allow-Origin": "*",
|
|
131
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
132
|
+
"Access-Control-Allow-Headers": "*",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.post("/webhook/sale/{fonte}")
|
|
138
|
+
async def webhook_sale(fonte: str, request: Request):
|
|
139
|
+
fonte = fonte.lower()
|
|
140
|
+
if fonte not in WEBHOOK_FONTES:
|
|
141
|
+
raise HTTPException(status_code=404, detail=f"Fonte desconhecida. Suportadas: {', '.join(WEBHOOK_FONTES)}")
|
|
142
|
+
raw = await request.body()
|
|
143
|
+
payload = None
|
|
144
|
+
try:
|
|
145
|
+
parsed = json.loads(raw)
|
|
146
|
+
if isinstance(parsed, dict):
|
|
147
|
+
payload = parsed
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
if payload is None:
|
|
151
|
+
# Plataformas que postam em application/x-www-form-urlencoded
|
|
152
|
+
from urllib.parse import parse_qs
|
|
153
|
+
|
|
154
|
+
form = parse_qs(raw.decode("utf-8", errors="replace"))
|
|
155
|
+
payload = {key: values[0] if len(values) == 1 else values for key, values in form.items()}
|
|
156
|
+
if not payload:
|
|
157
|
+
raise HTTPException(status_code=400, detail="Payload vazio ou não reconhecido (envie JSON ou form-urlencoded).")
|
|
158
|
+
try:
|
|
159
|
+
data = vendas.parse_webhook(fonte, payload)
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
raise HTTPException(status_code=400, detail=f"Payload não processado: {exc}")
|
|
162
|
+
venda = vendas.registrar_venda(data)
|
|
163
|
+
return Response(
|
|
164
|
+
content=json.dumps({"ok": True, "id": venda["id"]}, ensure_ascii=False),
|
|
165
|
+
media_type="application/json",
|
|
166
|
+
headers={"Access-Control-Allow-Origin": "*"},
|
|
167
|
+
)
|
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.09"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -30,12 +30,16 @@ from .api import llm as llm_router # noqa: E402
|
|
|
30
30
|
from .api import paginas as paginas_router # noqa: E402
|
|
31
31
|
from .api import dominios as dominios_router # noqa: E402
|
|
32
32
|
from .api import tracking as tracking_router # noqa: E402
|
|
33
|
+
from .api import vendas as vendas_router # noqa: E402
|
|
34
|
+
from .api import ads as ads_router # noqa: E402
|
|
33
35
|
|
|
34
36
|
app.include_router(llm_router.router)
|
|
35
37
|
app.include_router(infoprodutos_router.router)
|
|
36
38
|
app.include_router(paginas_router.router)
|
|
37
39
|
app.include_router(dominios_router.router)
|
|
38
40
|
app.include_router(tracking_router.router)
|
|
41
|
+
app.include_router(vendas_router.router)
|
|
42
|
+
app.include_router(ads_router.router)
|
|
39
43
|
|
|
40
44
|
ensure_db()
|
|
41
45
|
|
|
@@ -242,7 +246,7 @@ def health():
|
|
|
242
246
|
|
|
243
247
|
@app.get("/api/version")
|
|
244
248
|
def version():
|
|
245
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
249
|
+
return {"version": APP_VERSION, "canonical": "0.1.9"}
|
|
246
250
|
|
|
247
251
|
|
|
248
252
|
@app.get("/api/settings")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""AdsWords — geração de palavras-chave e negativas por IA + listas por produto."""
|
|
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, generate
|
|
7
|
+
from .store import STORAGE
|
|
8
|
+
|
|
9
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
10
|
+
SEED_NEGATIVAS = SEED_DIR / "negatives" / "google-words.txt"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _seed_negativas() -> list[str]:
|
|
14
|
+
if SEED_NEGATIVAS.exists():
|
|
15
|
+
return [line.strip() for line in SEED_NEGATIVAS.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
16
|
+
return ["gratis", "download", "torrent", "refund", "scams"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gerar(produto: dict) -> dict:
|
|
20
|
+
"""Gera palavras-chave e negativas com IA (seed/prompts/keywords.md)."""
|
|
21
|
+
template_path = SEED_DIR / "prompts" / "keywords.md"
|
|
22
|
+
template = template_path.read_text(encoding="utf-8") if template_path.exists() else (
|
|
23
|
+
"Para o produto {NOME_DO_PRODUTO} (nicho {NICHO}) liste palavras-chave de compra e negativas em JSON: "
|
|
24
|
+
'{"principais":[{"palavra":"","correspondencia":"frase"}],"negativas":[{"palavra":""}]}'
|
|
25
|
+
)
|
|
26
|
+
prompt = (
|
|
27
|
+
template.replace("{NOME_DO_PRODUTO}", produto.get("nome") or "")
|
|
28
|
+
.replace("{NICHO}", produto.get("nicho") or "")
|
|
29
|
+
.replace("{PLATAFORMA}", produto.get("plataforma") or "")
|
|
30
|
+
)
|
|
31
|
+
result = generate(
|
|
32
|
+
prompt,
|
|
33
|
+
system="Você é um especialista em Google Ads. Responda SOMENTE com JSON válido, sem markdown.",
|
|
34
|
+
max_tokens=2000,
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
parsed = json.loads(_clean_json(result["text"]))
|
|
38
|
+
except Exception as exc: # noqa: BLE001
|
|
39
|
+
raise LLMError(f"Resposta do LLM não trouxe JSON utilizável: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
positivas = []
|
|
42
|
+
for item in parsed.get("principais") or []:
|
|
43
|
+
text = (item.get("palavra") or "").strip()
|
|
44
|
+
if text:
|
|
45
|
+
positivas.append({"texto": text, "correspondencia": item.get("correspondencia") or "frase"})
|
|
46
|
+
negativas_ia = []
|
|
47
|
+
for item in parsed.get("negativas") or []:
|
|
48
|
+
text = (item.get("palavra") or "").strip()
|
|
49
|
+
if text:
|
|
50
|
+
negativas_ia.append({"texto": text, "correspondencia": "exata"})
|
|
51
|
+
for seed_word in _seed_negativas():
|
|
52
|
+
if not any(n["texto"] == seed_word for n in negativas_ia):
|
|
53
|
+
negativas_ia.append({"texto": seed_word, "correspondencia": "exata", "seed": True})
|
|
54
|
+
|
|
55
|
+
salvar(produto, positivas, negativas_ia, origem="ia")
|
|
56
|
+
return {"positivas": positivas, "negativas": negativas_ia, "provider": result["provider"]}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_json(text: str) -> str:
|
|
60
|
+
import re
|
|
61
|
+
|
|
62
|
+
cleaned = re.sub(r"```(?:json)?", "", text).strip()
|
|
63
|
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
64
|
+
return match.group(0) if match else cleaned
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def salvar(produto: dict, positivas: list, negativas: list, origem: str = "manual") -> None:
|
|
68
|
+
timestamp = now_str()
|
|
69
|
+
with get_db() as conn:
|
|
70
|
+
for item in positivas:
|
|
71
|
+
exists = conn.execute(
|
|
72
|
+
"SELECT id FROM palavras WHERE produto_id = ? AND tipo = 'positiva' AND texto = ?",
|
|
73
|
+
(produto.get("id"), item["texto"]),
|
|
74
|
+
).fetchone()
|
|
75
|
+
if not exists:
|
|
76
|
+
conn.execute(
|
|
77
|
+
"INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, 'positiva', ?, ?, ?, ?)",
|
|
78
|
+
(produto.get("id"), produto.get("nome") or "", item["texto"], item.get("correspondencia", "frase"), origem, timestamp),
|
|
79
|
+
)
|
|
80
|
+
for item in negativas:
|
|
81
|
+
exists = conn.execute(
|
|
82
|
+
"SELECT id FROM palavras WHERE produto_id = ? AND tipo = 'negativa' AND texto = ?",
|
|
83
|
+
(produto.get("id"), item["texto"]),
|
|
84
|
+
).fetchone()
|
|
85
|
+
if not exists:
|
|
86
|
+
conn.execute(
|
|
87
|
+
"INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, 'negativa', ?, ?, ?, ?)",
|
|
88
|
+
(produto.get("id"), produto.get("nome") or "", item["texto"], "exata", origem, timestamp),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def listar(produto_id: int | None = None) -> dict:
|
|
93
|
+
query = "SELECT * FROM palavras"
|
|
94
|
+
params: tuple = ()
|
|
95
|
+
if produto_id:
|
|
96
|
+
query += " WHERE produto_id = ?"
|
|
97
|
+
params = (produto_id,)
|
|
98
|
+
query += " ORDER BY id DESC"
|
|
99
|
+
with get_db() as conn:
|
|
100
|
+
rows = conn.execute(query, params).fetchall()
|
|
101
|
+
items = [row_to_dict(row) for row in rows]
|
|
102
|
+
return {
|
|
103
|
+
"positivas": [item for item in items if item["tipo"] == "positiva"],
|
|
104
|
+
"negativas": [item for item in items if item["tipo"] == "negativa"],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def adicionar_manual(produto: dict, tipo: str, texto: str, correspondencia: str = "frase") -> dict:
|
|
109
|
+
timestamp = now_str()
|
|
110
|
+
with get_db() as conn:
|
|
111
|
+
cursor = conn.execute(
|
|
112
|
+
"INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, ?, ?, ?, 'manual', ?)",
|
|
113
|
+
(produto.get("id"), produto.get("nome") or "", tipo, texto.strip(), correspondencia, timestamp),
|
|
114
|
+
)
|
|
115
|
+
row = conn.execute("SELECT * FROM palavras WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
116
|
+
return row_to_dict(row)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remover(palavra_id: int) -> None:
|
|
120
|
+
with get_db() as conn:
|
|
121
|
+
cursor = conn.execute("DELETE FROM palavras WHERE id = ?", (palavra_id,))
|
|
122
|
+
if cursor.rowcount == 0:
|
|
123
|
+
raise LLMError("Palavra não encontrada.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def gerar_criativos(produto: dict, quantidade: int = 3) -> dict:
|
|
127
|
+
"""Gera roteiros de criativos por IA (seed/prompts/criativos.md)."""
|
|
128
|
+
template_path = SEED_DIR / "prompts" / "criativos.md"
|
|
129
|
+
template = template_path.read_text(encoding="utf-8") if template_path.exists() else (
|
|
130
|
+
'Gere {QUANTIDADE} roteiros de criativo para {NOME_DO_PRODUTO} no formato '
|
|
131
|
+
'{"criativos":[{"gancho_3s":"","roteiro":"","texto_na_tela":"","cta":""}]}'
|
|
132
|
+
)
|
|
133
|
+
prompt = (
|
|
134
|
+
template.replace("{QUANTIDADE}", str(quantidade))
|
|
135
|
+
.replace("{NOME_DO_PRODUTO}", produto.get("nome") or "")
|
|
136
|
+
.replace("{DOR}", produto.get("nicho") or "")
|
|
137
|
+
)
|
|
138
|
+
result = generate(
|
|
139
|
+
prompt,
|
|
140
|
+
system="Você é um copywriter de direct response para tráfego pago. Responda SOMENTE com JSON válido.",
|
|
141
|
+
max_tokens=3000,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(_clean_json(result["text"]))
|
|
145
|
+
except Exception as exc: # noqa: BLE001
|
|
146
|
+
raise LLMError(f"Resposta do LLM não trouxe JSON utilizável: {exc}") from exc
|
|
147
|
+
criativos = parsed.get("criativos") or parsed.get("roteiros") or []
|
|
148
|
+
normalizados = [
|
|
149
|
+
{
|
|
150
|
+
"gancho_3s": item.get("gancho_3s") or "",
|
|
151
|
+
"roteiro": item.get("roteiro") or "",
|
|
152
|
+
"texto_na_tela": item.get("texto_na_tela") or "",
|
|
153
|
+
"cta": item.get("cta") or "Clique no link abaixo e comece agora!",
|
|
154
|
+
}
|
|
155
|
+
for item in criativos
|
|
156
|
+
if isinstance(item, dict)
|
|
157
|
+
]
|
|
158
|
+
return {"criativos": normalizados, "provider": result["provider"]}
|