@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 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
- 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
- }
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))
@@ -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.13"
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.13"}
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
- json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False),
335
+ extra_params,
318
336
  now_str(),
319
337
  ),
320
338
  )
@@ -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=5000)
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 (Gamma/Canva). Salva em infoprodutos."""
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 salvar(anuncios: list, record_id: int) -> dict:
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
- row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
215
- record = row_to_dict(row)
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}
@@ -1,17 +1,17 @@
1
- """Banco de dados — façade sobre dbengine (SQLite padrão, PostgreSQL/Supabase via DATABASE_URL).
1
+ """Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
2
2
 
3
- Esquema único para os dois engines. Todas as tabelas carregam `user_id` (preparação
4
- multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migração.
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 serviços)
10
- row_to_dict, # noqa: F401 (reexportado para os serviç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 definições são traduzidas.
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
- # Migração: bancos criados antes da coluna user_id / da tabela investimentos
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