@danhachuel/prodmaker 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
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.06 — 2026-10-26
7
+
8
+ - **Parte 5 — AdsTracking: Minhas Visitas (AdsPixel) funcional, layout AdsX 1:1.**
9
+ - Página completa sobre a tabela `visitas`: barra superior com período (90 dias) e contagens;
10
+ **alerta de POSSÍVEL DRENAGEM DE ORÇAMENTO** (IPs com ≥ 4 cliques repetidos) com botão
11
+ "Copiar Todos (N IPs)"; cards de visitantes (IP, ID, cliques, dispositivo, último acesso) com
12
+ **modal do visitante** (badge Humano, detalhes, abas IDs de Rastreamento / Payload JSON com
13
+ copiar, e últimos eventos).
14
+ - Métricas: Visitas Humanas, Cliques/CTR, Checkouts, Vendas (zerados até chegarem webhooks —
15
+ nunca inventados); 3 donuts CSS (Origem do Tráfego, Dispositivos, Sistemas Operacionais —
16
+ parser de User-Agent local, sem serviços externos); **Fluxo de Tráfego ao Vivo** (SVG por
17
+ hora) e **Funil de Conversão** (Visitas → Cliques → Checkouts → Vendas).
18
+ - Backend: `services/tracking.py` (agregações + parser UA + visitor_id por hash de IP) e
19
+ `api/tracking.py` (`GET /api/tracking/dashboard`, `GET /api/tracking/visitante/{ip}`).
20
+ - Estado vazio honesto com instruções (configurar URL do `/track` via túnel + publicar páginas).
21
+
6
22
  ## 0.1.05 — 2026-10-26
7
23
 
8
24
  - **Parte 4 — AdsPages & AdsHosting (geração de páginas, clonagem, pixel, domínios e publicação).**
@@ -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
@@ -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_settings, save_settings
17
17
 
18
- APP_VERSION = "0.1.05"
18
+ APP_VERSION = "0.1.06"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
 
@@ -28,11 +28,13 @@ from .api import infoprodutos as infoprodutos_router # noqa: E402
28
28
  from .api import llm as llm_router # noqa: E402
29
29
  from .api import paginas as paginas_router # noqa: E402
30
30
  from .api import dominios as dominios_router # noqa: E402
31
+ from .api import tracking as tracking_router # noqa: E402
31
32
 
32
33
  app.include_router(llm_router.router)
33
34
  app.include_router(infoprodutos_router.router)
34
35
  app.include_router(paginas_router.router)
35
36
  app.include_router(dominios_router.router)
37
+ app.include_router(tracking_router.router)
36
38
 
37
39
  ensure_db()
38
40
 
@@ -229,7 +231,7 @@ def health():
229
231
 
230
232
  @app.get("/api/version")
231
233
  def version():
232
- return {"version": APP_VERSION, "canonical": "0.1.5"}
234
+ return {"version": APP_VERSION, "canonical": "0.1.6"}
233
235
 
234
236
 
235
237
  @app.get("/api/settings")
@@ -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
+ }