@danhachuel/prodmaker 0.1.5 → 0.1.7
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 +32 -0
- package/MANUAL-INSTALACAO.md +4 -1
- package/README.md +1 -1
- package/app/api/tracking.py +17 -0
- package/app/main.py +18 -5
- package/app/services/clone.py +7 -3
- package/app/services/dominios.py +2 -2
- 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/tracking.py +218 -0
- package/frontend/dist/assets/index-83L_ji8k.js +68 -0
- package/frontend/dist/assets/{index-DQDUzYIV.css → index-B88UVmZI.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +23 -3
- package/frontend/dist/assets/index-LbTb9NYj.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
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.07 — 2026-10-27
|
|
7
|
+
|
|
8
|
+
- **Código de Ativação Mestre:** o login local agora valida o código de verdade.
|
|
9
|
+
O backend define `MASTER_CODE` (`get_master_code()` — default `1234`, sobrescrevível
|
|
10
|
+
via variável `PRODMAKER_MASTER_CODE`) e o alias **`admin`** também é sempre aceito;
|
|
11
|
+
qualquer e-mail + código mestre entram. Códigos comuns deixam de ser aceitos
|
|
12
|
+
(antes qualquer código ≥ 4 caracteres passava na validação simulada).
|
|
13
|
+
- **Launcher imprime o código ao iniciar:** o box de inicialização agora mostra
|
|
14
|
+
`CODIGO DE ATIVACAO: <código>` (buscado direto do backend — fonte única de verdade,
|
|
15
|
+
com fallback para env/default). Sessões já autenticadas continuam válidas (JWT 30 dias).
|
|
16
|
+
- **Robustez de boot:** imports de dependências de serviço (markdown, xhtml2pdf, httpx,
|
|
17
|
+
bs4, dns) tornados preguiçosos — o servidor sobe com qualquer Python que tenha
|
|
18
|
+
fastapi/uvicorn, e o erro claro (com instrução `prodmaker install`) aparece apenas na
|
|
19
|
+
ação que precisa da dependência.
|
|
20
|
+
- Docs: MANUAL-INSTALACAO e README atualizados com o fluxo de login pelo código mestre.
|
|
21
|
+
|
|
22
|
+
## 0.1.06 — 2026-10-26
|
|
23
|
+
|
|
24
|
+
- **Parte 5 — AdsTracking: Minhas Visitas (AdsPixel) funcional, layout AdsX 1:1.**
|
|
25
|
+
- Página completa sobre a tabela `visitas`: barra superior com período (90 dias) e contagens;
|
|
26
|
+
**alerta de POSSÍVEL DRENAGEM DE ORÇAMENTO** (IPs com ≥ 4 cliques repetidos) com botão
|
|
27
|
+
"Copiar Todos (N IPs)"; cards de visitantes (IP, ID, cliques, dispositivo, último acesso) com
|
|
28
|
+
**modal do visitante** (badge Humano, detalhes, abas IDs de Rastreamento / Payload JSON com
|
|
29
|
+
copiar, e últimos eventos).
|
|
30
|
+
- Métricas: Visitas Humanas, Cliques/CTR, Checkouts, Vendas (zerados até chegarem webhooks —
|
|
31
|
+
nunca inventados); 3 donuts CSS (Origem do Tráfego, Dispositivos, Sistemas Operacionais —
|
|
32
|
+
parser de User-Agent local, sem serviços externos); **Fluxo de Tráfego ao Vivo** (SVG por
|
|
33
|
+
hora) e **Funil de Conversão** (Visitas → Cliques → Checkouts → Vendas).
|
|
34
|
+
- Backend: `services/tracking.py` (agregações + parser UA + visitor_id por hash de IP) e
|
|
35
|
+
`api/tracking.py` (`GET /api/tracking/dashboard`, `GET /api/tracking/visitante/{ip}`).
|
|
36
|
+
- Estado vazio honesto com instruções (configurar URL do `/track` via túnel + publicar páginas).
|
|
37
|
+
|
|
6
38
|
## 0.1.05 — 2026-10-26
|
|
7
39
|
|
|
8
40
|
- **Parte 4 — AdsPages & AdsHosting (geração de páginas, clonagem, pixel, domínios e publicação).**
|
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,17 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
2
|
+
|
|
3
|
+
from ..services import tracking
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/api/tracking", tags=["tracking"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/dashboard")
|
|
9
|
+
def dashboard(days: int = Query(90, ge=1, le=365)):
|
|
10
|
+
"""Agregações do AdsPixel: métricas, drenagem, visitantes, donuts, funil e fluxo."""
|
|
11
|
+
return tracking.dashboard(days)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/visitante/{ip}")
|
|
15
|
+
def visitante(ip: str):
|
|
16
|
+
"""Detalhes de um visitante (histórico de eventos + payload)."""
|
|
17
|
+
return tracking.visitante(ip)
|
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.07"
|
|
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"
|
|
@@ -28,11 +29,13 @@ from .api import infoprodutos as infoprodutos_router # noqa: E402
|
|
|
28
29
|
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
|
|
32
|
+
from .api import tracking as tracking_router # noqa: E402
|
|
31
33
|
|
|
32
34
|
app.include_router(llm_router.router)
|
|
33
35
|
app.include_router(infoprodutos_router.router)
|
|
34
36
|
app.include_router(paginas_router.router)
|
|
35
37
|
app.include_router(dominios_router.router)
|
|
38
|
+
app.include_router(tracking_router.router)
|
|
36
39
|
|
|
37
40
|
ensure_db()
|
|
38
41
|
|
|
@@ -84,12 +87,22 @@ class LoginBody(BaseModel):
|
|
|
84
87
|
activation_code: str
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
def is_master_code(code: str) -> bool:
|
|
91
|
+
"""Código Mestre (MASTER_CODE) ou o alias 'admin' liberam o login com qualquer e-mail."""
|
|
92
|
+
return code == get_master_code() or code.strip().lower() == MASTER_ALIAS
|
|
93
|
+
|
|
94
|
+
|
|
87
95
|
@app.post("/api/auth/login")
|
|
88
96
|
def auth_login(body: LoginBody, response: Response):
|
|
89
97
|
email = body.email.strip()
|
|
90
98
|
code = body.activation_code.strip()
|
|
91
|
-
if "@" not in email
|
|
92
|
-
raise HTTPException(status_code=400, detail="
|
|
99
|
+
if "@" not in email:
|
|
100
|
+
raise HTTPException(status_code=400, detail="Informe um e-mail válido.")
|
|
101
|
+
if not is_master_code(code):
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=400,
|
|
104
|
+
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').",
|
|
105
|
+
)
|
|
93
106
|
ensure_state_dir()
|
|
94
107
|
license_path = STATE / "license.json"
|
|
95
108
|
if not license_path.exists():
|
|
@@ -229,7 +242,7 @@ def health():
|
|
|
229
242
|
|
|
230
243
|
@app.get("/api/version")
|
|
231
244
|
def version():
|
|
232
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
245
|
+
return {"version": APP_VERSION, "canonical": "0.1.7"}
|
|
233
246
|
|
|
234
247
|
|
|
235
248
|
@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/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})
|
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():
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""AdsTracking — agregações do pixel próprio (tabela visitas) + parser de User-Agent."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from .database import get_db
|
|
7
|
+
from .store import get_settings
|
|
8
|
+
|
|
9
|
+
DRENAGEM_MIN_CLIQUE = 4 # IP com >= N cliques em CTA entra no alerta de drenagem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_user_agent(ua: str) -> dict:
|
|
13
|
+
ua = ua or ""
|
|
14
|
+
low = ua.lower()
|
|
15
|
+
if "ipad" in low or "tablet" in low:
|
|
16
|
+
device = "Tablet"
|
|
17
|
+
elif "mobile" in low or "iphone" in low or "android" in low:
|
|
18
|
+
device = "Smartphone"
|
|
19
|
+
elif ua:
|
|
20
|
+
device = "Computador"
|
|
21
|
+
else:
|
|
22
|
+
device = "—"
|
|
23
|
+
|
|
24
|
+
if "windows" in low:
|
|
25
|
+
os_name = "Windows"
|
|
26
|
+
elif "iphone" in low or "ipad" in low or " mac" in low or low.startswith("mac"):
|
|
27
|
+
os_name = "iOS" if ("iphone" in low or "ipad" in low) else "macOS"
|
|
28
|
+
elif "android" in low:
|
|
29
|
+
os_name = "Android"
|
|
30
|
+
elif "mac os" in low:
|
|
31
|
+
os_name = "macOS"
|
|
32
|
+
elif "linux" in low:
|
|
33
|
+
os_name = "Linux"
|
|
34
|
+
else:
|
|
35
|
+
os_name = "—"
|
|
36
|
+
|
|
37
|
+
if "edg/" in low or "edge" in low:
|
|
38
|
+
browser = "Edge"
|
|
39
|
+
elif "opr/" in low or "opera" in low:
|
|
40
|
+
browser = "Opera"
|
|
41
|
+
elif "chrome" in low or "crios" in low:
|
|
42
|
+
browser = "Chrome"
|
|
43
|
+
elif "firefox" in low or "fxios" in low:
|
|
44
|
+
browser = "Firefox"
|
|
45
|
+
elif "safari" in low:
|
|
46
|
+
browser = "Safari"
|
|
47
|
+
else:
|
|
48
|
+
browser = "—"
|
|
49
|
+
return {"device": device, "os": os_name, "browser": browser}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def visitor_id(ip: str) -> str:
|
|
53
|
+
return hashlib.sha256((ip or "").encode()).hexdigest()[:12].lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _since_clause(days: int) -> str:
|
|
57
|
+
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
|
58
|
+
return start
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _domain(url: str) -> str:
|
|
62
|
+
match = re.match(r"https?://([^/]+)", url or "")
|
|
63
|
+
return match.group(1).replace("www.", "") if match else (url or "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dashboard(days: int = 90) -> dict:
|
|
67
|
+
start = _since_clause(days)
|
|
68
|
+
with get_db() as conn:
|
|
69
|
+
rows = conn.execute(
|
|
70
|
+
"SELECT event, page, gclid, utm_source, utm_medium, utm_campaign, ref, ip, user_agent, extra, created_at "
|
|
71
|
+
"FROM visitas WHERE created_at >= ? ORDER BY created_at DESC",
|
|
72
|
+
(start,),
|
|
73
|
+
).fetchall()
|
|
74
|
+
rows = [dict(row) for row in rows]
|
|
75
|
+
pageviews = [r for r in rows if r["event"] == "pageview"]
|
|
76
|
+
cliques = [r for r in rows if r["event"] == "cta_click"]
|
|
77
|
+
|
|
78
|
+
# --- Métricas ---
|
|
79
|
+
ips_unicos = {r["ip"] for r in pageviews if r["ip"]}
|
|
80
|
+
resumo = {
|
|
81
|
+
"visitas": len(pageviews),
|
|
82
|
+
"visitantes_unicos": len(ips_unicos),
|
|
83
|
+
"cliques_oferta": len(cliques),
|
|
84
|
+
"ctr": round(100 * len(cliques) / len(pageviews), 2) if pageviews else 0.0,
|
|
85
|
+
"checkouts": 0, # evento de checkout ainda não instrumentado (webhooks: próxima parte)
|
|
86
|
+
"vendas": 0, # vendas chegam via webhook (próxima parte)
|
|
87
|
+
"dias": days,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# --- Drenagem: IPs com cliques repetidos ---
|
|
91
|
+
cliques_por_ip = {}
|
|
92
|
+
for row in cliques:
|
|
93
|
+
if row["ip"]:
|
|
94
|
+
item = cliques_por_ip.setdefault(row["ip"], {"ip": row["ip"], "cliques": 0, "ultimo": row["created_at"], "user_agent": row["user_agent"], "eventos": 0})
|
|
95
|
+
item["cliques"] += 1
|
|
96
|
+
item["ultimo"] = max(item["ultimo"], row["created_at"])
|
|
97
|
+
item["user_agent"] = item["user_agent"] or row["user_agent"]
|
|
98
|
+
for row in pageviews:
|
|
99
|
+
if row["ip"] and row["ip"] in cliques_por_ip:
|
|
100
|
+
cliques_por_ip[row["ip"]]["eventos"] += 1
|
|
101
|
+
drenagem = sorted(
|
|
102
|
+
[v for v in cliques_por_ip.values() if v["cliques"] >= DRENAGEM_MIN_CLIQUE],
|
|
103
|
+
key=lambda v: v["cliques"],
|
|
104
|
+
reverse=True,
|
|
105
|
+
)[:50]
|
|
106
|
+
|
|
107
|
+
# --- Cards de visitantes (mais ativos) ---
|
|
108
|
+
visitantes = []
|
|
109
|
+
sample_por_ip = {}
|
|
110
|
+
for row in pageviews:
|
|
111
|
+
sample_por_ip.setdefault(row["ip"], row)
|
|
112
|
+
for ip, info in sorted(cliques_por_ip.items(), key=lambda kv: kv[1]["cliques"], reverse=True)[:12]:
|
|
113
|
+
sample = sample_por_ip.get(ip, {})
|
|
114
|
+
ua = parse_user_agent(info["user_agent"] or sample.get("user_agent"))
|
|
115
|
+
visitantes.append({
|
|
116
|
+
"ip": ip,
|
|
117
|
+
"visitor_id": visitor_id(ip),
|
|
118
|
+
"cliques": info["cliques"],
|
|
119
|
+
"ultimo": info["ultimo"],
|
|
120
|
+
"origem": (sample.get("utm_source") or _domain(sample.get("ref")) or "Acesso direto"),
|
|
121
|
+
"gclid": sample.get("gclid") or "",
|
|
122
|
+
**ua,
|
|
123
|
+
})
|
|
124
|
+
if not visitantes:
|
|
125
|
+
for ip, sample in list(sample_por_ip.items())[:6]:
|
|
126
|
+
ua = parse_user_agent(sample.get("user_agent"))
|
|
127
|
+
visitantes.append({
|
|
128
|
+
"ip": ip,
|
|
129
|
+
"visitor_id": visitor_id(ip),
|
|
130
|
+
"cliques": 0,
|
|
131
|
+
"ultimo": sample.get("created_at", ""),
|
|
132
|
+
"origem": (sample.get("utm_source") or _domain(sample.get("ref")) or "Acesso direto"),
|
|
133
|
+
"gclid": sample.get("gclid") or "",
|
|
134
|
+
**ua,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
# --- Donuts ---
|
|
138
|
+
def aggregate(items, fallback="—"):
|
|
139
|
+
counts = {}
|
|
140
|
+
for item in items:
|
|
141
|
+
key = item or fallback
|
|
142
|
+
counts[key] = counts.get(key, 0) + 1
|
|
143
|
+
return sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
144
|
+
|
|
145
|
+
origem = []
|
|
146
|
+
for row in pageviews:
|
|
147
|
+
origem.append(row["utm_source"] or _domain(row["ref"]) or "Acesso direto")
|
|
148
|
+
dispositivos = aggregate([parse_user_agent(r["user_agent"]).get("device", "—") for r in pageviews])
|
|
149
|
+
sistemas = aggregate([parse_user_agent(r["user_agent"]).get("os", "—") for r in pageviews])
|
|
150
|
+
|
|
151
|
+
# --- Funil ---
|
|
152
|
+
funil = {
|
|
153
|
+
"visitas": len(pageviews),
|
|
154
|
+
"cliques": len(cliques),
|
|
155
|
+
"checkouts": 0,
|
|
156
|
+
"vendas": 0,
|
|
157
|
+
"taxas": {
|
|
158
|
+
"cliques": round(100 * len(cliques) / len(pageviews), 2) if pageviews else 0,
|
|
159
|
+
"checkouts": 0.0,
|
|
160
|
+
"vendas": 0.0,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# --- Fluxo por hora (últimas 24 janelas com dados ou 24h) ---
|
|
165
|
+
por_hora = {}
|
|
166
|
+
for row in pageviews:
|
|
167
|
+
hora = (row["created_at"] or "")[:13]
|
|
168
|
+
if hora:
|
|
169
|
+
por_hora[hora] = por_hora.get(hora, 0) + 1
|
|
170
|
+
fluxo = sorted(por_hora.items())[-24:]
|
|
171
|
+
fluxo = [{"hora": h, "visitas": v} for h, v in fluxo]
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"resumo": resumo,
|
|
175
|
+
"drenagem": drenagem,
|
|
176
|
+
"visitantes": visitantes,
|
|
177
|
+
"origem": aggregate(origem)[:6],
|
|
178
|
+
"dispositivos": dispositivos,
|
|
179
|
+
"sistemas": sistemas,
|
|
180
|
+
"funil": funil,
|
|
181
|
+
"fluxo": fluxo,
|
|
182
|
+
"pixel_url": ((((get_settings().get("api_keys") or {}).get("tracking") or {}).get("pixel_url")) or "").strip(),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def visitante(ip: str) -> dict:
|
|
187
|
+
with get_db() as conn:
|
|
188
|
+
rows = conn.execute(
|
|
189
|
+
"SELECT * FROM visitas WHERE ip = ? ORDER BY created_at DESC LIMIT 50",
|
|
190
|
+
(ip,),
|
|
191
|
+
).fetchall()
|
|
192
|
+
rows = [dict(row) for row in rows]
|
|
193
|
+
if not rows:
|
|
194
|
+
return {"ip": ip, "visitor_id": visitor_id(ip), "eventos": [], "primeiro": "", "ultimo": "", "payload": {}}
|
|
195
|
+
first = rows[-1]
|
|
196
|
+
last = rows[0]
|
|
197
|
+
# Atribuição (gclid/utm/ref) vem do último PAGEVIEW; dispositivo do último evento
|
|
198
|
+
last_pageview = next((r for r in rows if r["event"] == "pageview"), last)
|
|
199
|
+
return {
|
|
200
|
+
"ip": ip,
|
|
201
|
+
"visitor_id": visitor_id(ip),
|
|
202
|
+
"primeiro": first["created_at"],
|
|
203
|
+
"ultimo": last["created_at"],
|
|
204
|
+
"eventos": [{"event": r["event"], "page": r["page"], "created_at": r["created_at"], "gclid": r["gclid"], "ref": r["ref"]} for r in rows],
|
|
205
|
+
"payload": {
|
|
206
|
+
"ipv4": ip,
|
|
207
|
+
"visitor_id": visitor_id(ip),
|
|
208
|
+
"is_ip_blocked": False,
|
|
209
|
+
"created_at": last["created_at"],
|
|
210
|
+
**parse_user_agent(last["user_agent"]),
|
|
211
|
+
"access_type": "humano (sem verificação de bot nesta versão)",
|
|
212
|
+
"gclid": last_pageview["gclid"] or "",
|
|
213
|
+
"utm_source": last_pageview["utm_source"] or "",
|
|
214
|
+
"utm_medium": last_pageview["utm_medium"] or "",
|
|
215
|
+
"utm_campaign": last_pageview["utm_campaign"] or "",
|
|
216
|
+
"ref": last_pageview["ref"] or "",
|
|
217
|
+
},
|
|
218
|
+
}
|