@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""AdsCreator — construtor de planos de campanha (defaults do seed + produto vinculado)."""
|
|
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
|
|
7
|
+
|
|
8
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
9
|
+
PLATAFORMAS = ("google", "meta", "tiktok")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _defaults(plataforma: str) -> dict:
|
|
13
|
+
aliases = {"google": "google", "meta": "meta", "tiktok": "tiktok"}
|
|
14
|
+
path = SEED_DIR / "campaign-defaults" / f"{aliases[plataforma]}.json"
|
|
15
|
+
if path.exists():
|
|
16
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def construir(produto: dict, plataforma: str, form: dict) -> dict:
|
|
21
|
+
"""Monta o plano da campanha com defaults do seed + dados do produto."""
|
|
22
|
+
if plataforma not in PLATAFORMAS:
|
|
23
|
+
raise LLMError(f"Plataforma inválida: {plataforma}. Use: google, meta, tiktok.")
|
|
24
|
+
comissao = float(produto.get("comissao") or 0)
|
|
25
|
+
preco = float(produto.get("preco") or 0)
|
|
26
|
+
receita = comissao or preco * 0.5
|
|
27
|
+
defaults = _defaults(plataforma)
|
|
28
|
+
|
|
29
|
+
if plataforma == "google":
|
|
30
|
+
cpa_alvo = round(receita * 0.7, 2) if receita else 120.0
|
|
31
|
+
orcamento = float(form.get("orcamento_diario") or 110)
|
|
32
|
+
plano = {
|
|
33
|
+
"rede": "Pesquisa (Search)",
|
|
34
|
+
"objetivo": "Vendas — conversões",
|
|
35
|
+
"lance": f"Manual CPC com lance = CPA alvo (R$ {cpa_alvo}) — troque para CPA automático quando a conversão estiver ativa",
|
|
36
|
+
"cpa_alvo": cpa_alvo,
|
|
37
|
+
"orcamento_diario": orcamento,
|
|
38
|
+
"localizacao": "Presença (apenas): Estados Unidos, Canadá, Austrália, Reino Unido",
|
|
39
|
+
"idiomas": "Inglês e Francês",
|
|
40
|
+
"correspondencia": "frase",
|
|
41
|
+
"anuncio": defaults.get("anuncio", {}),
|
|
42
|
+
}
|
|
43
|
+
elif plataforma == "meta":
|
|
44
|
+
ticket = preco or (receita / 0.5 if receita else 0)
|
|
45
|
+
criativos = max(5, int(form.get("quantidade_criativos") or 5))
|
|
46
|
+
orcamento = round(criativos * (ticket / 2), 2) if ticket else 300.0
|
|
47
|
+
plano = {
|
|
48
|
+
"estrutura": f"1 campanha → 1 conjunto → {criativos} criativos",
|
|
49
|
+
"objetivo": "Vendas (Conversões)",
|
|
50
|
+
"publico": "Aberto + Advantage+",
|
|
51
|
+
"orcamento_diario": orcamento,
|
|
52
|
+
"regra_budget": "criativos × metade do ticket",
|
|
53
|
+
"criativos": criativos,
|
|
54
|
+
}
|
|
55
|
+
else:
|
|
56
|
+
plano = {
|
|
57
|
+
"formato": "Vídeo vertical 9:16, 15–30s, estilo UGC",
|
|
58
|
+
"objetivo": "Conversões",
|
|
59
|
+
"publico": "Idade 18–45 (EUA/UK/CA/AU)",
|
|
60
|
+
"orcamento_diario": float(form.get("orcamento_diario") or 15),
|
|
61
|
+
"moeda": "USD",
|
|
62
|
+
"teste": "US$10–20/dia por criativo; escala US$50–100/dia no vencedor",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
positivas = form.get("keywords") or []
|
|
66
|
+
negativas = form.get("negativas") or []
|
|
67
|
+
criativos = form.get("criativos") or []
|
|
68
|
+
return {
|
|
69
|
+
"plataforma": plataforma,
|
|
70
|
+
"nome": form.get("nome") or f"{produto.get('nome') or 'Produto'} — {plataforma.title()}",
|
|
71
|
+
"produto_id": produto.get("id"),
|
|
72
|
+
"cpa_alvo": float(plano.get("cpa_alvo") or 0),
|
|
73
|
+
"orcamento_diario": float(plano.get("orcamento_diario") or 0),
|
|
74
|
+
"keywords": positivas,
|
|
75
|
+
"negativas": negativas,
|
|
76
|
+
"criativos": criativos,
|
|
77
|
+
"plano": plano,
|
|
78
|
+
"manual": plataforma == "tiktok",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def salvar(campanha: dict) -> dict:
|
|
83
|
+
timestamp = now_str()
|
|
84
|
+
with get_db() as conn:
|
|
85
|
+
cursor = conn.execute(
|
|
86
|
+
"INSERT INTO campanhas (produto_id, plataforma, nome, objetivo, cpa_alvo, orcamento_diario, keywords_json, negativas_json, criativos_json, plano_json, status, created_at, updated_at) "
|
|
87
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'rascunho', ?, ?)",
|
|
88
|
+
(
|
|
89
|
+
campanha.get("produto_id"),
|
|
90
|
+
campanha["plataforma"],
|
|
91
|
+
campanha["nome"],
|
|
92
|
+
json.dumps(campanha["plano"].get("objetivo", ""), ensure_ascii=False),
|
|
93
|
+
campanha.get("cpa_alvo") or 0,
|
|
94
|
+
campanha.get("orcamento_diario") or 0,
|
|
95
|
+
json.dumps(campanha["keywords"], ensure_ascii=False),
|
|
96
|
+
json.dumps(campanha["negativas"], ensure_ascii=False),
|
|
97
|
+
json.dumps(campanha["criativos"], ensure_ascii=False),
|
|
98
|
+
json.dumps(campanha["plano"], ensure_ascii=False),
|
|
99
|
+
timestamp,
|
|
100
|
+
timestamp,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
row = conn.execute("SELECT * FROM campanhas WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
104
|
+
return row_to_dict(row)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def listar(produto_id: int | None = None) -> list[dict]:
|
|
108
|
+
query = "SELECT * FROM campanhas"
|
|
109
|
+
params: tuple = ()
|
|
110
|
+
if produto_id:
|
|
111
|
+
query += " WHERE produto_id = ?"
|
|
112
|
+
params = (produto_id,)
|
|
113
|
+
query += " ORDER BY id DESC"
|
|
114
|
+
with get_db() as conn:
|
|
115
|
+
rows = conn.execute(query, params).fetchall()
|
|
116
|
+
return [row_to_dict(row) for row in rows]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get(campanha_id: int) -> dict:
|
|
120
|
+
with get_db() as conn:
|
|
121
|
+
row = conn.execute("SELECT * FROM campanhas WHERE id = ?", (campanha_id,)).fetchone()
|
|
122
|
+
if not row:
|
|
123
|
+
raise LLMError("Campanha não encontrada.")
|
|
124
|
+
record = row_to_dict(row)
|
|
125
|
+
for field in ("keywords_json", "negativas_json", "criativos_json", "plano_json", "resultado_json"):
|
|
126
|
+
if record.get(field):
|
|
127
|
+
try:
|
|
128
|
+
record[field.replace("_json", "")] = json.loads(record[field])
|
|
129
|
+
except Exception: # noqa: BLE001
|
|
130
|
+
record[field.replace("_json", "")] = record[field]
|
|
131
|
+
return record
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def remover(campanha_id: int) -> None:
|
|
135
|
+
with get_db() as conn:
|
|
136
|
+
cursor = conn.execute("DELETE FROM campanhas WHERE id = ?", (campanha_id,))
|
|
137
|
+
if cursor.rowcount == 0:
|
|
138
|
+
raise LLMError("Campanha não encontrada.")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def marcar_publicada(campanha_id: int, resultado: dict, status: str = "publicada", erro: str = "") -> dict:
|
|
142
|
+
with get_db() as conn:
|
|
143
|
+
conn.execute(
|
|
144
|
+
"UPDATE campanhas SET status = ?, resultado_json = ?, erro = ?, updated_at = ? WHERE id = ?",
|
|
145
|
+
(status, json.dumps(resultado, ensure_ascii=False), erro[:2000], now_str(), campanha_id),
|
|
146
|
+
)
|
|
147
|
+
if status == "publicada":
|
|
148
|
+
from .notify import notify_event
|
|
149
|
+
|
|
150
|
+
campanha = get(campanha_id)
|
|
151
|
+
notify_event("campanha_publicada", f"Campanha publicada ({campanha['plataforma']})", f"{campanha['nome']} — criada PAUSADA para revisão.")
|
|
152
|
+
return get(campanha_id)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def atualizar(campanha_id: int, campanha: dict) -> dict:
|
|
156
|
+
with get_db() as conn:
|
|
157
|
+
conn.execute(
|
|
158
|
+
"UPDATE campanhas SET nome = ?, cpa_alvo = ?, orcamento_diario = ?, keywords_json = ?, negativas_json = ?, criativos_json = ?, plano_json = ?, updated_at = ? WHERE id = ?",
|
|
159
|
+
(
|
|
160
|
+
campanha["nome"],
|
|
161
|
+
campanha.get("cpa_alvo") or 0,
|
|
162
|
+
campanha.get("orcamento_diario") or 0,
|
|
163
|
+
json.dumps(campanha["keywords"], ensure_ascii=False),
|
|
164
|
+
json.dumps(campanha["negativas"], ensure_ascii=False),
|
|
165
|
+
json.dumps(campanha["criativos"], ensure_ascii=False),
|
|
166
|
+
json.dumps(campanha["plano"], ensure_ascii=False),
|
|
167
|
+
now_str(),
|
|
168
|
+
campanha_id,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
return get(campanha_id)
|
package/app/services/database.py
CHANGED
|
@@ -124,9 +124,72 @@ def ensure_db() -> None:
|
|
|
124
124
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
|
|
125
125
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_created ON vendas (created_at)")
|
|
126
126
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
|
|
127
|
+
conn.execute(
|
|
128
|
+
"""
|
|
129
|
+
CREATE TABLE IF NOT EXISTS palavras (
|
|
130
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
produto_id INTEGER,
|
|
132
|
+
produto_nome TEXT NOT NULL DEFAULT '',
|
|
133
|
+
tipo TEXT NOT NULL DEFAULT 'positiva',
|
|
134
|
+
texto TEXT NOT NULL,
|
|
135
|
+
correspondencia TEXT NOT NULL DEFAULT 'frase',
|
|
136
|
+
origem TEXT NOT NULL DEFAULT 'ia',
|
|
137
|
+
created_at TEXT NOT NULL
|
|
138
|
+
)
|
|
139
|
+
"""
|
|
140
|
+
)
|
|
141
|
+
conn.execute(
|
|
142
|
+
"""
|
|
143
|
+
CREATE TABLE IF NOT EXISTS campanhas (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
produto_id INTEGER,
|
|
146
|
+
plataforma TEXT NOT NULL DEFAULT 'google',
|
|
147
|
+
nome TEXT NOT NULL,
|
|
148
|
+
objetivo TEXT NOT NULL DEFAULT '',
|
|
149
|
+
cpa_alvo REAL NOT NULL DEFAULT 0,
|
|
150
|
+
orcamento_diario REAL NOT NULL DEFAULT 0,
|
|
151
|
+
keywords_json TEXT NOT NULL DEFAULT '[]',
|
|
152
|
+
negativas_json TEXT NOT NULL DEFAULT '[]',
|
|
153
|
+
criativos_json TEXT NOT NULL DEFAULT '[]',
|
|
154
|
+
plano_json TEXT NOT NULL DEFAULT '',
|
|
155
|
+
status TEXT NOT NULL DEFAULT 'rascunho',
|
|
156
|
+
resultado_json TEXT NOT NULL DEFAULT '',
|
|
157
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
158
|
+
created_at TEXT NOT NULL,
|
|
159
|
+
updated_at TEXT NOT NULL
|
|
160
|
+
)
|
|
161
|
+
"""
|
|
162
|
+
)
|
|
127
163
|
columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
|
|
128
164
|
if "analysis" not in columns:
|
|
129
165
|
conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
|
|
166
|
+
conn.execute(
|
|
167
|
+
"""
|
|
168
|
+
CREATE TABLE IF NOT EXISTS lancamentos (
|
|
169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
+
produto_id INTEGER,
|
|
171
|
+
etapa_atual INTEGER NOT NULL DEFAULT 1,
|
|
172
|
+
status TEXT NOT NULL DEFAULT 'em_andamento',
|
|
173
|
+
dados_json TEXT NOT NULL DEFAULT '{}',
|
|
174
|
+
created_at TEXT NOT NULL,
|
|
175
|
+
updated_at TEXT NOT NULL
|
|
176
|
+
)
|
|
177
|
+
"""
|
|
178
|
+
)
|
|
179
|
+
conn.execute(
|
|
180
|
+
"""
|
|
181
|
+
CREATE TABLE IF NOT EXISTS notificacoes (
|
|
182
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
|
+
event TEXT NOT NULL DEFAULT '',
|
|
184
|
+
titulo TEXT NOT NULL DEFAULT '',
|
|
185
|
+
mensagem TEXT NOT NULL DEFAULT '',
|
|
186
|
+
canal TEXT NOT NULL DEFAULT '',
|
|
187
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
188
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
189
|
+
created_at TEXT NOT NULL
|
|
190
|
+
)
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
130
193
|
|
|
131
194
|
|
|
132
195
|
def now_str() -> str:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""AdsBot — Lançamento Assistido: wizard persistido + Relatório de Lançamento."""
|
|
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
|
|
7
|
+
|
|
8
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
9
|
+
ETAPAS = [
|
|
10
|
+
{"numero": 1, "nome": "Produto", "emoji": "📦", "rota": "/"},
|
|
11
|
+
{"numero": 2, "nome": "Página", "emoji": "🛡️", "rota": "/adspages"},
|
|
12
|
+
{"numero": 3, "nome": "Publicar", "emoji": "🌐", "rota": "/hosting/paginas"},
|
|
13
|
+
{"numero": 4, "nome": "Campanhas", "emoji": "🎯", "rota": "/adscreator"},
|
|
14
|
+
{"numero": 5, "nome": "Tracking", "emoji": "🔄", "rota": "/tracking/visitas"},
|
|
15
|
+
{"numero": 6, "nome": "Conclusão", "emoji": "✅", "rota": "/adsbot"},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _decode(record: dict) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
record["dados"] = json.loads(record.get("dados_json") or "{}")
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
record["dados"] = {}
|
|
24
|
+
return record
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_row(lancamento_id: int) -> dict:
|
|
28
|
+
with get_db() as conn:
|
|
29
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
30
|
+
if not row:
|
|
31
|
+
raise LLMError("Lançamento não encontrado.")
|
|
32
|
+
return row_to_dict(row)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def atual() -> dict | None:
|
|
36
|
+
with get_db() as conn:
|
|
37
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE status = 'em_andamento' ORDER BY id DESC LIMIT 1").fetchone()
|
|
38
|
+
return _decode(row_to_dict(row)) if row else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def listar() -> list[dict]:
|
|
42
|
+
with get_db() as conn:
|
|
43
|
+
rows = conn.execute("SELECT * FROM lancamentos ORDER BY id DESC").fetchall()
|
|
44
|
+
return [_decode(row_to_dict(row)) for row in rows]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iniciar(produto_id: int | None = None) -> dict:
|
|
48
|
+
existing = atual()
|
|
49
|
+
if existing:
|
|
50
|
+
if produto_id and not existing.get("produto_id"):
|
|
51
|
+
return definir_produto(existing["id"], produto_id)
|
|
52
|
+
return existing
|
|
53
|
+
timestamp = now_str()
|
|
54
|
+
with get_db() as conn:
|
|
55
|
+
cursor = conn.execute(
|
|
56
|
+
"INSERT INTO lancamentos (produto_id, etapa_atual, status, dados_json, created_at, updated_at) VALUES (?, 1, 'em_andamento', '{}', ?, ?)",
|
|
57
|
+
(produto_id, timestamp, timestamp),
|
|
58
|
+
)
|
|
59
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
60
|
+
return _decode(row_to_dict(row))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def definir_produto(lancamento_id: int, produto_id: int) -> dict:
|
|
64
|
+
with get_db() as conn:
|
|
65
|
+
conn.execute("UPDATE lancamentos SET produto_id = ?, updated_at = ? WHERE id = ?", (produto_id, now_str(), lancamento_id))
|
|
66
|
+
return _get_row(lancamento_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def salvar_etapa(lancamento_id: int, etapa: int, dados: dict) -> dict:
|
|
70
|
+
if not 1 <= etapa <= len(ETAPAS):
|
|
71
|
+
raise LLMError(f"Etapa inválida (1–{len(ETAPAS)}).")
|
|
72
|
+
with get_db() as conn:
|
|
73
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
74
|
+
if not row:
|
|
75
|
+
raise LLMError("Lançamento não encontrado.")
|
|
76
|
+
merged = {}
|
|
77
|
+
try:
|
|
78
|
+
merged = json.loads(row["dados_json"] or "{}")
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
merged = {}
|
|
81
|
+
merged.update(dados or {})
|
|
82
|
+
conn.execute(
|
|
83
|
+
"UPDATE lancamentos SET etapa_atual = ?, dados_json = ?, updated_at = ? WHERE id = ?",
|
|
84
|
+
(max(etapa, row["etapa_atual"] if row["status"] == "concluido" else 0), json.dumps(merged, ensure_ascii=False), now_str(), lancamento_id),
|
|
85
|
+
)
|
|
86
|
+
return _get_row(lancamento_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def concluir(lancamento_id: int) -> dict:
|
|
90
|
+
with get_db() as conn:
|
|
91
|
+
conn.execute("UPDATE lancamentos SET status = 'concluido', etapa_atual = ?, updated_at = ? WHERE id = ?", (len(ETAPAS), now_str(), lancamento_id))
|
|
92
|
+
return relatorio(lancamento_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def relatorio(lancamento_id: int) -> dict:
|
|
96
|
+
record = _get_row(lancamento_id)
|
|
97
|
+
dados = json.loads(record.get("dados_json") or "{}")
|
|
98
|
+
produto_id = record.get("produto_id") or dados.get("produto_id")
|
|
99
|
+
produto = None
|
|
100
|
+
analysis = None
|
|
101
|
+
paginas = []
|
|
102
|
+
campanhas = []
|
|
103
|
+
if produto_id:
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
produto_row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
106
|
+
if produto_row:
|
|
107
|
+
produto = row_to_dict(produto_row)
|
|
108
|
+
if produto and produto.get("analysis"):
|
|
109
|
+
try:
|
|
110
|
+
analysis = json.loads(produto["analysis"])
|
|
111
|
+
except Exception: # noqa: BLE001
|
|
112
|
+
analysis = None
|
|
113
|
+
with get_db() as conn:
|
|
114
|
+
paginas = [row_to_dict(r) for r in conn.execute("SELECT * FROM paginas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
115
|
+
campanhas = [row_to_dict(r) for r in conn.execute("SELECT * FROM campanhas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
116
|
+
checklist_path = SEED_DIR / "checklists" / "lancamento.md"
|
|
117
|
+
checklist = ""
|
|
118
|
+
if checklist_path.exists():
|
|
119
|
+
checklist = checklist_path.read_text(encoding="utf-8")
|
|
120
|
+
return {
|
|
121
|
+
"lancamento": record,
|
|
122
|
+
"produto": produto,
|
|
123
|
+
"analysis": analysis,
|
|
124
|
+
"paginas": [
|
|
125
|
+
{"id": p["id"], "titulo": p["titulo"], "tipo": p["tipo"], "status": p["status"], "published_url": p["published_url"]}
|
|
126
|
+
for p in paginas
|
|
127
|
+
],
|
|
128
|
+
"campanhas": [
|
|
129
|
+
{"id": c["id"], "nome": c["nome"], "plataforma": c["plataforma"], "status": c["status"], "cpa_alvo": c["cpa_alvo"], "orcamento_diario": c["orcamento_diario"]}
|
|
130
|
+
for c in campanhas
|
|
131
|
+
],
|
|
132
|
+
"checklist": checklist,
|
|
133
|
+
"etapas": ETAPAS,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def progresso(record: dict) -> dict:
|
|
138
|
+
etapa = record.get("etapa_atual") or 1
|
|
139
|
+
total = len(ETAPAS)
|
|
140
|
+
concluido = record.get("status") == "concluido"
|
|
141
|
+
percentual = 100 if concluido else round((etapa - 1) / total * 100)
|
|
142
|
+
return {
|
|
143
|
+
"etapa": etapa,
|
|
144
|
+
"total": total,
|
|
145
|
+
"percentual": percentual,
|
|
146
|
+
"faltam": 0 if concluido else total - etapa + 1,
|
|
147
|
+
"status": record.get("status"),
|
|
148
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""AdsNotify — notificações push (apprise) + histórico local.
|
|
2
|
+
|
|
3
|
+
- Canais: URLs apprise configuradas em settings.notifications.targets. Se houver Telegram
|
|
4
|
+
configurado em Chaves de API, o destino t://TOKEN/CHATID entra automaticamente.
|
|
5
|
+
- Eventos com toggle em settings.notifications.eventos (default: desligados).
|
|
6
|
+
- Falha de envio NUNCA quebra o fluxo — apenas registra no histórico.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .database import get_db, now_str, row_to_dict
|
|
12
|
+
from .llm import LLMError
|
|
13
|
+
from .store import get_settings
|
|
14
|
+
|
|
15
|
+
EVENTOS = {
|
|
16
|
+
"venda_recebida": "Venda recebida",
|
|
17
|
+
"campanha_publicada": "Campanha publicada",
|
|
18
|
+
"pagina_publicada": "Página publicada",
|
|
19
|
+
"infoproduto_gerado": "Infoproduto gerado",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _telegram_target() -> str | None:
|
|
24
|
+
keys = (get_settings().get("api_keys") or {}).get("telegram") or {}
|
|
25
|
+
token = (keys.get("bot_token") or "").strip()
|
|
26
|
+
chat_id = (keys.get("chat_id") or "").strip()
|
|
27
|
+
if token and chat_id:
|
|
28
|
+
return f"tgram://{token}/{chat_id}"
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def listar_config() -> dict:
|
|
33
|
+
settings = get_settings()
|
|
34
|
+
notifications = settings.get("notifications") or {}
|
|
35
|
+
return {
|
|
36
|
+
"eventos": {key: bool((notifications.get("eventos") or {}).get(key, False)) for key in EVENTOS},
|
|
37
|
+
"targets": notifications.get("targets") or [],
|
|
38
|
+
"eventos_disponiveis": EVENTOS,
|
|
39
|
+
"canal_telegram": bool(_telegram_target()),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def salvar_config(eventos: dict, targets: list) -> dict:
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
current = settings.get("notifications") or {}
|
|
46
|
+
merged_eventos = {key: bool((current.get("eventos") or {}).get(key, False)) for key in EVENTOS}
|
|
47
|
+
merged_eventos.update({key: bool(value) for key, value in (eventos or {}).items() if key in EVENTOS})
|
|
48
|
+
settings["notifications"] = {"eventos": merged_eventos, "targets": [str(item).strip() for item in (targets or []) if str(item).strip()]}
|
|
49
|
+
from .store import save_settings
|
|
50
|
+
|
|
51
|
+
save_settings(settings)
|
|
52
|
+
return listar_config()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def historico(limit: int = 50) -> list[dict]:
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
rows = conn.execute("SELECT * FROM notificacoes ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
58
|
+
return [row_to_dict(row) for row in rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _registrar(event: str, titulo: str, mensagem: str, canal: str, status: str, erro: str = "") -> None:
|
|
62
|
+
with get_db() as conn:
|
|
63
|
+
conn.execute(
|
|
64
|
+
"INSERT INTO notificacoes (event, titulo, mensagem, canal, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
65
|
+
(event, titulo, mensagem, canal, status, erro[:2000], now_str()),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _alvos() -> list[str]:
|
|
70
|
+
settings = get_settings()
|
|
71
|
+
targets = list((settings.get("notifications") or {}).get("targets") or [])
|
|
72
|
+
telegram = _telegram_target()
|
|
73
|
+
if telegram and telegram not in targets:
|
|
74
|
+
targets.insert(0, telegram)
|
|
75
|
+
return [item for item in targets if item.strip()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_enabled(event: str) -> bool:
|
|
79
|
+
settings = get_settings()
|
|
80
|
+
return bool(((settings.get("notifications") or {}).get("eventos") or {}).get(event, False))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enviar(event: str, titulo: str, mensagem: str, forcar: bool = False) -> dict:
|
|
84
|
+
"""Envia uma notificação pelos canais configurados (best-effort)."""
|
|
85
|
+
if not forcar and not _is_enabled(event):
|
|
86
|
+
return {"enviado": False, "motivo": "evento desligado"}
|
|
87
|
+
alvos = _alvos()
|
|
88
|
+
if not alvos:
|
|
89
|
+
erro = "Nenhum canal configurado (settings.notifications.targets ou Telegram em Chaves de API)."
|
|
90
|
+
_registrar(event, titulo, mensagem, "", "erro", erro)
|
|
91
|
+
if forcar:
|
|
92
|
+
raise LLMError(erro)
|
|
93
|
+
return {"enviado": False, "motivo": erro}
|
|
94
|
+
try:
|
|
95
|
+
import apprise
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", f"apprise ausente ({exc.name}) — rode 'prodmaker install'.")
|
|
98
|
+
return {"enviado": False, "motivo": "apprise não instalado"}
|
|
99
|
+
apobj = apprise.Apprise()
|
|
100
|
+
enviados = []
|
|
101
|
+
erros = []
|
|
102
|
+
for alvo in alvos:
|
|
103
|
+
if apobj.add(alvo):
|
|
104
|
+
enviados.append(alvo)
|
|
105
|
+
else:
|
|
106
|
+
erros.append(f"canal inválido: {alvo}")
|
|
107
|
+
if not enviados:
|
|
108
|
+
erro = "; ".join(erros)
|
|
109
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", erro)
|
|
110
|
+
raise LLMError(erro)
|
|
111
|
+
ok = apobj.notify(title=titulo, body=mensagem)
|
|
112
|
+
if ok:
|
|
113
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "enviada")
|
|
114
|
+
return {"enviado": True, "canais": enviados}
|
|
115
|
+
erro = "apprise não conseguiu entregar (verifique tokens/chat id)."
|
|
116
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "erro", erro)
|
|
117
|
+
raise LLMError(erro)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def notify_event(event: str, titulo: str, mensagem: str) -> None:
|
|
121
|
+
"""Hook seguro: usado pelos fluxos de venda/página/campanha/infoproduto."""
|
|
122
|
+
try:
|
|
123
|
+
enviar(event, titulo, mensagem)
|
|
124
|
+
except LLMError:
|
|
125
|
+
pass
|
|
126
|
+
except Exception: # noqa: BLE001 — notificação nunca quebra a operação principal
|
|
127
|
+
pass
|