@danhachuel/prodmaker 0.1.6 → 0.1.8
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 +36 -0
- package/MANUAL-INSTALACAO.md +4 -1
- package/README.md +1 -1
- package/app/api/vendas.py +167 -0
- package/app/main.py +18 -5
- package/app/services/clone.py +7 -3
- package/app/services/database.py +27 -0
- package/app/services/dominios.py +2 -2
- package/app/services/gads.py +109 -0
- package/app/services/gamma.py +2 -2
- package/app/services/pdf.py +7 -3
- package/app/services/publish.py +2 -2
- package/app/services/store.py +10 -0
- package/app/services/vendas.py +324 -0
- package/frontend/dist/assets/index-BrfPg9k5.js +69 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +23 -3
- package/frontend/dist/assets/index-83L_ji8k.js +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
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.08 — 2026-10-30
|
|
7
|
+
|
|
8
|
+
- **Parte 6 — Minhas Vendas & Conversões GCLID.**
|
|
9
|
+
- **Vendas funcionais:** tabela com cards (vendas aprovadas, receita, comissão, conversões
|
|
10
|
+
pendentes), adição manual, **importação CSV** (colunas em ordem livre + upload de arquivo),
|
|
11
|
+
exclusão e filtro por status de conversão.
|
|
12
|
+
- **Webhooks de venda públicos:** `POST /api/webhook/sale/{hotmart|clickbank|digistore24|
|
|
13
|
+
kiify|cakto}` com parser **tolerante** (percorre o JSON procurando valor, comissão, e-mail,
|
|
14
|
+
transação, GCLID, produto e status — cada plataforma usa nomes diferentes; aceita JSON e form).
|
|
15
|
+
Página lista as URLs prontas para copiar no postback de cada plataforma.
|
|
16
|
+
- **Matching por GCLID:** ao registrar venda com GCLID, o sistema cruza com a visita
|
|
17
|
+
correspondente na tabela `visitas` (página, IP, origem) e marca 🎯 no GCLID rastreado.
|
|
18
|
+
- **Conversões offline (GCLID):** enxuta implementação REST `uploadClickConversions`
|
|
19
|
+
(`services/gads.py`): refresh token OAuth → POST Google Ads API (versão configurável,
|
|
20
|
+
default v22); upload individual ou **em lote** das pendentes; status por venda
|
|
21
|
+
(pendente/enviada/erro) com erro atribuído; exige Google Ads completo em Chaves de API
|
|
22
|
+
(developer token, client, refresh token, customer_id e **conversion_action**).
|
|
23
|
+
- Nova tabela `vendas` (com índices em gclid/created_at). Sem novas dependências Python
|
|
24
|
+
(upload via httpx já incluso).
|
|
25
|
+
|
|
26
|
+
## 0.1.07 — 2026-10-27
|
|
27
|
+
|
|
28
|
+
- **Código de Ativação Mestre:** o login local agora valida o código de verdade.
|
|
29
|
+
O backend define `MASTER_CODE` (`get_master_code()` — default `1234`, sobrescrevível
|
|
30
|
+
via variável `PRODMAKER_MASTER_CODE`) e o alias **`admin`** também é sempre aceito;
|
|
31
|
+
qualquer e-mail + código mestre entram. Códigos comuns deixam de ser aceitos
|
|
32
|
+
(antes qualquer código ≥ 4 caracteres passava na validação simulada).
|
|
33
|
+
- **Launcher imprime o código ao iniciar:** o box de inicialização agora mostra
|
|
34
|
+
`CODIGO DE ATIVACAO: <código>` (buscado direto do backend — fonte única de verdade,
|
|
35
|
+
com fallback para env/default). Sessões já autenticadas continuam válidas (JWT 30 dias).
|
|
36
|
+
- **Robustez de boot:** imports de dependências de serviço (markdown, xhtml2pdf, httpx,
|
|
37
|
+
bs4, dns) tornados preguiçosos — o servidor sobe com qualquer Python que tenha
|
|
38
|
+
fastapi/uvicorn, e o erro claro (com instrução `prodmaker install`) aparece apenas na
|
|
39
|
+
ação que precisa da dependência.
|
|
40
|
+
- Docs: MANUAL-INSTALACAO e README atualizados com o fluxo de login pelo código mestre.
|
|
41
|
+
|
|
6
42
|
## 0.1.06 — 2026-10-26
|
|
7
43
|
|
|
8
44
|
- **Parte 5 — AdsTracking: Minhas Visitas (AdsPixel) funcional, layout AdsX 1:1.**
|
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -45,9 +45,12 @@ O terminal imprimirá:
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
**Abra a URL manualmente.** O ProdMaker nunca abre o navegador sozinho (REGRA ZERO).
|
|
48
|
+
O box de inicialização também imprime o **Código de Ativação Mestre** (default: `1234`) —
|
|
49
|
+
no login, use qualquer e-mail + esse código (ou digite `admin` como código).
|
|
50
|
+
Código customizado: variável de ambiente `PRODMAKER_MASTER_CODE`.
|
|
48
51
|
Se a porta 8188 estiver ocupada, o launcher usará automaticamente 8189–8199 e informará.
|
|
49
52
|
|
|
50
|
-
- Login: informe e-mail e
|
|
53
|
+
- Login: informe qualquer e-mail e o **Código de Ativação Mestre** impresso no terminal ao iniciar (default: `1234`; ou digite `admin`).
|
|
51
54
|
- Para encerrar: `Ctrl+C` no terminal.
|
|
52
55
|
|
|
53
56
|
## Diagnóstico
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npx --yes @danhachuel/prodmaker@0.1.1
|
|
|
18
18
|
|
|
19
19
|
1. `install` cria a pasta de dados (`PRODMAKER_HOME`), o storage local e instala as dependências Python.
|
|
20
20
|
2. O segundo comando inicia o servidor em `http://localhost:8188` e imprime a URL no terminal.
|
|
21
|
-
3. **Abra a URL manualmente no navegador**, faça login (e-mail +
|
|
21
|
+
3. **Abra a URL manualmente no navegador**, faça login (qualquer e-mail + o **Código de Ativação Mestre** impresso no terminal — default `1234` — ou `admin`) e acesse o painel.
|
|
22
22
|
|
|
23
23
|
## O que está incluído (0.1.01 — Parte 2: Frontend)
|
|
24
24
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
4
|
+
from fastapi.responses import Response
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..services import gads, vendas
|
|
8
|
+
from ..services.database import get_db, now_str, row_to_dict
|
|
9
|
+
from ..services.llm import LLMError
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api", tags=["vendas"])
|
|
12
|
+
|
|
13
|
+
WEBHOOK_FONTES = ("hotmart", "clickbank", "digistore24", "kiify", "cakto")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VendaBody(BaseModel):
|
|
17
|
+
produto_nome: str = ""
|
|
18
|
+
produto_id: int | None = None
|
|
19
|
+
valor: float = 0
|
|
20
|
+
comissao: float = 0
|
|
21
|
+
gclid: str = ""
|
|
22
|
+
comprador_email: str = ""
|
|
23
|
+
transacao_id: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CsvBody(BaseModel):
|
|
27
|
+
csv: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Vendas
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/vendas")
|
|
36
|
+
def list_vendas():
|
|
37
|
+
return vendas.list_vendas()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/vendas/resumo")
|
|
41
|
+
def resumo_vendas():
|
|
42
|
+
return vendas.resumo()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/vendas")
|
|
46
|
+
def create_venda(body: VendaBody):
|
|
47
|
+
if not body.produto_nome.strip():
|
|
48
|
+
raise HTTPException(status_code=400, detail="Informe o nome do produto.")
|
|
49
|
+
return vendas.registrar_venda(
|
|
50
|
+
{
|
|
51
|
+
"fonte": "manual",
|
|
52
|
+
"produto_id": body.produto_id,
|
|
53
|
+
"produto_nome": body.produto_nome.strip(),
|
|
54
|
+
"valor": body.valor,
|
|
55
|
+
"comissao": body.comissao or round(body.valor * 0.5, 2),
|
|
56
|
+
"gclid": body.gclid.strip(),
|
|
57
|
+
"comprador_email": body.comprador_email.strip(),
|
|
58
|
+
"transacao_id": body.transacao_id.strip(),
|
|
59
|
+
"status": "aprovada",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.delete("/vendas/{venda_id}")
|
|
65
|
+
def delete_venda(venda_id: int):
|
|
66
|
+
vendas.delete_venda(venda_id)
|
|
67
|
+
return {"ok": True}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/vendas/csv")
|
|
71
|
+
def importar_csv(body: CsvBody):
|
|
72
|
+
if not body.csv.strip():
|
|
73
|
+
raise HTTPException(status_code=400, detail="CSV vazio.")
|
|
74
|
+
try:
|
|
75
|
+
return vendas.importar_csv(body.csv)
|
|
76
|
+
except LLMError as exc:
|
|
77
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Conversões GCLID (upload offline ao Google Ads)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/vendas/{venda_id}/conversao")
|
|
86
|
+
def upload_conversao(venda_id: int):
|
|
87
|
+
venda = vendas.get_venda(venda_id)
|
|
88
|
+
if venda["conversao_status"] == "enviada":
|
|
89
|
+
raise HTTPException(status_code=400, detail="Conversão desta venda já foi enviada.")
|
|
90
|
+
try:
|
|
91
|
+
resultado = gads.upload_conversao(venda["gclid"], venda["valor"], venda["created_at"])
|
|
92
|
+
except LLMError as exc:
|
|
93
|
+
vendas.marcar_conversao(venda_id, "erro", str(exc))
|
|
94
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
95
|
+
vendas.marcar_conversao(venda_id, "enviada")
|
|
96
|
+
return {"ok": True, "venda": vendas.get_venda(venda_id), "resultado": resultado}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.post("/vendas/conversoes/upload")
|
|
100
|
+
def upload_conversoes_pendentes():
|
|
101
|
+
"""Envia todas as vendas aprovadas com GCLID cuja conversão ainda está pendente."""
|
|
102
|
+
enviadas = []
|
|
103
|
+
erros = []
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
rows = conn.execute(
|
|
106
|
+
"SELECT * FROM vendas WHERE status = 'aprovada' AND gclid != '' AND conversao_status != 'enviada' ORDER BY id"
|
|
107
|
+
).fetchall()
|
|
108
|
+
for row in rows:
|
|
109
|
+
venda = row_to_dict(row)
|
|
110
|
+
try:
|
|
111
|
+
gads.upload_conversao(venda["gclid"], venda["valor"], venda["created_at"])
|
|
112
|
+
vendas.marcar_conversao(venda["id"], "enviada")
|
|
113
|
+
enviadas.append(venda["id"])
|
|
114
|
+
except LLMError as exc:
|
|
115
|
+
vendas.marcar_conversao(venda["id"], "erro", str(exc))
|
|
116
|
+
erros.append(f"#{venda['id']}: {exc}")
|
|
117
|
+
return {"enviadas": enviadas, "erros": erros}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Webhooks de vendas (públicos — POST das plataformas de afiliados)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.options("/webhook/sale/{fonte}")
|
|
126
|
+
def webhook_options(fonte: str):
|
|
127
|
+
return Response(
|
|
128
|
+
status_code=204,
|
|
129
|
+
headers={
|
|
130
|
+
"Access-Control-Allow-Origin": "*",
|
|
131
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
132
|
+
"Access-Control-Allow-Headers": "*",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.post("/webhook/sale/{fonte}")
|
|
138
|
+
async def webhook_sale(fonte: str, request: Request):
|
|
139
|
+
fonte = fonte.lower()
|
|
140
|
+
if fonte not in WEBHOOK_FONTES:
|
|
141
|
+
raise HTTPException(status_code=404, detail=f"Fonte desconhecida. Suportadas: {', '.join(WEBHOOK_FONTES)}")
|
|
142
|
+
raw = await request.body()
|
|
143
|
+
payload = None
|
|
144
|
+
try:
|
|
145
|
+
parsed = json.loads(raw)
|
|
146
|
+
if isinstance(parsed, dict):
|
|
147
|
+
payload = parsed
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
if payload is None:
|
|
151
|
+
# Plataformas que postam em application/x-www-form-urlencoded
|
|
152
|
+
from urllib.parse import parse_qs
|
|
153
|
+
|
|
154
|
+
form = parse_qs(raw.decode("utf-8", errors="replace"))
|
|
155
|
+
payload = {key: values[0] if len(values) == 1 else values for key, values in form.items()}
|
|
156
|
+
if not payload:
|
|
157
|
+
raise HTTPException(status_code=400, detail="Payload vazio ou não reconhecido (envie JSON ou form-urlencoded).")
|
|
158
|
+
try:
|
|
159
|
+
data = vendas.parse_webhook(fonte, payload)
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
raise HTTPException(status_code=400, detail=f"Payload não processado: {exc}")
|
|
162
|
+
venda = vendas.registrar_venda(data)
|
|
163
|
+
return Response(
|
|
164
|
+
content=json.dumps({"ok": True, "id": venda["id"]}, ensure_ascii=False),
|
|
165
|
+
media_type="application/json",
|
|
166
|
+
headers={"Access-Control-Allow-Origin": "*"},
|
|
167
|
+
)
|
package/app/main.py
CHANGED
|
@@ -13,11 +13,12 @@ from pydantic import BaseModel
|
|
|
13
13
|
|
|
14
14
|
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
|
-
from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_settings, save_settings
|
|
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.08"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
|
+
MASTER_ALIAS = "admin"
|
|
21
22
|
|
|
22
23
|
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
23
24
|
LEGAL_DIR = Path(__file__).resolve().parent.parent / "legal"
|
|
@@ -29,12 +30,14 @@ from .api import llm as llm_router # noqa: E402
|
|
|
29
30
|
from .api import paginas as paginas_router # noqa: E402
|
|
30
31
|
from .api import dominios as dominios_router # noqa: E402
|
|
31
32
|
from .api import tracking as tracking_router # noqa: E402
|
|
33
|
+
from .api import vendas as vendas_router # noqa: E402
|
|
32
34
|
|
|
33
35
|
app.include_router(llm_router.router)
|
|
34
36
|
app.include_router(infoprodutos_router.router)
|
|
35
37
|
app.include_router(paginas_router.router)
|
|
36
38
|
app.include_router(dominios_router.router)
|
|
37
39
|
app.include_router(tracking_router.router)
|
|
40
|
+
app.include_router(vendas_router.router)
|
|
38
41
|
|
|
39
42
|
ensure_db()
|
|
40
43
|
|
|
@@ -86,12 +89,22 @@ class LoginBody(BaseModel):
|
|
|
86
89
|
activation_code: str
|
|
87
90
|
|
|
88
91
|
|
|
92
|
+
def is_master_code(code: str) -> bool:
|
|
93
|
+
"""Código Mestre (MASTER_CODE) ou o alias 'admin' liberam o login com qualquer e-mail."""
|
|
94
|
+
return code == get_master_code() or code.strip().lower() == MASTER_ALIAS
|
|
95
|
+
|
|
96
|
+
|
|
89
97
|
@app.post("/api/auth/login")
|
|
90
98
|
def auth_login(body: LoginBody, response: Response):
|
|
91
99
|
email = body.email.strip()
|
|
92
100
|
code = body.activation_code.strip()
|
|
93
|
-
if "@" not in email
|
|
94
|
-
raise HTTPException(status_code=400, detail="
|
|
101
|
+
if "@" not in email:
|
|
102
|
+
raise HTTPException(status_code=400, detail="Informe um e-mail válido.")
|
|
103
|
+
if not is_master_code(code):
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=400,
|
|
106
|
+
detail="Código de ativação inválido. Use o Código de Ativação Mestre impresso no terminal ao iniciar o ProdMaker (ou digite 'admin').",
|
|
107
|
+
)
|
|
95
108
|
ensure_state_dir()
|
|
96
109
|
license_path = STATE / "license.json"
|
|
97
110
|
if not license_path.exists():
|
|
@@ -231,7 +244,7 @@ def health():
|
|
|
231
244
|
|
|
232
245
|
@app.get("/api/version")
|
|
233
246
|
def version():
|
|
234
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
247
|
+
return {"version": APP_VERSION, "canonical": "0.1.8"}
|
|
235
248
|
|
|
236
249
|
|
|
237
250
|
@app.get("/api/settings")
|
package/app/services/clone.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
"""Clonagem de páginas com higienização — remove scripts, pixels e iframes do original."""
|
|
2
|
-
import httpx
|
|
3
|
-
from bs4 import BeautifulSoup
|
|
4
|
-
|
|
5
2
|
from .llm import LLMError
|
|
6
3
|
|
|
7
4
|
|
|
8
5
|
def clonar_url(url: str) -> tuple[str, str]:
|
|
9
6
|
"""Baixa a URL e devolve (HTML higienizado, título) — scripts/pixels/iframes removidos."""
|
|
7
|
+
try:
|
|
8
|
+
import httpx
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
except ImportError as exc:
|
|
11
|
+
raise LLMError(
|
|
12
|
+
f"Dependência de clonagem ausente ({exc.name}) — rode 'prodmaker install' para concluir a instalação."
|
|
13
|
+
) from exc
|
|
10
14
|
if not url.strip().startswith(("http://", "https://")):
|
|
11
15
|
raise LLMError("Informe uma URL válida (http/https) para clonar.")
|
|
12
16
|
try:
|
package/app/services/database.py
CHANGED
|
@@ -97,6 +97,33 @@ def ensure_db() -> None:
|
|
|
97
97
|
)
|
|
98
98
|
"""
|
|
99
99
|
)
|
|
100
|
+
conn.execute(
|
|
101
|
+
"""
|
|
102
|
+
CREATE TABLE IF NOT EXISTS vendas (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
fonte TEXT NOT NULL DEFAULT 'manual',
|
|
105
|
+
produto_id INTEGER,
|
|
106
|
+
produto_nome TEXT NOT NULL DEFAULT '',
|
|
107
|
+
transacao_id TEXT NOT NULL DEFAULT '',
|
|
108
|
+
comprador_email TEXT NOT NULL DEFAULT '',
|
|
109
|
+
valor REAL NOT NULL DEFAULT 0,
|
|
110
|
+
comissao REAL NOT NULL DEFAULT 0,
|
|
111
|
+
gclid TEXT NOT NULL DEFAULT '',
|
|
112
|
+
utm_source TEXT NOT NULL DEFAULT '',
|
|
113
|
+
utm_medium TEXT NOT NULL DEFAULT '',
|
|
114
|
+
utm_campaign TEXT NOT NULL DEFAULT '',
|
|
115
|
+
status TEXT NOT NULL DEFAULT 'aprovada',
|
|
116
|
+
conversao_status TEXT NOT NULL DEFAULT 'pendente',
|
|
117
|
+
conversao_erro TEXT NOT NULL DEFAULT '',
|
|
118
|
+
gclid_match TEXT NOT NULL DEFAULT '',
|
|
119
|
+
raw TEXT NOT NULL DEFAULT '',
|
|
120
|
+
created_at TEXT NOT NULL
|
|
121
|
+
)
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
|
|
125
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_created ON vendas (created_at)")
|
|
126
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
|
|
100
127
|
columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
|
|
101
128
|
if "analysis" not in columns:
|
|
102
129
|
conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
|
package/app/services/dominios.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""AdsHosting — verificação de domínios via Cloudflare API + DNS público."""
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
|
-
import httpx
|
|
5
|
-
|
|
6
4
|
from .llm import LLMError
|
|
7
5
|
|
|
8
6
|
CF_BASE = "https://api.cloudflare.com/client/v4"
|
|
@@ -56,6 +54,8 @@ def verificar_dominio(nome: str) -> dict:
|
|
|
56
54
|
|
|
57
55
|
token = _cloudflare_token()
|
|
58
56
|
if token:
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
59
|
try:
|
|
60
60
|
with httpx.Client(timeout=30.0) as client:
|
|
61
61
|
response = client.get(f"{CF_BASE}/zones", headers={"Authorization": f"Bearer {token}"}, params={"name": nome})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Google Ads — upload de conversões offline (click conversions via GCLID).
|
|
2
|
+
|
|
3
|
+
Implementação enxuta via REST (httpx), sem SDK pesado:
|
|
4
|
+
1. refresh_token -> access_token (OAuth2)
|
|
5
|
+
2. POST /{version}/customers/{customer_id}:uploadClickConversions
|
|
6
|
+
|
|
7
|
+
Best-effort: a versão da API é configurável (settings.api_keys['google-ads'].api_version,
|
|
8
|
+
default v22) e erros da API são devolvidos com attribution clara.
|
|
9
|
+
"""
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .llm import LLMError
|
|
15
|
+
from .store import get_settings
|
|
16
|
+
|
|
17
|
+
DEFAULT_API_VERSION = "v22"
|
|
18
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
19
|
+
ADS_BASE = "https://googleads.googleapis.com"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _google_ads_config() -> dict:
|
|
23
|
+
keys = (get_settings().get("api_keys") or {}).get("google-ads") or {}
|
|
24
|
+
return {
|
|
25
|
+
"developer_token": (keys.get("developer_token") or "").strip(),
|
|
26
|
+
"client_id": (keys.get("client_id") or "").strip(),
|
|
27
|
+
"client_secret": (keys.get("client_secret") or "").strip(),
|
|
28
|
+
"refresh_token": (keys.get("refresh_token") or "").strip(),
|
|
29
|
+
"customer_id": (keys.get("customer_id") or "").strip().replace("-", ""),
|
|
30
|
+
"conversion_action": (keys.get("conversion_action") or "").strip(),
|
|
31
|
+
"api_version": (keys.get("api_version") or "").strip() or DEFAULT_API_VERSION,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _access_token(client_id: str, client_secret: str, refresh_token: str) -> str:
|
|
36
|
+
try:
|
|
37
|
+
response = httpx.post(
|
|
38
|
+
TOKEN_URL,
|
|
39
|
+
data={
|
|
40
|
+
"client_id": client_id,
|
|
41
|
+
"client_secret": client_secret,
|
|
42
|
+
"refresh_token": refresh_token,
|
|
43
|
+
"grant_type": "refresh_token",
|
|
44
|
+
},
|
|
45
|
+
timeout=30.0,
|
|
46
|
+
)
|
|
47
|
+
except httpx.HTTPError as exc:
|
|
48
|
+
raise LLMError(f"Falha de conexão com o OAuth do Google: {exc}", provider="google-ads", transient=True) from exc
|
|
49
|
+
if response.status_code != 200:
|
|
50
|
+
raise LLMError(
|
|
51
|
+
f"Google OAuth rejeitou as credenciais ({response.status_code}): {response.text[:200]} — confira client_id/client_secret/refresh_token em Chaves de API.",
|
|
52
|
+
provider="google-ads",
|
|
53
|
+
)
|
|
54
|
+
return (response.json() or {}).get("access_token", "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def upload_conversao(gclid: str, valor: float, ocorrido_em: str | None = None, currency: str = "BRL") -> dict:
|
|
58
|
+
"""Envia uma conversão offline (GCLID) ao Google Ads. Retorna dict de resultado."""
|
|
59
|
+
config = _google_ads_config()
|
|
60
|
+
missing = [field for field in ("developer_token", "client_id", "client_secret", "refresh_token", "customer_id", "conversion_action") if not config[field]]
|
|
61
|
+
if missing:
|
|
62
|
+
raise LLMError(
|
|
63
|
+
"Conversões GCLID requerem Google Ads configurado em Chaves de API: " + ", ".join(missing) + ".",
|
|
64
|
+
provider="google-ads",
|
|
65
|
+
)
|
|
66
|
+
if not gclid:
|
|
67
|
+
raise LLMError("Venda sem GCLID — não é possível enviar conversão offline.", provider="google-ads")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
access_token = _access_token(config["client_id"], config["client_secret"], config["refresh_token"])
|
|
71
|
+
except LLMError:
|
|
72
|
+
raise
|
|
73
|
+
if not access_token:
|
|
74
|
+
raise LLMError("OAuth do Google não devolveu access_token.", provider="google-ads")
|
|
75
|
+
|
|
76
|
+
ocorrido_em = ocorrido_em or time.strftime("%Y-%m-%d %H:%M:%S%z")
|
|
77
|
+
if "%z" in ocorrido_em:
|
|
78
|
+
# Formato esperado pelo Google Ads: yyyy-mm-dd hh:mm:ss+|-hh:mm
|
|
79
|
+
ocorrido_em = ocorrido_em.replace("+0000", "+00:00")
|
|
80
|
+
if len(ocorrido_em) >= 5 and ocorrido_em[-5] in "+-":
|
|
81
|
+
ocorrido_em = ocorrido_em[:-2] + ":" + ocorrido_em[-2:]
|
|
82
|
+
url = f"{ADS_BASE}/{config['api_version']}/customers/{config['customer_id']}:uploadClickConversions"
|
|
83
|
+
body = {
|
|
84
|
+
"conversions": [
|
|
85
|
+
{
|
|
86
|
+
"gclid": gclid,
|
|
87
|
+
"conversionAction": config["conversion_action"],
|
|
88
|
+
"conversionDateTime": ocorrido_em,
|
|
89
|
+
"conversionValue": valor,
|
|
90
|
+
"currencyCode": currency,
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"partialFailure": True,
|
|
94
|
+
}
|
|
95
|
+
headers = {
|
|
96
|
+
"Authorization": f"Bearer {access_token}",
|
|
97
|
+
"developer-token": config["developer_token"],
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
}
|
|
100
|
+
try:
|
|
101
|
+
response = httpx.post(url, headers=headers, json=body, timeout=60.0)
|
|
102
|
+
except httpx.HTTPError as exc:
|
|
103
|
+
raise LLMError(f"Falha de conexão com a Google Ads API: {exc}", provider="google-ads", transient=True) from exc
|
|
104
|
+
if response.status_code == 200:
|
|
105
|
+
return {"ok": True, "resultado": response.json()}
|
|
106
|
+
raise LLMError(
|
|
107
|
+
f"Google Ads API respondeu {response.status_code}: {response.text[:300]} (API {config['api_version']}).",
|
|
108
|
+
provider="google-ads",
|
|
109
|
+
)
|
package/app/services/gamma.py
CHANGED
|
@@ -4,8 +4,6 @@ Base URL configurável em Chaves de API (padrão https://api.gamma.app).
|
|
|
4
4
|
"""
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
7
|
|
|
10
8
|
class GammaError(Exception):
|
|
11
9
|
pass
|
|
@@ -16,6 +14,8 @@ def _headers(api_key: str) -> dict:
|
|
|
16
14
|
|
|
17
15
|
|
|
18
16
|
def gerar_pdf_gamma(api_key: str, base_url: str, titulo: str, prompt: str, total_timeout: float = 600.0) -> bytes:
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
19
|
base = (base_url or "https://api.app.gamma").rstrip("/")
|
|
20
20
|
started = time.time()
|
|
21
21
|
with httpx.Client(timeout=60.0) as client:
|
package/app/services/pdf.py
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
import re
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
-
import markdown
|
|
6
|
-
|
|
7
5
|
EMOJI_RE = re.compile("[\U00010000-\U0010FFFF]")
|
|
8
6
|
|
|
9
7
|
A4_CSS = """
|
|
@@ -24,7 +22,13 @@ li { margin-bottom: 3pt; }
|
|
|
24
22
|
|
|
25
23
|
def markdown_para_pdf(md_text: str, output_path: Path) -> Path:
|
|
26
24
|
"""Converte Markdown em PDF A4. Retorna o caminho do PDF gerado."""
|
|
27
|
-
|
|
25
|
+
try:
|
|
26
|
+
import markdown
|
|
27
|
+
from xhtml2pdf import pisa
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"Dependência de PDF ausente ({exc.name}) — rode 'prodmaker install' para concluir a instalação."
|
|
31
|
+
) from exc
|
|
28
32
|
|
|
29
33
|
clean = EMOJI_RE.sub("", md_text or "")
|
|
30
34
|
body = markdown.markdown(clean, extensions=["extra", "toc"])
|
package/app/services/publish.py
CHANGED
|
@@ -5,8 +5,6 @@ está conectado a um projeto do Cloudflare Pages (conexão única, feita no pain
|
|
|
5
5
|
"""
|
|
6
6
|
import base64
|
|
7
7
|
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
8
|
from .llm import LLMError
|
|
11
9
|
|
|
12
10
|
GITHUB_API = "https://api.github.com"
|
|
@@ -22,6 +20,8 @@ def _github_config() -> tuple[str, str]:
|
|
|
22
20
|
|
|
23
21
|
|
|
24
22
|
def publicar_pagina(page_id: int, titulo: str, html: str) -> dict:
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
25
|
token, repo = _github_config()
|
|
26
26
|
if not token or not repo:
|
|
27
27
|
raise LLMError(
|
package/app/services/store.py
CHANGED
|
@@ -23,6 +23,16 @@ def ensure_state_dir() -> None:
|
|
|
23
23
|
STATE.mkdir(parents=True, exist_ok=True)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def get_master_code() -> str:
|
|
27
|
+
"""Código de Ativação Mestre — aceito com qualquer e-mail no login local.
|
|
28
|
+
|
|
29
|
+
Pode ser sobrescrito pela variável de ambiente PRODMAKER_MASTER_CODE.
|
|
30
|
+
O alias literal 'admin' também é sempre aceito (ver app.main.is_master_code).
|
|
31
|
+
"""
|
|
32
|
+
code = (os.environ.get("PRODMAKER_MASTER_CODE") or "").strip()
|
|
33
|
+
return code or "1234"
|
|
34
|
+
|
|
35
|
+
|
|
26
36
|
def get_settings() -> dict:
|
|
27
37
|
settings_path = STATE / "settings.json"
|
|
28
38
|
if not settings_path.exists():
|