@rk911/tracker-sdk 1.6.4

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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * 元素曝光插件(增强版)
3
+ *
4
+ * 能力:
5
+ * 1. v-track-exposure 指令 — 走 SDK 自动采集链路
6
+ * 2. 停留时长验证 — 元素必须完全可见超过 validExposureTime 才算有效曝光
7
+ * 3. 退出比例判定 — 可见比例低于 exitRatio 时取消计时
8
+ * 4. MutationObserver — 动态 DOM 元素自动挂载观察
9
+ * 5. v-impression 兼容 — 在 compat.js 中单独处理,与本插件并存
10
+ *
11
+ * 配置(通过 tracker config.exposure 传入):
12
+ * validExposureTime: number — 有效曝光最小停留时长(ms),默认 1000
13
+ * exitRatio: number — 低于此比例判定为离开视口,默认 0.7
14
+ * threshold: number — 普通模式的可见比例阈值,默认 0.5
15
+ * useDuration: boolean — 是否启用停留时长验证模式,默认 false
16
+ * observeDynamicElements: boolean — 是否监控动态 DOM,默认 true
17
+ */
18
+ export class ExposurePlugin {
19
+ constructor() {
20
+ this.name = 'exposure'
21
+ this.tracker = null
22
+ this._observers = null
23
+ this._reported = null
24
+ this._entryTimers = null
25
+ this._intersectionObserver = null
26
+ this._mutationObserver = null
27
+ this._config = null
28
+ }
29
+
30
+ install(tracker) {
31
+ this.tracker = tracker
32
+ var adapter = tracker.getAdapter()
33
+
34
+ if (!adapter.supportsIntersectionObserver()) return
35
+
36
+ this._observers = new WeakMap()
37
+ this._reported = new WeakSet()
38
+ this._entryTimers = new WeakMap()
39
+
40
+ // 读取曝光配置
41
+ var trackerConfig = tracker.getConfig()
42
+ this._config = {
43
+ validExposureTime: 1000,
44
+ exitRatio: 0.7,
45
+ threshold: 0.5,
46
+ useDuration: false,
47
+ observeDynamicElements: true,
48
+ ...(trackerConfig.exposure || {}),
49
+ }
50
+
51
+ // 在 H5 端自动监控 data-exposure 属性元素
52
+ if (adapter.supportsDOM()) {
53
+ this._initAutoExposure()
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 自动监控页面上带 data-exposure / data-scroll-exposure 属性的元素
59
+ */
60
+ _initAutoExposure() {
61
+ var self = this
62
+ var config = this._config
63
+ var tracker = this.tracker
64
+ var adapter = tracker.getAdapter()
65
+
66
+ // 计算 IntersectionObserver 阈值
67
+ var thresholds
68
+ if (config.useDuration) {
69
+ thresholds = [0, config.exitRatio, 1.0]
70
+ } else {
71
+ thresholds = [0, config.threshold]
72
+ }
73
+
74
+ this._intersectionObserver = new IntersectionObserver(
75
+ function (entries) {
76
+ entries.forEach(function (entry) {
77
+ var el = entry.target
78
+ if (self._reported.has(el)) return
79
+
80
+ if (config.useDuration) {
81
+ self._handleDurationExposure(el, entry)
82
+ } else {
83
+ self._handleSimpleExposure(el, entry)
84
+ }
85
+ })
86
+ },
87
+ { root: null, rootMargin: '0px', threshold: thresholds }
88
+ )
89
+
90
+ // 观察现有元素
91
+ this._observeExistingElements()
92
+
93
+ // MutationObserver 监控动态元素
94
+ if (config.observeDynamicElements && typeof MutationObserver !== 'undefined') {
95
+ this._initMutationObserver()
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 普通模式:可见比例超过阈值即上报
101
+ */
102
+ _handleSimpleExposure(el, entry) {
103
+ if (
104
+ entry.isIntersecting &&
105
+ entry.intersectionRatio >= this._config.threshold
106
+ ) {
107
+ this._reportExposure(el, entry.intersectionRatio)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 停留时长验证模式:元素 100% 可见 + 持续 N 毫秒才算有效曝光
113
+ * 移植自 smart-tracker 的 _handleDurationBasedExposure
114
+ */
115
+ _handleDurationExposure(el, entry) {
116
+ var self = this
117
+ var config = this._config
118
+
119
+ if (entry.intersectionRatio >= 1.0) {
120
+ // 元素完全进入视口 — 开始计时
121
+ if (this._entryTimers.has(el)) return
122
+
123
+ var timerId = setTimeout(function () {
124
+ if (self._reported.has(el)) {
125
+ self._entryTimers.delete(el)
126
+ return
127
+ }
128
+ self._reportExposure(el, 1.0)
129
+ self._entryTimers.delete(el)
130
+ }, config.validExposureTime)
131
+
132
+ this._entryTimers.set(el, timerId)
133
+ } else if (entry.intersectionRatio < config.exitRatio) {
134
+ // 可见比例低于退出阈值 — 取消计时
135
+ var timerId = this._entryTimers.get(el)
136
+ if (timerId) {
137
+ clearTimeout(timerId)
138
+ this._entryTimers.delete(el)
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 上报曝光事件
145
+ */
146
+ _reportExposure(el, visibleRatio) {
147
+ this._reported.add(el)
148
+
149
+ // 尝试 unobserve
150
+ if (this._intersectionObserver) {
151
+ this._intersectionObserver.unobserve(el)
152
+ }
153
+
154
+ var adapter = this.tracker.getAdapter()
155
+ var trackData = {
156
+ trackId: (el.dataset && el.dataset.trackId) || undefined,
157
+ trackName:
158
+ (el.dataset && el.dataset.trackName) ||
159
+ (el.dataset && el.dataset.exposure) ||
160
+ undefined,
161
+ text: (el.innerText || '').trim().substring(0, 50),
162
+ xpath: adapter.getXPath(el),
163
+ visibleRatio: visibleRatio,
164
+ }
165
+
166
+ // 如果元素上有自定义数据
167
+ if (el._trackExposureData) {
168
+ trackData.customData = el._trackExposureData
169
+ }
170
+
171
+ this.tracker.track('exposure', trackData)
172
+ }
173
+
174
+ /**
175
+ * 观察页面上已有的 data-exposure 元素
176
+ */
177
+ _observeExistingElements() {
178
+ if (!this._intersectionObserver || typeof document === 'undefined') return
179
+
180
+ var elements = document.querySelectorAll(
181
+ '[data-exposure], [data-scroll-exposure]'
182
+ )
183
+ var self = this
184
+ elements.forEach(function (el) {
185
+ self._intersectionObserver.observe(el)
186
+ })
187
+ }
188
+
189
+ /**
190
+ * MutationObserver:监控动态添加的带曝光属性的元素
191
+ * 移植自 smart-tracker 的 _observeDOMChanges
192
+ */
193
+ _initMutationObserver() {
194
+ if (typeof document === 'undefined' || !document.body) return
195
+
196
+ var self = this
197
+ this._mutationObserver = new MutationObserver(function (mutations) {
198
+ mutations.forEach(function (mutation) {
199
+ mutation.addedNodes.forEach(function (node) {
200
+ if (node.nodeType !== 1) return // 只处理元素节点
201
+
202
+ // 检查节点本身
203
+ if (
204
+ node.hasAttribute &&
205
+ (node.hasAttribute('data-exposure') ||
206
+ node.hasAttribute('data-scroll-exposure'))
207
+ ) {
208
+ if (!self._reported.has(node)) {
209
+ self._intersectionObserver.observe(node)
210
+ }
211
+ }
212
+
213
+ // 检查子元素
214
+ if (node.querySelectorAll) {
215
+ var children = node.querySelectorAll(
216
+ '[data-exposure], [data-scroll-exposure]'
217
+ )
218
+ children.forEach(function (child) {
219
+ if (!self._reported.has(child)) {
220
+ self._intersectionObserver.observe(child)
221
+ }
222
+ })
223
+ }
224
+ })
225
+ })
226
+ })
227
+
228
+ this._mutationObserver.observe(document.body, {
229
+ childList: true,
230
+ subtree: true,
231
+ })
232
+ }
233
+
234
+ /**
235
+ * 返回 v-track-exposure 指令定义(Vue 2 格式)
236
+ * 用法:v-track-exposure="{ name: '商品卡片', id: goodsId }"
237
+ */
238
+ createDirective() {
239
+ var self = this
240
+ var observers = this._observers
241
+ var reported = this._reported
242
+ var entryTimers = this._entryTimers
243
+ var tracker = this.tracker
244
+ var config = this._config
245
+
246
+ if (!observers || !reported || !tracker) return null
247
+
248
+ function handleIntersection(entries, observer) {
249
+ entries.forEach(function (entry) {
250
+ var el = entry.target
251
+ if (reported.has(el)) return
252
+
253
+ if (config && config.useDuration) {
254
+ // 停留时长验证模式
255
+ if (entry.intersectionRatio >= 1.0) {
256
+ if (entryTimers.has(el)) return
257
+ var timerId = setTimeout(function () {
258
+ if (reported.has(el)) {
259
+ entryTimers.delete(el)
260
+ return
261
+ }
262
+ reported.add(el)
263
+ observer.unobserve(el)
264
+
265
+ var data = el._trackExposureData || {}
266
+ var adapter = tracker.getAdapter()
267
+ tracker.track('exposure', {
268
+ trackId:
269
+ (el.dataset && el.dataset.trackId) || data.id || undefined,
270
+ trackName:
271
+ (el.dataset && el.dataset.trackName) ||
272
+ data.name ||
273
+ undefined,
274
+ text: (el.innerText || '').trim().substring(0, 50),
275
+ xpath: adapter.getXPath(el),
276
+ visibleRatio: 1.0,
277
+ viewDuration: config.validExposureTime,
278
+ customData: data,
279
+ })
280
+ entryTimers.delete(el)
281
+ }, config.validExposureTime)
282
+ entryTimers.set(el, timerId)
283
+ } else if (entry.intersectionRatio < config.exitRatio) {
284
+ var tid = entryTimers.get(el)
285
+ if (tid) {
286
+ clearTimeout(tid)
287
+ entryTimers.delete(el)
288
+ }
289
+ }
290
+ } else {
291
+ // 普通模式
292
+ var threshold = (config && config.threshold) || 0.5
293
+ if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
294
+ reported.add(el)
295
+ observer.unobserve(el)
296
+
297
+ var data = el._trackExposureData || {}
298
+ var adapter = tracker.getAdapter()
299
+ tracker.track('exposure', {
300
+ trackId:
301
+ (el.dataset && el.dataset.trackId) || data.id || undefined,
302
+ trackName:
303
+ (el.dataset && el.dataset.trackName) ||
304
+ data.name ||
305
+ undefined,
306
+ text: (el.innerText || '').trim().substring(0, 50),
307
+ xpath: adapter.getXPath(el),
308
+ visibleRatio: entry.intersectionRatio,
309
+ customData: data,
310
+ })
311
+ }
312
+ }
313
+ })
314
+ }
315
+
316
+ // 计算阈值
317
+ var thresholds
318
+ if (config && config.useDuration) {
319
+ thresholds = [0, config.exitRatio || 0.7, 1.0]
320
+ } else {
321
+ thresholds = [(config && config.threshold) || 0.5]
322
+ }
323
+
324
+ return {
325
+ bind: function (el, binding) {
326
+ el._trackExposureData = binding.value || {}
327
+
328
+ var observer = new IntersectionObserver(handleIntersection, {
329
+ root: null,
330
+ rootMargin: '0px',
331
+ threshold: thresholds,
332
+ })
333
+ observer.observe(el)
334
+ observers.set(el, observer)
335
+ },
336
+
337
+ update: function (el, binding) {
338
+ if (binding.value) {
339
+ el._trackExposureData = binding.value
340
+ }
341
+ },
342
+
343
+ unbind: function (el) {
344
+ var observer = observers.get(el)
345
+ if (observer) {
346
+ observer.unobserve(el)
347
+ observers.delete(el)
348
+ }
349
+ reported.delete(el)
350
+ // 清除可能存在的计时器
351
+ var tid = entryTimers.get(el)
352
+ if (tid) {
353
+ clearTimeout(tid)
354
+ entryTimers.delete(el)
355
+ }
356
+ delete el._trackExposureData
357
+ },
358
+ }
359
+ }
360
+
361
+ destroy() {
362
+ if (this._intersectionObserver) {
363
+ this._intersectionObserver.disconnect()
364
+ this._intersectionObserver = null
365
+ }
366
+ if (this._mutationObserver) {
367
+ this._mutationObserver.disconnect()
368
+ this._mutationObserver = null
369
+ }
370
+ // 清除所有待执行的计时器
371
+ this._entryTimers = null
372
+ this._observers = null
373
+ this._reported = null
374
+ this.tracker = null
375
+ }
376
+ }
@@ -0,0 +1,232 @@
1
+ export class JsErrorPlugin {
2
+ constructor() {
3
+ this.name = 'js-error'
4
+ this.tracker = null
5
+ this._errorHandler = null
6
+ this._rejectionHandler = null
7
+ this._recentErrors = {}
8
+ this._errorCount = 0
9
+ this._maxErrorsPerSession = 50
10
+ this._dedupWindowMs = 10000
11
+ }
12
+
13
+ install(tracker) {
14
+ this.tracker = tracker
15
+ var adapter = tracker.getAdapter()
16
+ var platform = adapter.getPlatformName()
17
+ var self = this
18
+
19
+ if (platform === 'web' || platform === 'app') {
20
+ this._errorHandler = function (event) {
21
+ if (event.target && event.target !== window && event.target.tagName) {
22
+ self._handleResourceError(event)
23
+ } else {
24
+ self._handleError(event)
25
+ }
26
+ }
27
+ this._rejectionHandler = function (event) {
28
+ self._handleRejection(event)
29
+ }
30
+
31
+ adapter.addErrorListener(this._errorHandler)
32
+ adapter.addUnhandledRejectionListener(this._rejectionHandler)
33
+ } else if (platform === 'weapp') {
34
+ this._errorHandler = function (errMsg) {
35
+ self._handleWeappError(errMsg)
36
+ }
37
+ adapter.addErrorListener(this._errorHandler)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Vue.config.errorHandler 调用入口
43
+ * 由 index.js install() 中注册,捕获 Vue 组件 render/lifecycle/watch 中的错误
44
+ */
45
+ handleVueError(err, vm, info) {
46
+ if (!this.tracker || !err) return
47
+
48
+ var message = err.message || String(err)
49
+ var stack = err.stack ? err.stack.substring(0, 1000) : ''
50
+ var componentName = ''
51
+
52
+ if (vm) {
53
+ var options = vm.$options || {}
54
+ componentName = options.name || options.__file || (options._componentTag) || ''
55
+ if (!componentName && vm.$parent) {
56
+ componentName = '(anonymous component)'
57
+ }
58
+ }
59
+
60
+ var fingerprint = 'vue|' + message + '|' + componentName + '|' + (info || '')
61
+
62
+ if (this._isDuplicate(fingerprint)) return
63
+ if (!this._shouldReport()) return
64
+
65
+ this.tracker.track('js_error', {
66
+ eventName: 'vue_error',
67
+ errorMessage: message,
68
+ errorStack: stack,
69
+ extraData: {
70
+ type: 'vue',
71
+ componentName: componentName,
72
+ lifecycleHook: info || '',
73
+ route: this._getCurrentRoute(),
74
+ referrer: this._getReferrer(),
75
+ }
76
+ })
77
+ }
78
+
79
+ _isDuplicate(fingerprint) {
80
+ var now = Date.now()
81
+ var last = this._recentErrors[fingerprint]
82
+ if (last && now - last < this._dedupWindowMs) return true
83
+ this._recentErrors[fingerprint] = now
84
+
85
+ var keys = Object.keys(this._recentErrors)
86
+ if (keys.length > 100) {
87
+ var cutoff = now - this._dedupWindowMs
88
+ for (var i = 0; i < keys.length; i++) {
89
+ if (this._recentErrors[keys[i]] < cutoff) {
90
+ delete this._recentErrors[keys[i]]
91
+ }
92
+ }
93
+ }
94
+ return false
95
+ }
96
+
97
+ _shouldReport() {
98
+ if (this._errorCount >= this._maxErrorsPerSession) return false
99
+ this._errorCount++
100
+ return true
101
+ }
102
+
103
+ _getCurrentRoute() {
104
+ try {
105
+ return this.tracker._currentPage || this.tracker.getAdapter().getCurrentPagePath() || ''
106
+ } catch (e) {
107
+ return ''
108
+ }
109
+ }
110
+
111
+ _getReferrer() {
112
+ try {
113
+ return this.tracker._referrer || ''
114
+ } catch (e) {
115
+ return ''
116
+ }
117
+ }
118
+
119
+ _handleError(event) {
120
+ if (!this.tracker || !event) return
121
+
122
+ var message = event.message || String(event)
123
+ var stack = (event.error && event.error.stack) ? event.error.stack.substring(0, 1000) : ''
124
+
125
+ // 跨域脚本报错:浏览器出于安全屏蔽详情,message 固定为 "Script error."
126
+ // 且 filename/lineno/stack 全空,无任何定位价值 → 丢弃,避免污染错误统计
127
+ if (/^script error\.?$/i.test(message) && !event.filename && !stack) {
128
+ return
129
+ }
130
+
131
+ var fingerprint = message + '|' + (event.filename || '') + '|' + (event.lineno || 0)
132
+
133
+ if (this._isDuplicate(fingerprint)) return
134
+ if (!this._shouldReport()) return
135
+
136
+ this.tracker.track('js_error', {
137
+ eventName: 'runtime_error',
138
+ errorMessage: message,
139
+ errorStack: stack,
140
+ extraData: {
141
+ type: 'runtime',
142
+ filename: event.filename || '',
143
+ lineno: event.lineno || 0,
144
+ colno: event.colno || 0,
145
+ route: this._getCurrentRoute(),
146
+ referrer: this._getReferrer(),
147
+ }
148
+ })
149
+ }
150
+
151
+ _handleRejection(event) {
152
+ if (!this.tracker || !event) return
153
+
154
+ var reason = event.reason || event
155
+ var message = reason ? (reason.message || String(reason)) : 'Unknown rejection'
156
+ var stack = (reason && reason.stack) ? reason.stack.substring(0, 1000) : ''
157
+ var fingerprint = 'rejection|' + message
158
+
159
+ if (this._isDuplicate(fingerprint)) return
160
+ if (!this._shouldReport()) return
161
+
162
+ this.tracker.track('js_error', {
163
+ eventName: 'unhandled_rejection',
164
+ errorMessage: message,
165
+ errorStack: stack,
166
+ extraData: {
167
+ type: 'unhandledRejection',
168
+ route: this._getCurrentRoute(),
169
+ referrer: this._getReferrer(),
170
+ }
171
+ })
172
+ }
173
+
174
+ _handleResourceError(event) {
175
+ if (!this.tracker || !event || !event.target) return
176
+
177
+ var el = event.target
178
+ var tagName = (el.tagName || '').toLowerCase()
179
+ var resourceUrl = el.src || el.href || ''
180
+
181
+ if (!resourceUrl) return
182
+
183
+ var fingerprint = 'resource|' + tagName + '|' + resourceUrl
184
+ if (this._isDuplicate(fingerprint)) return
185
+ if (!this._shouldReport()) return
186
+
187
+ this.tracker.track('js_error', {
188
+ eventName: 'resource_error',
189
+ errorMessage: tagName + ' load failed: ' + resourceUrl,
190
+ extraData: {
191
+ type: 'resource',
192
+ tagName: tagName,
193
+ resourceUrl: resourceUrl,
194
+ route: this._getCurrentRoute(),
195
+ }
196
+ })
197
+ }
198
+
199
+ _handleWeappError(errMsg) {
200
+ if (!this.tracker) return
201
+
202
+ var message = typeof errMsg === 'string' ? errMsg.substring(0, 1000) : String(errMsg)
203
+ var fingerprint = 'weapp|' + message
204
+
205
+ if (this._isDuplicate(fingerprint)) return
206
+ if (!this._shouldReport()) return
207
+
208
+ this.tracker.track('js_error', {
209
+ eventName: 'weapp_error',
210
+ errorMessage: message,
211
+ extraData: {
212
+ type: 'weapp',
213
+ route: this._getCurrentRoute(),
214
+ }
215
+ })
216
+ }
217
+
218
+ destroy() {
219
+ if (typeof window !== 'undefined') {
220
+ if (this._errorHandler) {
221
+ window.removeEventListener('error', this._errorHandler, true)
222
+ }
223
+ if (this._rejectionHandler) {
224
+ window.removeEventListener('unhandledrejection', this._rejectionHandler)
225
+ }
226
+ }
227
+ this._errorHandler = null
228
+ this._rejectionHandler = null
229
+ this.tracker = null
230
+ this._recentErrors = {}
231
+ }
232
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 页面停留时长插件
3
+ * 作为独立插件提供额外的停留追踪能力
4
+ * 注意:核心的页面停留 mixin 已在 compat.js 的 createPageStayMixin 中实现
5
+ * 本插件提供的是 H5 端基于 visibilitychange 的补充监听
6
+ */
7
+ export class PageStayPlugin {
8
+ constructor() {
9
+ this.name = 'page-stay'
10
+ this.tracker = null
11
+ this._beforeUnloadHandler = null
12
+ }
13
+
14
+ install(tracker) {
15
+ this.tracker = tracker
16
+ var adapter = tracker.getAdapter()
17
+
18
+ if (adapter.supportsDOM()) {
19
+ this._setupVisibilityTracking()
20
+ }
21
+ }
22
+
23
+ _setupVisibilityTracking() {
24
+ if (typeof document === 'undefined') return
25
+
26
+ var self = this
27
+
28
+ // beforeunload 确保关闭标签页时强制同步刷新
29
+ if (typeof window !== 'undefined') {
30
+ this._beforeUnloadHandler = function () {
31
+ self.tracker.getQueue().flushSync()
32
+ }
33
+ window.addEventListener('beforeunload', this._beforeUnloadHandler)
34
+ }
35
+ }
36
+
37
+ destroy() {
38
+ if (this._beforeUnloadHandler && typeof window !== 'undefined') {
39
+ window.removeEventListener('beforeunload', this._beforeUnloadHandler)
40
+ }
41
+ this._beforeUnloadHandler = null
42
+ this.tracker = null
43
+ }
44
+ }