@danhachuel/prodmaker 0.1.10 → 0.1.12
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 +40 -0
- package/app/api/contas.py +45 -0
- package/app/api/guias.py +34 -0
- package/app/api/invest.py +37 -0
- package/app/api/logs.py +42 -0
- package/app/api/pins.py +80 -0
- package/app/main.py +12 -2
- package/app/services/database.py +220 -183
- package/app/services/dbengine.py +195 -0
- package/app/services/invest.py +52 -0
- package/app/services/oauth_google.py +108 -0
- package/app/services/pinterest.py +174 -0
- package/frontend/dist/assets/index-B4wxnTCs.css +1 -0
- package/frontend/dist/assets/index-C8sX5Mkf.js +74 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +7 -0
- package/scripts/cli.mjs +1 -1
- package/seed/references/pixel-e-tuneis.md +39 -0
- package/seed/references/webhooks-de-venda.md +38 -0
- package/frontend/dist/assets/index-B88UVmZI.css +0 -1
- package/frontend/dist/assets/index-EOri3RlA.js +0 -74
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,46 @@
|
|
|
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.12 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
- **Parte 10 — Polish E2E + OAuth Google 1 clique + radar Supabase + ROAS real.**
|
|
9
|
+
- **OAuth Google (Contas do Google real):** `GET /api/google/oauth/url` monta o consentimento
|
|
10
|
+
(scope adwords, access_type=offline, prompt=consent) e `GET /api/google/callback` troca o
|
|
11
|
+
código e **salva o refresh_token automaticamente** em Chaves de API. Página "Contas do
|
|
12
|
+
Google" com status de conexão, botão conectar/reconectar (popup) e instruções do redirect
|
|
13
|
+
`http://localhost:8188/api/google/callback`. Basta colar Client ID/Secret uma vez.
|
|
14
|
+
- **Radar Supabase (camada pronta, multi-tenant NÃO ativado):** `dbengine.py` abstrai o banco —
|
|
15
|
+
SQLite continua o padrão; definindo `DATABASE_URL` (PostgreSQL/Supabase) o sistema conecta
|
|
16
|
+
via psycopg (dependência declarada, import preguiçoso), com tradução de placeholders `?`→`%s`
|
|
17
|
+
e linhas dict (mesmo acesso por coluna nos serviços). Todas as 12 tabelas passaram a ter
|
|
18
|
+
`user_id` (migração ALTER aplicada automaticamente nos bancos existentes) — base para o
|
|
19
|
+
isolamento multi-tenant via Supabase Auth na próxima etapa.
|
|
20
|
+
- **ROAS real:** tabela `investimentos` (gasto diário em tráfego) + registro/remoção no
|
|
21
|
+
Dashboard; cards **Investimento**, **ROAS** (Receita ÷ Investimento) e **Lucro Líquido**
|
|
22
|
+
(Receita − Investimento) calculados com dados reais — sem investimento registrado, ROAS
|
|
23
|
+
mostra "—" (nunca inventado).
|
|
24
|
+
- **Contas do Google** saiu do placeholder — menu 100% implementado.
|
|
25
|
+
|
|
26
|
+
## 0.1.11 — 2026-10-30
|
|
27
|
+
|
|
28
|
+
- **Parte 9 — Pinterest (orgânico), Logs unificado e FAQ/Documentação** — fecha o menu do AdsX.
|
|
29
|
+
- **Pinterest funcional:** geração de 10 ideias de pins por IA (título ≤100 chars, descrição ≤500,
|
|
30
|
+
texto na imagem, sugestão de imagem), board/link por produto, estados (ideia → pronto →
|
|
31
|
+
agendado → publicado), exportação CSV (selecionados ou todos) e agendamento via py3-pinterest
|
|
32
|
+
(import preguiçoso; erros honestos quando sem credenciais/sessão manual).
|
|
33
|
+
- **Logs unificado:** tabela com Operação, Estado, Data/Hora, Origem e Detalhes agregando
|
|
34
|
+
campanhas, páginas, infoprodutos, vendas, notificações e visitas — com filtros por operação,
|
|
35
|
+
estado e busca livre + cópia da linha.
|
|
36
|
+
- **FAQ & Documentação:** FAQ em accordion (login/REGRA ZERO/chaves/publicação/tracking/etc.)
|
|
37
|
+
+ guias de referência servidos do `seed/references` (pixel-e-tuneis.md, webhooks-de-venda.md)
|
|
38
|
+
+ link para /legal (verificação de aplicação LGPD).
|
|
39
|
+
- requirements.txt: + apprise, py3-pinterest (imports preguiçosos — boot preservado).
|
|
40
|
+
- Nova tabela `pins`. Único módulo restante como placeholder: **Contas do Google** (OAuth
|
|
41
|
+
multi-conta — backlog da parte de contas).
|
|
42
|
+
- **Conjunto AdsX completo:** Seus Produtos · AdsDashboard · AdsHosting · Infoprodutos ·
|
|
43
|
+
AdsPages · AdsWords · AdsCreator · AdsTracking (Visitas/Vendas/GCLID) · AdsBot · Pinterest ·
|
|
44
|
+
Chaves de API · Notificações · Logs · FAQ.
|
|
45
|
+
|
|
6
46
|
## 0.1.10 — 2026-10-30
|
|
7
47
|
|
|
8
48
|
- **Parte 8 — AdsBot (Lançamento Assistido) + Dashboard real + Notificações (adsNotify).**
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Contas do Google — OAuth 1 clique (URL de consentimento + callback)."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
3
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
4
|
+
|
|
5
|
+
from ..services import oauth_google
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/google", tags=["google-oauth"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/oauth/url")
|
|
12
|
+
def oauth_url(request: Request):
|
|
13
|
+
"""Monta a URL de consentimento do Google (abrir em popup/aba)."""
|
|
14
|
+
try:
|
|
15
|
+
return oauth_google.build_auth_url(request)
|
|
16
|
+
except LLMError as exc:
|
|
17
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/callback")
|
|
21
|
+
def oauth_callback(request: Request, code: str = "", error: str = ""):
|
|
22
|
+
"""Callback do OAuth do Google — troca o code e salva o refresh_token."""
|
|
23
|
+
if error:
|
|
24
|
+
return _result_page(False, f"Google retornou erro: {error}")
|
|
25
|
+
if not code:
|
|
26
|
+
return _result_page(False, "Callback sem código de autorização.")
|
|
27
|
+
try:
|
|
28
|
+
oauth_google.exchange_code(code, request)
|
|
29
|
+
except LLMError as exc:
|
|
30
|
+
return _result_page(False, str(exc))
|
|
31
|
+
return _result_page(True, "Conta Google conectada! O Refresh Token foi salvo automaticamente em Chaves de API.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _result_page(ok: bool, mensagem: str) -> HTMLResponse:
|
|
35
|
+
cor = "#10b981" if ok else "#ef4444"
|
|
36
|
+
titulo = "Conectado" if ok else "Falha na conexão"
|
|
37
|
+
return HTMLResponse(
|
|
38
|
+
f"""
|
|
39
|
+
<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><title>ProdMaker — Google OAuth</title>
|
|
40
|
+
<style>body{{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#f8fafc;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}}
|
|
41
|
+
.box{{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:36px;max-width:460px;text-align:center}}
|
|
42
|
+
.ico{{font-size:40px}} h1{{color:{cor};font-size:20px;margin:12px 0}} p{{color:#64748b;font-size:14px}}
|
|
43
|
+
</style></head><body><div class="box"><div class="ico">{'✅' if ok else '❌'}</div><h1>{titulo}</h1><p>{mensagem}</p>
|
|
44
|
+
<p style="font-size:12px;margin-top:16px">Pode fechar esta janela e voltar ao ProdMaker.</p></div></body></html>"""
|
|
45
|
+
)
|
package/app/api/guias.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Guias de referência (seed/references) — servidos para a página FAQ/Documentação."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/api/guias", tags=["guias"])
|
|
7
|
+
|
|
8
|
+
REFERENCES_DIR = Path(__file__).resolve().parent.parent.parent / "seed" / "references"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("")
|
|
12
|
+
def listar():
|
|
13
|
+
guias = []
|
|
14
|
+
if REFERENCES_DIR.exists():
|
|
15
|
+
for path in sorted(REFERENCES_DIR.glob("*.md")):
|
|
16
|
+
try:
|
|
17
|
+
titulo = ""
|
|
18
|
+
linhas = path.read_text(encoding="utf-8").splitlines()
|
|
19
|
+
if linhas and linhas[0].startswith("#"):
|
|
20
|
+
titulo = linhas[0].lstrip("#").strip()
|
|
21
|
+
guias.append({"arquivo": path.name, "titulo": titulo or path.stem, "chars": len(path.read_text(encoding="utf-8"))})
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
continue
|
|
24
|
+
return guias
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/{arquivo}")
|
|
28
|
+
def ler(arquivo: str):
|
|
29
|
+
if "/" in arquivo or ".." in arquivo or not arquivo.endswith(".md"):
|
|
30
|
+
raise HTTPException(status_code=400, detail="Arquivo inválido.")
|
|
31
|
+
path = (REFERENCES_DIR / arquivo).resolve()
|
|
32
|
+
if not path.is_file() or REFERENCES_DIR.resolve() not in path.parents:
|
|
33
|
+
raise HTTPException(status_code=404, detail="Guia não encontrado.")
|
|
34
|
+
return {"arquivo": path.name, "conteudo": path.read_text(encoding="utf-8")}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Investimento em tráfego — endpoints (registro diário para ROAS real)."""
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import invest
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/investimentos", tags=["investimentos"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvestBody(BaseModel):
|
|
13
|
+
valor: float
|
|
14
|
+
data: str = ""
|
|
15
|
+
observacao: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("")
|
|
19
|
+
def listar():
|
|
20
|
+
return {"registros": invest.listar(), "resumo": invest.resumo_financeiro()}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.post("")
|
|
24
|
+
def registrar(body: InvestBody):
|
|
25
|
+
try:
|
|
26
|
+
return invest.registrar(body.valor, body.data, body.observacao)
|
|
27
|
+
except LLMError as exc:
|
|
28
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.delete("/{investimento_id}")
|
|
32
|
+
def remover(investimento_id: int):
|
|
33
|
+
try:
|
|
34
|
+
invest.remover(investimento_id)
|
|
35
|
+
except LLMError as exc:
|
|
36
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
37
|
+
return {"ok": True}
|
package/app/api/logs.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Logs unificados — visão de operações de todos os módulos."""
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
|
|
4
|
+
from ..services.database import get_db, row_to_dict
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _entry(operacao: str, estado: str, data: str, origem: str, detalhe: str) -> dict:
|
|
10
|
+
return {"operacao": operacao, "estado": estado, "data": data, "origem": origem, "detalhe": detalhe}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
def logs(limite: int = 300, operacao: str = "", estado: str = ""):
|
|
15
|
+
entries: list[dict] = []
|
|
16
|
+
with get_db() as conn:
|
|
17
|
+
for row in conn.execute("SELECT * FROM campanhas ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
18
|
+
record = row_to_dict(row)
|
|
19
|
+
estado_row = record["status"]
|
|
20
|
+
entries.append(_entry("campanha", estado_row, record["updated_at"], record["plataforma"], f"{record['nome']} — CPA R$ {record['cpa_alvo']} · R$ {record['orcamento_diario']}/dia" + (f" · erro: {record['erro'][:120]}" if record["erro"] else "")))
|
|
21
|
+
for row in conn.execute("SELECT * FROM paginas ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
22
|
+
record = row_to_dict(row)
|
|
23
|
+
entries.append(_entry("pagina", record["status"], record["updated_at"], record["tipo"], f"{record['titulo']}" + (f" — {record['published_url']}" if record["published_url"] else "")))
|
|
24
|
+
for row in conn.execute("SELECT * FROM infoprodutos ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
25
|
+
record = row_to_dict(row)
|
|
26
|
+
entries.append(_entry("infoproduto", "gerado", record["created_at"], record["provider"] or "llm", f"{record['titulo']} ({record['modo']})"))
|
|
27
|
+
for row in conn.execute("SELECT * FROM vendas ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
28
|
+
record = row_to_dict(row)
|
|
29
|
+
entries.append(_entry("venda", record["status"], record["created_at"], record["fonte"], f"{record['produto_nome'] or '#' + str(record['id'])} — valor {record['valor']} · conversao {record['conversao_status']}" + (f" · gclid {record['gclid'][:16]}" if record["gclid"] else "")))
|
|
30
|
+
for row in conn.execute("SELECT * FROM notificacoes ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
31
|
+
record = row_to_dict(row)
|
|
32
|
+
entries.append(_entry("notificacao", record["status"], record["created_at"], record["canal"] or "apprise", f"{record['titulo']}" + (f" — {record['erro'][:120]}" if record["erro"] else "")))
|
|
33
|
+
for row in conn.execute("SELECT * FROM visitas ORDER BY id DESC LIMIT ?", (limite,)).fetchall():
|
|
34
|
+
record = row_to_dict(row)
|
|
35
|
+
entries.append(_entry("visita", "ok", record["created_at"], record["ip"], f"{record['event']} {record['page']}" + (f" · gclid {record['gclid'][:16]}" if record["gclid"] else "")))
|
|
36
|
+
|
|
37
|
+
if operacao:
|
|
38
|
+
entries = [item for item in entries if item["operacao"] == operacao]
|
|
39
|
+
if estado:
|
|
40
|
+
entries = [item for item in entries if item["estado"] == estado]
|
|
41
|
+
entries.sort(key=lambda item: item["data"] or "", reverse=True)
|
|
42
|
+
return entries[:limite]
|
package/app/api/pins.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import pinterest
|
|
5
|
+
from ..services.database import get_db, row_to_dict
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/pins", tags=["pinterest"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _produto_or_none(produto_id: int | None) -> dict:
|
|
12
|
+
if not produto_id:
|
|
13
|
+
return {"id": None, "nome": "Produto", "nicho": ""}
|
|
14
|
+
with get_db() as conn:
|
|
15
|
+
row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
16
|
+
return row_to_dict(row) if row else {"id": produto_id, "nome": "Produto", "nicho": ""}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GerarBody(BaseModel):
|
|
20
|
+
produto_id: int | None = None
|
|
21
|
+
board: str = ""
|
|
22
|
+
link: str = ""
|
|
23
|
+
quantidade: int = 10
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StatusBody(BaseModel):
|
|
27
|
+
status: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AgendarBody(BaseModel):
|
|
31
|
+
imagem_path: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExportBody(BaseModel):
|
|
35
|
+
pin_ids: list = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("")
|
|
39
|
+
def listar(produto_id: int | None = None):
|
|
40
|
+
return pinterest.listar(produto_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/gerar")
|
|
44
|
+
def gerar(body: GerarBody):
|
|
45
|
+
produto = _produto_or_none(body.produto_id)
|
|
46
|
+
try:
|
|
47
|
+
return pinterest.gerar_ideias(produto, body.board, body.link, body.quantidade)
|
|
48
|
+
except LLMError as exc:
|
|
49
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.put("/{pin_id}/status")
|
|
53
|
+
def status(pin_id: int, body: StatusBody):
|
|
54
|
+
try:
|
|
55
|
+
return pinterest.marcar(pin_id, body.status)
|
|
56
|
+
except LLMError as exc:
|
|
57
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.post("/{pin_id}/agendar")
|
|
61
|
+
def agendar(pin_id: int, body: AgendarBody):
|
|
62
|
+
try:
|
|
63
|
+
return pinterest.agendar(pin_id, body.imagem_path)
|
|
64
|
+
except LLMError as exc:
|
|
65
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/export")
|
|
69
|
+
def exportar(body: ExportBody):
|
|
70
|
+
csv_text = pinterest.exportar_csv([int(item) for item in body.pin_ids])
|
|
71
|
+
return {"csv": csv_text}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.delete("/{pin_id}")
|
|
75
|
+
def remover(pin_id: int):
|
|
76
|
+
try:
|
|
77
|
+
pinterest.remover(pin_id)
|
|
78
|
+
except LLMError as exc:
|
|
79
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
80
|
+
return {"ok": True}
|
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.12"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -35,6 +35,11 @@ from .api import ads as ads_router # noqa: E402
|
|
|
35
35
|
from .api import lancamentos as lancamentos_router # noqa: E402
|
|
36
36
|
from .api import notificacoes as notificacoes_router # noqa: E402
|
|
37
37
|
from .api import dashboard as dashboard_router # noqa: E402
|
|
38
|
+
from .api import pins as pins_router # noqa: E402
|
|
39
|
+
from .api import logs as logs_router # noqa: E402
|
|
40
|
+
from .api import guias as guias_router # noqa: E402
|
|
41
|
+
from .api import contas as contas_router # noqa: E402
|
|
42
|
+
from .api import invest as invest_router # noqa: E402
|
|
38
43
|
|
|
39
44
|
app.include_router(llm_router.router)
|
|
40
45
|
app.include_router(infoprodutos_router.router)
|
|
@@ -46,6 +51,11 @@ app.include_router(ads_router.router)
|
|
|
46
51
|
app.include_router(lancamentos_router.router)
|
|
47
52
|
app.include_router(notificacoes_router.router)
|
|
48
53
|
app.include_router(dashboard_router.router)
|
|
54
|
+
app.include_router(pins_router.router)
|
|
55
|
+
app.include_router(logs_router.router)
|
|
56
|
+
app.include_router(guias_router.router)
|
|
57
|
+
app.include_router(contas_router.router)
|
|
58
|
+
app.include_router(invest_router.router)
|
|
49
59
|
|
|
50
60
|
ensure_db()
|
|
51
61
|
|
|
@@ -252,7 +262,7 @@ def health():
|
|
|
252
262
|
|
|
253
263
|
@app.get("/api/version")
|
|
254
264
|
def version():
|
|
255
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
265
|
+
return {"version": APP_VERSION, "canonical": "0.1.12"}
|
|
256
266
|
|
|
257
267
|
|
|
258
268
|
@app.get("/api/settings")
|