dsh-heatmap 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/README.md +184 -0
- package/cordis.patch.yml +10 -0
- package/examples/push-to-platform.mjs +94 -0
- package/lib/client.js +897 -0
- package/lib/client.js.map +1 -0
- package/package.json +74 -0
- package/src/client/ConsentModal.tsx +77 -0
- package/src/client/HeatmapOverlay.tsx +353 -0
- package/src/client/index.ts +28 -0
- package/src/client/storage.ts +144 -0
- package/src/client/tracker.ts +219 -0
- package/src/http.ts +172 -0
- package/src/index.ts +135 -0
- package/src/shared/types.ts +103 -0
- package/src/store.ts +146 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 埋点事件契约(host 与 client 共用同一份,保证采集端与收集端字段一致)。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则(科学的埋点体系):
|
|
5
|
+
* - schema 带版本号 `v`,后续字段演进可平滑迁移;
|
|
6
|
+
* - 每条事件都有稳定 `id` / `sessionId` / `ts`,可做漏斗与会话回放;
|
|
7
|
+
* - 只记录「元素身份 + 位置 + 时间 + 视口」,绝不采集输入内容或对话正文,
|
|
8
|
+
* 从根上满足隐私与合规(默认最小化采集)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
/** 事件类型:覆盖页面生命周期、交互、滚动曝光、热力图坐标四类。 */
|
|
14
|
+
export type EventType =
|
|
15
|
+
| 'session_start' // 会话开始(页面加载)
|
|
16
|
+
| 'session_end' // 会话结束(卸载/隐藏)
|
|
17
|
+
| 'page_view' // 路由/hash 变化
|
|
18
|
+
| 'click' // 按钮/可点击元素点击(含坐标,供热力图)
|
|
19
|
+
| 'focus' // 获得焦点
|
|
20
|
+
| 'blur' // 失去焦点
|
|
21
|
+
| 'hover' // 鼠标悬停坐标(节流,供热力图)
|
|
22
|
+
| 'scroll_depth' // 滚动深度(0..1)
|
|
23
|
+
| 'visibility' // 页签可见性变化(用于停留时长)
|
|
24
|
+
| 'input' // 输入框输入(仅记录长度,不记录内容)
|
|
25
|
+
| 'impression' // 元素曝光(面板/控件首次进入视口 ≥50%)
|
|
26
|
+
|
|
27
|
+
/** 被操作元素的「身份指纹」,用于跨会话聚合到同一控件。 */
|
|
28
|
+
export interface ElementRef {
|
|
29
|
+
tag: string
|
|
30
|
+
id?: string
|
|
31
|
+
role?: string
|
|
32
|
+
ariaLabel?: string
|
|
33
|
+
title?: string
|
|
34
|
+
testId?: string
|
|
35
|
+
/** 表单控件名(input/textarea/select 的 name 属性,不含内容)。 */
|
|
36
|
+
name?: string
|
|
37
|
+
/** 表单占位符(placeholder,不含用户输入内容)。 */
|
|
38
|
+
placeholder?: string
|
|
39
|
+
/** 截断后的可见文本(仅限 UI 控件文案,如按钮/链接,最长 40 字;输入类控件不采集)。 */
|
|
40
|
+
text?: string
|
|
41
|
+
/** 稳定的元素标识(优先 data-testid > id > aria-label > role+tag > placeholder/name > tag+text)。 */
|
|
42
|
+
elementId: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 单条埋点事件。 */
|
|
46
|
+
export interface AnalyticsEvent {
|
|
47
|
+
v: number
|
|
48
|
+
id: string
|
|
49
|
+
ts: number
|
|
50
|
+
sessionId: string
|
|
51
|
+
type: EventType
|
|
52
|
+
page: { path: string; hash: string }
|
|
53
|
+
target?: ElementRef
|
|
54
|
+
/** 视口内坐标(click / hover 供热力图)。 */
|
|
55
|
+
position?: { x: number; y: number }
|
|
56
|
+
/** 滚动深度 0..1(scroll_depth)。 */
|
|
57
|
+
depth?: number
|
|
58
|
+
/** 页签是否可见(visibility)。 */
|
|
59
|
+
visible?: boolean
|
|
60
|
+
/** 输入长度(input,仅数字,不含内容)。 */
|
|
61
|
+
inputLength?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 客户端上传给 host 收集器的一批事件。 */
|
|
65
|
+
export interface AnalyticsBatch {
|
|
66
|
+
v: number
|
|
67
|
+
sessionId: string
|
|
68
|
+
sentAt: number
|
|
69
|
+
events: AnalyticsEvent[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** host 侧聚合统计结果。 */
|
|
73
|
+
export interface StoreStats {
|
|
74
|
+
total: number
|
|
75
|
+
byType: Record<string, number>
|
|
76
|
+
sessions: number
|
|
77
|
+
clicks: number
|
|
78
|
+
focus: number
|
|
79
|
+
impressions: number
|
|
80
|
+
earliestTs?: number
|
|
81
|
+
latestTs?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 漏斗阶段:有序步骤的会话到达数与相对上一阶段的转化率。 */
|
|
85
|
+
export interface FunnelStage {
|
|
86
|
+
step: string
|
|
87
|
+
sessions: number
|
|
88
|
+
conversion: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 漏斗分析结果。 */
|
|
92
|
+
export interface FunnelResult {
|
|
93
|
+
totalSessions: number
|
|
94
|
+
stages: FunnelStage[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 单个会话的有序事件时间线(用于会话回放/复现)。 */
|
|
98
|
+
export interface SessionTimeline {
|
|
99
|
+
sessionId: string
|
|
100
|
+
startTs?: number
|
|
101
|
+
endTs?: number
|
|
102
|
+
events: AnalyticsEvent[]
|
|
103
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host 侧埋点数据存储:内存环形缓冲 + NDJSON 落盘。
|
|
3
|
+
*
|
|
4
|
+
* NDJSON(一行一条 JSON)便于增量追加、流式读取,也能直接给后续统一分析
|
|
5
|
+
* 平台消费。内存缓冲用于快速聚合统计(stats / export),落盘用于持久化。
|
|
6
|
+
*/
|
|
7
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { dirname, resolve } from 'node:path'
|
|
9
|
+
import type { AnalyticsBatch, AnalyticsEvent, FunnelResult, SessionTimeline, StoreStats } from './shared/types.ts'
|
|
10
|
+
|
|
11
|
+
export class AnalyticsStore {
|
|
12
|
+
private events: AnalyticsEvent[] = []
|
|
13
|
+
private readonly file: string
|
|
14
|
+
private readonly ready: Promise<void>
|
|
15
|
+
private readonly maxEvents: number
|
|
16
|
+
|
|
17
|
+
constructor(dataDir: string, maxEvents = 20000) {
|
|
18
|
+
this.maxEvents = maxEvents
|
|
19
|
+
this.file = resolve(dataDir, 'events.ndjson')
|
|
20
|
+
this.ready = this.init()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 数据文件绝对路径(供诊断/调试)。 */
|
|
24
|
+
get filePath(): string {
|
|
25
|
+
return this.file
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async init(): Promise<void> {
|
|
29
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(this.file, 'utf8')
|
|
32
|
+
this.events = raw
|
|
33
|
+
.split('\n')
|
|
34
|
+
.filter(line => line.trim() !== '')
|
|
35
|
+
.map(line => JSON.parse(line) as AnalyticsEvent)
|
|
36
|
+
if (this.events.length > this.maxEvents) {
|
|
37
|
+
this.events = this.events.slice(-this.maxEvents)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
this.events = []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 接收一批事件:追加进内存与文件,返回实际接收条数。 */
|
|
45
|
+
async ingest(batch: AnalyticsBatch): Promise<number> {
|
|
46
|
+
await this.ready
|
|
47
|
+
const accepted = Array.isArray(batch?.events) ? batch.events : []
|
|
48
|
+
if (accepted.length === 0) return 0
|
|
49
|
+
this.events.push(...accepted)
|
|
50
|
+
if (this.events.length > this.maxEvents) {
|
|
51
|
+
this.events = this.events.slice(-this.maxEvents)
|
|
52
|
+
}
|
|
53
|
+
const lines = accepted.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
54
|
+
await appendFile(this.file, lines, 'utf8')
|
|
55
|
+
return accepted.length
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 导出事件(`limit <= 0` 表示全部)。 */
|
|
59
|
+
async list(limit = 0): Promise<AnalyticsEvent[]> {
|
|
60
|
+
await this.ready
|
|
61
|
+
if (limit > 0) return this.events.slice(-limit)
|
|
62
|
+
return this.events
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 聚合统计。 */
|
|
66
|
+
async stats(): Promise<StoreStats> {
|
|
67
|
+
await this.ready
|
|
68
|
+
const byType: Record<string, number> = {}
|
|
69
|
+
const sessions = new Set<string>()
|
|
70
|
+
let clicks = 0
|
|
71
|
+
let focus = 0
|
|
72
|
+
let impressions = 0
|
|
73
|
+
let earliestTs: number | undefined
|
|
74
|
+
let latestTs: number | undefined
|
|
75
|
+
for (const e of this.events) {
|
|
76
|
+
byType[e.type] = (byType[e.type] ?? 0) + 1
|
|
77
|
+
sessions.add(e.sessionId)
|
|
78
|
+
if (e.type === 'click') clicks += 1
|
|
79
|
+
if (e.type === 'focus') focus += 1
|
|
80
|
+
if (e.type === 'impression') impressions += 1
|
|
81
|
+
if (earliestTs === undefined || e.ts < earliestTs) earliestTs = e.ts
|
|
82
|
+
if (latestTs === undefined || e.ts > latestTs) latestTs = e.ts
|
|
83
|
+
}
|
|
84
|
+
return { total: this.events.length, byType, sessions: sessions.size, clicks, focus, impressions, earliestTs, latestTs }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 按会话聚合的有序事件时间线(供回放/复现与漏斗分析共用)。 */
|
|
88
|
+
private async timelines(): Promise<Map<string, AnalyticsEvent[]>> {
|
|
89
|
+
await this.ready
|
|
90
|
+
const bySession = new Map<string, AnalyticsEvent[]>()
|
|
91
|
+
for (const e of this.events) {
|
|
92
|
+
const list = bySession.get(e.sessionId)
|
|
93
|
+
if (list === undefined) bySession.set(e.sessionId, [e])
|
|
94
|
+
else list.push(e)
|
|
95
|
+
}
|
|
96
|
+
for (const list of bySession.values()) list.sort((a, b) => a.ts - b.ts)
|
|
97
|
+
return bySession
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 会话列表:每个会话的有序事件时间线。 */
|
|
101
|
+
async sessions(limit = 0): Promise<SessionTimeline[]> {
|
|
102
|
+
const bySession = await this.timelines()
|
|
103
|
+
const out: SessionTimeline[] = []
|
|
104
|
+
for (const [sessionId, events] of bySession) {
|
|
105
|
+
out.push({
|
|
106
|
+
sessionId,
|
|
107
|
+
startTs: events.length > 0 ? events[0].ts : undefined,
|
|
108
|
+
endTs: events.length > 0 ? events[events.length - 1].ts : undefined,
|
|
109
|
+
events,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
out.sort((a, b) => (b.endTs ?? 0) - (a.endTs ?? 0))
|
|
113
|
+
return limit > 0 ? out.slice(0, limit) : out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 漏斗分析:给定有序事件类型步骤,统计有多少会话按顺序走到每一步,
|
|
118
|
+
* 并计算相邻步骤的转化率。
|
|
119
|
+
* @param steps - 有序事件类型(如 ['session_start', 'click', 'input'])。
|
|
120
|
+
*/
|
|
121
|
+
async funnel(steps: string[]): Promise<FunnelResult> {
|
|
122
|
+
const bySession = await this.timelines()
|
|
123
|
+
const valid = (steps ?? []).filter((s): s is string => typeof s === 'string' && s !== '')
|
|
124
|
+
const counts = new Array<number>(valid.length).fill(0)
|
|
125
|
+
for (const events of bySession.values()) {
|
|
126
|
+
let stepIdx = 0
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (stepIdx < valid.length && e.type === valid[stepIdx]) stepIdx += 1
|
|
129
|
+
}
|
|
130
|
+
for (let i = 0; i < stepIdx; i += 1) counts[i] += 1
|
|
131
|
+
}
|
|
132
|
+
const stages = valid.map((step, i) => ({
|
|
133
|
+
step,
|
|
134
|
+
sessions: counts[i],
|
|
135
|
+
conversion: i === 0 ? 1 : (counts[i - 1] > 0 ? counts[i] / counts[i - 1] : 0),
|
|
136
|
+
}))
|
|
137
|
+
return { totalSessions: bySession.size, stages }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 清空数据。 */
|
|
141
|
+
async clear(): Promise<void> {
|
|
142
|
+
await this.ready
|
|
143
|
+
this.events = []
|
|
144
|
+
await writeFile(this.file, '', 'utf8')
|
|
145
|
+
}
|
|
146
|
+
}
|