@macroui/event-tracker 1.3.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -0
  3. package/bin/event-tracker-suggest.mjs +141 -0
  4. package/bin/event-tracker.mjs +157 -0
  5. package/dist/event-tracker.cjs +3449 -0
  6. package/dist/event-tracker.js +3455 -0
  7. package/dist/event-tracker.min.js +1 -0
  8. package/dist/event-tracker.mjs +3402 -0
  9. package/dist/plugin/auto-track.cjs +201 -0
  10. package/dist/plugin/auto-track.mjs +199 -0
  11. package/dist/plugin/debug-sender.cjs +214 -0
  12. package/dist/plugin/debug-sender.mjs +211 -0
  13. package/dist/plugin/encrypt.cjs +284 -0
  14. package/dist/plugin/encrypt.mjs +275 -0
  15. package/dist/plugin/exposure.cjs +121 -0
  16. package/dist/plugin/exposure.mjs +119 -0
  17. package/dist/plugin/indexeddb-sender.cjs +94 -0
  18. package/dist/plugin/indexeddb-sender.mjs +92 -0
  19. package/dist/plugin/pageleave.cjs +74 -0
  20. package/dist/plugin/pageleave.mjs +72 -0
  21. package/dist/plugin/pageload.cjs +198 -0
  22. package/dist/plugin/pageload.mjs +196 -0
  23. package/dist/plugin/session-event.cjs +142 -0
  24. package/dist/plugin/session-event.mjs +140 -0
  25. package/dist/plugin/webview-bridge.cjs +111 -0
  26. package/dist/plugin/webview-bridge.mjs +109 -0
  27. package/dist/types/core/env.d.ts +10 -0
  28. package/dist/types/core/errors.d.ts +76 -0
  29. package/dist/types/core/failed-queue.d.ts +24 -0
  30. package/dist/types/core/identity-api.d.ts +6 -0
  31. package/dist/types/core/identity.d.ts +39 -0
  32. package/dist/types/core/instance-channel.d.ts +34 -0
  33. package/dist/types/core/lifecycle.d.ts +26 -0
  34. package/dist/types/core/logger.d.ts +13 -0
  35. package/dist/types/core/plugin.d.ts +60 -0
  36. package/dist/types/core/profile-api.d.ts +6 -0
  37. package/dist/types/core/profile-store.d.ts +23 -0
  38. package/dist/types/core/register-api.d.ts +22 -0
  39. package/dist/types/core/retry-policy.d.ts +33 -0
  40. package/dist/types/core/send-pipeline.d.ts +55 -0
  41. package/dist/types/core/signed-identity.d.ts +29 -0
  42. package/dist/types/core/stage.d.ts +48 -0
  43. package/dist/types/core/track-api.d.ts +6 -0
  44. package/dist/types/core/tracker.d.ts +130 -0
  45. package/dist/types/core/uuid.d.ts +4 -0
  46. package/dist/types/index.d.ts +40 -0
  47. package/dist/types/plugins/auto-track/index.d.ts +21 -0
  48. package/dist/types/plugins/debug-sender/index.d.ts +24 -0
  49. package/dist/types/plugins/encrypt/index.d.ts +56 -0
  50. package/dist/types/plugins/exposure/index.d.ts +18 -0
  51. package/dist/types/plugins/indexeddb-sender/index.d.ts +15 -0
  52. package/dist/types/plugins/pageleave/index.d.ts +12 -0
  53. package/dist/types/plugins/pageload/index.d.ts +14 -0
  54. package/dist/types/plugins/session-event/index.d.ts +14 -0
  55. package/dist/types/plugins/webview-bridge/index.d.ts +33 -0
  56. package/dist/types/presets/preset-properties.d.ts +25 -0
  57. package/dist/types/send/ajax-sender.d.ts +11 -0
  58. package/dist/types/send/batch-sender.d.ts +17 -0
  59. package/dist/types/send/beacon-sender.d.ts +10 -0
  60. package/dist/types/send/encode.d.ts +8 -0
  61. package/dist/types/send/image-sender.d.ts +10 -0
  62. package/dist/types/send/sender.d.ts +20 -0
  63. package/dist/types/storage/indexeddb-store.d.ts +14 -0
  64. package/dist/types/storage/store.d.ts +56 -0
  65. package/dist/types/types/common.d.ts +26 -0
  66. package/dist/types/types/config.d.ts +117 -0
  67. package/dist/types/types/constants.d.ts +110 -0
  68. package/dist/types/types/index.d.ts +25 -0
  69. package/dist/types/types.test-d.d.ts +5 -0
  70. package/dist/types.test-d.ts +103 -0
  71. package/package.json +218 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 macroui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,343 @@
1
+ # @macroui/event-tracker
2
+
3
+ > 自研的 Web 埋点 SDK,参考神策 sa-sdk-javascript 的 API 风格
4
+ >
5
+ > 4 种发送方式 / 全埋点 / WebView 桥 / 加密插件 —— 全 TypeScript 源码,全 Jest 单测
6
+
7
+ [![Coverage](https://img.shields.io/badge/coverage-51%25-yellow)](.)
8
+ [![Tests](https://img.shields.io/badge/tests-90%20passed-brightgreen)](.)
9
+ [![License](https://img.shields.io/badge/license-MIT-blue)](.)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue)](.)
11
+
12
+ ## 特性
13
+
14
+ - **多发送方式**:`image` / `beacon` / `ajax` / `batch`,自动降级
15
+ - **多 sink 上报**:`server_url: string | string[]`
16
+ - **预置事件**:`$pageview` / `$WebClick` / `$WebStay` / `$SignUp` / `$BindID` / `$UnbindID`
17
+ - **公共属性**:`$lib` / `$app_id` / `$screen_width` / `$viewport_width` / `$url` / `$referrer` / `$latest_utm_*` ...
18
+ - **用户身份**:`distinct_id` / `anonymous_id` / `first_id` / `login_id` / `original_id`
19
+ - **3 阶段拦截器**:`buildDataStage` / `businessStage` / `sendDataStage`,priority 排序
20
+ - **7 生命周期钩子**:`sdkReady` / `sdkInitPara` / `sdkAfterInitPara` / `sdkInitAPI` / `sdkAfterInitAPI` / `changeDistinctId` / `switch`
21
+ - **加密插件**:XOR+Base64 / **AES-GCM(Web Crypto)**
22
+ - **WebView 桥**:vapph5 协议,自动取 `app_version` / `app_id` / `channel`
23
+ - **失败重试**:指数退避 1s→2s→4s→30s cap
24
+ - **失败队列**:localStorage 暂存,断网时延后上报
25
+ - **跨实例通信**:`BroadcastChannel`(localStorage 兜底)
26
+ - **签名身份**:`HMAC-SHA256` 防 cookie 篡改
27
+ - **13 个官方插件**:pageload / pageleave / exposure / session-event / debug-sender / indexeddb-sender / auto-track / webview-bridge / encrypt / etc.
28
+ - **TypeScript**:完整 .d.ts,IDE 提示
29
+ - **多格式**:`ESM` / `CJS` / `UMD` / `UMD-min`
30
+ - **零依赖**:核心 SDK 无第三方依赖
31
+ - **测试**:Jest 90 测试 / 15 套件 + fast-check 属性测试 + tsd 类型测试
32
+ - **覆盖率**:51% lines(开发期阈值:30%)
33
+
34
+ ## 快速开始
35
+
36
+ ### 安装
37
+
38
+ ```bash
39
+ npm install @macroui/event-tracker
40
+ ```
41
+
42
+ ### 初始化(UMD)
43
+
44
+ ```html
45
+ <script src="https://cdn.jsdelivr.net/npm/@macroui/event-tracker/dist/event-tracker.min.js"></script>
46
+ <script>
47
+ eventTracker.init({
48
+ app_id: 'demo',
49
+ server_url: 'https://api.example.com/sa',
50
+ send_type: 'beacon', // 'image' | 'beacon' | 'ajax' | 'batch'
51
+ debug: 1, // 0=关闭 1=basic 2=verbose
52
+ });
53
+ eventTracker.track('user_login', { method: 'email' });
54
+ </script>
55
+ ```
56
+
57
+ ### ESM
58
+
59
+ ```ts
60
+ import { EventTracker, pageLoad, sessionEvent } from '@macroui/event-tracker';
61
+
62
+ const sdk = new EventTracker();
63
+ sdk.init({
64
+ app_id: 'demo',
65
+ server_url: 'https://api.example.com/sa',
66
+ send_type: 'ajax',
67
+ });
68
+
69
+ sdk.use(pageLoad());
70
+ sdk.use(sessionEvent());
71
+
72
+ sdk.track('button_click', { btn_id: 'submit' });
73
+ sdk.setProfile({ $city: 'Beijing', $plan: 'pro' });
74
+ ```
75
+
76
+ ### 多 sink / 多实例
77
+
78
+ ```ts
79
+ sdk.switchServer(['https://primary.example.com/sa', 'https://backup.example.com/sa']);
80
+
81
+ const tracker2 = initPara({
82
+ app_id: 'demo',
83
+ server_url: 'https://api2.example.com/sa',
84
+ client_id: 'm2',
85
+ });
86
+ ```
87
+
88
+ ## API 一览
89
+
90
+ ### 事件
91
+
92
+ | API | 说明 |
93
+ |---|---|
94
+ | `track(event, props, cb?)` | 自定义事件 |
95
+ | `trackSignup(userId, props?, cb?)` | 注册即触发($SignUp) |
96
+ | `trackLinkage(props, cb?)` | 关联事件 |
97
+ | `trackLink(el, props?, cb?)` | 单链接点击 |
98
+ | `trackLinks(els, props?, cb?)` | 多链接点击 |
99
+ | `trackHeatMap(props, cb?)` | 热力图事件 |
100
+ | `trackWebClick(props, cb?)` | $WebClick |
101
+ | `trackWebStay(props, cb?)` | $WebStay |
102
+ | `alias(newId, originalId?)` | 合并身份 |
103
+
104
+ ### Profile
105
+
106
+ | API | 说明 |
107
+ |---|---|
108
+ | `setProfile(props, cb?)` | 设置 |
109
+ | `setOnceProfile(props, cb?)` | 仅首次 |
110
+ | `incrementProfile(props, cb?)` | 累加 |
111
+ | `appendProfile(props, cb?)` | 追加 |
112
+ | `unsetProfile(prop, cb?)` | 取消 |
113
+ | `deleteProfile(cb?)` | 删除 |
114
+
115
+ ### 身份
116
+
117
+ | API | 说明 |
118
+ |---|---|
119
+ | `identify(id, save=true)` | 设置 distinct_id |
120
+ | `login(id)` | 登录 |
121
+ | `logout(newAnon?)` | 登出 + 重置匿名 |
122
+ | `resetAnonymousIdentity(newAnon?)` | 同 logout |
123
+ | `getDistinctId()` | 当前 distinct_id |
124
+ | `getAnonymousId()` | 匿名 ID |
125
+ | `getFirstId()` | 首次访问 ID |
126
+ | `getLoginId()` | 登录 ID |
127
+ | `getOriginalId()` | 登录前 ID |
128
+
129
+ ### 公共属性
130
+
131
+ | API | 说明 |
132
+ |---|---|
133
+ | `register(props)` | 永久属性 |
134
+ | `registerOnce(props)` | 一次性 |
135
+ | `registerPage(props)` | 页面(一次性) |
136
+ | `registerSession(props)` | 会话(永久) |
137
+ | `registerSessionOnce(props)` | 会话(一次性) |
138
+ | `clearAllRegister()` | 清空 |
139
+ | `unsetRegister(key)` | 单个清空 |
140
+
141
+ ### 控制
142
+
143
+ | API | 说明 |
144
+ |---|---|
145
+ | `flush()` | 强制 flush batch + failed queue |
146
+ | `waitUntilIdle()` | 等待所有 in-flight 完成 |
147
+ | `abort(reason?)` | Abort 所有挂起任务 |
148
+ | `close()` / `shutdown()` | 完整关闭 |
149
+ | `disable()` / `enable()` | 禁用 / 启用 |
150
+ | `setServerURL(url)` | 切换 sink |
151
+ | `use(plugin)` | 注册插件 |
152
+ | `getPresetProperties()` | 当前 preset |
153
+
154
+ ## 插件
155
+
156
+ 13 个官方插件,按场景分组:
157
+
158
+ ### 自动采集
159
+ - `pageLoad()` — $WebPageLoad(性能 API)
160
+ - `pageLeave()` — $WebPageLeave(visibilitychange)
161
+ - `exposure(opts)` — $element_exposure(IntersectionObserver)
162
+ - `sessionEvent(opts)` — $session_start / $session_end
163
+ - `autoTrack()` — pageview / webclick / webstay
164
+
165
+ ### 发送增强
166
+ - `debugSender(opts)` — debug 模式(GET 到 ?debug URL + alert)
167
+ - `indexedDBSender(opts)` — 写到 IDB outbox
168
+ - `webviewBridge()` — vapph5 协议
169
+
170
+ ### 安全 / 隐私
171
+ - `encryptor({ encrypt, decrypt?, encryptEvent?, decryptEvent? })` — 通用加密
172
+ - `xorEncryptor(key)` — XOR+Base64 便捷工厂
173
+ - `aesGcmEncryptAsync(key)` / `aesGcmDecryptAsync(key)` — AES-GCM
174
+
175
+ ## Plugin Authoring Guide
176
+
177
+ 写自定义插件非常简单:
178
+
179
+ ```ts
180
+ import type { Plugin, InstallContext } from '@macroui/event-tracker';
181
+
182
+ export function myPlugin(): Plugin {
183
+ return {
184
+ name: 'my-plugin',
185
+ install({ stages, config, sdk }: InstallContext) {
186
+ // 注册阶段拦截器
187
+ stages.sendDataStage.register({
188
+ name: 'my-modifier',
189
+ priority: 50, // 越大越晚
190
+ entry(ctx) {
191
+ // 修改 ctx.data(事件或属性)
192
+ if (ctx.data && ctx.data.properties) {
193
+ ctx.data.properties.$my_plugin = 'ran';
194
+ }
195
+ },
196
+ });
197
+ },
198
+ uninstall({ config }) {
199
+ // 清理工作:删除 config 字段、解除事件监听等
200
+ },
201
+ };
202
+ }
203
+
204
+ sdk.use(myPlugin());
205
+ ```
206
+
207
+ ### 阶段(stage)约定
208
+
209
+ | 阶段 | 用途 | 推荐 priority |
210
+ |---|---|---|
211
+ | `buildDataStage` | 事件构造后,encrypt / send 之前 | 1-50(低) |
212
+ | `businessStage` | 业务校验 / 转换 / 风控 | 50-150 |
213
+ | `sendDataStage` | 实际发送 / 加密 / 落盘 | 100+(高) |
214
+
215
+ ### 生命周期钩子
216
+
217
+ ```ts
218
+ install({ sdk }) {
219
+ sdk.getRegistry().on('sdkReady', (cfg) => { /* cfg: TrackerConfig */ });
220
+ sdk.getRegistry().on('changeDistinctId', (id) => { /* 用户切换身份 */ });
221
+ }
222
+ ```
223
+
224
+ 钩子列表:`sdkReady` / `sdkInitPara` / `sdkAfterInitPara` / `sdkInitAPI` / `sdkAfterInitAPI` / `changeDistinctId` / `switch`
225
+
226
+ ## 加密 / 解密
227
+
228
+ ```ts
229
+ import { encryptor, aesGcmEncryptAsync, aesGcmDecryptAsync } from '@macroui/event-tracker';
230
+
231
+ sdk.use(encryptor({
232
+ encrypt: aesGcmEncryptAsync('my-32-byte-secret-key-1234567890ab'),
233
+ decrypt: aesGcmDecryptAsync('my-32-byte-secret-key-1234567890ab'),
234
+ encryptEvent: (e) => {
235
+ // 脱敏:去掉 PII 字段
236
+ if (e.properties?.phone) delete e.properties.phone;
237
+ return e;
238
+ },
239
+ }));
240
+ ```
241
+
242
+ > ⚠️ `aesGcmEncrypt` 同步版本仅做 API 完整性占位(Web Crypto 是异步的);
243
+ > 真同步场景请用 XOR(`xorEncryptor`)。
244
+
245
+ ## 失败处理
246
+
247
+ SDK 包含三层失败保护:
248
+
249
+ 1. **重试**:`max_retry_count` × 指数退避(默认 3 次)
250
+ 2. **失败队列**:失败的事件入 localStorage,下次启动重发
251
+ 3. **`flush()`**:手动强制 flush batch + failed queue
252
+
253
+ ```ts
254
+ sdk.track('important_event');
255
+ await sdk.waitUntilIdle(); // 等待所有 in-flight
256
+
257
+ // 出错捕获
258
+ sdk.track('foo', {}, (err) => {
259
+ if (err) console.error('send failed:', err);
260
+ });
261
+ ```
262
+
263
+ ## 从神策 v1.27 / v2.0 迁移
264
+
265
+ | 神策 API | event-tracker | 状态 |
266
+ |---|---|---|
267
+ | `init(config)` | `init(config)` | ✅ 完全兼容字段名 |
268
+ | `track(name, props)` | `track(name, props)` | ✅ |
269
+ | `trackSignup(userId)` | `trackSignup(userId)` | ✅ |
270
+ | `profileSet(props)` | `setProfile(props)` | ✅ 别名 `setProfile` |
271
+ | `profileSetOnce(props)` | `setOnceProfile(props)` | ✅ |
272
+ | `register(props)` | `register(props)` | ✅ |
273
+ | `initPara(config)` | `initPara(config)` | ✅ |
274
+ | `registerAppid(id)` | `registerAppid(id)` | ✅ |
275
+ | `alias(newId)` | `alias(newId)` | ✅ |
276
+ | `trackLinkage` / `trackLink` / `trackLinks` | 同名 | ✅ |
277
+ | `trackHeatMap` / `trackWebClick` / `trackWebStay` | 同名 | ✅ |
278
+ | `debug_mode` 集成 | `debugSender({ url })` 单独插件 | ⚠️ 需 use |
279
+ | 38 个官方插件 | 13 个核心插件 | ⚠️ 关键已覆盖 |
280
+ | 加密 `encode` | `encryptor({ encrypt })` | ⚠️ 插件化,需 use |
281
+
282
+ ## 开发
283
+
284
+ ```bash
285
+ git clone https://github.com/macroui/event-tracker
286
+ cd event-tracker
287
+ npm install
288
+
289
+ # 开发
290
+ npm run typecheck # tsc --noEmit
291
+ npm run lint # eslint
292
+ npm test # Jest
293
+ npm run test:cov # 覆盖率
294
+ npm run test:bench # 性能基准
295
+ npm run test:types # 类型测试(tsd)
296
+
297
+ # 构建
298
+ npm run build # rollup + tsc-dts
299
+ ```
300
+
301
+ ## 架构
302
+
303
+ ```
304
+ src/
305
+ ├── core/
306
+ │ ├── tracker.ts 主类(init + 公共 getter)
307
+ │ ├── track-api.ts track 家族方法
308
+ │ ├── identity-api.ts identify / login / logout / alias
309
+ │ ├── profile-api.ts profile set/append/...
310
+ │ ├── register-api.ts register / registerOnce
311
+ │ ├── send-pipeline.ts sendEvent + flush + waitUntilIdle
312
+ │ ├── identity.ts IdentityManager
313
+ │ ├── uuid.ts UUID v4
314
+ │ ├── logger.ts 日志
315
+ │ ├── env.ts 浏览器能力
316
+ │ ├── stage.ts StageChain(3 阶段)
317
+ │ ├── lifecycle.ts 7 生命周期钩子
318
+ │ ├── plugin.ts PluginRegistry
319
+ │ ├── instance-channel.ts 跨实例通信
320
+ │ ├── retry-policy.ts 指数退避
321
+ │ ├── failed-queue.ts 失败队列
322
+ │ ├── profile-store.ts 用户档案(v1.2)
323
+ │ ├── signed-identity.ts HMAC 签名
324
+ │ ├── errors.ts SDKError 体系
325
+ │ └── instance-factory.ts initPara / registerAppid
326
+ ├── storage/ KVStore + IndexedDB
327
+ ├── send/ ImageSender / BeaconSender / AjaxSender / BatchSender
328
+ ├── presets/ 预置属性
329
+ ├── plugins/ 13 个插件
330
+ └── types/ TrackerConfig(10 个子集)+ EventData + 常量
331
+ ```
332
+
333
+ ## 路线图
334
+
335
+ - v1.0:基础 6 插件 ✅
336
+ - v1.1:完整神策 API + 3 阶段 + 7 钩子 + 多 sink + 加密 + 重试 + 跨实例 ✅
337
+ - v1.1.1:架构重构 + 类型分层 + 错误体系 + signedIdentity + 9 个测试套件 ✅
338
+ - v1.2:plugin marketplace / web worker / WebAssembly 加密
339
+ - v2.0:service worker / Source Map / 多端协议统一
340
+
341
+ ## 许可
342
+
343
+ MIT
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * event-tracker suggest — AI Event Suggestion (v1.3.0)
4
+ *
5
+ * 扫描项目源码中的 sdk.track 调用,分析:
6
+ * - 事件名(按出现频次排序)
7
+ * - 缺失的预置 property
8
+ * - 命名规范违例(小写 / 中文 / emoji)
9
+ * - 循环里调用警告
10
+ *
11
+ * 输出建议到 console + 可选 JSON
12
+ */
13
+
14
+ import { readdir, readFile, stat } from 'node:fs/promises';
15
+ import { resolve, extname, basename } from 'node:path';
16
+
17
+ const TRACK_RE = /\bsdk\.track(?:Signup|Linkage)?\s*\(\s*['"`]([^'"`]+)['"`]/g;
18
+ const LOOP_RE = /\b(for|while)\s*\([^)]*\)\s*\{[^}]*sdk\.track/s;
19
+
20
+ async function* walk(dir: string): AsyncGenerator<string> {
21
+ let entries;
22
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
23
+ for (const e of entries) {
24
+ const p = resolve(dir, e.name);
25
+ if (e.isDirectory()) {
26
+ if (['node_modules', 'dist', 'coverage', '.git', 'build'].includes(e.name)) continue;
27
+ yield* walk(p);
28
+ } else {
29
+ const ext = extname(p);
30
+ if (['.ts', '.tsx', '.js', '.jsx', '.vue'].includes(ext)) yield p;
31
+ }
32
+ }
33
+ }
34
+
35
+ const RULES = [
36
+ { name: 'should_be_english', test: (n: string) => !/^[\x20-\x7E]+$/.test(n), msg: '事件名建议纯 ASCII(兼容服务端解析)' },
37
+ { name: 'lowercase_only', test: (n: string) => /^[a-z][a-z0-9_]*$/.test(n), msg: '建议小写 + 下划线(snake_case)' },
38
+ { name: 'should_be_short', test: (n: string) => n.length <= 32, msg: '事件名建议 ≤ 32 字符' },
39
+ ];
40
+
41
+ async function analyze(target: string): Promise<{
42
+ events: Record<string, number>;
43
+ loops: Array<{ file: string; line: number }>;
44
+ violations: Array<{ event: string; rule: string; msg: string }>;
45
+ suggestions: string[];
46
+ }> {
47
+ const events: Record<string, number> = {};
48
+ const loops: Array<{ file: string; line: number }> = [];
49
+ const violations: Array<{ event: string; rule: string; msg: string }> = [];
50
+
51
+ for await (const file of walk(resolve(process.cwd(), target))) {
52
+ let content: string;
53
+ try { content = await readFile(file, 'utf8'); } catch { continue; }
54
+ const lines = content.split('\n');
55
+ let m: RegExpExecArray | null;
56
+ TRACK_RE.lastIndex = 0;
57
+ while ((m = TRACK_RE.exec(content)) !== null) {
58
+ const name = m[1];
59
+ events[name] = (events[name] || 0) + 1;
60
+ for (const r of RULES) {
61
+ if (!r.test(name)) violations.push({ event: name, rule: r.name, msg: r.msg });
62
+ }
63
+ }
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (LOOP_RE.test(lines[i])) {
66
+ loops.push({ file, line: i + 1 });
67
+ }
68
+ LOOP_RE.lastIndex = 0;
69
+ }
70
+ }
71
+
72
+ // 建议
73
+ const suggestions: string[] = [];
74
+ const sorted = Object.entries(events).sort((a, b) => b[1] - a[1]);
75
+ for (const [name, count] of sorted) {
76
+ if (count > 10) suggestions.push(`考虑把高频事件 '${name}' 加为预置事件(count=${count})`);
77
+ }
78
+ if (loops.length) suggestions.push(`检测到 ${loops.length} 处循环内 sdk.track 调用,建议改用 batch send 或节流`);
79
+
80
+ return { events, loops, violations, suggestions };
81
+ }
82
+
83
+ const target = process.argv[2] || 'src';
84
+ const jsonOut = process.argv.includes('--json');
85
+
86
+ // 跳过命令本身
87
+ analyze(target).then((res) => {
88
+ if (jsonOut) {
89
+ // eslint-disable-next-line no-console
90
+ console.log(JSON.stringify(res, null, 2));
91
+ return;
92
+ }
93
+ // eslint-disable-next-line no-console
94
+ console.log(`\n📊 Event Suggestion Report (target: ${target})\n`);
95
+ // eslint-disable-next-line no-console
96
+ console.log('事件统计:');
97
+ for (const [name, count] of Object.entries(res.events).sort((a, b) => b[1] - a[1])) {
98
+ // eslint-disable-next-line no-console
99
+ console.log(` ${String(count).padStart(4)} ${name}`);
100
+ }
101
+ if (res.violations.length) {
102
+ // eslint-disable-next-line no-console
103
+ console.log(`\n❌ 命名违例(${res.violations.length}):`);
104
+ const grouped: Record<string, Array<{ event: string; msg: string }>> = {};
105
+ for (const v of res.violations) {
106
+ (grouped[v.rule] ||= []).push({ event: v.event, msg: v.msg });
107
+ }
108
+ for (const [rule, list] of Object.entries(grouped)) {
109
+ // eslint-disable-next-line no-console
110
+ console.log(` [${rule}] ${list.length} 次`);
111
+ for (const it of list.slice(0, 3)) {
112
+ // eslint-disable-next-line no-console
113
+ console.log(` - ${it.event}: ${it.msg}`);
114
+ }
115
+ }
116
+ }
117
+ if (res.loops.length) {
118
+ // eslint-disable-next-line no-console
119
+ console.log(`\n⚠️ 循环内调用(${res.loops.length}):`);
120
+ for (const l of res.loops.slice(0, 5)) {
121
+ // eslint-disable-next-line no-console
122
+ console.log(` ${l.file}:${l.line}`);
123
+ }
124
+ }
125
+ if (res.suggestions.length) {
126
+ // eslint-disable-next-line no-console
127
+ console.log(`\n💡 建议:`);
128
+ for (const s of res.suggestions) {
129
+ // eslint-disable-next-line no-console
130
+ console.log(` - ${s}`);
131
+ }
132
+ }
133
+ // eslint-disable-next-line no-console
134
+ console.log('');
135
+ }).catch((e) => {
136
+ // eslint-disable-next-line no-console
137
+ console.error('suggest failed:', e);
138
+ process.exit(1);
139
+ });
140
+ // keep basename used to avoid unused var warning
141
+ void basename;
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * event-tracker CLI (v1.2.0)
4
+ *
5
+ * Usage:
6
+ * npx event-tracker init 生成初始化代码片段
7
+ * npx event-tracker dev 启动本地 mock server
8
+ * npx event-tracker analyze 分析 SDK 产物大小(须先 build)
9
+ *
10
+ * 不强制依赖:纯 ESM CLI,单文件 0 依赖
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { readFile } from 'node:fs/promises';
15
+ import { resolve } from 'node:path';
16
+
17
+ const HELP = `event-tracker CLI (v1.3.0)
18
+
19
+ Commands:
20
+ init [target] 生成初始化代码片段(vue/react/html/node)
21
+ dev 启动 mock 上报服务(端口 8888)
22
+ analyze 分析 dist/ 下产物大小(含 visualizer)
23
+ suggest [dir] AI 事件埋点建议(基于源码扫描)
24
+ help 帮助
25
+ `;
26
+
27
+ function printInit(target: string): void {
28
+ const T = (target || 'html').toLowerCase();
29
+ const snippets: Record<string, string> = {
30
+ html: `<script src="https://unpkg.com/@macroui/event-tracker@latest/dist/event-tracker.min.js"></script>
31
+ <script>
32
+ eventTracker.init({
33
+ app_id: 'demo',
34
+ server_url: 'https://api.example.com/sa',
35
+ send_type: 'beacon',
36
+ });
37
+ eventTracker.track('PageView', { $referrer: document.referrer });
38
+ </script>`,
39
+ vue: `import eventTracker from '@macroui/event-tracker';
40
+ eventTracker.init({
41
+ app_id: import.meta.env.VITE_APP_ID,
42
+ server_url: import.meta.env.VITE_SA_URL,
43
+ send_type: 'beacon',
44
+ });
45
+ // 在 main.ts 调用即可`,
46
+ react: `import eventTracker from '@macroui/event-tracker';
47
+ eventTracker.init({
48
+ app_id: process.env.REACT_APP_SA_APP_ID!,
49
+ server_url: process.env.REACT_APP_SA_URL!,
50
+ send_type: 'beacon',
51
+ });
52
+ // 在 index.tsx 顶层调用`,
53
+ node: `import { initPara } from '@macroui/event-tracker';
54
+ const sdk = initPara({
55
+ app_id: 'node-app',
56
+ server_url: 'https://api.example.com/sa',
57
+ send_type: 'ajax',
58
+ });
59
+ sdk.track('ServerStart', { $pid: process.pid });`,
60
+ };
61
+ // eslint-disable-next-line no-console
62
+ console.log(snippets[T] || snippets.html);
63
+ }
64
+
65
+ async function startDev(): Promise<void> {
66
+ const { createServer } = await import('node:http');
67
+ const port = Number(process.env.PORT || 8888);
68
+ const srv = createServer((req, res) => {
69
+ let body = '';
70
+ req.on('data', (c) => { body += c; });
71
+ req.on('end', () => {
72
+ // eslint-disable-next-line no-console
73
+ console.log(`[mock] ${req.method} ${req.url} body=${body.slice(0, 200)}`);
74
+ res.writeHead(200, { 'Content-Type': 'application/json' });
75
+ res.end(JSON.stringify({ ok: true, received: body.length }));
76
+ });
77
+ });
78
+ srv.listen(port, () => {
79
+ // eslint-disable-next-line no-console
80
+ console.log(`[event-tracker mock] listening on http://localhost:${port}`);
81
+ });
82
+ }
83
+
84
+ async function analyze(): Promise<void> {
85
+ const distDir = resolve(process.cwd(), 'dist');
86
+ if (!existsSync(distDir)) {
87
+ // eslint-disable-next-line no-console
88
+ console.error('dist/ not found, run `npm run build` first.');
89
+ process.exit(1);
90
+ }
91
+ const { readdir, stat } = await import('node:fs/promises');
92
+ const files = await readdir(distDir);
93
+ const rows: Array<{ name: string; size: number; gzip: number }> = [];
94
+ for (const f of files) {
95
+ const p = resolve(distDir, f);
96
+ let s;
97
+ try { s = await stat(p); } catch { continue; }
98
+ if (!s.isFile()) continue;
99
+ if (f.endsWith('.map')) continue;
100
+ let gzip = 0;
101
+ if (f.endsWith('.js') || f.endsWith('.cjs') || f.endsWith('.mjs')) {
102
+ try {
103
+ const buf = await readFile(p);
104
+ const { gzipSync } = await import('node:zlib');
105
+ gzip = gzipSync(buf).length;
106
+ } catch { /* ignore */ }
107
+ }
108
+ rows.push({ name: f, size: s.size, gzip });
109
+ }
110
+ rows.sort((a, b) => b.size - a.size);
111
+ // eslint-disable-next-line no-console
112
+ console.log('File Size Gzip');
113
+ for (const r of rows) {
114
+ // eslint-disable-next-line no-console
115
+ console.log(
116
+ `${r.name.padEnd(40)}${(r.size / 1024).toFixed(2).padStart(8)}KB ${(r.gzip / 1024).toFixed(2).padStart(8)}KB`,
117
+ );
118
+ }
119
+ const vizPath = resolve(distDir, 'stats.html');
120
+ // eslint-disable-next-line no-console
121
+ console.log(`\n可视化报告:${existsSync(vizPath) ? '✅ ' + vizPath : '❌ 未生成(运行 ANALYZE=1 npm run visualize 重新 build)'}`);
122
+ }
123
+
124
+ const [, , cmd, arg] = process.argv;
125
+ switch (cmd) {
126
+ case 'init':
127
+ printInit(arg);
128
+ break;
129
+ case 'dev':
130
+ void startDev();
131
+ break;
132
+ case 'analyze':
133
+ void analyze();
134
+ break;
135
+ case 'suggest': {
136
+ // delegate to event-tracker-suggest.mjs
137
+ const { spawn } = await import('node:child_process');
138
+ const target = arg || 'src';
139
+ const isJson = process.argv.includes('--json');
140
+ const child = spawn(process.execPath, [
141
+ new URL('./event-tracker-suggest.mjs', import.meta.url).pathname,
142
+ target,
143
+ ...(isJson ? ['--json'] : []),
144
+ ], { stdio: 'inherit' });
145
+ child.on('exit', (code) => process.exit(code ?? 0));
146
+ break;
147
+ }
148
+ case 'help':
149
+ case undefined:
150
+ // eslint-disable-next-line no-console
151
+ console.log(HELP);
152
+ break;
153
+ default:
154
+ // eslint-disable-next-line no-console
155
+ console.error(`Unknown command: ${cmd}\n${HELP}`);
156
+ process.exit(1);
157
+ }