@chainlesschain/personal-data-hub 0.3.8 → 0.4.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/__tests__/adapters/apple-health.test.js +95 -0
- package/__tests__/adapters/email-templates.test.js +123 -0
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
- package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
- package/__tests__/adapters/git-activity.test.js +7 -1
- package/__tests__/adapters/local-im-pc.test.js +149 -0
- package/__tests__/adapters/netease-music.test.js +74 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
- package/__tests__/adapters/system-data-adapter.test.js +4 -1
- package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
- package/__tests__/adapters/weread.test.js +123 -0
- package/__tests__/analysis.test.js +120 -15
- package/__tests__/mobile-extractor-encrypted.test.js +460 -0
- package/__tests__/prompt-builder.test.js +47 -2
- package/__tests__/registry-readiness.test.js +233 -0
- package/__tests__/social-douyin-im-direct-read.test.js +311 -0
- package/__tests__/social-douyin-snapshot.test.js +5 -2
- package/__tests__/vault.test.js +99 -0
- package/lib/adapter-guide.js +520 -0
- package/lib/adapter-readiness.js +257 -0
- package/lib/adapters/_local-im-db-reader.js +218 -0
- package/lib/adapters/_local-im-pc-adapter.js +162 -0
- package/lib/adapters/apple-health/index.js +329 -0
- package/lib/adapters/dingtalk-pc/index.js +29 -0
- package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
- package/lib/adapters/edu-huawei-learning/index.js +255 -0
- package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
- package/lib/adapters/edu-zuoyebang/index.js +259 -0
- package/lib/adapters/email-imap/email-adapter.js +16 -0
- package/lib/adapters/email-imap/templates/bill.js +174 -18
- package/lib/adapters/feishu-pc/index.js +29 -0
- package/lib/adapters/finance-alipay/api-client.js +48 -0
- package/lib/adapters/finance-alipay/index.js +257 -0
- package/lib/adapters/game-genshin/api-client.js +59 -0
- package/lib/adapters/game-genshin/index.js +274 -0
- package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
- package/lib/adapters/game-honor-of-kings/index.js +259 -0
- package/lib/adapters/netease-music/index.js +227 -0
- package/lib/adapters/qq-pc/index.js +200 -0
- package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
- package/lib/adapters/social-douyin/index.js +194 -1
- package/lib/adapters/wechat/wechat-adapter.js +7 -1
- package/lib/adapters/wechat-pc/index.js +335 -0
- package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
- package/lib/adapters/weread/api-client.js +128 -0
- package/lib/adapters/weread/index.js +337 -0
- package/lib/analysis.js +65 -0
- package/lib/index.js +39 -0
- package/lib/mobile-extractor/bplist.js +233 -0
- package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
- package/lib/mobile-extractor/ios.js +131 -16
- package/lib/prompt-builder.js +19 -1
- package/lib/registry.js +170 -0
- package/lib/vault.js +105 -0
- package/package.json +1 -1
- package/scripts/run-native-tests-sandbox.sh +2 -0
- package/vitest.config.js +79 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apple Health adapter — file-import of the standard HealthKit `export.xml`.
|
|
5
|
+
*
|
|
6
|
+
* This is the MOST reliable health source: the user exports their own data
|
|
7
|
+
* from the iOS 健康 app ("头像 → 导出所有健康数据" → a zip containing
|
|
8
|
+
* `export.xml`), unzips, and points the adapter at the XML. The format is a
|
|
9
|
+
* stable, documented HealthKit schema — no device hacking, no decryption, no
|
|
10
|
+
* signing. Pure file-import (like local-files / git-activity).
|
|
11
|
+
*
|
|
12
|
+
* `export.xml` shape:
|
|
13
|
+
* <HealthData ...>
|
|
14
|
+
* <Record type="HKQuantityTypeIdentifierStepCount" sourceName="iPhone"
|
|
15
|
+
* unit="count" startDate="2024-01-15 08:30:00 +0800"
|
|
16
|
+
* endDate="..." value="123"/>
|
|
17
|
+
* <Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisAsleep" .../>
|
|
18
|
+
* <Workout workoutActivityType="HKWorkoutActivityTypeRunning"
|
|
19
|
+
* duration="30" durationUnit="min" totalDistance="5"
|
|
20
|
+
* totalDistanceUnit="km" startDate="..." endDate="..."/>
|
|
21
|
+
* ...
|
|
22
|
+
* </HealthData>
|
|
23
|
+
*
|
|
24
|
+
* Each Record/Workout is one line in practice, so we parse line-by-line and
|
|
25
|
+
* pull attributes with a small regex — robust against attribute reordering
|
|
26
|
+
* and avoids a heavy XML dependency. `export.xml` can be large (100s of MB);
|
|
27
|
+
* v1 reads the whole file with a `maxRecords` cap. (A streaming reader is a
|
|
28
|
+
* later optimization; flagged via diagnostic when the cap truncates.)
|
|
29
|
+
*
|
|
30
|
+
* Metrics → EVENT subtype "other" (extra carries metric/value/unit).
|
|
31
|
+
* Workouts → EVENT subtype "trip" (a bounded activity with duration/distance).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const { newId } = require("../../ids");
|
|
36
|
+
const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../../constants");
|
|
37
|
+
|
|
38
|
+
const NAME = "apple-health";
|
|
39
|
+
const VERSION = "0.1.0";
|
|
40
|
+
|
|
41
|
+
const KIND_RECORD = "record";
|
|
42
|
+
const KIND_WORKOUT = "workout";
|
|
43
|
+
|
|
44
|
+
// Whitelist of high-value metric types → short label. Anything not listed is
|
|
45
|
+
// still ingested (label = the raw type minus the HK prefix) so the user never
|
|
46
|
+
// silently loses a metric; the whitelist just gives nice names for the common
|
|
47
|
+
// ones. null value = skip (too noisy / not useful for a personal profile).
|
|
48
|
+
const METRIC_LABELS = Object.freeze({
|
|
49
|
+
HKQuantityTypeIdentifierStepCount: "步数",
|
|
50
|
+
HKQuantityTypeIdentifierDistanceWalkingRunning: "步行跑步距离",
|
|
51
|
+
HKQuantityTypeIdentifierFlightsClimbed: "爬楼层数",
|
|
52
|
+
HKQuantityTypeIdentifierActiveEnergyBurned: "活动能量",
|
|
53
|
+
HKQuantityTypeIdentifierBasalEnergyBurned: "静息能量",
|
|
54
|
+
HKQuantityTypeIdentifierHeartRate: "心率",
|
|
55
|
+
HKQuantityTypeIdentifierRestingHeartRate: "静息心率",
|
|
56
|
+
HKQuantityTypeIdentifierBodyMass: "体重",
|
|
57
|
+
HKQuantityTypeIdentifierHeight: "身高",
|
|
58
|
+
HKCategoryTypeIdentifierSleepAnalysis: "睡眠",
|
|
59
|
+
HKQuantityTypeIdentifierOxygenSaturation: "血氧",
|
|
60
|
+
HKQuantityTypeIdentifierBodyMassIndex: "BMI",
|
|
61
|
+
HKQuantityTypeIdentifierDietaryWater: "饮水",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function metricLabel(type) {
|
|
65
|
+
if (METRIC_LABELS[type]) return METRIC_LABELS[type];
|
|
66
|
+
if (typeof type !== "string") return "未知指标";
|
|
67
|
+
return type
|
|
68
|
+
.replace(/^HKQuantityTypeIdentifier/, "")
|
|
69
|
+
.replace(/^HKCategoryTypeIdentifier/, "")
|
|
70
|
+
.replace(/^HK/, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseAttrs(line) {
|
|
74
|
+
const attrs = {};
|
|
75
|
+
const re = /(\w+)="([^"]*)"/g;
|
|
76
|
+
let m;
|
|
77
|
+
while ((m = re.exec(line)) !== null) {
|
|
78
|
+
attrs[m[1]] = m[2];
|
|
79
|
+
}
|
|
80
|
+
return attrs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseAppleDate(s) {
|
|
84
|
+
if (typeof s !== "string" || s.length === 0) return null;
|
|
85
|
+
const t = Date.parse(s);
|
|
86
|
+
return Number.isFinite(t) ? t : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stableOriginalId(kind, parts) {
|
|
90
|
+
const safe = parts.filter(Boolean).join("|") || `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
91
|
+
return `apple-health:${kind}:${safe}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class AppleHealthAdapter {
|
|
95
|
+
constructor(opts = {}) {
|
|
96
|
+
this._inputPath = opts.inputPath || null;
|
|
97
|
+
|
|
98
|
+
this.name = NAME;
|
|
99
|
+
this.version = VERSION;
|
|
100
|
+
this.capabilities = [
|
|
101
|
+
"sync:file-import",
|
|
102
|
+
"parse:healthkit-record",
|
|
103
|
+
"parse:healthkit-workout",
|
|
104
|
+
];
|
|
105
|
+
this.extractMode = "file-import";
|
|
106
|
+
this.rateLimits = {};
|
|
107
|
+
this.dataDisclosure = {
|
|
108
|
+
fields: [
|
|
109
|
+
"apple-health:records (步数 / 心率 / 睡眠 / 体重 / 距离 / 能量 …)",
|
|
110
|
+
"apple-health:workouts (运动类型 / 时长 / 距离 / 能量)",
|
|
111
|
+
],
|
|
112
|
+
sensitivity: "high",
|
|
113
|
+
legalGate: false,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this._deps = { fs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async authenticate(ctx = {}) {
|
|
120
|
+
if (ctx && ctx.readinessOnly) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: "NO_FILE",
|
|
124
|
+
message:
|
|
125
|
+
"apple-health: 从 iOS 健康 App 导出 export.xml 后,选择该文件即可采集",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const inputPath = (ctx && ctx.inputPath) || this._inputPath;
|
|
129
|
+
if (inputPath) {
|
|
130
|
+
try {
|
|
131
|
+
this._deps.fs.accessSync(inputPath, this._deps.fs.constants.R_OK);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
136
|
+
message: `apple-health: export.xml not readable at ${inputPath}: ${err.message}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, mode: "file-import" };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: "NO_INPUT",
|
|
144
|
+
message: "apple-health.authenticate: needs opts.inputPath (export.xml)",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async healthCheck() {
|
|
149
|
+
return { ok: true, lastChecked: Date.now() };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async *sync(opts = {}) {
|
|
153
|
+
const inputPath = opts.inputPath || this._inputPath;
|
|
154
|
+
if (!inputPath) {
|
|
155
|
+
throw new Error("apple-health.sync: needs opts.inputPath (export.xml)");
|
|
156
|
+
}
|
|
157
|
+
if (!this._deps.fs.existsSync(inputPath)) return;
|
|
158
|
+
|
|
159
|
+
const maxRecords =
|
|
160
|
+
Number.isInteger(opts.maxRecords) && opts.maxRecords > 0 ? opts.maxRecords : 200_000;
|
|
161
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
162
|
+
const include = opts.include || {};
|
|
163
|
+
|
|
164
|
+
const content = this._deps.fs.readFileSync(inputPath, "utf-8");
|
|
165
|
+
const lines = content.split("\n");
|
|
166
|
+
let emitted = 0;
|
|
167
|
+
let seen = 0;
|
|
168
|
+
let truncated = false;
|
|
169
|
+
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (emitted >= limit) break;
|
|
172
|
+
const trimmed = line.trimStart();
|
|
173
|
+
const isRecord = trimmed.startsWith("<Record ");
|
|
174
|
+
const isWorkout = trimmed.startsWith("<Workout ");
|
|
175
|
+
if (!isRecord && !isWorkout) continue;
|
|
176
|
+
seen += 1;
|
|
177
|
+
if (seen > maxRecords) {
|
|
178
|
+
truncated = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const attrs = parseAttrs(trimmed);
|
|
182
|
+
|
|
183
|
+
if (isRecord && include[KIND_RECORD] !== false) {
|
|
184
|
+
const occurredAt =
|
|
185
|
+
parseAppleDate(attrs.startDate) || parseAppleDate(attrs.creationDate);
|
|
186
|
+
yield {
|
|
187
|
+
adapter: NAME,
|
|
188
|
+
kind: KIND_RECORD,
|
|
189
|
+
originalId: stableOriginalId(KIND_RECORD, [
|
|
190
|
+
attrs.type,
|
|
191
|
+
attrs.startDate,
|
|
192
|
+
attrs.value,
|
|
193
|
+
]),
|
|
194
|
+
capturedAt: occurredAt || Date.now(),
|
|
195
|
+
payload: { kind: KIND_RECORD, ...attrs },
|
|
196
|
+
};
|
|
197
|
+
emitted += 1;
|
|
198
|
+
} else if (isWorkout && include[KIND_WORKOUT] !== false) {
|
|
199
|
+
const occurredAt =
|
|
200
|
+
parseAppleDate(attrs.startDate) || parseAppleDate(attrs.creationDate);
|
|
201
|
+
yield {
|
|
202
|
+
adapter: NAME,
|
|
203
|
+
kind: KIND_WORKOUT,
|
|
204
|
+
originalId: stableOriginalId(KIND_WORKOUT, [
|
|
205
|
+
attrs.workoutActivityType,
|
|
206
|
+
attrs.startDate,
|
|
207
|
+
attrs.duration,
|
|
208
|
+
]),
|
|
209
|
+
capturedAt: occurredAt || Date.now(),
|
|
210
|
+
payload: { kind: KIND_WORKOUT, ...attrs },
|
|
211
|
+
};
|
|
212
|
+
emitted += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (truncated && typeof opts.onProgress === "function") {
|
|
217
|
+
try {
|
|
218
|
+
opts.onProgress({
|
|
219
|
+
phase: "truncated",
|
|
220
|
+
adapter: NAME,
|
|
221
|
+
maxRecords,
|
|
222
|
+
message: `export.xml 超过 ${maxRecords} 条,已截断(提高 maxRecords 可全量导入)`,
|
|
223
|
+
});
|
|
224
|
+
} catch (_e) { /* best-effort */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
normalize(raw) {
|
|
229
|
+
if (!raw || !raw.payload) {
|
|
230
|
+
throw new Error("AppleHealthAdapter.normalize: payload missing");
|
|
231
|
+
}
|
|
232
|
+
const kind = raw.kind || raw.payload.kind;
|
|
233
|
+
const ingestedAt = Date.now();
|
|
234
|
+
if (kind === KIND_RECORD) return normalizeRecord(raw.payload, raw, ingestedAt);
|
|
235
|
+
if (kind === KIND_WORKOUT) return normalizeWorkout(raw.payload, raw, ingestedAt);
|
|
236
|
+
throw new Error(`AppleHealthAdapter.normalize: unknown kind ${kind}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildSource(raw, occurredAt) {
|
|
241
|
+
return {
|
|
242
|
+
adapter: NAME,
|
|
243
|
+
adapterVersion: VERSION,
|
|
244
|
+
originalId: raw.originalId,
|
|
245
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
246
|
+
capturedBy: CAPTURED_BY.EXPORT,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeRecord(p, raw, ingestedAt) {
|
|
251
|
+
const occurredAt =
|
|
252
|
+
parseAppleDate(p.startDate) || parseAppleDate(p.creationDate) || raw.capturedAt || ingestedAt;
|
|
253
|
+
const source = buildSource(raw, occurredAt);
|
|
254
|
+
const label = metricLabel(p.type);
|
|
255
|
+
const value = p.value != null ? p.value : "";
|
|
256
|
+
const unit = p.unit || "";
|
|
257
|
+
return {
|
|
258
|
+
events: [{
|
|
259
|
+
id: newId(),
|
|
260
|
+
type: ENTITY_TYPES.EVENT,
|
|
261
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
262
|
+
occurredAt,
|
|
263
|
+
actor: "person-self",
|
|
264
|
+
content: {
|
|
265
|
+
title: `${label}: ${value}${unit ? " " + unit : ""}`.trim(),
|
|
266
|
+
text: `${label} ${value} ${unit}`.trim(),
|
|
267
|
+
},
|
|
268
|
+
ingestedAt,
|
|
269
|
+
source,
|
|
270
|
+
extra: {
|
|
271
|
+
platform: "apple-health",
|
|
272
|
+
category: "health",
|
|
273
|
+
metric: p.type || null,
|
|
274
|
+
metricLabel: label,
|
|
275
|
+
value: value || null,
|
|
276
|
+
unit: unit || null,
|
|
277
|
+
sourceName: p.sourceName || null,
|
|
278
|
+
endDate: parseAppleDate(p.endDate),
|
|
279
|
+
},
|
|
280
|
+
}],
|
|
281
|
+
persons: [], places: [], items: [], topics: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeWorkout(p, raw, ingestedAt) {
|
|
286
|
+
const occurredAt =
|
|
287
|
+
parseAppleDate(p.startDate) || parseAppleDate(p.creationDate) || raw.capturedAt || ingestedAt;
|
|
288
|
+
const source = buildSource(raw, occurredAt);
|
|
289
|
+
const activity =
|
|
290
|
+
typeof p.workoutActivityType === "string"
|
|
291
|
+
? p.workoutActivityType.replace(/^HKWorkoutActivityType/, "")
|
|
292
|
+
: "Workout";
|
|
293
|
+
const duration = p.duration ? `${p.duration}${p.durationUnit || ""}` : "";
|
|
294
|
+
const distance = p.totalDistance ? `${p.totalDistance}${p.totalDistanceUnit || ""}` : "";
|
|
295
|
+
return {
|
|
296
|
+
events: [{
|
|
297
|
+
id: newId(),
|
|
298
|
+
type: ENTITY_TYPES.EVENT,
|
|
299
|
+
subtype: EVENT_SUBTYPES.TRIP,
|
|
300
|
+
occurredAt,
|
|
301
|
+
actor: "person-self",
|
|
302
|
+
content: {
|
|
303
|
+
title: `运动: ${activity}${duration ? " " + duration : ""}${distance ? " " + distance : ""}`.trim(),
|
|
304
|
+
text: `${activity} ${duration} ${distance}`.trim(),
|
|
305
|
+
},
|
|
306
|
+
ingestedAt,
|
|
307
|
+
source,
|
|
308
|
+
extra: {
|
|
309
|
+
platform: "apple-health",
|
|
310
|
+
category: "workout",
|
|
311
|
+
activityType: activity,
|
|
312
|
+
duration: p.duration || null,
|
|
313
|
+
durationUnit: p.durationUnit || null,
|
|
314
|
+
totalDistance: p.totalDistance || null,
|
|
315
|
+
totalDistanceUnit: p.totalDistanceUnit || null,
|
|
316
|
+
totalEnergyBurned: p.totalEnergyBurned || null,
|
|
317
|
+
endDate: parseAppleDate(p.endDate),
|
|
318
|
+
},
|
|
319
|
+
}],
|
|
320
|
+
persons: [], places: [], items: [], topics: [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
AppleHealthAdapter,
|
|
326
|
+
NAME,
|
|
327
|
+
VERSION,
|
|
328
|
+
_internals: { parseAttrs, parseAppleDate, metricLabel },
|
|
329
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 钉钉 (DingTalk) 电脑版 — honest best-effort local IM DB reader (qq-pc 模式).
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ v0.1: 钉钉桌面本地库为私有结构、可能加密、随版本变化。本 adapter 做到
|
|
7
|
+
* 可靠开库 + 发现消息表 + 防御探测列 + 保留原始行 + 响亮诊断;文本解析尽力
|
|
8
|
+
* 而为,真机上按需扩展 colCandidates。建议先把库解密为明文再指向它。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { createLocalImPcAdapter } = require("../_local-im-pc-adapter");
|
|
12
|
+
|
|
13
|
+
const DingTalkPcAdapter = createLocalImPcAdapter({
|
|
14
|
+
name: "dingtalk-pc",
|
|
15
|
+
platform: "dingtalk",
|
|
16
|
+
version: "0.1.0",
|
|
17
|
+
tablePattern: /msg|message|chat|conversation|im_/i,
|
|
18
|
+
colCandidates: {
|
|
19
|
+
// 钉钉常见列猜测(真机微调)
|
|
20
|
+
time: ["createAt", "create_at", "sendTime", "send_time", "msgCreateTime"],
|
|
21
|
+
sender: ["senderId", "sender", "fromUid", "creatorId"],
|
|
22
|
+
peer: ["conversationId", "cid", "openConversationId"],
|
|
23
|
+
content: ["content", "text", "msgContent", "summary"],
|
|
24
|
+
},
|
|
25
|
+
needHint:
|
|
26
|
+
"dingtalk-pc: 需提供钉钉桌面本地库路径(私有/可能加密,建议先解密为明文或提供 key)",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
module.exports = { DingTalkPcAdapter, NAME: "dingtalk-pc", VERSION: "0.1.0" };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HuaweiLearningApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
|
|
3
|
+
*
|
|
4
|
+
* 华为学习中心走华为账号登录;v0.1 从 accountId / userId / huaweiUid 抽数字 uid。
|
|
5
|
+
* 课程/学习时长 走 v0.2(华为教育接口 + 账号签名)。
|
|
6
|
+
*/
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
class HuaweiLearningApiClient {
|
|
10
|
+
constructor() {
|
|
11
|
+
this._lastErrorCode = 0;
|
|
12
|
+
this._lastErrorMsg = "";
|
|
13
|
+
}
|
|
14
|
+
_setLastError(code, msg) {
|
|
15
|
+
this._lastErrorCode = code;
|
|
16
|
+
this._lastErrorMsg = msg;
|
|
17
|
+
}
|
|
18
|
+
_clearLastError() {
|
|
19
|
+
this._lastErrorCode = 0;
|
|
20
|
+
this._lastErrorMsg = "";
|
|
21
|
+
}
|
|
22
|
+
get lastError() {
|
|
23
|
+
return { code: this._lastErrorCode, message: this._lastErrorMsg };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} cookie @returns {string|null} */
|
|
27
|
+
extractUid(cookie) {
|
|
28
|
+
if (typeof cookie !== "string" || cookie.length === 0) {
|
|
29
|
+
this._setLastError(-1, "cookie 为空");
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
for (const key of ["accountId", "userId", "huaweiUid"]) {
|
|
33
|
+
const m = new RegExp(`(?:^|; ?)${key}=(\\d+)`).exec(cookie);
|
|
34
|
+
if (m && m[1] && m[1] !== "0") {
|
|
35
|
+
this._clearLastError();
|
|
36
|
+
return m[1];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this._setLastError(
|
|
40
|
+
-7,
|
|
41
|
+
"cookie 缺 accountId / userId / huaweiUid — 华为账号未登录",
|
|
42
|
+
);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { HuaweiLearningApiClient };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FAMILY-23 v0.1 — 华为学习中心 (Huawei Learning Center) adapter, snapshot mode.
|
|
3
|
+
*
|
|
4
|
+
* 家庭守护 telemetry:家长看孩子的课程/学习时长。v0.1 cookie-scrape 占位 —
|
|
5
|
+
* [HuaweiLearningApiClient.extractUid] 抽 uid;snapshot 模式消费手机端 collector
|
|
6
|
+
* 快照 (profile + study-session)。课程历史 HTTP fetcher 留 v0.2,故无 inputPath 时
|
|
7
|
+
* sync 抛 NO_INPUT。
|
|
8
|
+
*
|
|
9
|
+
* Snapshot schema (v1):
|
|
10
|
+
* { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
|
|
11
|
+
* { kind:"profile", id, capturedAt, uid, nickname },
|
|
12
|
+
* { kind:"study", id, capturedAt, course, durationMs, startAt } ] }
|
|
13
|
+
*
|
|
14
|
+
* Sensitivity: "medium"。
|
|
15
|
+
*/
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const { newId } = require("../../ids");
|
|
20
|
+
const {
|
|
21
|
+
ENTITY_TYPES,
|
|
22
|
+
PERSON_SUBTYPES,
|
|
23
|
+
EVENT_SUBTYPES,
|
|
24
|
+
CAPTURED_BY,
|
|
25
|
+
} = require("../../constants");
|
|
26
|
+
const { HuaweiLearningApiClient } = require("./api-client");
|
|
27
|
+
|
|
28
|
+
const NAME = "edu-huawei-learning";
|
|
29
|
+
const VERSION = "0.1.0";
|
|
30
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
31
|
+
const KIND_PROFILE = "profile";
|
|
32
|
+
const KIND_STUDY = "study";
|
|
33
|
+
const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_PROFILE, KIND_STUDY]);
|
|
34
|
+
|
|
35
|
+
function stableOriginalId(kind, id) {
|
|
36
|
+
const safe =
|
|
37
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
38
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
39
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
40
|
+
return `huaweilearning:${kind}:${safe}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseTime(v) {
|
|
44
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
45
|
+
if (typeof v === "string") {
|
|
46
|
+
if (/^\d+$/.test(v)) {
|
|
47
|
+
const n = parseInt(v, 10);
|
|
48
|
+
return n > 1e12 ? n : n * 1000;
|
|
49
|
+
}
|
|
50
|
+
const t = Date.parse(v);
|
|
51
|
+
return Number.isFinite(t) ? t : null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class HuaweiLearningAdapter {
|
|
57
|
+
constructor(opts = {}) {
|
|
58
|
+
this.account = opts.account || null;
|
|
59
|
+
this.name = NAME;
|
|
60
|
+
this.version = VERSION;
|
|
61
|
+
this.capabilities = [
|
|
62
|
+
"sync:snapshot",
|
|
63
|
+
"parse:huawei-learning-profile",
|
|
64
|
+
"parse:huawei-learning-study-session",
|
|
65
|
+
];
|
|
66
|
+
this.extractMode = "web-api";
|
|
67
|
+
this.rateLimits = {};
|
|
68
|
+
this.dataDisclosure = {
|
|
69
|
+
fields: [
|
|
70
|
+
"huawei-learning:profile (uid / nickname)",
|
|
71
|
+
"huawei-learning:study_session (course / start / duration)",
|
|
72
|
+
],
|
|
73
|
+
sensitivity: "medium",
|
|
74
|
+
legalGate: false,
|
|
75
|
+
defaultInclude: { profile: true, study: true },
|
|
76
|
+
};
|
|
77
|
+
this.apiClient = new HuaweiLearningApiClient();
|
|
78
|
+
this._deps = { fs };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async authenticate(ctx = {}) {
|
|
82
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
83
|
+
try {
|
|
84
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
89
|
+
message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, mode: "snapshot-file" };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: "NO_INPUT",
|
|
97
|
+
message:
|
|
98
|
+
"edu-huawei-learning.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher 待 v0.2",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async healthCheck() {
|
|
103
|
+
return { ok: true, lastChecked: Date.now() };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async *sync(opts = {}) {
|
|
107
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
108
|
+
yield* this._syncViaSnapshot(opts);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(
|
|
112
|
+
"edu-huawei-learning.sync: v0.1 needs opts.inputPath (snapshot mode); 课程历史 HTTP fetcher 待 v0.2",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async *_syncViaSnapshot(opts) {
|
|
117
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
118
|
+
const snapshot = JSON.parse(raw);
|
|
119
|
+
if (
|
|
120
|
+
!snapshot ||
|
|
121
|
+
typeof snapshot !== "object" ||
|
|
122
|
+
snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`edu-huawei-learning.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const fallbackCapturedAt =
|
|
129
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
|
|
130
|
+
? Math.floor(snapshot.snapshottedAt)
|
|
131
|
+
: Date.now();
|
|
132
|
+
const account =
|
|
133
|
+
snapshot.account && typeof snapshot.account === "object"
|
|
134
|
+
? snapshot.account
|
|
135
|
+
: null;
|
|
136
|
+
const include = opts.include || {};
|
|
137
|
+
const limit =
|
|
138
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
139
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
140
|
+
let emitted = 0;
|
|
141
|
+
for (const ev of events) {
|
|
142
|
+
if (emitted >= limit) return;
|
|
143
|
+
if (!ev || typeof ev !== "object") continue;
|
|
144
|
+
const kind = ev.kind;
|
|
145
|
+
if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
|
|
146
|
+
if (include[kind] === false) continue;
|
|
147
|
+
const capturedAt = parseTime(ev.capturedAt) || fallbackCapturedAt;
|
|
148
|
+
const id =
|
|
149
|
+
(typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
|
|
150
|
+
ev.uid ||
|
|
151
|
+
null;
|
|
152
|
+
yield {
|
|
153
|
+
adapter: NAME,
|
|
154
|
+
kind,
|
|
155
|
+
originalId: stableOriginalId(kind, id),
|
|
156
|
+
capturedAt,
|
|
157
|
+
payload: { ...ev, account },
|
|
158
|
+
};
|
|
159
|
+
emitted += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
normalize(raw) {
|
|
164
|
+
if (!raw || !raw.payload) {
|
|
165
|
+
throw new Error("HuaweiLearningAdapter.normalize: payload missing");
|
|
166
|
+
}
|
|
167
|
+
const ingestedAt = Date.now();
|
|
168
|
+
const kind = raw.kind || raw.payload.kind;
|
|
169
|
+
const p = raw.payload;
|
|
170
|
+
if (kind === KIND_PROFILE) return normalizeProfile(p, raw, ingestedAt);
|
|
171
|
+
if (kind === KIND_STUDY) return normalizeStudy(p, raw, ingestedAt);
|
|
172
|
+
throw new Error(`HuaweiLearningAdapter.normalize: unknown kind ${kind}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildSource(raw, occurredAt) {
|
|
177
|
+
return {
|
|
178
|
+
adapter: NAME,
|
|
179
|
+
adapterVersion: VERSION,
|
|
180
|
+
originalId: raw.originalId,
|
|
181
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
182
|
+
capturedBy: CAPTURED_BY.API,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeProfile(p, raw, ingestedAt) {
|
|
187
|
+
const uid = p.uid || (p.account && p.account.uid) || null;
|
|
188
|
+
const nickname =
|
|
189
|
+
p.nickname || (p.account && p.account.displayName) || "(unnamed)";
|
|
190
|
+
const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
191
|
+
const identifiers = {};
|
|
192
|
+
if (uid) identifiers["huawei-learning-uid"] = [String(uid)];
|
|
193
|
+
return {
|
|
194
|
+
events: [],
|
|
195
|
+
persons: [
|
|
196
|
+
{
|
|
197
|
+
id: uid
|
|
198
|
+
? `person-huaweilearning-${uid}`
|
|
199
|
+
: `person-huaweilearning-self-${newId()}`,
|
|
200
|
+
type: ENTITY_TYPES.PERSON,
|
|
201
|
+
subtype: PERSON_SUBTYPES.SELF,
|
|
202
|
+
names: [nickname],
|
|
203
|
+
ingestedAt,
|
|
204
|
+
source: buildSource(raw, occurredAt),
|
|
205
|
+
identifiers,
|
|
206
|
+
extra: { platform: "huawei-learning", snapshottedAt: occurredAt },
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
places: [],
|
|
210
|
+
items: [],
|
|
211
|
+
topics: [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeStudy(p, raw, ingestedAt) {
|
|
216
|
+
const occurredAt =
|
|
217
|
+
parseTime(p.startAt) ||
|
|
218
|
+
parseTime(p.capturedAt) ||
|
|
219
|
+
raw.capturedAt ||
|
|
220
|
+
ingestedAt;
|
|
221
|
+
return {
|
|
222
|
+
events: [
|
|
223
|
+
{
|
|
224
|
+
id: newId(),
|
|
225
|
+
type: ENTITY_TYPES.EVENT,
|
|
226
|
+
subtype: EVENT_SUBTYPES.OTHER,
|
|
227
|
+
occurredAt,
|
|
228
|
+
actor: "person-self",
|
|
229
|
+
content: { title: "华为学习中心 学习" },
|
|
230
|
+
ingestedAt,
|
|
231
|
+
source: buildSource(raw, occurredAt),
|
|
232
|
+
extra: {
|
|
233
|
+
platform: "huawei-learning",
|
|
234
|
+
kind: "study",
|
|
235
|
+
course: p.course || null,
|
|
236
|
+
durationMs: Number.isFinite(p.durationMs) ? p.durationMs : 0,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
persons: [],
|
|
241
|
+
places: [],
|
|
242
|
+
items: [],
|
|
243
|
+
topics: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
HuaweiLearningAdapter,
|
|
249
|
+
NAME,
|
|
250
|
+
VERSION,
|
|
251
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
252
|
+
VALID_SNAPSHOT_KINDS,
|
|
253
|
+
KIND_PROFILE,
|
|
254
|
+
KIND_STUDY,
|
|
255
|
+
};
|