cdp-edge 1.23.1 → 1.23.3
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/README.md +45 -21
- package/bin/cdp-edge.js +1 -1
- package/contracts/agent-versions.json +67 -66
- package/dist/commands/install.js +1 -1
- package/dist/commands/server.js +4 -4
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
- package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
- package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
- package/package.json +3 -2
- package/server-edge-tracker/INSTALAR.md +5 -5
- package/server-edge-tracker/index.js +109 -7
- package/server-edge-tracker/modules/db.js +71 -0
- package/server-edge-tracker/modules/dispatch/meta.js +12 -0
- package/server-edge-tracker/modules/ml/fraud.js +1 -16
- package/server-edge-tracker/modules/ml/ltv.js +62 -11
- package/server-edge-tracker/modules/ml/segmentation.js +157 -127
- package/server-edge-tracker/modules/utils.js +78 -0
- package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
- package/server-edge-tracker/wrangler.toml +26 -8
- package/templates/lancamento-imobiliario.md +344 -0
- package/server-edge-tracker/worker.js +0 -4596
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Route Intent Capture
|
|
3
|
+
* @version 2.0.0
|
|
4
|
+
*
|
|
5
|
+
* Fluxo:
|
|
6
|
+
* 1. Usuário clica em "Ver rota" → Google Maps abre normalmente
|
|
7
|
+
* 2. Widget aparece com um botão de WhatsApp
|
|
8
|
+
* 3. Usuário clica → WhatsApp abre com mensagem pré-escrita
|
|
9
|
+
* 4. Usuário envia → corretor recebe e responde
|
|
10
|
+
*
|
|
11
|
+
* Uso:
|
|
12
|
+
* initRouteIntentCapture({
|
|
13
|
+
* whatsappNumber: '5511999999999', // número do plantão/corretor
|
|
14
|
+
* propertyName: 'Reserva do Jardim',
|
|
15
|
+
* propertyId: 'rj-001',
|
|
16
|
+
* propertyLat: -23.6519,
|
|
17
|
+
* propertyLng: -46.5330,
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const isBrowser = typeof window !== 'undefined';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string} options.whatsappNumber Número do plantão (ex: '5511999999999')
|
|
26
|
+
* @param {string} [options.propertyName] Nome do empreendimento
|
|
27
|
+
* @param {string} [options.propertyId] ID interno do imóvel
|
|
28
|
+
* @param {number} [options.propertyLat] Latitude
|
|
29
|
+
* @param {number} [options.propertyLng] Longitude
|
|
30
|
+
* @param {string} [options.routeSelector] CSS selector dos botões de rota (default auto)
|
|
31
|
+
* @param {string} [options.distanceBucket] 'very_close'|'close'|'nearby'|'moderate'|'far'
|
|
32
|
+
* @param {number} [options.distanceKm] Distância em km — exibe tempo estimado
|
|
33
|
+
* @param {string} [options.metaSignalBucket] 'hot'|'warm'|'cold'
|
|
34
|
+
* @param {string} [options.brokerName] Nome do corretor de plantão (ex: 'Ramon')
|
|
35
|
+
*/
|
|
36
|
+
export function initRouteIntentCapture(options = {}) {
|
|
37
|
+
if (!isBrowser) return;
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
whatsappNumber,
|
|
41
|
+
propertyName = 'o imóvel',
|
|
42
|
+
propertyId = null,
|
|
43
|
+
propertyLat = null,
|
|
44
|
+
propertyLng = null,
|
|
45
|
+
routeSelector = '[data-route-intent], a[href*="maps/dir"], a[href*="maps?q="]',
|
|
46
|
+
distanceBucket = null,
|
|
47
|
+
distanceKm = null,
|
|
48
|
+
metaSignalBucket = null,
|
|
49
|
+
brokerName = null,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
if (!whatsappNumber) {
|
|
53
|
+
console.warn('[RouteIntent] whatsappNumber é obrigatório.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_injectStyles();
|
|
58
|
+
|
|
59
|
+
document.addEventListener('click', (e) => {
|
|
60
|
+
const btn = e.target.closest(routeSelector);
|
|
61
|
+
if (!btn) return;
|
|
62
|
+
_showWidget(btn, { whatsappNumber, propertyName, propertyId, propertyLat, propertyLng, distanceBucket, distanceKm, metaSignalBucket, brokerName });
|
|
63
|
+
}, true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Widget ────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function _showWidget(anchorEl, opts) {
|
|
69
|
+
document.getElementById('cdp-ri-widget')?.remove();
|
|
70
|
+
|
|
71
|
+
const travelMinutes = _estimateTravelMinutes(opts.distanceKm);
|
|
72
|
+
const travelText = travelMinutes
|
|
73
|
+
? `<p class="cdp-ri-travel">📍 Você está a cerca de <strong>${travelMinutes} min</strong> daqui</p>`
|
|
74
|
+
: '';
|
|
75
|
+
|
|
76
|
+
const brokerLine = opts.brokerName
|
|
77
|
+
? `<p class="cdp-ri-broker">Ao chegar, pergunte pelo <strong>${opts.brokerName}</strong> — ele vai estar te aguardando com tudo pronto 🤝</p>`
|
|
78
|
+
: '';
|
|
79
|
+
|
|
80
|
+
const widget = document.createElement('div');
|
|
81
|
+
widget.id = 'cdp-ri-widget';
|
|
82
|
+
widget.innerHTML = `
|
|
83
|
+
<div class="cdp-ri-inner">
|
|
84
|
+
${travelText}
|
|
85
|
+
<p class="cdp-ri-headline">Vi que você quer visitar o local! Confirme sua vinda enviando a mensagem abaixo — nossa equipe já fica de prontidão pra te receber 👇</p>
|
|
86
|
+
${brokerLine}
|
|
87
|
+
<button id="cdp-ri-btn" type="button">
|
|
88
|
+
${_waIcon()} Confirmar minha visita
|
|
89
|
+
</button>
|
|
90
|
+
<button id="cdp-ri-dismiss" type="button" class="cdp-ri-dismiss">Agora não</button>
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
anchorEl.insertAdjacentElement('afterend', widget);
|
|
95
|
+
setTimeout(() => widget.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 150);
|
|
96
|
+
|
|
97
|
+
document.getElementById('cdp-ri-btn').addEventListener('click', () => _handleClick(widget, opts));
|
|
98
|
+
document.getElementById('cdp-ri-dismiss').addEventListener('click', () => {
|
|
99
|
+
window.cdpTrack?.track?.('ViewContent', {
|
|
100
|
+
content_name: 'rota_dispensada',
|
|
101
|
+
property_id: opts.propertyId,
|
|
102
|
+
funnel_stage: 'route_dismiss',
|
|
103
|
+
intent_score: 'medium',
|
|
104
|
+
distance_bucket: opts.distanceBucket || undefined,
|
|
105
|
+
meta_signal_bucket: opts.metaSignalBucket || undefined,
|
|
106
|
+
});
|
|
107
|
+
widget.remove();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Click — dispara evento e abre WhatsApp ────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
async function _handleClick(widget, opts) {
|
|
114
|
+
const btn = document.getElementById('cdp-ri-btn');
|
|
115
|
+
if (btn) btn.disabled = true;
|
|
116
|
+
|
|
117
|
+
// Evento Contact → Worker: LTV + hot lead trigger + CAPI
|
|
118
|
+
// Phone capturado depois via webhook quando a mensagem chegar
|
|
119
|
+
window.cdpTrack?.track?.('Contact', {
|
|
120
|
+
content_name: 'aviso_chegada_whatsapp',
|
|
121
|
+
property_id: opts.propertyId,
|
|
122
|
+
property_lat: opts.propertyLat,
|
|
123
|
+
property_lng: opts.propertyLng,
|
|
124
|
+
funnel_stage: 'route_click',
|
|
125
|
+
intent_score: 'high',
|
|
126
|
+
distance_bucket: opts.distanceBucket || undefined,
|
|
127
|
+
meta_signal_bucket: opts.metaSignalBucket || undefined,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Monta mensagem — acolhedora, foco em confirmar chegada
|
|
131
|
+
const travelMinutes = _estimateTravelMinutes(opts.distanceKm);
|
|
132
|
+
const travelLine = travelMinutes ? `Estou a cerca de ${travelMinutes} minutos daí.` : '';
|
|
133
|
+
const brokerLine = opts.brokerName
|
|
134
|
+
? `Vou procurar pelo ${opts.brokerName} ao chegar.`
|
|
135
|
+
: '';
|
|
136
|
+
|
|
137
|
+
const msg = [
|
|
138
|
+
`Oi! Estou interessado(a) em visitar o ${opts.propertyName} e gostaria de confirmar minha visita.`,
|
|
139
|
+
travelLine,
|
|
140
|
+
brokerLine || `Vocês conseguem me receber agora ou preciso marcar horário?`,
|
|
141
|
+
].filter(Boolean).join(' ');
|
|
142
|
+
|
|
143
|
+
_showSuccess(widget, opts.whatsappNumber, msg);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Tela de confirmação ───────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function _showSuccess(widget, whatsappNumber, msg) {
|
|
149
|
+
widget.innerHTML = `
|
|
150
|
+
<div class="cdp-ri-inner cdp-ri-ok">
|
|
151
|
+
<span class="cdp-ri-check">✅</span>
|
|
152
|
+
<p><strong>Abrindo WhatsApp...</strong><br><span>A equipe já foi avisada!</span></p>
|
|
153
|
+
</div>
|
|
154
|
+
`;
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
window.open(`https://wa.me/${whatsappNumber}?text=${encodeURIComponent(msg)}`, '_blank');
|
|
157
|
+
setTimeout(() => widget?.remove(), 3500);
|
|
158
|
+
}, 600);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function _estimateTravelMinutes(distanceKm) {
|
|
164
|
+
if (!distanceKm || distanceKm <= 0) return null;
|
|
165
|
+
return Math.max(5, Math.round((distanceKm * 60 / 25) / 5) * 5);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _waIcon() {
|
|
169
|
+
return `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
170
|
+
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/>
|
|
171
|
+
<path d="M12 0C5.373 0 0 5.373 0 12c0 2.122.554 4.118 1.528 5.852L0 24l6.335-1.513A11.933 11.933 0 0012 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 21.818a9.818 9.818 0 01-5.002-1.368l-.359-.213-3.722.888.924-3.617-.234-.372A9.818 9.818 0 012.182 12C2.182 6.57 6.57 2.182 12 2.182S21.818 6.57 21.818 12 17.43 21.818 12 21.818z"/>
|
|
172
|
+
</svg>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Estilos ───────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function _injectStyles() {
|
|
178
|
+
if (document.getElementById('cdp-ri-styles')) return;
|
|
179
|
+
const s = document.createElement('style');
|
|
180
|
+
s.id = 'cdp-ri-styles';
|
|
181
|
+
s.textContent = `
|
|
182
|
+
#cdp-ri-widget {
|
|
183
|
+
margin-top: 12px;
|
|
184
|
+
padding: 16px 18px;
|
|
185
|
+
background: #f0fdf4;
|
|
186
|
+
border: 1.5px solid #22c55e;
|
|
187
|
+
border-radius: 12px;
|
|
188
|
+
font-family: Arial, sans-serif;
|
|
189
|
+
animation: cdp-ri-in .25s ease;
|
|
190
|
+
}
|
|
191
|
+
@keyframes cdp-ri-in {
|
|
192
|
+
from { opacity: 0; transform: translateY(-8px); }
|
|
193
|
+
to { opacity: 1; transform: translateY(0); }
|
|
194
|
+
}
|
|
195
|
+
.cdp-ri-inner { display: flex; flex-direction: column; gap: 10px; }
|
|
196
|
+
.cdp-ri-travel { margin: 0; font-size: 13px; color: #555; }
|
|
197
|
+
.cdp-ri-travel strong { color: #0f766e; }
|
|
198
|
+
.cdp-ri-headline { margin: 0; font-size: 14px; color: #15803d; font-weight: bold; line-height: 1.4; }
|
|
199
|
+
.cdp-ri-broker { margin: 0; font-size: 13px; color: #555; line-height: 1.4; }
|
|
200
|
+
.cdp-ri-broker strong { color: #0f766e; }
|
|
201
|
+
#cdp-ri-btn {
|
|
202
|
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
203
|
+
width: 100%; padding: 13px 16px;
|
|
204
|
+
background: #25D366; color: #fff;
|
|
205
|
+
border: none; border-radius: 10px;
|
|
206
|
+
font-size: 15px; font-weight: bold; cursor: pointer;
|
|
207
|
+
}
|
|
208
|
+
#cdp-ri-btn:hover { background: #1ebe5a; }
|
|
209
|
+
#cdp-ri-btn:disabled { background: #86efac; cursor: not-allowed; }
|
|
210
|
+
.cdp-ri-dismiss {
|
|
211
|
+
background: none; border: none;
|
|
212
|
+
color: #aaa; font-size: 12px;
|
|
213
|
+
cursor: pointer; padding: 2px 0; text-align: center;
|
|
214
|
+
}
|
|
215
|
+
.cdp-ri-dismiss:hover { color: #666; }
|
|
216
|
+
.cdp-ri-ok { align-items: center; text-align: center; gap: 8px; }
|
|
217
|
+
.cdp-ri-check { font-size: 28px; }
|
|
218
|
+
.cdp-ri-ok p { margin: 0; font-size: 14px; color: #15803d; line-height: 1.5; }
|
|
219
|
+
.cdp-ri-ok span { font-size: 13px; color: #555; }
|
|
220
|
+
`;
|
|
221
|
+
document.head.appendChild(s);
|
|
222
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-edge",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.3",
|
|
4
4
|
"description": "CDP Edge - Quantum Tracking - Sistema multi-agente para tracking digital Cloudflare Native (Workers + D1)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
"build": "node build.js",
|
|
24
24
|
"dev": "node build.js --watch",
|
|
25
25
|
"test": "node test.js",
|
|
26
|
-
"test:unit": "node tests/unit/normalization.test.js && node tests/unit/hashing.test.js && node tests/unit/deduplication.test.js && node tests/unit/payload-validation.test.js && node tests/unit/new-features.test.js",
|
|
26
|
+
"test:unit": "node tests/unit/normalization.test.js && node tests/unit/hashing.test.js && node tests/unit/deduplication.test.js && node tests/unit/payload-validation.test.js && node tests/unit/new-features.test.js && node tests/unit/utils.test.js",
|
|
27
27
|
"test:unit:normalize": "node tests/unit/normalization.test.js",
|
|
28
28
|
"test:unit:hash": "node tests/unit/hashing.test.js",
|
|
29
29
|
"test:unit:dedup": "node tests/unit/deduplication.test.js",
|
|
30
30
|
"test:unit:payload": "node tests/unit/payload-validation.test.js",
|
|
31
|
+
"test:unit:utils": "node tests/unit/utils.test.js",
|
|
31
32
|
"test:all": "npm run test:unit",
|
|
32
33
|
"test:integration": "cd tests/integration && npx vitest run",
|
|
33
34
|
"agents:check": "node scripts/validate-agents.js",
|
|
@@ -298,7 +298,7 @@ wrangler d1 execute cdp-edge-db --remote --command="SELECT event_name, email, ci
|
|
|
298
298
|
```
|
|
299
299
|
ERROR: Can't deploy routes that are assigned to another worker.
|
|
300
300
|
"server-edge-tracker" is already assigned to routes:
|
|
301
|
-
-
|
|
301
|
+
- SEU_DOMINIO/track*
|
|
302
302
|
```
|
|
303
303
|
|
|
304
304
|
### SOLUÇÃO 1 — Via Painel Cloudflare (RECOMENDADO):
|
|
@@ -306,7 +306,7 @@ ERROR: Can't deploy routes that are assigned to another worker.
|
|
|
306
306
|
1. Acesse: https://dash.cloudflare.com/[ID_DA_CONTA]/workers/overview
|
|
307
307
|
2. Clique no worker que está usando as rotas do seu domínio
|
|
308
308
|
3. Vá em Settings → Triggers → Routes
|
|
309
|
-
4. Clique "Delete" nas rotas do domínio `
|
|
309
|
+
4. Clique "Delete" nas rotas do domínio `SEU_DOMINIO`
|
|
310
310
|
5. Repita o `wrangler deploy`
|
|
311
311
|
|
|
312
312
|
### SOLUÇÃO 2 — Via Wrangler CLI:
|
|
@@ -325,11 +325,11 @@ Se não quiser remover rotas existentes, use sufixo:
|
|
|
325
325
|
|
|
326
326
|
```toml
|
|
327
327
|
[[routes]]
|
|
328
|
-
pattern = "
|
|
329
|
-
zone_name = "
|
|
328
|
+
pattern = "SEU_DOMINIO/track-worker-novo*"
|
|
329
|
+
zone_name = "SEU_DOMINIO"
|
|
330
330
|
```
|
|
331
331
|
|
|
332
|
-
**URL do tracking:** `https://
|
|
332
|
+
**URL do tracking:** `https://SEU_DOMINIO/track-worker-novo`
|
|
333
333
|
|
|
334
334
|
---
|
|
335
335
|
|
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
sha256,
|
|
17
17
|
META_TO_GA4,
|
|
18
18
|
VALID_EVENT_NAMES,
|
|
19
|
+
resolveFunnelStage,
|
|
20
|
+
resolveIntentScore,
|
|
21
|
+
distanceBucketWeight,
|
|
22
|
+
computeMetaSignalWeights,
|
|
23
|
+
metaSignalBucket,
|
|
19
24
|
} from './modules/utils.js';
|
|
20
25
|
|
|
21
26
|
// ── Banco de dados (D1) ───────────────────────────────────────────────────────
|
|
@@ -31,6 +36,7 @@ import {
|
|
|
31
36
|
saveEdgeFingerprint,
|
|
32
37
|
resurrectUTM,
|
|
33
38
|
upsertLtvProfile,
|
|
39
|
+
recordLtvFeedback,
|
|
34
40
|
} from './modules/db.js';
|
|
35
41
|
|
|
36
42
|
// ── Dispatch — plataformas de ads ─────────────────────────────────────────────
|
|
@@ -92,6 +98,16 @@ import {
|
|
|
92
98
|
buildGoogleCustomerMatchExport,
|
|
93
99
|
} from './modules/intelligence.js';
|
|
94
100
|
|
|
101
|
+
// ── Haversine distance (km) — sem dependência externa ────────────────────────
|
|
102
|
+
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
103
|
+
const R = 6371;
|
|
104
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
105
|
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
106
|
+
const a = Math.sin(dLat / 2) ** 2 +
|
|
107
|
+
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
|
108
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
112
|
// HANDLER PRINCIPAL
|
|
97
113
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -175,7 +191,7 @@ export default {
|
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
try {
|
|
178
|
-
await env.AI.run('@cf/
|
|
194
|
+
await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
179
195
|
messages: [{ role: 'user', content: 'ping' }],
|
|
180
196
|
max_tokens: 1,
|
|
181
197
|
});
|
|
@@ -270,6 +286,9 @@ export default {
|
|
|
270
286
|
payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
|
|
271
287
|
payload.intentionLevel = behavioral_data.intention_level ?? null;
|
|
272
288
|
payload.userScore = behavioral_data.user_score ?? null;
|
|
289
|
+
// Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
|
|
290
|
+
payload.scrollScore = behavioral_data.scroll_score ?? null;
|
|
291
|
+
payload.timeLevel = behavioral_data.time_level ?? null;
|
|
273
292
|
payload.email = payload.email || behavioral_data.email || null;
|
|
274
293
|
payload.phone = payload.phone || behavioral_data.phone || null;
|
|
275
294
|
payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
|
|
@@ -280,6 +299,35 @@ export default {
|
|
|
280
299
|
payload.dob = payload.dob || behavioral_data.dob || null;
|
|
281
300
|
}
|
|
282
301
|
|
|
302
|
+
// ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
|
|
303
|
+
// Aceita string ('high'/'medium'/'low'), 0-1 ou 0-100 enviados pelo front.
|
|
304
|
+
// intent_bucket mantém a label legível para D1 e logs.
|
|
305
|
+
const intentScoreNum = resolveIntentScore(payload.intent_score);
|
|
306
|
+
if (intentScoreNum !== null) {
|
|
307
|
+
payload.intent_score = intentScoreNum;
|
|
308
|
+
payload.intentScoreNum = intentScoreNum;
|
|
309
|
+
payload.intent_bucket = intentScoreNum >= 0.8 ? 'high'
|
|
310
|
+
: intentScoreNum >= 0.5 ? 'medium' : 'low';
|
|
311
|
+
} else {
|
|
312
|
+
payload.intentScoreNum = null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Anti-falso-positivo ───────────────────────────────────────────────
|
|
316
|
+
// Penaliza intent se engajamento insuficiente: scroll raso E tempo curto.
|
|
317
|
+
// scroll_score < 2.0 ≈ não passou de 50% da página.
|
|
318
|
+
// time_level 'curioso' = menos de 60 segundos na página.
|
|
319
|
+
if (payload.intentScoreNum !== null) {
|
|
320
|
+
const isShallowScroll = payload.scrollScore !== null && payload.scrollScore < 2.0;
|
|
321
|
+
const isShallowTime = payload.timeLevel === 'curioso';
|
|
322
|
+
if (isShallowScroll && isShallowTime) {
|
|
323
|
+
const penalized = Math.round(payload.intentScoreNum * 0.7 * 100) / 100;
|
|
324
|
+
payload.intentScoreNum = penalized;
|
|
325
|
+
payload.intent_score = penalized;
|
|
326
|
+
payload.intent_bucket = penalized >= 0.8 ? 'high' : penalized >= 0.5 ? 'medium' : 'low';
|
|
327
|
+
payload.intent_penalized = true; // flag auditável — visível no D1 e logs
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
283
331
|
// ── Edge Fingerprint + UTM Resurrection ──────────────────────────────
|
|
284
332
|
const fingerprint = await generateEdgeFingerprint(request);
|
|
285
333
|
payload.utmRestored = false;
|
|
@@ -296,7 +344,6 @@ export default {
|
|
|
296
344
|
payload.utmContent = payload.utmContent || recovered.utm_content;
|
|
297
345
|
payload.utmTerm = payload.utmTerm || recovered.utm_term;
|
|
298
346
|
payload.utmRestored = true;
|
|
299
|
-
console.log(`[UTM Resurrection] Recovered: ${recovered.utm_source}/${recovered.utm_medium}/${recovered.utm_campaign}`);
|
|
300
347
|
}
|
|
301
348
|
}
|
|
302
349
|
}
|
|
@@ -325,6 +372,34 @@ export default {
|
|
|
325
372
|
|
|
326
373
|
const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
|
|
327
374
|
|
|
375
|
+
// ── Dual-layer semantics ─────────────────────────────────────────────
|
|
376
|
+
// Meta sempre recebe o nome canônico (Schedule, Lead, etc.).
|
|
377
|
+
// Internamente o CDP usa semântica de funil precisa via FUNNEL_TAXONOMY.
|
|
378
|
+
if (payload.funnel_stage) {
|
|
379
|
+
const { depth, funnelDepth } = resolveFunnelStage(payload.funnel_stage);
|
|
380
|
+
payload.funnelDepth = funnelDepth; // ex: 'bottom_intent', 'bottom_conversion'
|
|
381
|
+
payload.funnelLevel = depth; // ex: 'bottom', 'conversion', 'mid'
|
|
382
|
+
}
|
|
383
|
+
if (eventName === 'Schedule' && payload.funnel_stage === 'route_click') {
|
|
384
|
+
payload.internalEvent = 'IntentToVisit';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Real Estate Distance Enrichment ──────────────────────────────────
|
|
388
|
+
// Calcula distância entre o IP do usuário (via Cloudflare) e o imóvel.
|
|
389
|
+
// Ativa quando o evento carrega property_lat + property_lng (ex: Schedule de rota).
|
|
390
|
+
const propLat = parseFloat(payload.property_lat ?? payload.propertyLat);
|
|
391
|
+
const propLng = parseFloat(payload.property_lng ?? payload.propertyLng);
|
|
392
|
+
const userLat = parseFloat(request.cf?.latitude);
|
|
393
|
+
const userLng = parseFloat(request.cf?.longitude);
|
|
394
|
+
if (!isNaN(propLat) && !isNaN(propLng) && !isNaN(userLat) && !isNaN(userLng)) {
|
|
395
|
+
const distKm = haversineKm(userLat, userLng, propLat, propLng);
|
|
396
|
+
payload.distanceKm = Math.round(distKm * 10) / 10;
|
|
397
|
+
payload.distanceBucket = distKm < 5 ? 'very_close' :
|
|
398
|
+
distKm < 15 ? 'close' :
|
|
399
|
+
distKm < 30 ? 'nearby' :
|
|
400
|
+
distKm < 60 ? 'moderate' : 'far';
|
|
401
|
+
}
|
|
402
|
+
|
|
328
403
|
// ── LTV Prediction (+ A/B Testing de Prompts) ─────────────────────────
|
|
329
404
|
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
|
|
330
405
|
if (LTV_EVENTS.includes(eventName) && !payload.value) {
|
|
@@ -353,6 +428,35 @@ export default {
|
|
|
353
428
|
}
|
|
354
429
|
}
|
|
355
430
|
|
|
431
|
+
// ── LTV Feedback Loop — fecha o ciclo preditivo ──────────────────────
|
|
432
|
+
// Quando uma compra real acontece, registra o valor real e recalcula accuracy.
|
|
433
|
+
// Alimenta ltv_ab_variations.accuracy_score → autoDecideAbWinner usa isso.
|
|
434
|
+
if (eventName === 'Purchase' && payload.value > 0) {
|
|
435
|
+
ctx.waitUntil(recordLtvFeedback(env, payload.userId, payload.value));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Meta Signal Score (composite, pesos dinâmicos) ───────────────────
|
|
439
|
+
// Pesos variam por profundidade de funil: fundo = comportamento pesa mais.
|
|
440
|
+
{
|
|
441
|
+
const w = computeMetaSignalWeights(payload.funnelLevel);
|
|
442
|
+
const iW = payload.intentScoreNum ?? 0.5;
|
|
443
|
+
const lW = payload.ltvScore ? payload.ltvScore / 100 : 0.5;
|
|
444
|
+
const dW = distanceBucketWeight(payload.distanceBucket);
|
|
445
|
+
payload.metaSignal = Math.round(((iW * w.intent) + (lW * w.ltv) + (dW * w.dist)) * 100) / 100;
|
|
446
|
+
payload.metaSignalBucket = metaSignalBucket(payload.metaSignal); // 'hot'/'warm'/'cold'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Hot Lead Trigger (timing + sinal) ────────────────────────────────
|
|
450
|
+
// Reage em tempo real: WhatsApp apenas quando comportamento + sinal justificam.
|
|
451
|
+
// Critérios: rota + muito próximo + (intent alto OU meta_signal alto)
|
|
452
|
+
// + (janela de decisão BRT 18–22h OU sinal excepcional ≥ 0.9)
|
|
453
|
+
const hourBRT = (new Date().getUTCHours() - 3 + 24) % 24;
|
|
454
|
+
const inWindow = hourBRT >= 18 && hourBRT <= 22;
|
|
455
|
+
const isHotLead = payload.funnel_stage === 'route_click'
|
|
456
|
+
&& payload.distanceBucket === 'very_close'
|
|
457
|
+
&& ((payload.intentScoreNum ?? 0) >= 0.8 || payload.metaSignal >= 0.85)
|
|
458
|
+
&& (inWindow || payload.metaSignal >= 0.9);
|
|
459
|
+
|
|
356
460
|
// Cross-Device Graph — background
|
|
357
461
|
if (env.DB && payload.userId && (payload.email || payload.phone)) {
|
|
358
462
|
ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone));
|
|
@@ -367,10 +471,10 @@ export default {
|
|
|
367
471
|
sendMetaCapi(env, eventName, payload, request, ctx),
|
|
368
472
|
sendGA4Mp(env, ga4Name, payload, ctx),
|
|
369
473
|
sendTikTokApi(env, eventName, payload, request, ctx),
|
|
370
|
-
saveLead(env, eventName, payload, request, 'website'),
|
|
474
|
+
saveLead(env, payload.internalEvent || eventName, payload, request, 'website'),
|
|
371
475
|
upsertProfile(env, eventName, payload, request),
|
|
372
|
-
...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
|
|
373
|
-
? [sendWhatsApp(env, eventName, payload)]
|
|
476
|
+
...(WHATSAPP_NOTIFY_EVENTS.includes(eventName) || isHotLead
|
|
477
|
+
? [sendWhatsApp(env, isHotLead ? 'hot_lead_intent_to_visit' : eventName, payload)]
|
|
374
478
|
: []),
|
|
375
479
|
]);
|
|
376
480
|
|
|
@@ -765,7 +869,6 @@ export default {
|
|
|
765
869
|
const cron = event.cron;
|
|
766
870
|
const isMonthly = cron === '0 3 1 * *';
|
|
767
871
|
|
|
768
|
-
console.log(`[Intelligence Agent] Cron executado: ${cron}`);
|
|
769
872
|
ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
|
|
770
873
|
},
|
|
771
874
|
|
|
@@ -774,7 +877,6 @@ export default {
|
|
|
774
877
|
for (const message of batch.messages) {
|
|
775
878
|
const { eventType, payload, platform, attempt = 1 } = message.body;
|
|
776
879
|
|
|
777
|
-
console.log(`[Queue] Reprocessando: ${platform}/${eventType} (tentativa ${attempt})`);
|
|
778
880
|
|
|
779
881
|
try {
|
|
780
882
|
if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
|
|
@@ -443,6 +443,77 @@ export async function upsertLtvProfile(env, userId, ltv) {
|
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// ── recordLtvFeedback — fecha o ciclo preditivo com valor real de compra ─────
|
|
447
|
+
// Chamado em background quando um Purchase chega com payload.value > 0.
|
|
448
|
+
// Atualiza user_profiles + ltv_ab_assignments + ltv_ab_variations em cascata.
|
|
449
|
+
export async function recordLtvFeedback(env, userId, realValue) {
|
|
450
|
+
if (!env.DB || !userId || !realValue || realValue <= 0) return;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
// 1. Busca predicted_ltv_value atual do perfil
|
|
454
|
+
const profile = await env.DB.prepare(`
|
|
455
|
+
SELECT predicted_ltv_value FROM user_profiles WHERE user_id = ?
|
|
456
|
+
`).bind(userId).first();
|
|
457
|
+
|
|
458
|
+
// accuracy = 1 - |pred-real|/real (0–1, mesmo padrão do A/B test accuracy_score)
|
|
459
|
+
const predictedValue = profile?.predicted_ltv_value;
|
|
460
|
+
const ltv_accuracy = (predictedValue !== null && predictedValue !== undefined)
|
|
461
|
+
? Math.max(0, Math.round((1 - Math.abs(predictedValue - realValue) / Math.max(realValue, 1)) * 100) / 100)
|
|
462
|
+
: null;
|
|
463
|
+
|
|
464
|
+
// 2. Grava valor real + accuracy no perfil
|
|
465
|
+
await env.DB.prepare(`
|
|
466
|
+
UPDATE user_profiles
|
|
467
|
+
SET real_ltv_value = ?,
|
|
468
|
+
ltv_accuracy = ?,
|
|
469
|
+
ltv_feedback_at = datetime('now'),
|
|
470
|
+
updated_at = datetime('now')
|
|
471
|
+
WHERE user_id = ?
|
|
472
|
+
`).bind(realValue, ltv_accuracy, userId).run();
|
|
473
|
+
|
|
474
|
+
// 3. Fecha assignment do A/B test mais recente não convertido (janela 60 dias)
|
|
475
|
+
const assignment = await env.DB.prepare(`
|
|
476
|
+
SELECT id, variation_id, predicted_ltv
|
|
477
|
+
FROM ltv_ab_assignments
|
|
478
|
+
WHERE user_id = ?
|
|
479
|
+
AND converted = 0
|
|
480
|
+
AND assigned_at > datetime('now', '-60 days')
|
|
481
|
+
ORDER BY assigned_at DESC
|
|
482
|
+
LIMIT 1
|
|
483
|
+
`).bind(userId).first();
|
|
484
|
+
|
|
485
|
+
if (!assignment) return;
|
|
486
|
+
|
|
487
|
+
// 3a. Marca assignment como convertido
|
|
488
|
+
await env.DB.prepare(`
|
|
489
|
+
UPDATE ltv_ab_assignments
|
|
490
|
+
SET converted = 1,
|
|
491
|
+
real_revenue = ?,
|
|
492
|
+
converted_at = datetime('now')
|
|
493
|
+
WHERE id = ?
|
|
494
|
+
`).bind(realValue, assignment.id).run();
|
|
495
|
+
|
|
496
|
+
// 3b. Atualiza métricas acumuladas da variação (running average — safe para concorrência D1)
|
|
497
|
+
const predLtv = assignment.predicted_ltv || 0;
|
|
498
|
+
const indivAcc = Math.max(0, 1 - Math.abs(predLtv - realValue) / Math.max(realValue, 1));
|
|
499
|
+
|
|
500
|
+
await env.DB.prepare(`
|
|
501
|
+
UPDATE ltv_ab_variations
|
|
502
|
+
SET total_purchases = total_purchases + 1,
|
|
503
|
+
sum_real_revenue = sum_real_revenue + ?,
|
|
504
|
+
avg_real_revenue = (sum_real_revenue + ?) / (total_purchases + 1),
|
|
505
|
+
accuracy_score = ROUND(
|
|
506
|
+
(COALESCE(accuracy_score, 0) * total_purchases + ?) / (total_purchases + 1),
|
|
507
|
+
4
|
|
508
|
+
)
|
|
509
|
+
WHERE id = ?
|
|
510
|
+
`).bind(realValue, realValue, indivAcc, assignment.variation_id).run();
|
|
511
|
+
|
|
512
|
+
} catch (err) {
|
|
513
|
+
console.error('[LTV-Feedback] recordLtvFeedback error:', err.message);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
446
517
|
// ── Feedback Loop — Log de falhas e métricas de saúde ────────────────────────
|
|
447
518
|
|
|
448
519
|
export async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
|
|
@@ -24,6 +24,9 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
24
24
|
eventId, pageUrl,
|
|
25
25
|
value, currency,
|
|
26
26
|
contentIds, contentName, contentType, numItems,
|
|
27
|
+
// Dual-layer context — funil avançado + imóveis
|
|
28
|
+
funnel_stage, distance_bucket, intent_score, intent_bucket,
|
|
29
|
+
ltvScore, ltvClass, metaSignal, metaSignalBucket: metaSignalBucketVal,
|
|
27
30
|
} = payload;
|
|
28
31
|
|
|
29
32
|
const phoneNorm = normalizePhone(phone);
|
|
@@ -57,6 +60,15 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
57
60
|
...(contentName && { content_name: contentName }),
|
|
58
61
|
...(contentType && { content_type: contentType }),
|
|
59
62
|
...(numItems && { num_items: parseInt(numItems) }),
|
|
63
|
+
// Contexto de funil e proximidade — enriquece matching e otimização Meta
|
|
64
|
+
...(funnel_stage && { funnel_stage }),
|
|
65
|
+
...(distance_bucket && { distance_bucket }),
|
|
66
|
+
...(intent_score && { intent_score }),
|
|
67
|
+
...(ltvScore !== undefined && ltvScore !== null && { ltv_score: ltvScore }),
|
|
68
|
+
...(ltvClass && { ltv_class: ltvClass }),
|
|
69
|
+
...(metaSignal !== undefined && metaSignal !== null && { meta_signal: metaSignal }),
|
|
70
|
+
...(metaSignalBucketVal && { meta_signal_bucket: metaSignalBucketVal }),
|
|
71
|
+
...(intent_bucket && { intent_bucket }),
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
const eventPayload = {
|
|
@@ -6,13 +6,6 @@
|
|
|
6
6
|
import { sha256, tryParseJson } from '../utils.js';
|
|
7
7
|
|
|
8
8
|
// ── Listas de detecção ────────────────────────────────────────────────────────
|
|
9
|
-
export const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
10
|
-
'mailinator.com','guerrillamail.com','tempmail.com','throwaway.email',
|
|
11
|
-
'yopmail.com','sharklasers.com','guerrillamailblock.com','spam4.me',
|
|
12
|
-
'10minutemail.com','trashmail.com','maildrop.cc','fakeinbox.com',
|
|
13
|
-
'dispostable.com','getairmail.com','mailnull.com',
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
9
|
export const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
|
|
17
10
|
|
|
18
11
|
// ── checkFraudGate — roda ANTES de qualquer processamento de evento ────────────
|
|
@@ -64,15 +57,7 @@ export async function checkFraudGate(env, request, payload) {
|
|
|
64
57
|
result.score += 20; result.reasons.push('no_accept_language');
|
|
65
58
|
}
|
|
66
59
|
|
|
67
|
-
// 6.
|
|
68
|
-
if (email) {
|
|
69
|
-
const domain = email.split('@')[1]?.toLowerCase();
|
|
70
|
-
if (domain && DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
|
|
71
|
-
result.score += 25; result.reasons.push('disposable_email');
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 7. Velocity check via KV
|
|
60
|
+
// 6. Velocity check via KV
|
|
76
61
|
if (env.GEO_CACHE && ip) {
|
|
77
62
|
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
78
63
|
const velStr = await env.GEO_CACHE.get(velKey1h);
|