@danhachuel/prodmaker 0.1.13 → 0.1.15
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/ads.py +3 -5
- package/app/api/builder.py +49 -3
- package/app/api/fraude.py +35 -0
- package/app/api/tiktok.py +16 -0
- package/app/main.py +21 -3
- package/app/services/builder.py +127 -11
- package/app/services/database.py +27 -9
- package/app/services/fraude.py +174 -0
- package/app/services/publish_ads.py +3 -2
- package/app/services/publish_tiktok.py +155 -0
- package/frontend/dist/assets/index-rK4wl-bk.js +78 -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 +10 -5
- package/seed/prompts/builder-assets.md +23 -0
- package/seed/prompts/builder-produto.md +19 -0
- package/frontend/dist/assets/index-dz1c2OPb.js +0 -75
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.15 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
- **Fase 11 — TikTok Ads real + Anti-Fraude AdsPixel (Redis).**
|
|
9
|
+
- **TikTok Ads (publicação automática):** `publish_tiktok.py` — troca do auth_code por
|
|
10
|
+
access_token/refresh_token (`POST /api/tiktok/token`) e criação de campanha + grupo
|
|
11
|
+
PAUSADOS via Marketing API v1.3 (objective CONVERSIONS, budget diário US$,
|
|
12
|
+
placement automático); passo final do criativo com instrução honesta quando não há
|
|
13
|
+
mídia. Erros da API atribuídos. Card TikTok em Chaves de API ganhou auth_code +
|
|
14
|
+
botão "Trocar auth_code por token". AdsCreator passa a publicar TikTok de verdade.
|
|
15
|
+
- **Anti-Fraude (AdsPixel):** `fraude.py` seguindo o padrão do redis-datasets/fraud-detection —
|
|
16
|
+
**IP blacklist (Redis Cuckoo Filter)** + **click spamming (Sorted Set com janela de 60s e
|
|
17
|
+
limite de 10 cliques)**; **fallback SQLite automático sem Redis**; página **Anti-Fraude**
|
|
18
|
+
(/tracking/fraude) com status do motor, bloqueio/desbloqueio manual, cópia da lista e
|
|
19
|
+
métricas de spam 24h. O `/track` agora avalia IP bloqueado antes de gravar e o pixel
|
|
20
|
+
nunca quebra a página do visitante. Card **Redis** em Chaves de API (REDIS_URL).
|
|
21
|
+
- OAuth Google Ads (item 1 da Fase 11) já estava entregue na 0.1.12.
|
|
22
|
+
|
|
23
|
+
## 0.1.14 — 2026-10-31
|
|
24
|
+
|
|
25
|
+
- **Fase 10 — REVISÃO (fechamento das lacunas apontadas na criação de produto/PDF).**
|
|
26
|
+
- **`POST /api/builder/generate-product`:** gera o **conteúdo completo** do produto em
|
|
27
|
+
Markdown conforme o agente low-ticket — 1. Capa (≤6 palavras); 2. Diagnóstico (5 motivos);
|
|
28
|
+
3. Solução (5–7 passos); 4. Exemplos (3 casos); 5. Checklist; 6. Bônus.
|
|
29
|
+
- **`POST /api/builder/generate-assets`:** 3 Order Bumps (checkout +R$21,90 · upsell +R$197 ·
|
|
30
|
+
parceiro 50/50) + **Quiz com IA** (5 perguntas) — rota própria conforme imagem 4.
|
|
31
|
+
- **`POST /api/builder/generate-landing`:** landing page de vendas com as **12 seções
|
|
32
|
+
validadas**, salva em `paginas` com preview em `/pages/{id}`.
|
|
33
|
+
- **Wizard com 4 passos:** (1) mineração + parceiro; (2) **seleção de formato** (E-book/
|
|
34
|
+
Planilha/Mini-curso/Desafio/Workshop) + estrutura + assets + preço R$27–97; (3) conteúdo
|
|
35
|
+
completo + **PDF** + landing page; (4) **12 anúncios + 10 pins** + **Publicar no GitHub +
|
|
36
|
+
Cloudflare** (reaproveita o motor da Fase 4).
|
|
37
|
+
- `generate-ads` agora devolve também os 10 pins; `generate-pdf` usa o conteúdo completo
|
|
38
|
+
quando disponível (antes renderia só o esqueleto).
|
|
39
|
+
- **Salvamento completo:** `salvar` grava anúncios + pins e **cria o registro em `produtos`**
|
|
40
|
+
(status aprovado, vinculado via `infoprodutos.produto_id`) — AdsPages e AdsCreator passam
|
|
41
|
+
a consumir o produto do builder automaticamente.
|
|
42
|
+
|
|
6
43
|
## 0.1.13 — 2026-10-31
|
|
7
44
|
|
|
8
45
|
- **Fase 10 — Product Builder Wizard (motor de criação de ofertas).**
|
package/app/api/ads.py
CHANGED
|
@@ -143,11 +143,9 @@ def campanhas_publicar(campanha_id: int):
|
|
|
143
143
|
elif plataforma == "meta":
|
|
144
144
|
resultado = publish_ads.publicar_meta(campanha)
|
|
145
145
|
else:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"observacao": "Plano manual — crie manualmente no TikTok Ads Manager.",
|
|
150
|
-
}
|
|
146
|
+
from ..services import publish_tiktok
|
|
147
|
+
|
|
148
|
+
resultado = publish_tiktok.publicar_campanha(campanha)
|
|
151
149
|
except LLMError as exc:
|
|
152
150
|
campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
|
|
153
151
|
raise HTTPException(status_code=400, detail=str(exc))
|
package/app/api/builder.py
CHANGED
|
@@ -17,6 +17,17 @@ class EstruturaBody(BaseModel):
|
|
|
17
17
|
oferta: dict
|
|
18
18
|
|
|
19
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
|
+
|
|
20
31
|
class AnunciosBody(BaseModel):
|
|
21
32
|
produto: str
|
|
22
33
|
promessa: str = ""
|
|
@@ -24,17 +35,25 @@ class AnunciosBody(BaseModel):
|
|
|
24
35
|
link: str = ""
|
|
25
36
|
|
|
26
37
|
|
|
38
|
+
class LandingBody(BaseModel):
|
|
39
|
+
oferta: dict
|
|
40
|
+
estrutura: dict = {}
|
|
41
|
+
produto_md: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
27
44
|
class PdfBody(BaseModel):
|
|
28
45
|
oferta: dict
|
|
29
|
-
estrutura: dict
|
|
46
|
+
estrutura: dict = {}
|
|
30
47
|
preco: float
|
|
31
48
|
parceiro: str = ""
|
|
32
49
|
modo: str = "rapido"
|
|
50
|
+
produto_md: str = ""
|
|
33
51
|
|
|
34
52
|
|
|
35
53
|
class SalvarBody(BaseModel):
|
|
36
54
|
record_id: int
|
|
37
|
-
anuncios: list
|
|
55
|
+
anuncios: list = []
|
|
56
|
+
pins: list = []
|
|
38
57
|
|
|
39
58
|
|
|
40
59
|
def _erro(exc: LLMError) -> HTTPException:
|
|
@@ -57,6 +76,33 @@ def generate_structure(body: EstruturaBody):
|
|
|
57
76
|
raise _erro(exc)
|
|
58
77
|
|
|
59
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
|
+
|
|
60
106
|
@router.post("/generate-ads")
|
|
61
107
|
def generate_ads(body: AnunciosBody):
|
|
62
108
|
try:
|
|
@@ -76,6 +122,6 @@ def generate_pdf(body: PdfBody):
|
|
|
76
122
|
@router.post("/salvar")
|
|
77
123
|
def salvar(body: SalvarBody):
|
|
78
124
|
try:
|
|
79
|
-
return builder.salvar(body.anuncios, body.record_id)
|
|
125
|
+
return builder.salvar(body.anuncios, body.record_id, body.pins)
|
|
80
126
|
except (LLMError, ValueError) as exc:
|
|
81
127
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Anti-Fraude (AdsPixel) — painel de IPs bloqueados, bloqueio/desbloqueio e resumo."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import fraude
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/fraude", tags=["anti-fraude"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BloquearBody(BaseModel):
|
|
12
|
+
ip: str
|
|
13
|
+
motivo: str = "manual"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/resumo")
|
|
17
|
+
def resumo():
|
|
18
|
+
return fraude.resumo()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/bloquear")
|
|
22
|
+
def bloquear(body: BloquearBody):
|
|
23
|
+
try:
|
|
24
|
+
return fraude.bloquear_ip(body.ip, body.motivo)
|
|
25
|
+
except LLMError as exc:
|
|
26
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.delete("/ips/{ip}")
|
|
30
|
+
def desbloquear(ip: str):
|
|
31
|
+
try:
|
|
32
|
+
fraude.desbloquear_ip(ip)
|
|
33
|
+
except LLMError as exc:
|
|
34
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
35
|
+
return {"ok": True}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""TikTok Ads — troca do auth_code por access_token (1 clique)."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
|
|
4
|
+
from ..services.llm import LLMError
|
|
5
|
+
from ..services.publish_tiktok import trocar_auth_code
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/tiktok", tags=["tiktok"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.post("/token")
|
|
11
|
+
def token():
|
|
12
|
+
"""Troca o auth_code salvo em Chaves de API por access_token + refresh_token."""
|
|
13
|
+
try:
|
|
14
|
+
return trocar_auth_code()
|
|
15
|
+
except LLMError as exc:
|
|
16
|
+
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.15"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -41,6 +41,8 @@ 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
43
|
from .api import builder as builder_router # noqa: E402
|
|
44
|
+
from .api import fraude as fraude_router # noqa: E402
|
|
45
|
+
from .api import tiktok as tiktok_router # noqa: E402
|
|
44
46
|
|
|
45
47
|
app.include_router(llm_router.router)
|
|
46
48
|
app.include_router(infoprodutos_router.router)
|
|
@@ -58,6 +60,8 @@ app.include_router(guias_router.router)
|
|
|
58
60
|
app.include_router(contas_router.router)
|
|
59
61
|
app.include_router(invest_router.router)
|
|
60
62
|
app.include_router(builder_router.router)
|
|
63
|
+
app.include_router(fraude_router.router)
|
|
64
|
+
app.include_router(tiktok_router.router)
|
|
61
65
|
|
|
62
66
|
ensure_db()
|
|
63
67
|
|
|
@@ -264,7 +268,7 @@ def health():
|
|
|
264
268
|
|
|
265
269
|
@app.get("/api/version")
|
|
266
270
|
def version():
|
|
267
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
271
|
+
return {"version": APP_VERSION, "canonical": "0.1.15"}
|
|
268
272
|
|
|
269
273
|
|
|
270
274
|
@app.get("/api/settings")
|
|
@@ -300,6 +304,20 @@ def track(request: Request):
|
|
|
300
304
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
301
305
|
ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
|
|
302
306
|
known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
|
|
307
|
+
|
|
308
|
+
# Anti-fraude (AdsPixel): IP bloqueado nao entra no tracking; spam e sinalizado
|
|
309
|
+
from .services import fraude
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
avaliacao = fraude.avaliar_visita(ip)
|
|
313
|
+
if avaliacao.get("bloqueado"):
|
|
314
|
+
# IP na blacklist: nao grava a visita (o pixel segue respondendo 204)
|
|
315
|
+
return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
|
|
316
|
+
if params.get("event") == "cta_click":
|
|
317
|
+
fraude.registrar_clique(ip, params.get("page", ""))
|
|
318
|
+
except Exception:
|
|
319
|
+
pass # anti-fraude nunca quebra o pixel
|
|
320
|
+
extra_params = json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False)
|
|
303
321
|
try:
|
|
304
322
|
with get_db() as conn:
|
|
305
323
|
conn.execute(
|
|
@@ -314,7 +332,7 @@ def track(request: Request):
|
|
|
314
332
|
params.get("ref", ""),
|
|
315
333
|
ip,
|
|
316
334
|
request.headers.get("user-agent", ""),
|
|
317
|
-
|
|
335
|
+
extra_params,
|
|
318
336
|
now_str(),
|
|
319
337
|
),
|
|
320
338
|
)
|
package/app/services/builder.py
CHANGED
|
@@ -94,6 +94,56 @@ def gerar_estrutura(oferta: dict) -> dict:
|
|
|
94
94
|
return {"estrutura": estrutura, "provider": result["provider"]}
|
|
95
95
|
|
|
96
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
|
+
|
|
97
147
|
def gerar_anuncios(produto: str, promessa: str, nicho: str = "", link: str = "") -> dict:
|
|
98
148
|
if not produto.strip():
|
|
99
149
|
raise LLMError("Informe o nome do produto para gerar os anúncios.")
|
|
@@ -101,15 +151,16 @@ def gerar_anuncios(produto: str, promessa: str, nicho: str = "", link: str = "")
|
|
|
101
151
|
_template("builder-anuncios.md", "Gere 12 anuncios para {PRODUTO} em JSON com chave 'anuncios'."),
|
|
102
152
|
{"PRODUTO": produto, "PROMESSA": promessa, "NICHO": nicho, "LINK": link},
|
|
103
153
|
)
|
|
104
|
-
result = generate(prompt, system="Você é um copywriter de direct response. Responda SOMENTE JSON válido.", max_tokens=
|
|
154
|
+
result = generate(prompt, system="Você é um copywriter de direct response e especialista em Pinterest. Responda SOMENTE JSON válido.", max_tokens=6000)
|
|
105
155
|
try:
|
|
106
156
|
parsed = _parse_json(result["text"])
|
|
107
157
|
except json.JSONDecodeError as exc:
|
|
108
158
|
raise LLMError(f"JSON do LLM inválido: {exc}") from exc
|
|
109
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)]
|
|
110
161
|
if len(anuncios) < 12:
|
|
111
162
|
raise LLMError(f"O LLM retornou {len(anuncios)} anúncios (esperado 12) — tente novamente.")
|
|
112
|
-
return {"anuncios": anuncios[:12], "provider": result["provider"]}
|
|
163
|
+
return {"anuncios": anuncios[:12], "pins": pins[:10], "provider": result["provider"]}
|
|
113
164
|
|
|
114
165
|
|
|
115
166
|
def _imagem_prompts(oferta: dict, estrutura: dict) -> list[str]:
|
|
@@ -154,14 +205,48 @@ def _ensure_columns() -> None:
|
|
|
154
205
|
add_column_if_missing(conn, "infoprodutos", "oferta_json TEXT NOT NULL DEFAULT ''", "oferta_json")
|
|
155
206
|
add_column_if_missing(conn, "infoprodutos", "estrutura_json TEXT NOT NULL DEFAULT ''", "estrutura_json")
|
|
156
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")
|
|
157
209
|
add_column_if_missing(conn, "infoprodutos", "preco REAL NOT NULL DEFAULT 0", "preco")
|
|
158
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
|
|
159
244
|
|
|
160
245
|
|
|
161
|
-
def gerar_pdf(oferta: dict, estrutura: dict, preco: float, parceiro: str = "", modo: str = "rapido") -> dict:
|
|
162
|
-
"""Gera o PDF do produto + prompts de imagem
|
|
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."""
|
|
163
248
|
_ensure_columns()
|
|
164
|
-
md_text = _markdown_produto(oferta, estrutura, preco, parceiro)
|
|
249
|
+
md_text = (produto_md or "").strip() or _markdown_produto(oferta, estrutura, preco, parceiro)
|
|
165
250
|
prompt_imagens = _imagem_prompts(oferta, estrutura)
|
|
166
251
|
timestamp = now_str()
|
|
167
252
|
with get_db() as conn:
|
|
@@ -204,14 +289,45 @@ def gerar_pdf(oferta: dict, estrutura: dict, preco: float, parceiro: str = "", m
|
|
|
204
289
|
return record
|
|
205
290
|
|
|
206
291
|
|
|
207
|
-
def
|
|
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:
|
|
208
324
|
_ensure_columns()
|
|
209
325
|
with get_db() as conn:
|
|
210
326
|
conn.execute(
|
|
211
|
-
"UPDATE infoprodutos SET anuncios_json = ? WHERE id = ?",
|
|
212
|
-
(json.dumps(anuncios[:12], ensure_ascii=False), record_id),
|
|
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),
|
|
213
329
|
)
|
|
214
|
-
|
|
215
|
-
record =
|
|
330
|
+
produto = _criar_produto_para_consumo(record_id)
|
|
331
|
+
record = _get_infoproduto(record_id)
|
|
216
332
|
oferta = json.loads(record.get("oferta_json") or "{}")
|
|
217
|
-
return {**record, "oferta": oferta}
|
|
333
|
+
return {**record, "oferta": oferta, "produto": produto}
|
package/app/services/database.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
"""Banco de dados —
|
|
1
|
+
"""Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
|
|
2
2
|
|
|
3
|
-
Esquema
|
|
4
|
-
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de
|
|
3
|
+
Esquema unico para os dois engines. Todas as tabelas carregam `user_id` (preparacao
|
|
4
|
+
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migracao.
|
|
5
5
|
"""
|
|
6
6
|
from .dbengine import (
|
|
7
7
|
add_column_if_missing,
|
|
8
8
|
is_postgres,
|
|
9
|
-
now_str, # noqa: F401 (reexportado para os
|
|
10
|
-
row_to_dict, # noqa: F401 (reexportado para os
|
|
9
|
+
now_str, # noqa: F401 (reexportado para os servicos)
|
|
10
|
+
row_to_dict, # noqa: F401 (reexportado para os servicos)
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
# ---------------------------------------------------------------------------
|
|
14
|
-
# Esquema — SQLite DDL; para PostgreSQL as mesmas
|
|
14
|
+
# Esquema — SQLite DDL; para PostgreSQL as mesmas definicoes sao traduzidas.
|
|
15
15
|
# ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
TABLES = {
|
|
@@ -201,6 +201,26 @@ TABLES = {
|
|
|
201
201
|
created_at TEXT NOT NULL
|
|
202
202
|
)
|
|
203
203
|
""",
|
|
204
|
+
"ips_bloqueados": """
|
|
205
|
+
CREATE TABLE IF NOT EXISTS ips_bloqueados (
|
|
206
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
207
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
208
|
+
ip TEXT NOT NULL,
|
|
209
|
+
motivo TEXT NOT NULL DEFAULT 'manual',
|
|
210
|
+
origem TEXT NOT NULL DEFAULT 'sqlite',
|
|
211
|
+
created_at TEXT NOT NULL
|
|
212
|
+
)
|
|
213
|
+
""",
|
|
214
|
+
"fraude_eventos": """
|
|
215
|
+
CREATE TABLE IF NOT EXISTS fraude_eventos (
|
|
216
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
217
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
218
|
+
ip TEXT NOT NULL,
|
|
219
|
+
page TEXT NOT NULL DEFAULT '',
|
|
220
|
+
created_epoch REAL NOT NULL DEFAULT 0,
|
|
221
|
+
created_at TEXT NOT NULL
|
|
222
|
+
)
|
|
223
|
+
""",
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
ALL_TABLES = list(TABLES.keys())
|
|
@@ -225,11 +245,9 @@ def ensure_db() -> None:
|
|
|
225
245
|
else:
|
|
226
246
|
for ddl in TABLES.values():
|
|
227
247
|
conn.execute(ddl)
|
|
228
|
-
#
|
|
248
|
+
# Migracao: bancos criados antes da coluna user_id / da tabela investimentos
|
|
229
249
|
for table in ALL_TABLES:
|
|
230
250
|
add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
|
|
231
|
-
if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
|
|
232
|
-
conn.execute(TABLES["investimentos"])
|
|
233
251
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
|
|
234
252
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
|
|
235
253
|
|