@danhachuel/prodmaker 0.1.8 → 0.1.10
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 +38 -0
- package/app/api/ads.py +155 -0
- package/app/api/dashboard.py +67 -0
- package/app/api/infoprodutos.py +10 -1
- package/app/api/lancamentos.py +81 -0
- package/app/api/notificacoes.py +40 -0
- package/app/api/paginas.py +8 -0
- package/app/main.py +10 -2
- package/app/services/adswords.py +158 -0
- package/app/services/campaigns.py +171 -0
- package/app/services/database.py +63 -0
- package/app/services/lancamentos.py +148 -0
- package/app/services/notify.py +127 -0
- package/app/services/publish_ads.py +273 -0
- package/app/services/vendas.py +11 -1
- package/frontend/dist/assets/index-EOri3RlA.js +74 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-BrfPg9k5.js +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
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.10 — 2026-10-30
|
|
7
|
+
|
|
8
|
+
- **Parte 8 — AdsBot (Lançamento Assistido) + Dashboard real + Notificações (adsNotify).**
|
|
9
|
+
- **AdsBot:** wizard de 6 etapas (Produto → Página → Publicar → Campanhas → Tracking →
|
|
10
|
+
Conclusão) com progresso salvo no banco e retomável; marcação de etapas concluídas;
|
|
11
|
+
**Relatório de Lançamento** (produto + resumo da análise IA, páginas vinculadas com URLs
|
|
12
|
+
públicas, campanhas com CPA/orçamento e o checklist final do seed).
|
|
13
|
+
- **Dashboard com dados reais:** conversões, receita, comissão, visitas, produtos, páginas,
|
|
14
|
+
campanhas e infoprodutos; banner do lançamento em andamento ("faltam X passos" — efeito
|
|
15
|
+
meta); últimas vendas e últimas páginas. ROAS explicitamente ausente até haver registro
|
|
16
|
+
de investimento (nunca inventado).
|
|
17
|
+
- **Notificações (adsNotify):** toggles por evento (venda recebida, campanha publicada,
|
|
18
|
+
página publicada, infoproduto gerado), canais apprise por URL (Telegram entra
|
|
19
|
+
automaticamente quando Bot Token + Chat ID estão em Chaves de API), teste de envio e
|
|
20
|
+
histórico local com status/erro. Hooks disparados nos fluxos reais — falha de envio
|
|
21
|
+
nunca quebra a operação principal.
|
|
22
|
+
- Novas tabelas: `lancamentos`, `notificacoes`. Novos endpoints: `/api/lancamentos/*`,
|
|
23
|
+
`/api/notificacoes/*`, `/api/dashboard`.
|
|
24
|
+
|
|
25
|
+
## 0.1.09 — 2026-10-30
|
|
26
|
+
|
|
27
|
+
- **Parte 7 — AdsWords & AdsCreator (palavras-chave e campanhas).**
|
|
28
|
+
- **AdsWords funcional:** geração de palavras-chave + negativas por IA (prompt do seed,
|
|
29
|
+
negativas padrão aplicadas automaticamente), listas por produto (positivas/negativas com
|
|
30
|
+
origem IA/manual), adição manual, remoção e cópia.
|
|
31
|
+
- **Gerador de criativos por IA** (fórmula gancho 3s + promessa + prova + CTA) reutilizado
|
|
32
|
+
pelo AdsCreator (Google/Meta/TikTok).
|
|
33
|
+
- **AdsCreator — construtor de campanhas:** cards por plataforma, formulário que puxa
|
|
34
|
+
produto vinculado (comissão → **CPA alvo 70%** no Google; regra criativos × ½ ticket no
|
|
35
|
+
Meta; teste US$10–20 no TikTok), defaults do seed versionados, salva **rascunho** com
|
|
36
|
+
plano completo (review modal com JSON do plano + palavras + criativos).
|
|
37
|
+
- **Publicação real (best-effort):** Google Ads via cadeia de mutates REST (orçamento →
|
|
38
|
+
campanha PAUSADA → grupo → keywords frase + negativas → anúncio RSA), Meta Ads via
|
|
39
|
+
Marketing API (campanha → conjunto → criativo → anúncio, POSTs simples) e TikTok como
|
|
40
|
+
plano manual guiado. Tudo criado **PAUSADO para revisão**; erros por etapa com attribution;
|
|
41
|
+
status por campanha (rascunho/publicada/erro).
|
|
42
|
+
- Novas tabelas: `palavras`, `campanhas`.
|
|
43
|
+
|
|
6
44
|
## 0.1.08 — 2026-10-30
|
|
7
45
|
|
|
8
46
|
- **Parte 6 — Minhas Vendas & Conversões GCLID.**
|
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,67 @@
|
|
|
1
|
+
"""Dashboard — agregação de dados reais de todos os módulos."""
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
|
|
4
|
+
from ..services.database import get_db
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("")
|
|
10
|
+
def dashboard():
|
|
11
|
+
with get_db() as conn:
|
|
12
|
+
produtos = {
|
|
13
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM produtos").fetchone()["n"],
|
|
14
|
+
"aprovados": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'aprovado'").fetchone()["n"],
|
|
15
|
+
"em_analise": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'em_analise'").fetchone()["n"],
|
|
16
|
+
}
|
|
17
|
+
paginas = {
|
|
18
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM paginas").fetchone()["n"],
|
|
19
|
+
"publicadas": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status = 'publicada'").fetchone()["n"],
|
|
20
|
+
"rascunho": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status != 'publicada'").fetchone()["n"],
|
|
21
|
+
}
|
|
22
|
+
campanhas = {
|
|
23
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM campanhas").fetchone()["n"],
|
|
24
|
+
"publicadas": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'publicada'").fetchone()["n"],
|
|
25
|
+
"rascunho": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'rascunho'").fetchone()["n"],
|
|
26
|
+
"erro": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'erro'").fetchone()["n"],
|
|
27
|
+
}
|
|
28
|
+
vendas_row = conn.execute(
|
|
29
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita, COALESCE(SUM(comissao),0) AS comissao FROM vendas WHERE status = 'aprovada'"
|
|
30
|
+
).fetchone()
|
|
31
|
+
infoprodutos = {"total": conn.execute("SELECT COUNT(*) AS n FROM infoprodutos").fetchone()["n"]}
|
|
32
|
+
tracking = {
|
|
33
|
+
"visitas": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'pageview'").fetchone()["n"],
|
|
34
|
+
"cliques": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'cta_click'").fetchone()["n"],
|
|
35
|
+
"conversoes_pendentes": conn.execute(
|
|
36
|
+
"SELECT COUNT(*) AS n FROM vendas WHERE status = 'aprovada' AND gclid != '' AND conversao_status != 'enviada'"
|
|
37
|
+
).fetchone()["n"],
|
|
38
|
+
}
|
|
39
|
+
ultimas_vendas = [
|
|
40
|
+
{key: row[key] for key in row.keys()}
|
|
41
|
+
for row in conn.execute("SELECT id, produto_nome, valor, fonte, status, created_at FROM vendas ORDER BY id DESC LIMIT 5")
|
|
42
|
+
]
|
|
43
|
+
ultimas_paginas = [
|
|
44
|
+
{key: row[key] for key in row.keys()}
|
|
45
|
+
for row in conn.execute("SELECT id, titulo, tipo, status, created_at FROM paginas ORDER BY id DESC LIMIT 5")
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
from ..services.lancamentos import atual, progresso
|
|
49
|
+
lancamento_atual = atual()
|
|
50
|
+
progresso_atual = progresso(lancamento_atual) if lancamento_atual else None
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"produtos": produtos,
|
|
54
|
+
"paginas": paginas,
|
|
55
|
+
"campanhas": campanhas,
|
|
56
|
+
"infoprodutos": infoprodutos,
|
|
57
|
+
"vendas": {
|
|
58
|
+
"total": vendas_row["n"],
|
|
59
|
+
"receita": round(vendas_row["receita"], 2),
|
|
60
|
+
"comissao": round(vendas_row["comissao"], 2),
|
|
61
|
+
},
|
|
62
|
+
"tracking": tracking,
|
|
63
|
+
"ultimas_vendas": ultimas_vendas,
|
|
64
|
+
"ultimas_paginas": ultimas_paginas,
|
|
65
|
+
"lancamento": lancamento_atual,
|
|
66
|
+
"progresso": progresso_atual,
|
|
67
|
+
}
|
package/app/api/infoprodutos.py
CHANGED
|
@@ -88,7 +88,16 @@ def create_infoproduto(body: InfoprodutoBody):
|
|
|
88
88
|
(provider, rel_pdf, rel_md, record_id),
|
|
89
89
|
)
|
|
90
90
|
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
|
|
91
|
-
|
|
91
|
+
record = row_to_dict(row)
|
|
92
|
+
|
|
93
|
+
from ..services.notify import notify_event
|
|
94
|
+
|
|
95
|
+
notify_event(
|
|
96
|
+
"infoproduto_gerado",
|
|
97
|
+
"Infoproduto gerado",
|
|
98
|
+
f"{record['titulo']} ({record['formato']}) — {record['provider']}",
|
|
99
|
+
)
|
|
100
|
+
return record
|
|
92
101
|
except (LLMError, gamma_service.GammaError, RuntimeError) as exc:
|
|
93
102
|
with get_db() as conn:
|
|
94
103
|
conn.execute("DELETE FROM infoprodutos WHERE id = ?", (record_id,))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import lancamentos
|
|
5
|
+
from ..services.llm import LLMError
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/lancamentos", tags=["lancamentos"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IniciarBody(BaseModel):
|
|
11
|
+
produto_id: int | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EtapaBody(BaseModel):
|
|
15
|
+
etapa: int
|
|
16
|
+
dados: dict = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProdutoBody(BaseModel):
|
|
20
|
+
produto_id: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/etapas")
|
|
24
|
+
def etapas():
|
|
25
|
+
return lancamentos.ETAPAS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/atual")
|
|
29
|
+
def lancamento_atual():
|
|
30
|
+
return lancamentos.atual()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("")
|
|
34
|
+
def listar():
|
|
35
|
+
return lancamentos.listar()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.post("")
|
|
39
|
+
def iniciar(body: IniciarBody):
|
|
40
|
+
return lancamentos.iniciar(body.produto_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/{lancamento_id}/etapa")
|
|
44
|
+
def salvar_etapa(lancamento_id: int, body: EtapaBody):
|
|
45
|
+
try:
|
|
46
|
+
return lancamentos.salvar_etapa(lancamento_id, body.etapa, body.dados)
|
|
47
|
+
except LLMError as exc:
|
|
48
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/{lancamento_id}/produto")
|
|
52
|
+
def definir_produto(lancamento_id: int, body: ProdutoBody):
|
|
53
|
+
try:
|
|
54
|
+
return lancamentos.definir_produto(lancamento_id, body.produto_id)
|
|
55
|
+
except LLMError as exc:
|
|
56
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.get("/{lancamento_id}/relatorio")
|
|
60
|
+
def relatorio(lancamento_id: int):
|
|
61
|
+
try:
|
|
62
|
+
return lancamentos.relatorio(lancamento_id)
|
|
63
|
+
except LLMError as exc:
|
|
64
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/{lancamento_id}/progresso")
|
|
68
|
+
def progresso(lancamento_id: int):
|
|
69
|
+
try:
|
|
70
|
+
record = lancamentos._get_row(lancamento_id)
|
|
71
|
+
except LLMError as exc:
|
|
72
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
73
|
+
return lancamentos.progresso(record)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/{lancamento_id}/concluir")
|
|
77
|
+
def concluir(lancamento_id: int):
|
|
78
|
+
try:
|
|
79
|
+
return lancamentos.concluir(lancamento_id)
|
|
80
|
+
except LLMError as exc:
|
|
81
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import notify
|
|
5
|
+
from ..services.llm import LLMError
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/notificacoes", tags=["notificacoes"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigBody(BaseModel):
|
|
11
|
+
eventos: dict = {}
|
|
12
|
+
targets: list = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TesteBody(BaseModel):
|
|
16
|
+
titulo: str = "Teste ProdMaker"
|
|
17
|
+
mensagem: str = "Notificação de teste do ProdMaker."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("")
|
|
21
|
+
def config():
|
|
22
|
+
return notify.listar_config()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.put("")
|
|
26
|
+
def salvar(body: ConfigBody):
|
|
27
|
+
return notify.salvar_config(body.eventos, body.targets)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/historico")
|
|
31
|
+
def historico():
|
|
32
|
+
return notify.historico()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post("/teste")
|
|
36
|
+
def teste(body: TesteBody):
|
|
37
|
+
try:
|
|
38
|
+
return notify.enviar("teste", body.titulo, body.mensagem, forcar=True)
|
|
39
|
+
except LLMError as exc:
|
|
40
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
package/app/api/paginas.py
CHANGED
|
@@ -130,6 +130,14 @@ def publicar(page_id: int):
|
|
|
130
130
|
)
|
|
131
131
|
updated = _get_or_404(page_id)
|
|
132
132
|
updated["publish_result"] = result
|
|
133
|
+
|
|
134
|
+
from ..services.notify import notify_event
|
|
135
|
+
|
|
136
|
+
notify_event(
|
|
137
|
+
"pagina_publicada",
|
|
138
|
+
"Página publicada",
|
|
139
|
+
f"{updated['titulo']} — {result['pages_url']}",
|
|
140
|
+
)
|
|
133
141
|
return updated
|
|
134
142
|
|
|
135
143
|
|
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.10"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -31,6 +31,10 @@ 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
33
|
from .api import vendas as vendas_router # noqa: E402
|
|
34
|
+
from .api import ads as ads_router # noqa: E402
|
|
35
|
+
from .api import lancamentos as lancamentos_router # noqa: E402
|
|
36
|
+
from .api import notificacoes as notificacoes_router # noqa: E402
|
|
37
|
+
from .api import dashboard as dashboard_router # noqa: E402
|
|
34
38
|
|
|
35
39
|
app.include_router(llm_router.router)
|
|
36
40
|
app.include_router(infoprodutos_router.router)
|
|
@@ -38,6 +42,10 @@ app.include_router(paginas_router.router)
|
|
|
38
42
|
app.include_router(dominios_router.router)
|
|
39
43
|
app.include_router(tracking_router.router)
|
|
40
44
|
app.include_router(vendas_router.router)
|
|
45
|
+
app.include_router(ads_router.router)
|
|
46
|
+
app.include_router(lancamentos_router.router)
|
|
47
|
+
app.include_router(notificacoes_router.router)
|
|
48
|
+
app.include_router(dashboard_router.router)
|
|
41
49
|
|
|
42
50
|
ensure_db()
|
|
43
51
|
|
|
@@ -244,7 +252,7 @@ def health():
|
|
|
244
252
|
|
|
245
253
|
@app.get("/api/version")
|
|
246
254
|
def version():
|
|
247
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
255
|
+
return {"version": APP_VERSION, "canonical": "0.1.10"}
|
|
248
256
|
|
|
249
257
|
|
|
250
258
|
@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"]}
|