@danhachuel/prodmaker 0.1.2 → 0.1.4
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 +30 -0
- package/THIRD-PARTY-NOTICES.md +8 -0
- package/app/api/__init__.py +1 -0
- package/app/api/infoprodutos.py +141 -0
- package/app/api/llm.py +52 -0
- package/app/main.py +50 -87
- package/app/services/__init__.py +1 -0
- package/app/services/database.py +61 -0
- package/app/services/gamma.py +64 -0
- package/app/services/llm.py +183 -0
- package/app/services/pdf.py +37 -0
- package/app/services/store.py +60 -0
- package/frontend/dist/assets/index-BMQemvVJ.js +67 -0
- package/frontend/dist/assets/index-DZBtvZhA.css +1 -0
- package/frontend/dist/favicon.svg +3 -0
- package/frontend/dist/index.html +3 -2
- package/legal/index.html +1 -0
- package/legal/privacy.html +1 -0
- package/legal/terms.html +1 -0
- package/package.json +1 -1
- package/requirements.txt +7 -1
- package/scripts/cli.mjs +1 -1
- package/seed/prompts/analise-produto.md +22 -0
- package/frontend/dist/assets/index-COSPz2F9.css +0 -1
- package/frontend/dist/assets/index-CoHrpz8h.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
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.04 — 2026-10-25
|
|
7
|
+
|
|
8
|
+
- Favicon 🚀 (SVG) nas guias do navegador: app (SPA), login e páginas `/legal`.
|
|
9
|
+
|
|
10
|
+
## 0.1.03 — 2026-10-25
|
|
11
|
+
|
|
12
|
+
- **Parte 3 — Motor LLM + Chaves de API + Análise de Produto por IA + Studio de PDF.**
|
|
13
|
+
- **Motor LLM (`app/services/llm.py`):** pool NVIDIA NIM (compatível OpenAI) + Google Gemini
|
|
14
|
+
com prioridade (1 = primeiro) e failover apenas em erros transitórios (quota, timeout,
|
|
15
|
+
transporte, 429/5xx); erro de credencial/payload registrado com attribution e nunca
|
|
16
|
+
mascarado. Importações preguiçosas — o servidor sobe mesmo sem deps de LLM instaladas.
|
|
17
|
+
- **Chaves de API funcional:** página com cards (AdsX api-grid) para NVIDIA, Gemini, Gamma,
|
|
18
|
+
GitHub, Cloudflare, Telegram, Pinterest, Google Ads, Meta, TikTok e Canva (em breve);
|
|
19
|
+
modais por card; LLMs com modelo/prioridade/ativo + **Testar chamada** (read-only, mostra
|
|
20
|
+
latência e modelo); demais integrações com campos por serviço salvos no storage local.
|
|
21
|
+
- **Análise de produto por IA:** ao **✓ Aprovar**, se houver LLM ativo o produto recebe o
|
|
22
|
+
plano de ataque (resumo, ângulos de copy, palavras-chave, negativas, brief da presell,
|
|
23
|
+
público) via `seed/prompts/analise-produto.md`; modal 🧠 na listagem e regeneração via 🔬.
|
|
24
|
+
Sem LLM configurado a aprovação funciona normalmente (sem travas).
|
|
25
|
+
- **Studio (Criar Infoproduto):** formulário completo (modo, formato, título, nicho, dor,
|
|
26
|
+
avatar, resultado, prompt) → Modo Rápido (LLM → Markdown → HTML A4 → PDF via xhtml2pdf,
|
|
27
|
+
sem dependências de sistema) e Modo Premium (Gamma AI: `POST /v1.0/generations` com
|
|
28
|
+
`exportAs: "pdf"`, polling e download); resultado com download direto do PDF.
|
|
29
|
+
- **Biblioteca de Infoprodutos:** tabela (criado, título, formato, modo) com download de PDF,
|
|
30
|
+
visualização do Markdown e exclusão.
|
|
31
|
+
- Backend reorganizado em módulos: `services/store.py` (settings), `services/database.py`
|
|
32
|
+
(SQLite + migração: coluna `analysis` em produtos + tabela `infoprodutos`),
|
|
33
|
+
`services/llm.py`, `services/pdf.py`, `services/gamma.py`, `api/llm.py`, `api/infoprodutos.py`.
|
|
34
|
+
- requirements.txt: + openai, google-genai, markdown, xhtml2pdf.
|
|
35
|
+
|
|
6
36
|
## 0.1.02 — 2026-10-25
|
|
7
37
|
|
|
8
38
|
- Launcher: caixa da URL em ASCII puro (evita mojibake de caracteres Unicode no CMD/Windows).
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -10,6 +10,14 @@ Esta lista cresce a cada fase conforme novas integrações são ativadas.
|
|
|
10
10
|
- **Pydantic** — MIT License — https://github.com/pydantic/pydantic
|
|
11
11
|
- **Node.js** — MIT License — https://nodejs.org
|
|
12
12
|
|
|
13
|
+
## Runtime (Parte 3)
|
|
14
|
+
|
|
15
|
+
- **openai (SDK Python)** — Apache-2.0 — https://github.com/openai/openai-python (usado no endpoint compatível com OpenAI da NVIDIA NIM)
|
|
16
|
+
- **google-genai (SDK oficial Google Gemini)** — Apache-2.0 — https://github.com/googleapis/python-genai
|
|
17
|
+
- **Markdown** — BSD-3-Clause — https://github.com/Python-Markdown/markdown
|
|
18
|
+
- **xhtml2pdf** (inclui ReportLab) — MIT/Apache-2.0 — https://github.com/xhtml2pdf/xhtml2pdf
|
|
19
|
+
- **httpx** (transitivo do openai; usado pelo cliente Gamma) — BSD-3-Clause — https://github.com/encode/httpx
|
|
20
|
+
|
|
13
21
|
## Integrações planejadas (fases futuras)
|
|
14
22
|
|
|
15
23
|
- **google-ads-mcp-rw** — Google Ads MCP (write) — https://pypi.org/project/google-ads-mcp-rw
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Torna `app.api` um pacote Python.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from fastapi.responses import FileResponse
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..services.database import get_db, now_str, row_to_dict
|
|
8
|
+
from ..services.llm import LLMError, generate
|
|
9
|
+
from ..services.pdf import markdown_para_pdf
|
|
10
|
+
from ..services.store import STORAGE
|
|
11
|
+
from ..services import gamma as gamma_service
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/infoprodutos", tags=["infoprodutos"])
|
|
14
|
+
|
|
15
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InfoprodutoBody(BaseModel):
|
|
19
|
+
titulo: str
|
|
20
|
+
prompt: str
|
|
21
|
+
formato: str = "E-book"
|
|
22
|
+
nicho: str = ""
|
|
23
|
+
dor: str = ""
|
|
24
|
+
avatar: str = ""
|
|
25
|
+
resultado: str = ""
|
|
26
|
+
modo: str = "rapido" # rapido (LLM+PDF local) | gamma (API premium)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_prompt(body: InfoprodutoBody) -> str:
|
|
30
|
+
template_path = SEED_DIR / "prompts" / "infoproduto.md"
|
|
31
|
+
template = template_path.read_text(encoding="utf-8") if template_path.exists() else ""
|
|
32
|
+
return (
|
|
33
|
+
template.replace("{FORMATO}", body.formato)
|
|
34
|
+
.replace("{NICHO}", body.nicho)
|
|
35
|
+
.replace("{DOR}", body.dor)
|
|
36
|
+
.replace("{AVATAR}", body.avatar)
|
|
37
|
+
.replace("{RESULTADO}", body.resultado)
|
|
38
|
+
+ f"\n\n**Título desejado:** {body.titulo}\n**Instrução adicional do usuário:** {body.prompt}\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("")
|
|
43
|
+
def create_infoproduto(body: InfoprodutoBody):
|
|
44
|
+
if not body.titulo.strip():
|
|
45
|
+
raise HTTPException(status_code=400, detail="Informe o título do infoproduto.")
|
|
46
|
+
timestamp = now_str()
|
|
47
|
+
with get_db() as conn:
|
|
48
|
+
cursor = conn.execute(
|
|
49
|
+
"INSERT INTO infoprodutos (titulo, formato, nicho, modo, provider, created_at) VALUES (?, ?, ?, ?, '', ?)",
|
|
50
|
+
(body.titulo.strip(), body.formato, body.nicho.strip(), body.modo, timestamp),
|
|
51
|
+
)
|
|
52
|
+
record_id = cursor.lastrowid
|
|
53
|
+
|
|
54
|
+
folder = STORAGE / "produtos" / str(record_id)
|
|
55
|
+
try:
|
|
56
|
+
if body.modo == "gamma":
|
|
57
|
+
from ..services.store import get_settings
|
|
58
|
+
|
|
59
|
+
gamma_keys = (get_settings().get("api_keys") or {}).get("gamma") or {}
|
|
60
|
+
api_key = (gamma_keys.get("api_key") or "").strip()
|
|
61
|
+
base_url = (gamma_keys.get("base_url") or "").strip()
|
|
62
|
+
if not api_key:
|
|
63
|
+
raise LLMError("Modo Premium (Gamma) requer a API key da Gamma — configure em Chaves de API.")
|
|
64
|
+
pdf_bytes = gamma_service.gerar_pdf_gamma(api_key, base_url, body.titulo.strip(), _build_prompt(body))
|
|
65
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
pdf_path = folder / "infoproduto.pdf"
|
|
67
|
+
pdf_path.write_bytes(pdf_bytes)
|
|
68
|
+
md_path = None
|
|
69
|
+
provider = "gamma"
|
|
70
|
+
else:
|
|
71
|
+
result = generate(
|
|
72
|
+
_build_prompt(body),
|
|
73
|
+
system="Você é um especialista em criação de infoprodutos low-ticket. Gere conteúdo prático e direto em Markdown.",
|
|
74
|
+
max_tokens=8000,
|
|
75
|
+
)
|
|
76
|
+
md_text = result["text"]
|
|
77
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
md_path = folder / "infoproduto.md"
|
|
79
|
+
md_path.write_text(md_text, encoding="utf-8")
|
|
80
|
+
pdf_path = markdown_para_pdf(md_text, folder / "infoproduto.pdf")
|
|
81
|
+
provider = result["provider"]
|
|
82
|
+
|
|
83
|
+
rel_pdf = pdf_path.relative_to(STORAGE).as_posix()
|
|
84
|
+
rel_md = md_path.relative_to(STORAGE).as_posix() if md_path else ""
|
|
85
|
+
with get_db() as conn:
|
|
86
|
+
conn.execute(
|
|
87
|
+
"UPDATE infoprodutos SET provider = ?, pdf_path = ?, md_path = ? WHERE id = ?",
|
|
88
|
+
(provider, rel_pdf, rel_md, record_id),
|
|
89
|
+
)
|
|
90
|
+
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
|
|
91
|
+
return row_to_dict(row)
|
|
92
|
+
except (LLMError, gamma_service.GammaError, RuntimeError) as exc:
|
|
93
|
+
with get_db() as conn:
|
|
94
|
+
conn.execute("DELETE FROM infoprodutos WHERE id = ?", (record_id,))
|
|
95
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("")
|
|
99
|
+
def list_infoprodutos():
|
|
100
|
+
with get_db() as conn:
|
|
101
|
+
rows = conn.execute("SELECT * FROM infoprodutos ORDER BY id DESC").fetchall()
|
|
102
|
+
return [row_to_dict(row) for row in rows]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.get("/{infoproduto_id}/download")
|
|
106
|
+
def download_infoproduto(infoproduto_id: int):
|
|
107
|
+
row = _get_or_404(infoproduto_id)
|
|
108
|
+
pdf_path = STORAGE / row["pdf_path"]
|
|
109
|
+
if not row["pdf_path"] or not pdf_path.exists():
|
|
110
|
+
raise HTTPException(status_code=404, detail="PDF não encontrado.")
|
|
111
|
+
return FileResponse(pdf_path, filename=f"{row['titulo']}.pdf", media_type="application/pdf")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/{infoproduto_id}/markdown")
|
|
115
|
+
def infoproduto_markdown(infoproduto_id: int):
|
|
116
|
+
row = _get_or_404(infoproduto_id)
|
|
117
|
+
md_path = STORAGE / row["md_path"] if row["md_path"] else None
|
|
118
|
+
if not md_path or not md_path.exists():
|
|
119
|
+
raise HTTPException(status_code=404, detail="Markdown não disponível para este infoproduto.")
|
|
120
|
+
return {"markdown": md_path.read_text(encoding="utf-8")}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.delete("/{infoproduto_id}")
|
|
124
|
+
def delete_infoproduto(infoproduto_id: int):
|
|
125
|
+
row = _get_or_404(infoproduto_id)
|
|
126
|
+
for rel in (row["pdf_path"], row["md_path"]):
|
|
127
|
+
if rel:
|
|
128
|
+
path = STORAGE / rel
|
|
129
|
+
if path.exists():
|
|
130
|
+
path.unlink(missing_ok=True)
|
|
131
|
+
with get_db() as conn:
|
|
132
|
+
conn.execute("DELETE FROM infoprodutos WHERE id = ?", (infoproduto_id,))
|
|
133
|
+
return {"ok": True}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_or_404(infoproduto_id: int):
|
|
137
|
+
with get_db() as conn:
|
|
138
|
+
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (infoproduto_id,)).fetchone()
|
|
139
|
+
if not row:
|
|
140
|
+
raise HTTPException(status_code=404, detail="Infoproduto não encontrado.")
|
|
141
|
+
return row
|
package/app/api/llm.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services.llm import LLMError, generate, list_providers, test_provider
|
|
5
|
+
from ..services.store import get_settings, save_settings
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/llm", tags=["llm"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestBody(BaseModel):
|
|
11
|
+
id: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GenerateBody(BaseModel):
|
|
15
|
+
prompt: str
|
|
16
|
+
system: str | None = None
|
|
17
|
+
max_tokens: int = 4000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/providers")
|
|
21
|
+
def providers():
|
|
22
|
+
"""Provedores LLM salvos (cartões de Chaves de API)."""
|
|
23
|
+
return list_providers(active_only=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.put("/providers")
|
|
27
|
+
def save_providers(payload: dict):
|
|
28
|
+
"""Salva a lista de cartões de provedor LLM (substitui settings.llm.providers)."""
|
|
29
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("providers"), list):
|
|
30
|
+
raise HTTPException(status_code=400, detail="Envie { 'providers': [ ... ] }.")
|
|
31
|
+
settings = get_settings()
|
|
32
|
+
settings["llm"] = {"providers": payload["providers"]}
|
|
33
|
+
save_settings(settings)
|
|
34
|
+
return {"ok": True, "providers": settings["llm"]["providers"]}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/test")
|
|
38
|
+
def test(body: TestBody):
|
|
39
|
+
"""Teste de chamada read-only — valida credencial/modelo no momento da ação."""
|
|
40
|
+
try:
|
|
41
|
+
return test_provider(body.id)
|
|
42
|
+
except LLMError as exc:
|
|
43
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/generate")
|
|
47
|
+
def generate_text(body: GenerateBody):
|
|
48
|
+
"""Geração pelo pool com failover — usada por Produtos, Studio, AdsPages etc."""
|
|
49
|
+
try:
|
|
50
|
+
return generate(body.prompt, system=body.system, max_tokens=body.max_tokens)
|
|
51
|
+
except LLMError as exc:
|
|
52
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
package/app/main.py
CHANGED
|
@@ -3,7 +3,6 @@ import hashlib
|
|
|
3
3
|
import hmac
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
-
import sqlite3
|
|
7
6
|
import time
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
@@ -12,50 +11,30 @@ from fastapi.responses import FileResponse, HTMLResponse
|
|
|
12
11
|
from fastapi.staticfiles import StaticFiles
|
|
13
12
|
from pydantic import BaseModel
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
from .services.database import ensure_db, get_db, now_str, row_to_dict
|
|
15
|
+
from .services.llm import LLMError, analyze_product, list_providers
|
|
16
|
+
from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_settings, save_settings
|
|
17
|
+
|
|
18
|
+
APP_VERSION = "0.1.04"
|
|
16
19
|
COOKIE_NAME = "prodmaker_session"
|
|
17
20
|
SESSION_DAYS = 30
|
|
18
21
|
|
|
19
|
-
PRODMAKER_HOME = Path(os.environ.get("PRODMAKER_HOME", str(Path.home() / "ProdMaker")))
|
|
20
|
-
STORAGE = PRODMAKER_HOME / "storage"
|
|
21
|
-
STATE = STORAGE / "state"
|
|
22
22
|
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
23
23
|
LEGAL_DIR = Path(__file__).resolve().parent.parent / "legal"
|
|
24
24
|
|
|
25
25
|
app = FastAPI(title="ProdMaker", version=APP_VERSION)
|
|
26
26
|
|
|
27
|
+
from .api import infoprodutos as infoprodutos_router # noqa: E402
|
|
28
|
+
from .api import llm as llm_router # noqa: E402
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def ensure_state_dir() -> None:
|
|
34
|
-
STATE.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def get_settings() -> dict:
|
|
38
|
-
settings_path = STATE / "settings.json"
|
|
39
|
-
if not settings_path.exists():
|
|
40
|
-
return {"schema": 1, "port": 8188, "language": "pt-BR", "theme": "light", "api_keys": {}, "llm": {"providers": []}}
|
|
41
|
-
try:
|
|
42
|
-
return json.loads(settings_path.read_text(encoding="utf-8"))
|
|
43
|
-
except json.JSONDecodeError:
|
|
44
|
-
raise HTTPException(status_code=500, detail="settings.json corrompido — leitura protegida. Restaure o arquivo manualmente.")
|
|
30
|
+
app.include_router(llm_router.router)
|
|
31
|
+
app.include_router(infoprodutos_router.router)
|
|
45
32
|
|
|
33
|
+
ensure_db()
|
|
46
34
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if settings_path.exists():
|
|
51
|
-
try:
|
|
52
|
-
json.loads(settings_path.read_text(encoding="utf-8"))
|
|
53
|
-
except json.JSONDecodeError:
|
|
54
|
-
backup = STATE / "settings.corrupt.json"
|
|
55
|
-
backup.write_text(settings_path.read_text(encoding="utf-8", errors="replace"), encoding="utf-8")
|
|
56
|
-
raise HTTPException(status_code=500, detail="settings.json corrompido — cópia preservada em settings.corrupt.json.")
|
|
57
|
-
settings_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
58
|
-
return data
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Auth local (v1: validação simulada; estrutura pronta para validação remota)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
59
38
|
|
|
60
39
|
|
|
61
40
|
def get_secret() -> bytes:
|
|
@@ -66,54 +45,6 @@ def get_secret() -> bytes:
|
|
|
66
45
|
return secret_path.read_bytes()
|
|
67
46
|
|
|
68
47
|
|
|
69
|
-
# ---------------------------------------------------------------------------
|
|
70
|
-
# Banco de dados local (SQLite via stdlib)
|
|
71
|
-
# ---------------------------------------------------------------------------
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def get_db():
|
|
75
|
-
ensure_state_dir()
|
|
76
|
-
conn = sqlite3.connect(STATE / "prodmaker.db")
|
|
77
|
-
conn.row_factory = sqlite3.Row
|
|
78
|
-
return conn
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def ensure_db() -> None:
|
|
82
|
-
with get_db() as conn:
|
|
83
|
-
conn.execute(
|
|
84
|
-
"""
|
|
85
|
-
CREATE TABLE IF NOT EXISTS produtos (
|
|
86
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
-
nome TEXT NOT NULL,
|
|
88
|
-
plataforma TEXT NOT NULL DEFAULT 'Outro',
|
|
89
|
-
link_afiliado TEXT NOT NULL DEFAULT '',
|
|
90
|
-
comissao REAL NOT NULL DEFAULT 0,
|
|
91
|
-
preco REAL NOT NULL DEFAULT 0,
|
|
92
|
-
nicho TEXT NOT NULL DEFAULT '',
|
|
93
|
-
status TEXT NOT NULL DEFAULT 'em_analise',
|
|
94
|
-
created_at TEXT NOT NULL,
|
|
95
|
-
updated_at TEXT NOT NULL
|
|
96
|
-
)
|
|
97
|
-
"""
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
ensure_db()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def now_str() -> str:
|
|
105
|
-
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def row_to_dict(row) -> dict:
|
|
109
|
-
return {key: row[key] for key in row.keys()}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# ---------------------------------------------------------------------------
|
|
113
|
-
# Auth local (v1: validação simulada; estrutura pronta para validação remota)
|
|
114
|
-
# ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
|
|
117
48
|
def make_token(email: str) -> str:
|
|
118
49
|
payload = base64.urlsafe_b64encode(json.dumps({"email": email, "exp": int(time.time()) + SESSION_DAYS * 86400}).encode()).decode()
|
|
119
50
|
signature = hmac.new(get_secret(), payload.encode(), hashlib.sha256).hexdigest()
|
|
@@ -180,7 +111,7 @@ def auth_session(request: Request):
|
|
|
180
111
|
|
|
181
112
|
|
|
182
113
|
# ---------------------------------------------------------------------------
|
|
183
|
-
# Produtos (Seus Produtos — em análise / aprovados)
|
|
114
|
+
# Produtos (Seus Produtos — em análise / aprovados, com análise por IA)
|
|
184
115
|
# ---------------------------------------------------------------------------
|
|
185
116
|
|
|
186
117
|
|
|
@@ -241,12 +172,45 @@ def delete_produto(produto_id: int):
|
|
|
241
172
|
|
|
242
173
|
@app.post("/api/produtos/{produto_id}/aprovar")
|
|
243
174
|
def aprovar_produto(produto_id: int):
|
|
175
|
+
"""Aprova o produto e, se houver LLM ativo, gera a análise (plano de ataque).
|
|
176
|
+
|
|
177
|
+
Sem LLM configurado a aprovação acontece normalmente (sem travas de pré-registo);
|
|
178
|
+
a análise fica pendente e pode ser gerada depois via /api/produtos/{id}/analisar.
|
|
179
|
+
"""
|
|
244
180
|
with get_db() as conn:
|
|
245
181
|
cursor = conn.execute("UPDATE produtos SET status = 'aprovado', updated_at = ? WHERE id = ?", (now_str(), produto_id))
|
|
246
182
|
if cursor.rowcount == 0:
|
|
247
183
|
raise HTTPException(status_code=404, detail="Produto não encontrado.")
|
|
248
184
|
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
249
|
-
|
|
185
|
+
produto = row_to_dict(row)
|
|
186
|
+
analysis = None
|
|
187
|
+
if list_providers():
|
|
188
|
+
try:
|
|
189
|
+
analysis = analyze_product(produto)
|
|
190
|
+
with get_db() as conn:
|
|
191
|
+
conn.execute("UPDATE produtos SET analysis = ? WHERE id = ?", (json.dumps(analysis, ensure_ascii=False), produto_id))
|
|
192
|
+
produto["analysis"] = analysis
|
|
193
|
+
except LLMError as exc:
|
|
194
|
+
produto["analysis_error"] = str(exc)
|
|
195
|
+
return produto
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.post("/api/produtos/{produto_id}/analisar")
|
|
199
|
+
def analisar_produto(produto_id: int):
|
|
200
|
+
"""Gera (ou regenera) a análise de IA de um produto aprovado."""
|
|
201
|
+
with get_db() as conn:
|
|
202
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
203
|
+
if not row:
|
|
204
|
+
raise HTTPException(status_code=404, detail="Produto não encontrado.")
|
|
205
|
+
produto = row_to_dict(row)
|
|
206
|
+
try:
|
|
207
|
+
analysis = analyze_product(produto)
|
|
208
|
+
except LLMError as exc:
|
|
209
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
210
|
+
with get_db() as conn:
|
|
211
|
+
conn.execute("UPDATE produtos SET analysis = ?, updated_at = ? WHERE id = ?", (json.dumps(analysis, ensure_ascii=False), now_str(), produto_id))
|
|
212
|
+
produto["analysis"] = analysis
|
|
213
|
+
return produto
|
|
250
214
|
|
|
251
215
|
|
|
252
216
|
# ---------------------------------------------------------------------------
|
|
@@ -261,7 +225,7 @@ def health():
|
|
|
261
225
|
|
|
262
226
|
@app.get("/api/version")
|
|
263
227
|
def version():
|
|
264
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
228
|
+
return {"version": APP_VERSION, "canonical": "0.1.4"}
|
|
265
229
|
|
|
266
230
|
|
|
267
231
|
@app.get("/api/settings")
|
|
@@ -304,8 +268,7 @@ def _spa_index():
|
|
|
304
268
|
.box{max-width:520px;padding:40px;text-align:center}h1{color:#7C3AED}.muted{color:#64748b}</style></head>
|
|
305
269
|
<body><div class="box"><h1>🚀 ProdMaker</h1>
|
|
306
270
|
<p>O backend está rodando.</p>
|
|
307
|
-
<p class="muted">
|
|
308
|
-
<p class="muted">API: <code>/api/health</code> · Legal: <code>/legal/</code></p></div></body></html>"""
|
|
271
|
+
<p class="muted">Frontend build pendente — rode <code>npm run build</code> em frontend/.</p></div></body></html>"""
|
|
309
272
|
)
|
|
310
273
|
|
|
311
274
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Torna `app.services` um pacote Python.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Banco SQLite local (stdlib) — helpers compartilhados."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .store import STATE
|
|
7
|
+
|
|
8
|
+
DB_PATH = STATE / "prodmaker.db"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_db():
|
|
12
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
conn = sqlite3.connect(DB_PATH)
|
|
14
|
+
conn.row_factory = sqlite3.Row
|
|
15
|
+
return conn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ensure_db() -> None:
|
|
19
|
+
with get_db() as conn:
|
|
20
|
+
conn.execute(
|
|
21
|
+
"""
|
|
22
|
+
CREATE TABLE IF NOT EXISTS produtos (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
nome TEXT NOT NULL,
|
|
25
|
+
plataforma TEXT NOT NULL DEFAULT 'Outro',
|
|
26
|
+
link_afiliado TEXT NOT NULL DEFAULT '',
|
|
27
|
+
comissao REAL NOT NULL DEFAULT 0,
|
|
28
|
+
preco REAL NOT NULL DEFAULT 0,
|
|
29
|
+
nicho TEXT NOT NULL DEFAULT '',
|
|
30
|
+
status TEXT NOT NULL DEFAULT 'em_analise',
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
updated_at TEXT NOT NULL
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
)
|
|
36
|
+
conn.execute(
|
|
37
|
+
"""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS infoprodutos (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
titulo TEXT NOT NULL,
|
|
41
|
+
formato TEXT NOT NULL DEFAULT 'E-book',
|
|
42
|
+
nicho TEXT NOT NULL DEFAULT '',
|
|
43
|
+
modo TEXT NOT NULL DEFAULT 'rapido',
|
|
44
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
45
|
+
pdf_path TEXT NOT NULL DEFAULT '',
|
|
46
|
+
md_path TEXT NOT NULL DEFAULT '',
|
|
47
|
+
created_at TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
|
|
52
|
+
if "analysis" not in columns:
|
|
53
|
+
conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def now_str() -> str:
|
|
57
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def row_to_dict(row) -> dict:
|
|
61
|
+
return {key: row[key] for key in row.keys()}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Cliente Gamma AI (modo Premium) — melhor esforço conforme contrato do prompt mestre:
|
|
2
|
+
POST {base}/v1.0/generations (exportAs: "pdf") -> polling -> download do PDF.
|
|
3
|
+
Base URL configurável em Chaves de API (padrão https://api.gamma.app).
|
|
4
|
+
"""
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GammaError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _headers(api_key: str) -> dict:
|
|
15
|
+
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def gerar_pdf_gamma(api_key: str, base_url: str, titulo: str, prompt: str, total_timeout: float = 600.0) -> bytes:
|
|
19
|
+
base = (base_url or "https://api.app.gamma").rstrip("/")
|
|
20
|
+
started = time.time()
|
|
21
|
+
with httpx.Client(timeout=60.0) as client:
|
|
22
|
+
try:
|
|
23
|
+
response = client.post(
|
|
24
|
+
f"{base}/v1.0/generations",
|
|
25
|
+
headers=_headers(api_key),
|
|
26
|
+
json={"title": titulo, "prompt": prompt, "exportAs": "pdf"},
|
|
27
|
+
)
|
|
28
|
+
except httpx.HTTPError as exc:
|
|
29
|
+
raise GammaError(f"Falha de conexão com a Gamma API: {exc}") from exc
|
|
30
|
+
if response.status_code in (401, 403):
|
|
31
|
+
raise GammaError("Gamma API rejeitou a chave (401/403). Confira a API key em Chaves de API.")
|
|
32
|
+
if response.status_code != 200:
|
|
33
|
+
raise GammaError(f"Gamma API respondeu {response.status_code}: {response.text[:300]}")
|
|
34
|
+
data = response.json()
|
|
35
|
+
generation_id = data.get("id") or data.get("generationId") or (data.get("data") or {}).get("id")
|
|
36
|
+
if not generation_id:
|
|
37
|
+
raise GammaError(f"Resposta inesperada da Gamma API (sem id): {str(data)[:300]}")
|
|
38
|
+
|
|
39
|
+
while time.time() - started < total_timeout:
|
|
40
|
+
try:
|
|
41
|
+
poll = client.get(f"{base}/v1.0/generations/{generation_id}", headers=_headers(api_key))
|
|
42
|
+
except httpx.HTTPError as exc:
|
|
43
|
+
raise GammaError(f"Falha de conexão no polling da Gamma API: {exc}") from exc
|
|
44
|
+
if poll.status_code != 200:
|
|
45
|
+
raise GammaError(f"Polling Gamma API respondeu {poll.status_code}: {poll.text[:300]}")
|
|
46
|
+
info = poll.json()
|
|
47
|
+
status = str(info.get("status") or (info.get("data") or {}).get("status") or "").lower()
|
|
48
|
+
if status in ("ready", "completed", "succeeded", "done"):
|
|
49
|
+
pdf_url = (
|
|
50
|
+
info.get("pdfUrl")
|
|
51
|
+
or info.get("pdf_url")
|
|
52
|
+
or (info.get("data") or {}).get("pdfUrl")
|
|
53
|
+
or info.get("exportUrl")
|
|
54
|
+
)
|
|
55
|
+
if not pdf_url:
|
|
56
|
+
raise GammaError(f"Geração concluída sem URL de PDF: {str(info)[:300]}")
|
|
57
|
+
file_response = client.get(pdf_url)
|
|
58
|
+
if file_response.status_code != 200:
|
|
59
|
+
raise GammaError(f"Download do PDF falhou ({file_response.status_code}).")
|
|
60
|
+
return file_response.content
|
|
61
|
+
if status in ("failed", "error", "cancelled"):
|
|
62
|
+
raise GammaError(f"Geração Gamma falhou: {str(info)[:300]}")
|
|
63
|
+
time.sleep(5)
|
|
64
|
+
raise GammaError("Tempo limite aguardando a Gamma API concluir a geração.")
|