@toffee-at/sdk 0.1.0
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/.turbo/turbo-build.log +23 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.global.js +1 -0
- package/dist/index.js +1 -0
- package/package.json +31 -0
- package/src/core.ts +196 -0
- package/src/detect/automation.ts +34 -0
- package/src/detect/behavioral.ts +382 -0
- package/src/detect/features.ts +126 -0
- package/src/detect/fingerprint.ts +115 -0
- package/src/detect/headless.ts +44 -0
- package/src/detect/index.ts +202 -0
- package/src/detect/navigator.ts +102 -0
- package/src/detect/types.ts +52 -0
- package/src/detect/user-agent.ts +21 -0
- package/src/index.ts +22 -0
- package/src/track/elements.ts +105 -0
- package/src/track/index.ts +15 -0
- package/src/track/page.ts +43 -0
- package/src/track/session.ts +19 -0
- package/src/track/types.ts +19 -0
- package/src/transport/batch.ts +119 -0
- package/src/transport/index.ts +60 -0
- package/tests/features.test.ts +94 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { DetectorResult, DetectionOutput, RiskTier } from './types';
|
|
2
|
+
import { detectUserAgent } from './user-agent';
|
|
3
|
+
import { detectHeadless } from './headless';
|
|
4
|
+
import { detectAutomation } from './automation';
|
|
5
|
+
import { detectBehavioral } from './behavioral';
|
|
6
|
+
import { detectNavigator } from './navigator';
|
|
7
|
+
import { detectFingerprint } from './fingerprint';
|
|
8
|
+
|
|
9
|
+
export type { DetectorResult, DetectionOutput } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-detector LLR ranges.
|
|
13
|
+
*
|
|
14
|
+
* Key insight: a rawScore of 0 means different things for different detectors.
|
|
15
|
+
* - behavioral 0 = "observed human mouse/scroll/keyboard" → strong human evidence (LLR = -3.0)
|
|
16
|
+
* - user-agent 0 = "no known bot UA match" → weak signal (many stealthy bots also score 0)
|
|
17
|
+
* - headless 0 = "no headless indicators" → mild human evidence
|
|
18
|
+
*
|
|
19
|
+
* [llrAtZero, llrAt100]
|
|
20
|
+
*/
|
|
21
|
+
const DETECTOR_LLR_RANGE: Record<string, [number, number]> = {
|
|
22
|
+
'user-agent': [-0.1, 5.0], // UA match is definitive, but absence means almost nothing
|
|
23
|
+
'headless': [-0.2, 4.0], // Headless signals are strong, absence is mild
|
|
24
|
+
'automation': [-0.1, 5.0], // Globals are definitive, absence means nothing
|
|
25
|
+
'navigator': [-0.1, 3.5], // Consistency checks — clean is expected
|
|
26
|
+
'fingerprint': [-0.1, 3.5], // Canvas/WebGL — clean is expected
|
|
27
|
+
'behavioral': [-2.5, 6.0], // The most important detector for extension-based agents
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function rawScoreToLLR(detector: string, rawScore: number): number {
|
|
31
|
+
const [llrZero, llrHundred] = DETECTOR_LLR_RANGE[detector] ?? [-0.5, 4.0];
|
|
32
|
+
// Linear interpolation from [0 → llrZero] to [100 → llrHundred]
|
|
33
|
+
return llrZero + (rawScore / 100) * (llrHundred - llrZero);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Log-odds Bayesian fusion.
|
|
38
|
+
* Combines independent evidence from multiple detectors into a single probability.
|
|
39
|
+
*/
|
|
40
|
+
function bayesianFusion(results: DetectorResult[]): DetectionOutput {
|
|
41
|
+
// Prior: assume 15% bot traffic (slightly higher than average given the agent era)
|
|
42
|
+
const PRIOR_LOG_ODDS = Math.log(0.15 / 0.85); // ≈ -1.735
|
|
43
|
+
|
|
44
|
+
let logOddsPosterior = PRIOR_LOG_ODDS;
|
|
45
|
+
|
|
46
|
+
const signalDetails = results.map((r) => {
|
|
47
|
+
const llr = rawScoreToLLR(r.detector, r.rawScore);
|
|
48
|
+
logOddsPosterior += llr;
|
|
49
|
+
return {
|
|
50
|
+
detector: r.detector,
|
|
51
|
+
fired: r.rawScore > 0,
|
|
52
|
+
llr,
|
|
53
|
+
rawScore: r.rawScore,
|
|
54
|
+
evidence: r.signals,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Convert log-odds to probability
|
|
59
|
+
let probability = 1 / (1 + Math.exp(-logOddsPosterior));
|
|
60
|
+
|
|
61
|
+
// Apply boosting rules for high-confidence combinations
|
|
62
|
+
const allSignals = results.flatMap((r) => r.signals);
|
|
63
|
+
|
|
64
|
+
// Webdriver + no mouse → almost certainly agent
|
|
65
|
+
const hasWebdriver = allSignals.includes('webdriver-flag') || allSignals.includes('webdriver');
|
|
66
|
+
const noMouse = allSignals.includes('no-mouse-events');
|
|
67
|
+
const noScroll = allSignals.includes('no-scroll-events');
|
|
68
|
+
|
|
69
|
+
if (hasWebdriver && noMouse) {
|
|
70
|
+
probability = Math.max(probability, 0.90);
|
|
71
|
+
} else if (hasWebdriver && noScroll) {
|
|
72
|
+
probability = Math.max(probability, 0.80);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extension-based agent detection: behavioral pattern combos
|
|
76
|
+
const hasTeleportClicks = allSignals.some((s) => s.startsWith('teleport-clicks:'));
|
|
77
|
+
const hasMinimalApproach = allSignals.some((s) => s.startsWith('minimal-approach-path:'));
|
|
78
|
+
const hasLowEntropy = allSignals.some((s) => s.startsWith('low-trajectory-entropy:'));
|
|
79
|
+
const hasMediumEntropy = allSignals.some((s) => s.startsWith('medium-trajectory-entropy:'));
|
|
80
|
+
const hasNoJitter = allSignals.includes('no-micro-jitter');
|
|
81
|
+
const hasSparseEvents = allSignals.some((s) => s.startsWith('very-sparse-mouse-events:') || s.startsWith('sparse-mouse-events:'));
|
|
82
|
+
const hasTeleportation = allSignals.some((s) => s.startsWith('mouse-teleportation:'));
|
|
83
|
+
const hasLowPointsPerClick = allSignals.some((s) => s.startsWith('low-points-per-click:'));
|
|
84
|
+
|
|
85
|
+
// Teleportation + sparse events = classic extension-based agent pattern
|
|
86
|
+
if (hasTeleportation && hasSparseEvents) {
|
|
87
|
+
probability = Math.max(probability, 0.85);
|
|
88
|
+
}
|
|
89
|
+
if (hasTeleportClicks && hasNoJitter) {
|
|
90
|
+
probability = Math.max(probability, 0.85);
|
|
91
|
+
}
|
|
92
|
+
if (hasTeleportClicks && (hasLowEntropy || hasMediumEntropy)) {
|
|
93
|
+
probability = Math.max(probability, 0.80);
|
|
94
|
+
}
|
|
95
|
+
if (hasMinimalApproach && hasNoJitter) {
|
|
96
|
+
probability = Math.max(probability, 0.80);
|
|
97
|
+
}
|
|
98
|
+
// Sparse events alone with low entropy is very suspicious
|
|
99
|
+
if (hasSparseEvents && (hasLowEntropy || hasMediumEntropy) && hasTeleportation) {
|
|
100
|
+
probability = Math.max(probability, 0.90);
|
|
101
|
+
}
|
|
102
|
+
// Low points per click + sparse events = definitive agent pattern
|
|
103
|
+
if (hasLowPointsPerClick && hasSparseEvents) {
|
|
104
|
+
probability = Math.max(probability, 0.85);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Software renderer is a strong headless signal
|
|
108
|
+
const hasSoftwareRenderer = allSignals.some((s) => s.startsWith('software-renderer:'));
|
|
109
|
+
if (hasSoftwareRenderer) {
|
|
110
|
+
probability = Math.max(probability, 0.80);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Clamp probability
|
|
114
|
+
probability = Math.max(0, Math.min(1, probability));
|
|
115
|
+
|
|
116
|
+
// Determine risk tier
|
|
117
|
+
let riskTier: RiskTier;
|
|
118
|
+
if (probability >= 0.95) riskTier = 'definite-bot';
|
|
119
|
+
else if (probability >= 0.80) riskTier = 'likely-bot';
|
|
120
|
+
else if (probability >= 0.50) riskTier = 'suspicious';
|
|
121
|
+
else if (probability >= 0.20) riskTier = 'likely-human';
|
|
122
|
+
else riskTier = 'definite-human';
|
|
123
|
+
|
|
124
|
+
// Legacy score (0-100) for backwards compatibility
|
|
125
|
+
const legacyScore = Math.round(probability * 100);
|
|
126
|
+
|
|
127
|
+
const isAgent = probability >= 0.50;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
score: legacyScore,
|
|
131
|
+
probability,
|
|
132
|
+
riskTier,
|
|
133
|
+
isAgent,
|
|
134
|
+
results,
|
|
135
|
+
signals: signalDetails,
|
|
136
|
+
classification: {
|
|
137
|
+
classification: isAgent ? 'bot' : 'human',
|
|
138
|
+
probabilities: { human: 1 - probability, bot: probability, agent: 0 },
|
|
139
|
+
source: 'heuristic',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function runSync(): DetectionOutput {
|
|
145
|
+
const results = [
|
|
146
|
+
detectUserAgent(),
|
|
147
|
+
detectHeadless(),
|
|
148
|
+
detectAutomation(),
|
|
149
|
+
detectNavigator(),
|
|
150
|
+
detectFingerprint(),
|
|
151
|
+
];
|
|
152
|
+
return bayesianFusion(results);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runFull(): Promise<DetectionOutput> {
|
|
156
|
+
const syncResults = [
|
|
157
|
+
detectUserAgent(),
|
|
158
|
+
detectHeadless(),
|
|
159
|
+
detectAutomation(),
|
|
160
|
+
detectNavigator(),
|
|
161
|
+
detectFingerprint(),
|
|
162
|
+
];
|
|
163
|
+
const behavioral = await detectBehavioral();
|
|
164
|
+
return bayesianFusion([...syncResults, behavioral]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function applyModelClassification(
|
|
168
|
+
output: DetectionOutput,
|
|
169
|
+
modelResult: { classification: string; probabilities: { human: number; bot: number; agent: number } },
|
|
170
|
+
): DetectionOutput {
|
|
171
|
+
const prob = 1 - modelResult.probabilities.human;
|
|
172
|
+
let riskTier: RiskTier;
|
|
173
|
+
if (prob >= 0.95) riskTier = 'definite-bot';
|
|
174
|
+
else if (prob >= 0.80) riskTier = 'likely-bot';
|
|
175
|
+
else if (prob >= 0.50) riskTier = 'suspicious';
|
|
176
|
+
else if (prob >= 0.20) riskTier = 'likely-human';
|
|
177
|
+
else riskTier = 'definite-human';
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...output,
|
|
181
|
+
probability: prob,
|
|
182
|
+
riskTier,
|
|
183
|
+
isAgent: modelResult.classification !== 'human',
|
|
184
|
+
classification: {
|
|
185
|
+
classification: modelResult.classification as 'human' | 'bot' | 'agent',
|
|
186
|
+
probabilities: modelResult.probabilities,
|
|
187
|
+
source: 'model',
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Run sync detectors + an externally-provided behavioral result (from persistent monitor) */
|
|
193
|
+
export function runWithBehavioral(behavioral: import('./types').DetectorResult): DetectionOutput {
|
|
194
|
+
const syncResults = [
|
|
195
|
+
detectUserAgent(),
|
|
196
|
+
detectHeadless(),
|
|
197
|
+
detectAutomation(),
|
|
198
|
+
detectNavigator(),
|
|
199
|
+
detectFingerprint(),
|
|
200
|
+
];
|
|
201
|
+
return bayesianFusion([...syncResults, behavioral]);
|
|
202
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { DetectorResult } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Navigator/API consistency checks.
|
|
5
|
+
* Catches bots that spoof user-agent but forget to sync Client Hints,
|
|
6
|
+
* platform, and browser-specific APIs.
|
|
7
|
+
*/
|
|
8
|
+
export function detectNavigator(): DetectorResult {
|
|
9
|
+
let score = 0;
|
|
10
|
+
const signals: string[] = [];
|
|
11
|
+
const ua = navigator.userAgent;
|
|
12
|
+
|
|
13
|
+
// 1. Client Hints mismatch: UA says one platform but userAgentData says another
|
|
14
|
+
try {
|
|
15
|
+
const uaData = (navigator as any).userAgentData;
|
|
16
|
+
if (uaData) {
|
|
17
|
+
const uaPlatform = uaData.platform?.toLowerCase() || '';
|
|
18
|
+
const claimsMac = /Macintosh|Mac OS X/i.test(ua);
|
|
19
|
+
const claimsWindows = /Windows/i.test(ua);
|
|
20
|
+
const claimsLinux = /Linux/i.test(ua) && !/Android/i.test(ua);
|
|
21
|
+
|
|
22
|
+
if (claimsMac && uaPlatform && uaPlatform !== 'macos' && uaPlatform !== 'mac os x') {
|
|
23
|
+
score += 30;
|
|
24
|
+
signals.push('client-hints-platform-mismatch');
|
|
25
|
+
} else if (claimsWindows && uaPlatform && uaPlatform !== 'windows') {
|
|
26
|
+
score += 30;
|
|
27
|
+
signals.push('client-hints-platform-mismatch');
|
|
28
|
+
} else if (claimsLinux && uaPlatform && uaPlatform !== 'linux') {
|
|
29
|
+
score += 30;
|
|
30
|
+
signals.push('client-hints-platform-mismatch');
|
|
31
|
+
}
|
|
32
|
+
} else if (/Chrome\/\d/.test(ua) && parseInt(ua.match(/Chrome\/(\d+)/)?.[1] || '0') >= 90) {
|
|
33
|
+
// Chrome 90+ should have userAgentData
|
|
34
|
+
score += 15;
|
|
35
|
+
signals.push('missing-user-agent-data');
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore errors
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Hardware concurrency: containerized/headless often has 1 or undefined
|
|
42
|
+
if (navigator.hardwareConcurrency !== undefined && navigator.hardwareConcurrency <= 1) {
|
|
43
|
+
score += 15;
|
|
44
|
+
signals.push('low-hardware-concurrency');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Device memory: missing in many headless setups
|
|
48
|
+
if (/Chrome/.test(ua) && (navigator as any).deviceMemory === undefined) {
|
|
49
|
+
score += 10;
|
|
50
|
+
signals.push('missing-device-memory');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. Connection API: real browsers typically have this
|
|
54
|
+
if (/Chrome/.test(ua) && !(navigator as any).connection) {
|
|
55
|
+
score += 10;
|
|
56
|
+
signals.push('missing-connection-api');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 5. Platform inconsistency: UA claims one OS but navigator.platform says another
|
|
60
|
+
try {
|
|
61
|
+
const platform = navigator.platform?.toLowerCase() || '';
|
|
62
|
+
const claimsMac = /Macintosh|Mac OS X/i.test(ua);
|
|
63
|
+
const claimsWindows = /Windows/i.test(ua);
|
|
64
|
+
|
|
65
|
+
if (claimsMac && platform && !platform.includes('mac')) {
|
|
66
|
+
score += 25;
|
|
67
|
+
signals.push('platform-ua-mismatch');
|
|
68
|
+
} else if (claimsWindows && platform && !platform.includes('win')) {
|
|
69
|
+
score += 25;
|
|
70
|
+
signals.push('platform-ua-mismatch');
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 6. Screen/display anomalies (moved here from headless for better grouping)
|
|
77
|
+
// outerHeight === innerHeight means no browser chrome (tabs, bookmarks, address bar)
|
|
78
|
+
if (window.outerHeight > 0 && window.innerHeight > 0 &&
|
|
79
|
+
window.outerHeight === window.innerHeight) {
|
|
80
|
+
score += 10;
|
|
81
|
+
signals.push('no-browser-chrome');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// screen.width === screen.availWidth means no OS taskbar
|
|
85
|
+
if (screen.width === screen.availWidth && screen.height === screen.availHeight &&
|
|
86
|
+
screen.width > 0) {
|
|
87
|
+
score += 5;
|
|
88
|
+
signals.push('no-taskbar');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Mobile UA claiming devicePixelRatio === 1 (real mobile devices have DPR >= 2)
|
|
92
|
+
if (/Mobile|Android|iPhone/i.test(ua) && window.devicePixelRatio === 1) {
|
|
93
|
+
score += 15;
|
|
94
|
+
signals.push('mobile-ua-low-dpr');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
detector: 'navigator',
|
|
99
|
+
rawScore: Math.min(score, 100),
|
|
100
|
+
signals,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ModelClassification } from '@toffee-at/shared';
|
|
2
|
+
|
|
3
|
+
export type DetectorName =
|
|
4
|
+
| 'user-agent'
|
|
5
|
+
| 'headless'
|
|
6
|
+
| 'automation'
|
|
7
|
+
| 'behavioral'
|
|
8
|
+
| 'navigator'
|
|
9
|
+
| 'fingerprint';
|
|
10
|
+
|
|
11
|
+
export interface DetectorResult {
|
|
12
|
+
detector: DetectorName;
|
|
13
|
+
rawScore: number; // 0-100
|
|
14
|
+
signals: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type RiskTier =
|
|
18
|
+
| 'definite-bot' // probability >= 0.95
|
|
19
|
+
| 'likely-bot' // probability >= 0.80
|
|
20
|
+
| 'suspicious' // probability >= 0.50
|
|
21
|
+
| 'likely-human' // probability >= 0.20
|
|
22
|
+
| 'definite-human'; // probability < 0.20
|
|
23
|
+
|
|
24
|
+
export type AgentCategory =
|
|
25
|
+
| 'human'
|
|
26
|
+
| 'ai-assistant'
|
|
27
|
+
| 'ai-crawler'
|
|
28
|
+
| 'ai-agent'
|
|
29
|
+
| 'search-engine'
|
|
30
|
+
| 'social-preview'
|
|
31
|
+
| 'monitoring'
|
|
32
|
+
| 'scraper'
|
|
33
|
+
| 'automation-tool'
|
|
34
|
+
| 'unknown-bot';
|
|
35
|
+
|
|
36
|
+
export interface DetectionOutput {
|
|
37
|
+
score: number; // 0-100 legacy score
|
|
38
|
+
probability: number; // 0.0-1.0
|
|
39
|
+
riskTier: RiskTier;
|
|
40
|
+
isAgent: boolean;
|
|
41
|
+
results: DetectorResult[];
|
|
42
|
+
signals: {
|
|
43
|
+
detector: string;
|
|
44
|
+
fired: boolean;
|
|
45
|
+
llr: number;
|
|
46
|
+
rawScore: number;
|
|
47
|
+
evidence: string[];
|
|
48
|
+
}[];
|
|
49
|
+
|
|
50
|
+
// ML model classification
|
|
51
|
+
classification: ModelClassification;
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { KNOWN_AGENTS, matchAgent } from '@toffee-at/shared';
|
|
2
|
+
import type { DetectorResult } from './types';
|
|
3
|
+
|
|
4
|
+
export function detectUserAgent(): DetectorResult {
|
|
5
|
+
const ua = navigator.userAgent;
|
|
6
|
+
const match = matchAgent(ua);
|
|
7
|
+
|
|
8
|
+
if (match) {
|
|
9
|
+
return {
|
|
10
|
+
detector: 'user-agent',
|
|
11
|
+
rawScore: 100,
|
|
12
|
+
signals: [`matched-agent:${match.name}`],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
detector: 'user-agent',
|
|
18
|
+
rawScore: 0,
|
|
19
|
+
signals: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createCore, type AgentRadarConfig } from './core';
|
|
2
|
+
import type { DetectionOutput } from './detect/types';
|
|
3
|
+
|
|
4
|
+
let instance: ReturnType<typeof createCore> | null = null;
|
|
5
|
+
|
|
6
|
+
export function init(config: AgentRadarConfig) {
|
|
7
|
+
if (instance) return instance;
|
|
8
|
+
instance = createCore(config);
|
|
9
|
+
instance.start();
|
|
10
|
+
return instance;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function identify(metadata: Record<string, string>) {
|
|
14
|
+
instance?.identify(metadata);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getDetection(): DetectionOutput | null {
|
|
18
|
+
return instance?.getDetection() ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { AgentRadarConfig } from './core';
|
|
22
|
+
export type { DetectionOutput, RiskTier, AgentCategory } from './detect/types';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { InteractionEvent, TrackingEvent } from './types';
|
|
2
|
+
|
|
3
|
+
const TRACKED_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,a,button,form,input,img,[data-ar-track]';
|
|
4
|
+
|
|
5
|
+
function getSelector(el: Element): string {
|
|
6
|
+
if (el.id) return `#${el.id}`;
|
|
7
|
+
const arTrack = el.getAttribute('data-ar-track');
|
|
8
|
+
if (arTrack) return `[data-ar-track="${arTrack}"]`;
|
|
9
|
+
const tag = el.tagName.toLowerCase();
|
|
10
|
+
const cls = el.className && typeof el.className === 'string'
|
|
11
|
+
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
|
|
12
|
+
: '';
|
|
13
|
+
return `${tag}${cls}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getTextContent(el: Element): string | undefined {
|
|
17
|
+
const text = el.textContent?.trim().slice(0, 100);
|
|
18
|
+
return text || undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setupElementTracking(onEvent: (event: TrackingEvent) => void): () => void {
|
|
22
|
+
const dwellMap = new Map<Element, number>();
|
|
23
|
+
|
|
24
|
+
// IntersectionObserver for visibility tracking
|
|
25
|
+
const observer = new IntersectionObserver(
|
|
26
|
+
(entries) => {
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.isIntersecting) {
|
|
29
|
+
dwellMap.set(entry.target, Date.now());
|
|
30
|
+
} else {
|
|
31
|
+
const start = dwellMap.get(entry.target);
|
|
32
|
+
if (start) {
|
|
33
|
+
const event: InteractionEvent = {
|
|
34
|
+
type: 'interaction',
|
|
35
|
+
action: 'view',
|
|
36
|
+
selector: getSelector(entry.target),
|
|
37
|
+
tag: entry.target.tagName.toLowerCase(),
|
|
38
|
+
text: getTextContent(entry.target),
|
|
39
|
+
dwellMs: Date.now() - start,
|
|
40
|
+
url: location.href,
|
|
41
|
+
timestamp: Date.now(),
|
|
42
|
+
};
|
|
43
|
+
onEvent(event);
|
|
44
|
+
dwellMap.delete(entry.target);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{ threshold: 0.5 }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Observe existing elements
|
|
53
|
+
function observeElements() {
|
|
54
|
+
const elements = document.querySelectorAll(TRACKED_SELECTOR);
|
|
55
|
+
for (const el of elements) {
|
|
56
|
+
observer.observe(el);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
observeElements();
|
|
60
|
+
|
|
61
|
+
// MutationObserver to pick up dynamically added elements
|
|
62
|
+
const mutationObserver = new MutationObserver(() => observeElements());
|
|
63
|
+
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
64
|
+
|
|
65
|
+
// Event delegation for clicks
|
|
66
|
+
const onClick = (e: Event) => {
|
|
67
|
+
const target = (e.target as Element)?.closest?.(TRACKED_SELECTOR);
|
|
68
|
+
if (!target) return;
|
|
69
|
+
const event: InteractionEvent = {
|
|
70
|
+
type: 'interaction',
|
|
71
|
+
action: 'click',
|
|
72
|
+
selector: getSelector(target),
|
|
73
|
+
tag: target.tagName.toLowerCase(),
|
|
74
|
+
text: getTextContent(target),
|
|
75
|
+
url: location.href,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
onEvent(event);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Event delegation for input
|
|
82
|
+
const onInput = (e: Event) => {
|
|
83
|
+
const target = e.target as Element;
|
|
84
|
+
if (!target?.matches?.('input,textarea,select,[data-ar-track]')) return;
|
|
85
|
+
const event: InteractionEvent = {
|
|
86
|
+
type: 'interaction',
|
|
87
|
+
action: 'input',
|
|
88
|
+
selector: getSelector(target),
|
|
89
|
+
tag: target.tagName.toLowerCase(),
|
|
90
|
+
url: location.href,
|
|
91
|
+
timestamp: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
onEvent(event);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
document.body.addEventListener('click', onClick, { passive: true });
|
|
97
|
+
document.body.addEventListener('input', onInput, { passive: true });
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
observer.disconnect();
|
|
101
|
+
mutationObserver.disconnect();
|
|
102
|
+
document.body.removeEventListener('click', onClick);
|
|
103
|
+
document.body.removeEventListener('input', onInput);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TrackingEvent } from './types';
|
|
2
|
+
import { setupPageTracking } from './page';
|
|
3
|
+
import { setupElementTracking } from './elements';
|
|
4
|
+
|
|
5
|
+
export type { TrackingEvent, PageviewEvent, InteractionEvent } from './types';
|
|
6
|
+
|
|
7
|
+
export function setupTracking(onEvent: (event: TrackingEvent) => void): () => void {
|
|
8
|
+
const destroyPage = setupPageTracking(onEvent);
|
|
9
|
+
const destroyElements = setupElementTracking(onEvent);
|
|
10
|
+
|
|
11
|
+
return () => {
|
|
12
|
+
destroyPage();
|
|
13
|
+
destroyElements();
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PageviewEvent, TrackingEvent } from './types';
|
|
2
|
+
|
|
3
|
+
export function setupPageTracking(onEvent: (event: TrackingEvent) => void): () => void {
|
|
4
|
+
let startTime = Date.now();
|
|
5
|
+
|
|
6
|
+
function emitPageview() {
|
|
7
|
+
const event: PageviewEvent = {
|
|
8
|
+
type: 'pageview',
|
|
9
|
+
url: location.href,
|
|
10
|
+
referrer: document.referrer,
|
|
11
|
+
timestamp: Date.now(),
|
|
12
|
+
};
|
|
13
|
+
startTime = Date.now();
|
|
14
|
+
onEvent(event);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Emit initial pageview
|
|
18
|
+
emitPageview();
|
|
19
|
+
|
|
20
|
+
// Listen for popstate (back/forward navigation)
|
|
21
|
+
const onPopState = () => emitPageview();
|
|
22
|
+
window.addEventListener('popstate', onPopState);
|
|
23
|
+
|
|
24
|
+
// Monkey-patch pushState and replaceState for SPA navigation
|
|
25
|
+
const originalPushState = history.pushState.bind(history);
|
|
26
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
27
|
+
|
|
28
|
+
history.pushState = function (...args: Parameters<typeof history.pushState>) {
|
|
29
|
+
originalPushState(...args);
|
|
30
|
+
emitPageview();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
|
|
34
|
+
originalReplaceState(...args);
|
|
35
|
+
emitPageview();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener('popstate', onPopState);
|
|
40
|
+
history.pushState = originalPushState;
|
|
41
|
+
history.replaceState = originalReplaceState;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function fnv1a(str: string): string {
|
|
2
|
+
let hash = 0x811c9dc5;
|
|
3
|
+
for (let i = 0; i < str.length; i++) {
|
|
4
|
+
hash ^= str.charCodeAt(i);
|
|
5
|
+
hash = (hash * 0x01000193) >>> 0;
|
|
6
|
+
}
|
|
7
|
+
return hash.toString(36);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createSessionId(): string {
|
|
11
|
+
const parts = [
|
|
12
|
+
navigator.userAgent,
|
|
13
|
+
`${screen.width}x${screen.height}`,
|
|
14
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
15
|
+
navigator.language,
|
|
16
|
+
new Date().toDateString(), // daily rotation
|
|
17
|
+
];
|
|
18
|
+
return fnv1a(parts.join('|'));
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface PageviewEvent {
|
|
2
|
+
type: 'pageview';
|
|
3
|
+
url: string;
|
|
4
|
+
referrer: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InteractionEvent {
|
|
9
|
+
type: 'interaction';
|
|
10
|
+
action: 'click' | 'input' | 'view';
|
|
11
|
+
selector: string;
|
|
12
|
+
tag: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
dwellMs?: number;
|
|
15
|
+
url: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TrackingEvent = PageviewEvent | InteractionEvent;
|