@danhachuel/prodmaker 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/app/api/builder.py +127 -0
- package/app/main.py +4 -2
- package/app/services/builder.py +333 -0
- package/frontend/dist/assets/index-tkOFAfR-.js +77 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/seed/prompts/builder-anuncios.md +25 -0
- package/seed/prompts/builder-assets.md +23 -0
- package/seed/prompts/builder-estrutura.md +33 -0
- package/seed/prompts/builder-ofertas.md +27 -0
- package/seed/prompts/builder-produto.md +19 -0
- package/frontend/dist/assets/index-C8sX5Mkf.js +0 -74
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,43 @@
|
|
|
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.14 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
- **Fase 10 — REVISÃO (fechamento das lacunas apontadas na criação de produto/PDF).**
|
|
9
|
+
- **`POST /api/builder/generate-product`:** gera o **conteúdo completo** do produto em
|
|
10
|
+
Markdown conforme o agente low-ticket — 1. Capa (≤6 palavras); 2. Diagnóstico (5 motivos);
|
|
11
|
+
3. Solução (5–7 passos); 4. Exemplos (3 casos); 5. Checklist; 6. Bônus.
|
|
12
|
+
- **`POST /api/builder/generate-assets`:** 3 Order Bumps (checkout +R$21,90 · upsell +R$197 ·
|
|
13
|
+
parceiro 50/50) + **Quiz com IA** (5 perguntas) — rota própria conforme imagem 4.
|
|
14
|
+
- **`POST /api/builder/generate-landing`:** landing page de vendas com as **12 seções
|
|
15
|
+
validadas**, salva em `paginas` com preview em `/pages/{id}`.
|
|
16
|
+
- **Wizard com 4 passos:** (1) mineração + parceiro; (2) **seleção de formato** (E-book/
|
|
17
|
+
Planilha/Mini-curso/Desafio/Workshop) + estrutura + assets + preço R$27–97; (3) conteúdo
|
|
18
|
+
completo + **PDF** + landing page; (4) **12 anúncios + 10 pins** + **Publicar no GitHub +
|
|
19
|
+
Cloudflare** (reaproveita o motor da Fase 4).
|
|
20
|
+
- `generate-ads` agora devolve também os 10 pins; `generate-pdf` usa o conteúdo completo
|
|
21
|
+
quando disponível (antes renderia só o esqueleto).
|
|
22
|
+
- **Salvamento completo:** `salvar` grava anúncios + pins e **cria o registro em `produtos`**
|
|
23
|
+
(status aprovado, vinculado via `infoprodutos.produto_id`) — AdsPages e AdsCreator passam
|
|
24
|
+
a consumir o produto do builder automaticamente.
|
|
25
|
+
|
|
26
|
+
## 0.1.13 — 2026-10-31
|
|
27
|
+
|
|
28
|
+
- **Fase 10 — Product Builder Wizard (motor de criação de ofertas).**
|
|
29
|
+
- Studio substituído pelo **Wizard de 3 passos**: (1) mineração de **5 ofertas validadas**
|
|
30
|
+
a partir do nicho (promessa, dores, entregáveis, bônus, preço R$27–97) + registro do
|
|
31
|
+
**Parceiro 50/50**; (2) **estrutura** (módulos com aulas, e-books, ferramentas, **Quiz
|
|
32
|
+
com IA** e **3 Order Bumps** — checkout +R$21,90, upsell +R$197, parceiro 50/50) +
|
|
33
|
+
precificação com trava de faixa; (3) **12 anúncios** (Prova social/material/lógica,
|
|
34
|
+
Desafio-garantia, Comparação isso-vs-aquilo) com cópia por anúncio + **exportação de PDF**
|
|
35
|
+
(Markdown → HTML A4 → PDF) e **prompts de imagem** para Gamma/Canva.
|
|
36
|
+
- Backend: `services/builder.py` + `api/builder.py` (`POST /api/builder/generate-offers`,
|
|
37
|
+
`/generate-structure`, `/generate-ads`, `/generate-pdf`, `/salvar`). Tudo é salvo em
|
|
38
|
+
`infoprodutos` (novas colunas `oferta_json`, `estrutura_json`, `anuncios_json`, `preco`,
|
|
39
|
+
`parceiro` com migração automática) para AdsPages/AdsCreator consumirem.
|
|
40
|
+
- Novos prompts no seed: `builder-ofertas.md`, `builder-estrutura.md`, `builder-anuncios.md`.
|
|
41
|
+
- Rota `/studio` agora abre o Wizard; barra de progresso com "passo X de 3".
|
|
42
|
+
|
|
6
43
|
## 0.1.12 — 2026-10-31
|
|
7
44
|
|
|
8
45
|
- **Parte 10 — Polish E2E + OAuth Google 1 clique + radar Supabase + ROAS real.**
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Product Builder Wizard — 4 rotas de LLM + geração de PDF (Fase 10)."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import builder
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/builder", tags=["builder"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OfertasBody(BaseModel):
|
|
12
|
+
nicho: str
|
|
13
|
+
avatar: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EstruturaBody(BaseModel):
|
|
17
|
+
oferta: dict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProdutoBody(BaseModel):
|
|
21
|
+
oferta: dict
|
|
22
|
+
estrutura: dict = {}
|
|
23
|
+
formato: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AssetsBody(BaseModel):
|
|
27
|
+
oferta: dict
|
|
28
|
+
preco: float = 47
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AnunciosBody(BaseModel):
|
|
32
|
+
produto: str
|
|
33
|
+
promessa: str = ""
|
|
34
|
+
nicho: str = ""
|
|
35
|
+
link: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LandingBody(BaseModel):
|
|
39
|
+
oferta: dict
|
|
40
|
+
estrutura: dict = {}
|
|
41
|
+
produto_md: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PdfBody(BaseModel):
|
|
45
|
+
oferta: dict
|
|
46
|
+
estrutura: dict = {}
|
|
47
|
+
preco: float
|
|
48
|
+
parceiro: str = ""
|
|
49
|
+
modo: str = "rapido"
|
|
50
|
+
produto_md: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SalvarBody(BaseModel):
|
|
54
|
+
record_id: int
|
|
55
|
+
anuncios: list = []
|
|
56
|
+
pins: list = []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _erro(exc: LLMError) -> HTTPException:
|
|
60
|
+
return HTTPException(status_code=400, detail=str(exc))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/generate-offers")
|
|
64
|
+
def generate_offers(body: OfertasBody):
|
|
65
|
+
try:
|
|
66
|
+
return builder.gerar_ofertas(body.nicho, body.avatar)
|
|
67
|
+
except LLMError as exc:
|
|
68
|
+
raise _erro(exc)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/generate-structure")
|
|
72
|
+
def generate_structure(body: EstruturaBody):
|
|
73
|
+
try:
|
|
74
|
+
return builder.gerar_estrutura(body.oferta)
|
|
75
|
+
except LLMError as exc:
|
|
76
|
+
raise _erro(exc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.post("/generate-product")
|
|
80
|
+
def generate_product(body: ProdutoBody):
|
|
81
|
+
"""Conteúdo completo do produto em Markdown (agente low-ticket)."""
|
|
82
|
+
try:
|
|
83
|
+
return builder.gerar_produto(body.oferta, body.estrutura, body.formato)
|
|
84
|
+
except LLMError as exc:
|
|
85
|
+
raise _erro(exc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post("/generate-assets")
|
|
89
|
+
def generate_assets(body: AssetsBody):
|
|
90
|
+
"""3 Order Bumps (checkout/upsell/parceiro) + Quiz com IA."""
|
|
91
|
+
try:
|
|
92
|
+
return builder.gerar_assets(body.oferta, body.preco)
|
|
93
|
+
except LLMError as exc:
|
|
94
|
+
raise _erro(exc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/generate-landing")
|
|
98
|
+
def generate_landing(body: LandingBody):
|
|
99
|
+
"""Landing page de vendas (12 seções) para o produto do wizard."""
|
|
100
|
+
try:
|
|
101
|
+
return builder.gerar_landing(body.oferta, body.estrutura, body.produto_md)
|
|
102
|
+
except LLMError as exc:
|
|
103
|
+
raise _erro(exc)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.post("/generate-ads")
|
|
107
|
+
def generate_ads(body: AnunciosBody):
|
|
108
|
+
try:
|
|
109
|
+
return builder.gerar_anuncios(body.produto, body.promessa, body.nicho, body.link)
|
|
110
|
+
except LLMError as exc:
|
|
111
|
+
raise _erro(exc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.post("/generate-pdf")
|
|
115
|
+
def generate_pdf(body: PdfBody):
|
|
116
|
+
try:
|
|
117
|
+
return builder.gerar_pdf(body.oferta, body.estrutura, body.preco, body.parceiro, body.modo)
|
|
118
|
+
except LLMError as exc:
|
|
119
|
+
raise _erro(exc)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.post("/salvar")
|
|
123
|
+
def salvar(body: SalvarBody):
|
|
124
|
+
try:
|
|
125
|
+
return builder.salvar(body.anuncios, body.record_id, body.pins)
|
|
126
|
+
except (LLMError, ValueError) as exc:
|
|
127
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
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.14"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -40,6 +40,7 @@ from .api import logs as logs_router # noqa: E402
|
|
|
40
40
|
from .api import guias as guias_router # noqa: E402
|
|
41
41
|
from .api import contas as contas_router # noqa: E402
|
|
42
42
|
from .api import invest as invest_router # noqa: E402
|
|
43
|
+
from .api import builder as builder_router # noqa: E402
|
|
43
44
|
|
|
44
45
|
app.include_router(llm_router.router)
|
|
45
46
|
app.include_router(infoprodutos_router.router)
|
|
@@ -56,6 +57,7 @@ app.include_router(logs_router.router)
|
|
|
56
57
|
app.include_router(guias_router.router)
|
|
57
58
|
app.include_router(contas_router.router)
|
|
58
59
|
app.include_router(invest_router.router)
|
|
60
|
+
app.include_router(builder_router.router)
|
|
59
61
|
|
|
60
62
|
ensure_db()
|
|
61
63
|
|
|
@@ -262,7 +264,7 @@ def health():
|
|
|
262
264
|
|
|
263
265
|
@app.get("/api/version")
|
|
264
266
|
def version():
|
|
265
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
267
|
+
return {"version": APP_VERSION, "canonical": "0.1.14"}
|
|
266
268
|
|
|
267
269
|
|
|
268
270
|
@app.get("/api/settings")
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Product Builder Wizard — motor de criação de ofertas (Fase 10).
|
|
2
|
+
|
|
3
|
+
Fluxo: 5 ofertas (nicho) -> estrutura + quiz + 3 order bumps -> 12 anuncios ->
|
|
4
|
+
PDF (Markdown -> HTML -> PDF) -> salva em infoprodutos + produto (AdsPages/AdsCreator
|
|
5
|
+
consomem automaticamente).
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .database import get_db, now_str, row_to_dict
|
|
12
|
+
from .llm import LLMError, generate
|
|
13
|
+
from .pdf import markdown_para_pdf
|
|
14
|
+
from .store import STORAGE
|
|
15
|
+
|
|
16
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _template(nome: str, fallback: str) -> str:
|
|
20
|
+
path = SEED_DIR / "prompts" / nome
|
|
21
|
+
return path.read_text(encoding="utf-8") if path.exists() else fallback
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fill(template: str, values: dict) -> str:
|
|
25
|
+
for key, value in values.items():
|
|
26
|
+
template = template.replace("{" + key + "}", str(value or ""))
|
|
27
|
+
return template
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_json(text: str) -> dict:
|
|
31
|
+
cleaned = re.sub(r"```(?:json)?", "", text).strip()
|
|
32
|
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
33
|
+
if not match:
|
|
34
|
+
raise LLMError("A resposta do LLM não contém JSON válido.")
|
|
35
|
+
return json.loads(match.group(0))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _limit_ofertas(ofertas: list) -> list:
|
|
39
|
+
normalizadas = []
|
|
40
|
+
for item in ofertas[:5]:
|
|
41
|
+
if not isinstance(item, dict) or not (item.get("promessa") or "").strip():
|
|
42
|
+
continue
|
|
43
|
+
normalizadas.append({
|
|
44
|
+
"nome": (item.get("nome") or "Oferta").strip()[:80],
|
|
45
|
+
"promessa": (item.get("promessa") or "").strip(),
|
|
46
|
+
"dores": [str(item) for item in (item.get("dores") or [])][:6],
|
|
47
|
+
"entregaveis": [str(item) for item in (item.get("entregaveis") or [])][:8],
|
|
48
|
+
"bonus": [str(item) for item in (item.get("bonus") or [])][:4],
|
|
49
|
+
"preco_sugerido": float(item.get("preco_sugerido") or 47),
|
|
50
|
+
"formato": (item.get("formato") or "E-book").strip(),
|
|
51
|
+
"publico": (item.get("publico") or "").strip(),
|
|
52
|
+
})
|
|
53
|
+
return normalizadas
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def gerar_ofertas(nicho: str, avatar: str = "") -> dict:
|
|
57
|
+
nicho = (nicho or "").strip()
|
|
58
|
+
if not nicho:
|
|
59
|
+
raise LLMError("Informe o nicho para gerar as ofertas.")
|
|
60
|
+
prompt = _fill(
|
|
61
|
+
_template("builder-ofertas.md", "Gere 5 ofertas low-ticket para o nicho {NICHO} em JSON com chave 'ofertas'."),
|
|
62
|
+
{"NICHO": nicho, "AVATAR": avatar or "empreendedores e afiliados do nicho"},
|
|
63
|
+
)
|
|
64
|
+
result = generate(prompt, system="Você é um estrategista de ofertas low-ticket. Responda SOMENTE JSON válido.", max_tokens=4000)
|
|
65
|
+
try:
|
|
66
|
+
parsed = _parse_json(result["text"])
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise LLMError(f"JSON do LLM inválido: {exc}") from exc
|
|
69
|
+
ofertas = _limit_ofertas(parsed.get("ofertas") or [])
|
|
70
|
+
if not ofertas:
|
|
71
|
+
raise LLMError("O LLM não retornou ofertas utilizáveis — tente novamente.")
|
|
72
|
+
return {"ofertas": ofertas, "provider": result["provider"]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gerar_estrutura(oferta: dict) -> dict:
|
|
76
|
+
if not oferta or not (oferta.get("promessa") or "").strip():
|
|
77
|
+
raise LLMError("Selecione uma oferta antes de gerar a estrutura.")
|
|
78
|
+
prompt = _fill(
|
|
79
|
+
_template("builder-estrutura.md", "Gere a estrutura do produto para {OFERTA} em JSON."),
|
|
80
|
+
{
|
|
81
|
+
"OFERTA": oferta.get("nome") or "",
|
|
82
|
+
"PROMESSA": oferta.get("promessa") or "",
|
|
83
|
+
"NICHO": oferta.get("publico") or (oferta.get("dores") or [""])[0],
|
|
84
|
+
"AVATAR": oferta.get("publico") or "",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
result = generate(prompt, system="Você é um especialista em produtos digitais. Responda SOMENTE JSON válido.", max_tokens=6000)
|
|
88
|
+
try:
|
|
89
|
+
estrutura = _parse_json(result["text"])
|
|
90
|
+
except json.JSONDecodeError as exc:
|
|
91
|
+
raise LLMError(f"JSON do LLM inválido: {exc}") from exc
|
|
92
|
+
if not estrutura.get("modulos"):
|
|
93
|
+
raise LLMError("O LLM não retornou módulos — tente novamente.")
|
|
94
|
+
return {"estrutura": estrutura, "provider": result["provider"]}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def gerar_produto(oferta: dict, estrutura: dict, formato: str = "") -> dict:
|
|
98
|
+
"""Gera o CONTEÚDO COMPLETO do produto em Markdown (estrutura obrigatória do agente .md)."""
|
|
99
|
+
if not oferta or not (oferta.get("promessa") or "").strip():
|
|
100
|
+
raise LLMError("Selecione uma oferta antes de gerar o conteúdo do produto.")
|
|
101
|
+
formato = (formato or oferta.get("formato") or "E-book").strip()
|
|
102
|
+
prompt = _fill(
|
|
103
|
+
_template("builder-produto.md", "Gere o conteudo completo do produto {OFERTA} em Markdown."),
|
|
104
|
+
{
|
|
105
|
+
"OFERTA": oferta.get("nome") or "",
|
|
106
|
+
"FORMATO": formato,
|
|
107
|
+
"PROMESSA": oferta.get("promessa") or "",
|
|
108
|
+
"NICHO": oferta.get("publico") or (oferta.get("dores") or [""])[0],
|
|
109
|
+
"AVATAR": oferta.get("publico") or "",
|
|
110
|
+
"PRECO": oferta.get("preco_sugerido") or 47,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
result = generate(
|
|
114
|
+
prompt,
|
|
115
|
+
system="Você é um especialista em produtos digitais práticos. Escreva o produto completo em Markdown, sem comentários.",
|
|
116
|
+
max_tokens=12000,
|
|
117
|
+
)
|
|
118
|
+
md_text = (result["text"] or "").strip()
|
|
119
|
+
if len(md_text) < 400 or "##" not in md_text:
|
|
120
|
+
raise LLMError("O LLM não retornou o conteúdo completo do produto — tente novamente.")
|
|
121
|
+
return {"markdown": md_text, "provider": result["provider"]}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def gerar_assets(oferta: dict, preco: float = 47) -> dict:
|
|
125
|
+
"""Gera 3 Order Bumps (checkout/upsell/parceiro 50-50) + Quiz com IA."""
|
|
126
|
+
if not oferta or not (oferta.get("promessa") or "").strip():
|
|
127
|
+
raise LLMError("Selecione uma oferta antes de gerar os assets.")
|
|
128
|
+
prompt = _fill(
|
|
129
|
+
_template("builder-assets.md", "Gere order bumps e quiz para {OFERTA} em JSON."),
|
|
130
|
+
{
|
|
131
|
+
"OFERTA": oferta.get("nome") or "",
|
|
132
|
+
"PROMESSA": oferta.get("promessa") or "",
|
|
133
|
+
"PRECO": preco or oferta.get("preco_sugerido") or 47,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
result = generate(prompt, system="Você é um estrategista de monetização. Responda SOMENTE JSON válido.", max_tokens=5000)
|
|
137
|
+
try:
|
|
138
|
+
parsed = _parse_json(result["text"])
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
raise LLMError(f"JSON do LLM inválido: {exc}") from exc
|
|
141
|
+
bumps = parsed.get("order_bumps") or []
|
|
142
|
+
if len(bumps) < 3:
|
|
143
|
+
raise LLMError(f"O LLM retornou {len(bumps)} order bumps (esperado 3) — tente novamente.")
|
|
144
|
+
return {"order_bumps": bumps[:3], "quiz": parsed.get("quiz") or {}, "provider": result["provider"]}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def gerar_anuncios(produto: str, promessa: str, nicho: str = "", link: str = "") -> dict:
|
|
148
|
+
if not produto.strip():
|
|
149
|
+
raise LLMError("Informe o nome do produto para gerar os anúncios.")
|
|
150
|
+
prompt = _fill(
|
|
151
|
+
_template("builder-anuncios.md", "Gere 12 anuncios para {PRODUTO} em JSON com chave 'anuncios'."),
|
|
152
|
+
{"PRODUTO": produto, "PROMESSA": promessa, "NICHO": nicho, "LINK": link},
|
|
153
|
+
)
|
|
154
|
+
result = generate(prompt, system="Você é um copywriter de direct response e especialista em Pinterest. Responda SOMENTE JSON válido.", max_tokens=6000)
|
|
155
|
+
try:
|
|
156
|
+
parsed = _parse_json(result["text"])
|
|
157
|
+
except json.JSONDecodeError as exc:
|
|
158
|
+
raise LLMError(f"JSON do LLM inválido: {exc}") from exc
|
|
159
|
+
anuncios = [item for item in (parsed.get("anuncios") or []) if isinstance(item, dict)]
|
|
160
|
+
pins = [item for item in (parsed.get("pins") or []) if isinstance(item, dict)]
|
|
161
|
+
if len(anuncios) < 12:
|
|
162
|
+
raise LLMError(f"O LLM retornou {len(anuncios)} anúncios (esperado 12) — tente novamente.")
|
|
163
|
+
return {"anuncios": anuncios[:12], "pins": pins[:10], "provider": result["provider"]}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _imagem_prompts(oferta: dict, estrutura: dict) -> list[str]:
|
|
167
|
+
prompts = [
|
|
168
|
+
f"Capa de e-book high-conversion para '{oferta.get('nome')}' ({oferta.get('formato')}), promessa: {oferta.get('promessa')}",
|
|
169
|
+
f"Mockup 3D do produto '{oferta.get('nome')}' para página de vendas, fundo claro, estilo premium",
|
|
170
|
+
]
|
|
171
|
+
for bonus in (oferta.get("bonus") or [])[:2]:
|
|
172
|
+
prompts.append(f"Capa do bônus '{bonus}', estilo coeso com a capa principal")
|
|
173
|
+
for modulo in (estrutura.get("modulos") or [])[:3]:
|
|
174
|
+
prompts.append(f"Imagem de módulo: {modulo.get('titulo')}")
|
|
175
|
+
return prompts
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _markdown_produto(oferta: dict, estrutura: dict, preco: float, parceiro: str) -> str:
|
|
179
|
+
linhas = [f"# {oferta.get('nome') or 'Produto'}", "", f"**Promessa:** {oferta.get('promessa')}", ""]
|
|
180
|
+
if oferta.get("publico"):
|
|
181
|
+
linhas += [f"**Público:** {oferta['publico']}", ""]
|
|
182
|
+
linhas += ["## Módulos", ""]
|
|
183
|
+
for modulo in estrutura.get("modulos") or []:
|
|
184
|
+
linhas.append(f"### {modulo.get('titulo')}")
|
|
185
|
+
for aula in modulo.get("aulas") or []:
|
|
186
|
+
linhas.append(f"- {aula}")
|
|
187
|
+
linhas.append("")
|
|
188
|
+
if estrutura.get("ebooks"):
|
|
189
|
+
linhas += ["## E-books inclusos", ""] + [f"- {item}" for item in estrutura["ebooks"]] + [""]
|
|
190
|
+
if estrutura.get("ferramentas"):
|
|
191
|
+
linhas += ["## Ferramentas e planilhas", ""] + [f"- {item}" for item in estrutura["ferramentas"]] + [""]
|
|
192
|
+
linhas += ["## Order Bumps", ""]
|
|
193
|
+
for bump in estrutura.get("order_bumps") or []:
|
|
194
|
+
linhas.append(f"- **{bump.get('tipo')}**: {bump.get('nome')} — {bump.get('descricao')}")
|
|
195
|
+
linhas += ["", f"## Preço", "", f"**R$ {preco:.2f}**", ""]
|
|
196
|
+
if parceiro:
|
|
197
|
+
linhas += [f"## Parceiro 50/50", "", parceiro, ""]
|
|
198
|
+
return "\n".join(linhas)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _ensure_columns() -> None:
|
|
202
|
+
from .dbengine import add_column_if_missing
|
|
203
|
+
|
|
204
|
+
with get_db() as conn:
|
|
205
|
+
add_column_if_missing(conn, "infoprodutos", "oferta_json TEXT NOT NULL DEFAULT ''", "oferta_json")
|
|
206
|
+
add_column_if_missing(conn, "infoprodutos", "estrutura_json TEXT NOT NULL DEFAULT ''", "estrutura_json")
|
|
207
|
+
add_column_if_missing(conn, "infoprodutos", "anuncios_json TEXT NOT NULL DEFAULT ''", "anuncios_json")
|
|
208
|
+
add_column_if_missing(conn, "infoprodutos", "pins_json TEXT NOT NULL DEFAULT ''", "pins_json")
|
|
209
|
+
add_column_if_missing(conn, "infoprodutos", "preco REAL NOT NULL DEFAULT 0", "preco")
|
|
210
|
+
add_column_if_missing(conn, "infoprodutos", "parceiro TEXT NOT NULL DEFAULT ''", "parceiro")
|
|
211
|
+
add_column_if_missing(conn, "infoprodutos", "produto_id INTEGER", "produto_id")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def gerar_landing(oferta: dict, estrutura: dict, produto_md: str = "") -> dict:
|
|
215
|
+
"""Gera a landing page de vendas (12 seções) via motor de páginas e salva em paginas."""
|
|
216
|
+
from . import pages
|
|
217
|
+
|
|
218
|
+
form = {
|
|
219
|
+
"titulo": oferta.get("nome") or "Produto",
|
|
220
|
+
"avatar": oferta.get("publico") or "",
|
|
221
|
+
"resultado": oferta.get("promessa") or "",
|
|
222
|
+
"dor": (oferta.get("dores") or [""])[0],
|
|
223
|
+
"afiliado_link": oferta.get("checkout_link") or "",
|
|
224
|
+
"checkout_link": oferta.get("checkout_link") or "",
|
|
225
|
+
"prompt": (produto_md or "")[:3000],
|
|
226
|
+
}
|
|
227
|
+
produto_ctx = {"id": None, "nome": oferta.get("nome") or "Produto", "link_afiliado": oferta.get("checkout_link") or ""}
|
|
228
|
+
resultado = pages.gerar_pagina("vendas", produto_ctx, form)
|
|
229
|
+
timestamp = now_str()
|
|
230
|
+
with get_db() as conn:
|
|
231
|
+
cursor = conn.execute(
|
|
232
|
+
"INSERT INTO paginas (titulo, tipo, produto_id, idioma, html_path, status, created_at, updated_at) VALUES (?, 'vendas', NULL, ?, '', 'rascunho', ?, ?)",
|
|
233
|
+
((oferta.get("nome") or "Produto")[:120], oferta.get("idioma") or "Portugues", timestamp, timestamp),
|
|
234
|
+
)
|
|
235
|
+
page_id = cursor.lastrowid
|
|
236
|
+
pages.salvar_html(page_id, resultado["html"])
|
|
237
|
+
with get_db() as conn:
|
|
238
|
+
conn.execute("UPDATE paginas SET html_path = ? WHERE id = ?", (f"pages/{page_id}/index.html", page_id))
|
|
239
|
+
row = conn.execute("SELECT * FROM paginas WHERE id = ?", (page_id,)).fetchone()
|
|
240
|
+
record = row_to_dict(row)
|
|
241
|
+
record["preview_url"] = f"/pages/{page_id}"
|
|
242
|
+
record["provider"] = resultado["provider"]
|
|
243
|
+
return record
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def gerar_pdf(oferta: dict, estrutura: dict, preco: float, parceiro: str = "", modo: str = "rapido", produto_md: str = "") -> dict:
|
|
247
|
+
"""Gera o PDF do produto (conteúdo completo quando disponível) + prompts de imagem."""
|
|
248
|
+
_ensure_columns()
|
|
249
|
+
md_text = (produto_md or "").strip() or _markdown_produto(oferta, estrutura, preco, parceiro)
|
|
250
|
+
prompt_imagens = _imagem_prompts(oferta, estrutura)
|
|
251
|
+
timestamp = now_str()
|
|
252
|
+
with get_db() as conn:
|
|
253
|
+
cursor = conn.execute(
|
|
254
|
+
"INSERT INTO infoprodutos (titulo, formato, nicho, modo, provider, created_at) VALUES (?, ?, ?, '', 'builder', ?)",
|
|
255
|
+
((oferta.get("nome") or "Produto")[:120], oferta.get("formato") or "E-book", (oferta.get("publico") or "")[:80], timestamp),
|
|
256
|
+
)
|
|
257
|
+
record_id = cursor.lastrowid
|
|
258
|
+
|
|
259
|
+
pasta = STORAGE / "produtos" / str(record_id)
|
|
260
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
md_path = pasta / "infoproduto.md"
|
|
262
|
+
md_path.write_text(md_text, encoding="utf-8")
|
|
263
|
+
pdf_path = None
|
|
264
|
+
erro_pdf = ""
|
|
265
|
+
if modo != "somente_markdown":
|
|
266
|
+
try:
|
|
267
|
+
pdf_path = markdown_para_pdf(md_text, pasta / "infoproduto.pdf")
|
|
268
|
+
except RuntimeError as exc:
|
|
269
|
+
erro_pdf = str(exc)
|
|
270
|
+
|
|
271
|
+
with get_db() as conn:
|
|
272
|
+
conn.execute(
|
|
273
|
+
"UPDATE infoprodutos SET oferta_json = ?, estrutura_json = ?, preco = ?, parceiro = ?, md_path = ?, pdf_path = ? WHERE id = ?",
|
|
274
|
+
(
|
|
275
|
+
json.dumps(oferta, ensure_ascii=False),
|
|
276
|
+
json.dumps(estrutura, ensure_ascii=False),
|
|
277
|
+
float(preco or 0),
|
|
278
|
+
parceiro or "",
|
|
279
|
+
md_path.relative_to(STORAGE).as_posix(),
|
|
280
|
+
pdf_path.relative_to(STORAGE).as_posix() if pdf_path else "",
|
|
281
|
+
record_id,
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
|
|
285
|
+
record = row_to_dict(row)
|
|
286
|
+
record["markdown"] = md_text
|
|
287
|
+
record["imagem_prompts"] = prompt_imagens
|
|
288
|
+
record["erro_pdf"] = erro_pdf
|
|
289
|
+
return record
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _criar_produto_para_consumo(record_id: int) -> dict:
|
|
293
|
+
"""Cria (ou reusa) o registro em `produtos` para AdsPages/AdsCreator consumirem."""
|
|
294
|
+
row = _get_infoproduto(record_id)
|
|
295
|
+
oferta = json.loads(row.get("oferta_json") or "{}")
|
|
296
|
+
nome = (oferta.get("nome") or row["titulo"] or "Produto")[:160]
|
|
297
|
+
with get_db() as conn:
|
|
298
|
+
existente = None
|
|
299
|
+
if row.get("produto_id"):
|
|
300
|
+
existente = conn.execute("SELECT id FROM produtos WHERE id = ?", (row["produto_id"],)).fetchone()
|
|
301
|
+
if not existente:
|
|
302
|
+
mesmo_nome = conn.execute("SELECT id FROM produtos WHERE nome = ? ORDER BY id DESC LIMIT 1", (nome,)).fetchone()
|
|
303
|
+
if mesmo_nome:
|
|
304
|
+
produto_id = mesmo_nome["id"]
|
|
305
|
+
else:
|
|
306
|
+
cursor = conn.execute(
|
|
307
|
+
"INSERT INTO produtos (nome, plataforma, link_afiliado, comissao, preco, nicho, status, created_at, updated_at) VALUES (?, 'Outro', '', 0, ?, ?, 'aprovado', ?, ?)",
|
|
308
|
+
(nome, float(row.get("preco") or oferta.get("preco_sugerido") or 47), (oferta.get("publico") or "")[:120], now_str(), now_str()),
|
|
309
|
+
)
|
|
310
|
+
produto_id = cursor.lastrowid
|
|
311
|
+
conn.execute("UPDATE infoprodutos SET produto_id = ? WHERE id = ?", (produto_id, record_id))
|
|
312
|
+
return {"produto_id": produto_id, "nome": nome}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _get_infoproduto(record_id: int) -> dict:
|
|
316
|
+
with get_db() as conn:
|
|
317
|
+
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
|
|
318
|
+
if not row:
|
|
319
|
+
raise LLMError("Infoproduto não encontrado.")
|
|
320
|
+
return row_to_dict(row)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def salvar(anuncios: list, record_id: int, pins: list | None = None) -> dict:
|
|
324
|
+
_ensure_columns()
|
|
325
|
+
with get_db() as conn:
|
|
326
|
+
conn.execute(
|
|
327
|
+
"UPDATE infoprodutos SET anuncios_json = ?, pins_json = ? WHERE id = ?",
|
|
328
|
+
(json.dumps((anuncios or [])[:12], ensure_ascii=False), json.dumps((pins or [])[:10], ensure_ascii=False), record_id),
|
|
329
|
+
)
|
|
330
|
+
produto = _criar_produto_para_consumo(record_id)
|
|
331
|
+
record = _get_infoproduto(record_id)
|
|
332
|
+
oferta = json.loads(record.get("oferta_json") or "{}")
|
|
333
|
+
return {**record, "oferta": oferta, "produto": produto}
|