@danhachuel/prodmaker 0.1.8 → 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 +19 -0
- package/app/api/ads.py +155 -0
- package/app/main.py +4 -2
- package/app/services/adswords.py +158 -0
- package/app/services/campaigns.py +166 -0
- package/app/services/database.py +36 -0
- package/app/services/publish_ads.py +273 -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-BrfPg9k5.js +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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
|
+
|
|
6
25
|
## 0.1.08 — 2026-10-30
|
|
7
26
|
|
|
8
27
|
- **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
|
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"
|
|
@@ -31,6 +31,7 @@ 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
|
|
34
35
|
|
|
35
36
|
app.include_router(llm_router.router)
|
|
36
37
|
app.include_router(infoprodutos_router.router)
|
|
@@ -38,6 +39,7 @@ app.include_router(paginas_router.router)
|
|
|
38
39
|
app.include_router(dominios_router.router)
|
|
39
40
|
app.include_router(tracking_router.router)
|
|
40
41
|
app.include_router(vendas_router.router)
|
|
42
|
+
app.include_router(ads_router.router)
|
|
41
43
|
|
|
42
44
|
ensure_db()
|
|
43
45
|
|
|
@@ -244,7 +246,7 @@ def health():
|
|
|
244
246
|
|
|
245
247
|
@app.get("/api/version")
|
|
246
248
|
def version():
|
|
247
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
249
|
+
return {"version": APP_VERSION, "canonical": "0.1.9"}
|
|
248
250
|
|
|
249
251
|
|
|
250
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"]}
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
return get(campanha_id)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def atualizar(campanha_id: int, campanha: dict) -> dict:
|
|
151
|
+
with get_db() as conn:
|
|
152
|
+
conn.execute(
|
|
153
|
+
"UPDATE campanhas SET nome = ?, cpa_alvo = ?, orcamento_diario = ?, keywords_json = ?, negativas_json = ?, criativos_json = ?, plano_json = ?, updated_at = ? WHERE id = ?",
|
|
154
|
+
(
|
|
155
|
+
campanha["nome"],
|
|
156
|
+
campanha.get("cpa_alvo") or 0,
|
|
157
|
+
campanha.get("orcamento_diario") or 0,
|
|
158
|
+
json.dumps(campanha["keywords"], ensure_ascii=False),
|
|
159
|
+
json.dumps(campanha["negativas"], ensure_ascii=False),
|
|
160
|
+
json.dumps(campanha["criativos"], ensure_ascii=False),
|
|
161
|
+
json.dumps(campanha["plano"], ensure_ascii=False),
|
|
162
|
+
now_str(),
|
|
163
|
+
campanha_id,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
return get(campanha_id)
|
package/app/services/database.py
CHANGED
|
@@ -124,6 +124,42 @@ 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 ''")
|