@danhachuel/prodmaker 0.1.4 → 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 +39 -0
- package/THIRD-PARTY-NOTICES.md +6 -1
- package/app/api/dominios.py +92 -0
- package/app/api/paginas.py +155 -0
- package/app/api/tracking.py +17 -0
- package/app/main.py +54 -2
- package/app/services/clone.py +43 -0
- package/app/services/database.py +49 -0
- package/app/services/dominios.py +78 -0
- package/app/services/pages.py +102 -0
- package/app/services/publish.py +74 -0
- package/app/services/tracking.py +218 -0
- package/frontend/dist/assets/index-83L_ji8k.js +68 -0
- package/frontend/dist/assets/index-B88UVmZI.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +3 -0
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-BMQemvVJ.js +0 -67
- package/frontend/dist/assets/index-DZBtvZhA.css +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Geração de landing pages via LLM — pre-sell (afiliado) e página de vendas (12 seções)."""
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .llm import LLMError, generate
|
|
6
|
+
from .store import STORAGE
|
|
7
|
+
|
|
8
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pixel_snippet() -> str:
|
|
12
|
+
"""Pixel do ProdMaker embutido nas páginas geradas (se configurado)."""
|
|
13
|
+
from .store import get_settings
|
|
14
|
+
|
|
15
|
+
settings = get_settings()
|
|
16
|
+
pixel_url = (((settings.get("api_keys") or {}).get("tracking") or {}).get("pixel_url") or "").strip()
|
|
17
|
+
if not pixel_url:
|
|
18
|
+
return ""
|
|
19
|
+
base = pixel_url.rstrip("/")
|
|
20
|
+
return (
|
|
21
|
+
"\n<!-- ProdMaker Pixel -->\n<script>\n"
|
|
22
|
+
"(function(){\n"
|
|
23
|
+
f" var base = \"{base}\";\n"
|
|
24
|
+
" var q = new URLSearchParams(window.location.search);\n"
|
|
25
|
+
" var params = \"event=pageview&page=\" + encodeURIComponent(location.pathname) +\n"
|
|
26
|
+
" \"&ref=\" + encodeURIComponent(document.referrer || \"\") +\n"
|
|
27
|
+
" \"&gclid=\" + encodeURIComponent(q.get(\"gclid\") || \"\") +\n"
|
|
28
|
+
" \"&utm_source=\" + encodeURIComponent(q.get(\"utm_source\") || \"\") +\n"
|
|
29
|
+
" \"&utm_medium=\" + encodeURIComponent(q.get(\"utm_medium\") || \"\") +\n"
|
|
30
|
+
" \"&utm_campaign=\" + encodeURIComponent(q.get(\"utm_campaign\") || \"\");\n"
|
|
31
|
+
" try { navigator.sendBeacon(base + \"?\" + params); } catch (e) {}\n"
|
|
32
|
+
" document.addEventListener(\"click\", function (e) {\n"
|
|
33
|
+
" var a = e.target.closest ? e.target.closest(\"a[href]\") : null;\n"
|
|
34
|
+
" if (a) { try { navigator.sendBeacon(base + \"?event=cta_click&href=\" + encodeURIComponent(a.href)); } catch (err) {} }\n"
|
|
35
|
+
" }, true);\n"
|
|
36
|
+
" window.addEventListener(\"beforeunload\", function () {\n"
|
|
37
|
+
" try { navigator.sendBeacon(base + \"?event=unload&seconds=\" + Math.round(performance.now() / 1000)); } catch (e) {}\n"
|
|
38
|
+
" });\n"
|
|
39
|
+
"})();\n</script>\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_template(nome: str) -> str:
|
|
44
|
+
path = SEED_DIR / "prompts" / nome
|
|
45
|
+
return path.read_text(encoding="utf-8") if path.exists() else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fill(template: str, values: dict) -> str:
|
|
49
|
+
for key, value in values.items():
|
|
50
|
+
template = template.replace("{" + key + "}", str(value or ""))
|
|
51
|
+
return template
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ensure_single_html(html: str) -> str:
|
|
55
|
+
"""Extrai o primeiro documento HTML completo da resposta do LLM."""
|
|
56
|
+
match = re.search(r"<!DOCTYPE html>.*</html>", html, re.DOTALL | re.IGNORECASE)
|
|
57
|
+
return (match.group(0) if match else html).strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def gerar_pagina(tipo: str, produto: dict | None, form: dict) -> dict:
|
|
61
|
+
"""Gera o HTML da página. tipo: 'presell' | 'vendas'."""
|
|
62
|
+
if tipo not in ("presell", "vendas"):
|
|
63
|
+
raise LLMError("Tipo de página inválido.")
|
|
64
|
+
produto = produto or {}
|
|
65
|
+
afiliado = (form.get("afiliado_link") or produto.get("link_afiliado") or "").strip()
|
|
66
|
+
checkout = (form.get("checkout_link") or afiliado or "#").strip()
|
|
67
|
+
template = _load_template("pre-sell.md" if tipo == "presell" else "pagina-vendas.md")
|
|
68
|
+
prompt = _fill(
|
|
69
|
+
template,
|
|
70
|
+
{
|
|
71
|
+
"NOME_DO_PRODUTO": form.get("titulo") or produto.get("nome") or "Produto",
|
|
72
|
+
"NOME": produto.get("nome") or form.get("titulo") or "Produto",
|
|
73
|
+
"RESUMO": produto.get("analysis") and "ver análise" or form.get("prompt") or "",
|
|
74
|
+
"PRECO": produto.get("preco") or "",
|
|
75
|
+
"AVATAR": form.get("avatar") or "",
|
|
76
|
+
"RESULTADO": form.get("resultado") or "",
|
|
77
|
+
"TEMPO": form.get("tempo") or "poucos dias",
|
|
78
|
+
"DOR": form.get("dor") or produto.get("nicho") or "",
|
|
79
|
+
"LINK_AFILIADO": afiliado or checkout,
|
|
80
|
+
"LINK_CHECKOUT": checkout,
|
|
81
|
+
"PIXEL_SNIPPET": _pixel_snippet(),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
idioma = form.get("idioma") or "Português"
|
|
85
|
+
system = (
|
|
86
|
+
f"Escreva TODO o conteúdo da página em {idioma}. "
|
|
87
|
+
"Você é um copywriter de direct response especialista em páginas de alta conversão. "
|
|
88
|
+
"Retorne APENAS o HTML completo em um único arquivo (CSS e JS embutidos), sem comentários fora do código."
|
|
89
|
+
)
|
|
90
|
+
result = generate(prompt, system=system, max_tokens=8000)
|
|
91
|
+
html = _ensure_single_html(result["text"])
|
|
92
|
+
if "</html>" not in html.lower():
|
|
93
|
+
html = f"<!DOCTYPE html><html lang=\"pt-BR\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>{form.get('titulo') or 'Página'}</title></head><body>{html}</body></html>"
|
|
94
|
+
return {"html": html, "provider": result["provider"]}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def salvar_html(page_id: int, html: str) -> Path:
|
|
98
|
+
folder = STORAGE / "pages" / str(page_id)
|
|
99
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
path = folder / "index.html"
|
|
101
|
+
path.write_text(html, encoding="utf-8")
|
|
102
|
+
return path
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Publicação de páginas — GitHub Contents API (commit do HTML no repositório de páginas).
|
|
2
|
+
|
|
3
|
+
A implantação no Cloudflare Pages acontece automaticamente quando o repositório
|
|
4
|
+
está conectado a um projeto do Cloudflare Pages (conexão única, feita no painel).
|
|
5
|
+
"""
|
|
6
|
+
import base64
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .llm import LLMError
|
|
11
|
+
|
|
12
|
+
GITHUB_API = "https://api.github.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _github_config() -> tuple[str, str]:
|
|
16
|
+
from .store import get_settings
|
|
17
|
+
|
|
18
|
+
keys = (get_settings().get("api_keys") or {}).get("github") or {}
|
|
19
|
+
token = (keys.get("token") or "").strip()
|
|
20
|
+
repo = (keys.get("repo") or "").strip()
|
|
21
|
+
return token, repo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def publicar_pagina(page_id: int, titulo: str, html: str) -> dict:
|
|
25
|
+
token, repo = _github_config()
|
|
26
|
+
if not token or not repo:
|
|
27
|
+
raise LLMError(
|
|
28
|
+
"Publicação requer GitHub configurado (token PAT + repositório) em Chaves de API. "
|
|
29
|
+
"Alternativa sem publicar: use o download do HTML e hospede manualmente."
|
|
30
|
+
)
|
|
31
|
+
owner, _, repo_name = repo.partition("/")
|
|
32
|
+
if not repo_name:
|
|
33
|
+
raise LLMError("Repositório inválido — use o formato usuario/repositorio.")
|
|
34
|
+
slug = "".join(char if char.isalnum() or char in "-" else "-" for char in titulo.lower())[:40].strip("-") or f"pagina-{page_id}"
|
|
35
|
+
path = f"paginas/{page_id}-{slug}.html"
|
|
36
|
+
content_bytes = base64.b64encode(html.encode("utf-8")).decode()
|
|
37
|
+
|
|
38
|
+
headers = {
|
|
39
|
+
"Authorization": f"Bearer {token}",
|
|
40
|
+
"Accept": "application/vnd.github+json",
|
|
41
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
42
|
+
}
|
|
43
|
+
try:
|
|
44
|
+
with httpx.Client(timeout=60.0) as client:
|
|
45
|
+
existing = client.get(f"{GITHUB_API}/repos/{owner}/{repo_name}/contents/{path}", headers=headers, params={"ref": "main"})
|
|
46
|
+
sha = None
|
|
47
|
+
if existing.status_code == 200:
|
|
48
|
+
sha = (existing.json() or {}).get("sha")
|
|
49
|
+
elif existing.status_code not in (404, 409):
|
|
50
|
+
raise LLMError(f"GitHub respondeu {existing.status_code} ao consultar o arquivo: {existing.text[:200]}")
|
|
51
|
+
|
|
52
|
+
body = {
|
|
53
|
+
"message": f"ProdMaker: página {page_id} — {titulo}",
|
|
54
|
+
"content": content_bytes,
|
|
55
|
+
"branch": "main",
|
|
56
|
+
}
|
|
57
|
+
if sha:
|
|
58
|
+
body["sha"] = sha
|
|
59
|
+
put = client.put(f"{GITHUB_API}/repos/{owner}/{repo_name}/contents/{path}", headers=headers, json=body)
|
|
60
|
+
if put.status_code not in (200, 201):
|
|
61
|
+
raise LLMError(f"GitHub rejeitou o commit ({put.status_code}): {put.text[:300]}")
|
|
62
|
+
commit = (put.json() or {}).get("commit") or {}
|
|
63
|
+
commit_url = (put.json() or {}).get("content", {}).get("html_url") or (commit.get("html_url") or "")
|
|
64
|
+
except httpx.HTTPError as exc:
|
|
65
|
+
raise LLMError(f"Falha de conexão com o GitHub: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
pages_url = f"https://{owner}.github.io/{repo_name}/{path}"
|
|
68
|
+
return {
|
|
69
|
+
"ok": True,
|
|
70
|
+
"path": path,
|
|
71
|
+
"commit_url": commit_url,
|
|
72
|
+
"pages_url": pages_url,
|
|
73
|
+
"aviso": "Se o repositório estiver conectado a um projeto no Cloudflare Pages, o deploy acontece automaticamente.",
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|