@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.
Files changed (57) hide show
  1. package/__tests__/adapters/apple-health.test.js +95 -0
  2. package/__tests__/adapters/email-templates.test.js +123 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
  4. package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
  5. package/__tests__/adapters/git-activity.test.js +7 -1
  6. package/__tests__/adapters/local-im-pc.test.js +149 -0
  7. package/__tests__/adapters/netease-music.test.js +74 -0
  8. package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
  9. package/__tests__/adapters/system-data-adapter.test.js +4 -1
  10. package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
  11. package/__tests__/adapters/weread.test.js +123 -0
  12. package/__tests__/analysis.test.js +120 -15
  13. package/__tests__/mobile-extractor-encrypted.test.js +460 -0
  14. package/__tests__/prompt-builder.test.js +47 -2
  15. package/__tests__/registry-readiness.test.js +233 -0
  16. package/__tests__/social-douyin-im-direct-read.test.js +311 -0
  17. package/__tests__/social-douyin-snapshot.test.js +5 -2
  18. package/__tests__/vault.test.js +99 -0
  19. package/lib/adapter-guide.js +520 -0
  20. package/lib/adapter-readiness.js +257 -0
  21. package/lib/adapters/_local-im-db-reader.js +218 -0
  22. package/lib/adapters/_local-im-pc-adapter.js +162 -0
  23. package/lib/adapters/apple-health/index.js +329 -0
  24. package/lib/adapters/dingtalk-pc/index.js +29 -0
  25. package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
  26. package/lib/adapters/edu-huawei-learning/index.js +255 -0
  27. package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
  28. package/lib/adapters/edu-zuoyebang/index.js +259 -0
  29. package/lib/adapters/email-imap/email-adapter.js +16 -0
  30. package/lib/adapters/email-imap/templates/bill.js +174 -18
  31. package/lib/adapters/feishu-pc/index.js +29 -0
  32. package/lib/adapters/finance-alipay/api-client.js +48 -0
  33. package/lib/adapters/finance-alipay/index.js +257 -0
  34. package/lib/adapters/game-genshin/api-client.js +59 -0
  35. package/lib/adapters/game-genshin/index.js +274 -0
  36. package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
  37. package/lib/adapters/game-honor-of-kings/index.js +259 -0
  38. package/lib/adapters/netease-music/index.js +227 -0
  39. package/lib/adapters/qq-pc/index.js +200 -0
  40. package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
  41. package/lib/adapters/social-douyin/index.js +194 -1
  42. package/lib/adapters/wechat/wechat-adapter.js +7 -1
  43. package/lib/adapters/wechat-pc/index.js +335 -0
  44. package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
  45. package/lib/adapters/weread/api-client.js +128 -0
  46. package/lib/adapters/weread/index.js +337 -0
  47. package/lib/analysis.js +65 -0
  48. package/lib/index.js +39 -0
  49. package/lib/mobile-extractor/bplist.js +233 -0
  50. package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
  51. package/lib/mobile-extractor/ios.js +131 -16
  52. package/lib/prompt-builder.js +19 -1
  53. package/lib/registry.js +170 -0
  54. package/lib/vault.js +105 -0
  55. package/package.json +1 -1
  56. package/scripts/run-native-tests-sandbox.sh +2 -0
  57. package/vitest.config.js +79 -1
@@ -0,0 +1,257 @@
1
+ /**
2
+ * FAMILY-23 v0.1 — 支付宝 (Alipay) adapter, snapshot mode.
3
+ *
4
+ * 家庭守护 telemetry:家长看孩子的消费情况。**高敏感**(涉资金)— 上行受
5
+ * telemetry level + quiet hours 闸(FAMILY-24/25)。v0.1 cookie-scrape 占位 —
6
+ * [AlipayApiClient.extractUid] 抽 uid;snapshot 模式消费手机端 collector 快照
7
+ * (profile + order)。账单 HTTP fetcher(mobilegw + 签名)留 v0.2,故无 inputPath
8
+ * 时 sync 抛 NO_INPUT。
9
+ *
10
+ * Snapshot schema (v1):
11
+ * { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
12
+ * { kind:"profile", id, capturedAt, uid, nickname },
13
+ * { kind:"order", id, capturedAt, merchant, amountFen, direction, startAt } ] }
14
+ * direction: "out"(支出) | "in"(收入)。amountFen: 分(整数)。
15
+ *
16
+ * Sensitivity: "high"。
17
+ */
18
+ "use strict";
19
+
20
+ const fs = require("node:fs");
21
+ const { newId } = require("../../ids");
22
+ const {
23
+ ENTITY_TYPES,
24
+ PERSON_SUBTYPES,
25
+ EVENT_SUBTYPES,
26
+ CAPTURED_BY,
27
+ } = require("../../constants");
28
+ const { AlipayApiClient } = require("./api-client");
29
+
30
+ const NAME = "finance-alipay";
31
+ const VERSION = "0.1.0";
32
+ const SNAPSHOT_SCHEMA_VERSION = 1;
33
+ const KIND_PROFILE = "profile";
34
+ const KIND_ORDER = "order";
35
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_PROFILE, KIND_ORDER]);
36
+
37
+ function stableOriginalId(kind, id) {
38
+ const safe =
39
+ (typeof id === "string" && id.length > 0 && id) ||
40
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
41
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
+ return `alipay:${kind}:${safe}`;
43
+ }
44
+
45
+ function parseTime(v) {
46
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
47
+ if (typeof v === "string") {
48
+ if (/^\d+$/.test(v)) {
49
+ const n = parseInt(v, 10);
50
+ return n > 1e12 ? n : n * 1000;
51
+ }
52
+ const t = Date.parse(v);
53
+ return Number.isFinite(t) ? t : null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ class AlipayAdapter {
59
+ constructor(opts = {}) {
60
+ this.account = opts.account || null;
61
+ this.name = NAME;
62
+ this.version = VERSION;
63
+ this.capabilities = [
64
+ "sync:snapshot",
65
+ "parse:alipay-profile",
66
+ "parse:alipay-order",
67
+ ];
68
+ this.extractMode = "web-api";
69
+ this.rateLimits = {};
70
+ this.dataDisclosure = {
71
+ fields: [
72
+ "alipay:profile (uid / nickname)",
73
+ "alipay:order (merchant / amount / direction / time)",
74
+ ],
75
+ sensitivity: "high",
76
+ legalGate: false,
77
+ defaultInclude: { profile: true, order: true },
78
+ };
79
+ this.apiClient = new AlipayApiClient();
80
+ this._deps = { fs };
81
+ }
82
+
83
+ async authenticate(ctx = {}) {
84
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
85
+ try {
86
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
87
+ } catch (err) {
88
+ return {
89
+ ok: false,
90
+ reason: "INPUT_PATH_UNREADABLE",
91
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
92
+ };
93
+ }
94
+ return { ok: true, mode: "snapshot-file" };
95
+ }
96
+ return {
97
+ ok: false,
98
+ reason: "NO_INPUT",
99
+ message:
100
+ "finance-alipay.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher 待 v0.2",
101
+ };
102
+ }
103
+
104
+ async healthCheck() {
105
+ return { ok: true, lastChecked: Date.now() };
106
+ }
107
+
108
+ async *sync(opts = {}) {
109
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
110
+ yield* this._syncViaSnapshot(opts);
111
+ return;
112
+ }
113
+ throw new Error(
114
+ "finance-alipay.sync: v0.1 needs opts.inputPath (snapshot mode); 账单 HTTP fetcher (mobilegw + 签名) 待 v0.2",
115
+ );
116
+ }
117
+
118
+ async *_syncViaSnapshot(opts) {
119
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
120
+ const snapshot = JSON.parse(raw);
121
+ if (
122
+ !snapshot ||
123
+ typeof snapshot !== "object" ||
124
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
125
+ ) {
126
+ throw new Error(
127
+ `finance-alipay.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
128
+ );
129
+ }
130
+ const fallbackCapturedAt =
131
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
132
+ ? Math.floor(snapshot.snapshottedAt)
133
+ : Date.now();
134
+ const account =
135
+ snapshot.account && typeof snapshot.account === "object"
136
+ ? snapshot.account
137
+ : null;
138
+ const include = opts.include || {};
139
+ const limit =
140
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
141
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
142
+ let emitted = 0;
143
+ for (const ev of events) {
144
+ if (emitted >= limit) return;
145
+ if (!ev || typeof ev !== "object") continue;
146
+ const kind = ev.kind;
147
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
148
+ if (include[kind] === false) continue;
149
+ const capturedAt = parseTime(ev.capturedAt) || fallbackCapturedAt;
150
+ const id =
151
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
152
+ ev.uid ||
153
+ null;
154
+ yield {
155
+ adapter: NAME,
156
+ kind,
157
+ originalId: stableOriginalId(kind, id),
158
+ capturedAt,
159
+ payload: { ...ev, account },
160
+ };
161
+ emitted += 1;
162
+ }
163
+ }
164
+
165
+ normalize(raw) {
166
+ if (!raw || !raw.payload) {
167
+ throw new Error("AlipayAdapter.normalize: payload missing");
168
+ }
169
+ const ingestedAt = Date.now();
170
+ const kind = raw.kind || raw.payload.kind;
171
+ const p = raw.payload;
172
+ if (kind === KIND_PROFILE) return normalizeProfile(p, raw, ingestedAt);
173
+ if (kind === KIND_ORDER) return normalizeOrder(p, raw, ingestedAt);
174
+ throw new Error(`AlipayAdapter.normalize: unknown kind ${kind}`);
175
+ }
176
+ }
177
+
178
+ function buildSource(raw, occurredAt) {
179
+ return {
180
+ adapter: NAME,
181
+ adapterVersion: VERSION,
182
+ originalId: raw.originalId,
183
+ capturedAt: raw.capturedAt || occurredAt,
184
+ capturedBy: CAPTURED_BY.API,
185
+ };
186
+ }
187
+
188
+ function normalizeProfile(p, raw, ingestedAt) {
189
+ const uid = p.uid || (p.account && p.account.uid) || null;
190
+ const nickname =
191
+ p.nickname || (p.account && p.account.displayName) || "(unnamed)";
192
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
193
+ const identifiers = {};
194
+ if (uid) identifiers["alipay-uid"] = [String(uid)];
195
+ return {
196
+ events: [],
197
+ persons: [
198
+ {
199
+ id: uid ? `person-alipay-${uid}` : `person-alipay-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: "alipay", snapshottedAt: occurredAt },
207
+ },
208
+ ],
209
+ places: [],
210
+ items: [],
211
+ topics: [],
212
+ };
213
+ }
214
+
215
+ function normalizeOrder(p, raw, ingestedAt) {
216
+ const occurredAt =
217
+ parseTime(p.startAt) ||
218
+ parseTime(p.capturedAt) ||
219
+ raw.capturedAt ||
220
+ ingestedAt;
221
+ const merchant = p.merchant || "(unknown merchant)";
222
+ return {
223
+ events: [
224
+ {
225
+ id: newId(),
226
+ type: ENTITY_TYPES.EVENT,
227
+ subtype: EVENT_SUBTYPES.PAYMENT,
228
+ occurredAt,
229
+ actor: "person-self",
230
+ content: { title: merchant },
231
+ ingestedAt,
232
+ source: buildSource(raw, occurredAt),
233
+ extra: {
234
+ platform: "alipay",
235
+ kind: "order",
236
+ merchant,
237
+ amountFen: Number.isFinite(p.amountFen) ? p.amountFen : null,
238
+ direction: p.direction === "in" ? "in" : "out",
239
+ },
240
+ },
241
+ ],
242
+ persons: [],
243
+ places: [],
244
+ items: [],
245
+ topics: [],
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ AlipayAdapter,
251
+ NAME,
252
+ VERSION,
253
+ SNAPSHOT_SCHEMA_VERSION,
254
+ VALID_SNAPSHOT_KINDS,
255
+ KIND_PROFILE,
256
+ KIND_ORDER,
257
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * GenshinApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
3
+ *
4
+ * 原神 / 米哈游通行证 (HoYoLAB / 米游社) 走 cookie 鉴权;v0.1 仅从 cookie 抽 uid
5
+ * (extractUid),不做 HTTP fetcher(历史战绩 / 游戏时长 走 v0.2 通过 takumi/hk4e
6
+ * 接口 + DS 签名)。镜像 social-toutiao-adb/api-client.js 的 extractUid 形态。
7
+ *
8
+ * Cookie key 优先级 (米游社 2023+ → 旧版):
9
+ * account_id_v2 > ltuid_v2 > account_id > ltuid
10
+ */
11
+ "use strict";
12
+
13
+ class GenshinApiClient {
14
+ constructor() {
15
+ this._lastErrorCode = 0;
16
+ this._lastErrorMsg = "";
17
+ }
18
+
19
+ _setLastError(code, msg) {
20
+ this._lastErrorCode = code;
21
+ this._lastErrorMsg = msg;
22
+ }
23
+
24
+ _clearLastError() {
25
+ this._lastErrorCode = 0;
26
+ this._lastErrorMsg = "";
27
+ }
28
+
29
+ get lastError() {
30
+ return { code: this._lastErrorCode, message: this._lastErrorMsg };
31
+ }
32
+
33
+ /**
34
+ * 从 cookie 串抽米哈游通行证 uid。失败返 null + 设 lastError。
35
+ * @param {string} cookie 形如 "account_id_v2=12345; ltoken_v2=...; ..."
36
+ * @returns {string|null}
37
+ */
38
+ extractUid(cookie) {
39
+ if (typeof cookie !== "string" || cookie.length === 0) {
40
+ this._setLastError(-1, "cookie 为空");
41
+ return null;
42
+ }
43
+ const keys = ["account_id_v2", "ltuid_v2", "account_id", "ltuid"];
44
+ for (const key of keys) {
45
+ const m = new RegExp(`(?:^|; ?)${key}=(\\d+)`).exec(cookie);
46
+ if (m && m[1] && m[1] !== "0") {
47
+ this._clearLastError();
48
+ return m[1];
49
+ }
50
+ }
51
+ this._setLastError(
52
+ -7,
53
+ "cookie 缺 account_id_v2 / ltuid_v2 / account_id / ltuid — 米游社未登录或仅游客态",
54
+ );
55
+ return null;
56
+ }
57
+ }
58
+
59
+ module.exports = { GenshinApiClient };
@@ -0,0 +1,274 @@
1
+ /**
2
+ * FAMILY-23 v0.1 — Genshin Impact (原神 / 米哈游) adapter, snapshot mode.
3
+ *
4
+ * 家庭守护 telemetry:家长想看孩子玩什么游戏 / 玩多久。v0.1 cookie-scrape 占位 —
5
+ * [GenshinApiClient.extractUid] 从米游社 cookie 抽 uid;snapshot 模式消费手机端
6
+ * collector 产的快照 JSON(profile + play-session 事件)。HTTP fetcher(takumi/hk4e
7
+ * 战绩 + DS 签名)留 v0.2,故无 inputPath 时 sync 抛 NO_INPUT(同 social-kuaishou)。
8
+ *
9
+ * Snapshot schema (v1):
10
+ * {
11
+ * "schemaVersion": 1,
12
+ * "snapshottedAt": <epoch-ms>,
13
+ * "account": { "uid": "12345", "displayName": "旅行者" },
14
+ * "events": [
15
+ * { "kind": "profile", "id": "profile-<uid>", "capturedAt": <ms>,
16
+ * "uid": "...", "nickname": "...", "level": N, "avatarUrl": "..." },
17
+ * { "kind": "play", "id": "play-<sessionId>", "capturedAt": <ms>,
18
+ * "durationMs": N, "mode": "...", "startAt": <ms> }
19
+ * ]
20
+ * }
21
+ *
22
+ * Sensitivity: "medium" — 游戏时长 / 等级揭示娱乐偏好与在线时段。
23
+ */
24
+ "use strict";
25
+
26
+ const fs = require("node:fs");
27
+ const { newId } = require("../../ids");
28
+ const {
29
+ ENTITY_TYPES,
30
+ PERSON_SUBTYPES,
31
+ EVENT_SUBTYPES,
32
+ CAPTURED_BY,
33
+ } = require("../../constants");
34
+ const { GenshinApiClient } = require("./api-client");
35
+
36
+ const NAME = "game-genshin";
37
+ const VERSION = "0.1.0";
38
+ const SNAPSHOT_SCHEMA_VERSION = 1;
39
+
40
+ const KIND_PROFILE = "profile";
41
+ const KIND_PLAY = "play";
42
+ const VALID_SNAPSHOT_KINDS = Object.freeze([KIND_PROFILE, KIND_PLAY]);
43
+
44
+ function stableOriginalId(kind, id) {
45
+ const safe =
46
+ (typeof id === "string" && id.length > 0 && id) ||
47
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
48
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ return `genshin:${kind}:${safe}`;
50
+ }
51
+
52
+ function parseTime(v) {
53
+ if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
54
+ if (typeof v === "string") {
55
+ if (/^\d+$/.test(v)) {
56
+ const n = parseInt(v, 10);
57
+ return n > 1e12 ? n : n * 1000;
58
+ }
59
+ const t = Date.parse(v);
60
+ return Number.isFinite(t) ? t : null;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ class GenshinAdapter {
66
+ constructor(opts = {}) {
67
+ this.account = opts.account || null;
68
+ this.name = NAME;
69
+ this.version = VERSION;
70
+ this.capabilities = [
71
+ "sync:snapshot",
72
+ "parse:genshin-profile",
73
+ "parse:genshin-play-session",
74
+ ];
75
+ this.extractMode = "web-api";
76
+ this.rateLimits = {};
77
+ this.dataDisclosure = {
78
+ fields: [
79
+ "genshin:profile (uid / nickname / level / avatar)",
80
+ "genshin:play_session (start / duration / mode)",
81
+ ],
82
+ sensitivity: "medium",
83
+ legalGate: false,
84
+ defaultInclude: { profile: true, play: true },
85
+ };
86
+ this.apiClient = new GenshinApiClient();
87
+ this._deps = { fs };
88
+ }
89
+
90
+ async authenticate(ctx = {}) {
91
+ if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
92
+ try {
93
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
94
+ } catch (err) {
95
+ return {
96
+ ok: false,
97
+ reason: "INPUT_PATH_UNREADABLE",
98
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
99
+ };
100
+ }
101
+ return { ok: true, mode: "snapshot-file" };
102
+ }
103
+ return {
104
+ ok: false,
105
+ reason: "NO_INPUT",
106
+ message:
107
+ "game-genshin.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher 待 v0.2",
108
+ };
109
+ }
110
+
111
+ async healthCheck() {
112
+ return { ok: true, lastChecked: Date.now() };
113
+ }
114
+
115
+ async *sync(opts = {}) {
116
+ if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
117
+ yield* this._syncViaSnapshot(opts);
118
+ return;
119
+ }
120
+ throw new Error(
121
+ "game-genshin.sync: v0.1 needs opts.inputPath (snapshot mode, Android in-APK cc); live HTTP fetcher (takumi/hk4e + DS 签名) 待 v0.2",
122
+ );
123
+ }
124
+
125
+ async *_syncViaSnapshot(opts) {
126
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
127
+ const snapshot = JSON.parse(raw);
128
+ if (
129
+ !snapshot ||
130
+ typeof snapshot !== "object" ||
131
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
132
+ ) {
133
+ throw new Error(
134
+ `game-genshin.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
135
+ );
136
+ }
137
+ const fallbackCapturedAt =
138
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
139
+ ? Math.floor(snapshot.snapshottedAt)
140
+ : Date.now();
141
+ const account =
142
+ snapshot.account && typeof snapshot.account === "object"
143
+ ? snapshot.account
144
+ : null;
145
+ const include = opts.include || {};
146
+ const limit =
147
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
148
+
149
+ const events = Array.isArray(snapshot.events) ? snapshot.events : [];
150
+ let emitted = 0;
151
+ for (const ev of events) {
152
+ if (emitted >= limit) return;
153
+ if (!ev || typeof ev !== "object") continue;
154
+ const kind = ev.kind;
155
+ if (!VALID_SNAPSHOT_KINDS.includes(kind)) continue;
156
+ if (include[kind] === false) continue;
157
+
158
+ const capturedAt = parseTime(ev.capturedAt) || fallbackCapturedAt;
159
+ const id =
160
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) ||
161
+ ev.uid ||
162
+ null;
163
+ yield {
164
+ adapter: NAME,
165
+ kind,
166
+ originalId: stableOriginalId(kind, id),
167
+ capturedAt,
168
+ payload: { ...ev, account },
169
+ };
170
+ emitted += 1;
171
+ }
172
+ }
173
+
174
+ normalize(raw) {
175
+ if (!raw || !raw.payload) {
176
+ throw new Error("GenshinAdapter.normalize: payload missing");
177
+ }
178
+ const ingestedAt = Date.now();
179
+ const kind = raw.kind || raw.payload.kind;
180
+ const p = raw.payload;
181
+ if (kind === KIND_PROFILE) {
182
+ return normalizeProfile(p, raw, ingestedAt);
183
+ }
184
+ if (kind === KIND_PLAY) {
185
+ return normalizePlay(p, raw, ingestedAt);
186
+ }
187
+ throw new Error(`GenshinAdapter.normalize: unknown kind ${kind}`);
188
+ }
189
+ }
190
+
191
+ function buildSource(raw, occurredAt) {
192
+ return {
193
+ adapter: NAME,
194
+ adapterVersion: VERSION,
195
+ originalId: raw.originalId,
196
+ capturedAt: raw.capturedAt || occurredAt,
197
+ capturedBy: CAPTURED_BY.API,
198
+ };
199
+ }
200
+
201
+ function normalizeProfile(p, raw, ingestedAt) {
202
+ const uid = p.uid || (p.account && p.account.uid) || null;
203
+ const nickname =
204
+ p.nickname || (p.account && p.account.displayName) || "(unnamed)";
205
+ const occurredAt = parseTime(p.capturedAt) || raw.capturedAt || ingestedAt;
206
+ const identifiers = {};
207
+ if (uid) identifiers["genshin-uid"] = [String(uid)];
208
+ return {
209
+ events: [],
210
+ persons: [
211
+ {
212
+ id: uid ? `person-genshin-${uid}` : `person-genshin-self-${newId()}`,
213
+ type: ENTITY_TYPES.PERSON,
214
+ subtype: PERSON_SUBTYPES.SELF,
215
+ names: [nickname],
216
+ ingestedAt,
217
+ source: buildSource(raw, occurredAt),
218
+ identifiers,
219
+ extra: {
220
+ platform: "genshin",
221
+ level: p.level != null ? p.level : null,
222
+ avatarUrl: p.avatarUrl || null,
223
+ snapshottedAt: occurredAt,
224
+ },
225
+ },
226
+ ],
227
+ places: [],
228
+ items: [],
229
+ topics: [],
230
+ };
231
+ }
232
+
233
+ function normalizePlay(p, raw, ingestedAt) {
234
+ const occurredAt =
235
+ parseTime(p.startAt) ||
236
+ parseTime(p.capturedAt) ||
237
+ raw.capturedAt ||
238
+ ingestedAt;
239
+ const durationMs = Number.isFinite(p.durationMs) ? p.durationMs : 0;
240
+ return {
241
+ events: [
242
+ {
243
+ id: newId(),
244
+ type: ENTITY_TYPES.EVENT,
245
+ subtype: EVENT_SUBTYPES.MEDIA,
246
+ occurredAt,
247
+ actor: "person-self",
248
+ content: { title: "原神 游戏时长" },
249
+ ingestedAt,
250
+ source: buildSource(raw, occurredAt),
251
+ extra: {
252
+ platform: "genshin",
253
+ kind: "play",
254
+ durationMs,
255
+ mode: p.mode || null,
256
+ },
257
+ },
258
+ ],
259
+ persons: [],
260
+ places: [],
261
+ items: [],
262
+ topics: [],
263
+ };
264
+ }
265
+
266
+ module.exports = {
267
+ GenshinAdapter,
268
+ NAME,
269
+ VERSION,
270
+ SNAPSHOT_SCHEMA_VERSION,
271
+ VALID_SNAPSHOT_KINDS,
272
+ KIND_PROFILE,
273
+ KIND_PLAY,
274
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * HonorOfKingsApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
3
+ *
4
+ * 王者荣耀走腾讯系登录(微信/QQ openid 或营地 gamehelper cookie);v0.1 仅从
5
+ * cookie 抽 uid(extractUid),战绩/对局时长 走 v0.2(营地接口 + 腾讯签名)。
6
+ * Cookie key 优先级: openid > uin(QQ号) > tencent_uid。
7
+ */
8
+ "use strict";
9
+
10
+ class HonorOfKingsApiClient {
11
+ constructor() {
12
+ this._lastErrorCode = 0;
13
+ this._lastErrorMsg = "";
14
+ }
15
+ _setLastError(code, msg) {
16
+ this._lastErrorCode = code;
17
+ this._lastErrorMsg = msg;
18
+ }
19
+ _clearLastError() {
20
+ this._lastErrorCode = 0;
21
+ this._lastErrorMsg = "";
22
+ }
23
+ get lastError() {
24
+ return { code: this._lastErrorCode, message: this._lastErrorMsg };
25
+ }
26
+
27
+ /** @param {string} cookie @returns {string|null} */
28
+ extractUid(cookie) {
29
+ if (typeof cookie !== "string" || cookie.length === 0) {
30
+ this._setLastError(-1, "cookie 为空");
31
+ return null;
32
+ }
33
+ // openid 是字母数字混合; uin / tencent_uid 是纯数字。
34
+ const openid = /(?:^|; ?)openid=([A-Za-z0-9_-]+)/.exec(cookie);
35
+ if (openid && openid[1] && openid[1].length >= 8) {
36
+ this._clearLastError();
37
+ return openid[1];
38
+ }
39
+ for (const key of ["uin", "tencent_uid"]) {
40
+ const m = new RegExp(`(?:^|; ?)${key}=o?0*(\\d+)`).exec(cookie);
41
+ if (m && m[1] && m[1] !== "0") {
42
+ this._clearLastError();
43
+ return m[1];
44
+ }
45
+ }
46
+ this._setLastError(
47
+ -7,
48
+ "cookie 缺 openid / uin / tencent_uid — 营地/微信/QQ 未登录",
49
+ );
50
+ return null;
51
+ }
52
+ }
53
+
54
+ module.exports = { HonorOfKingsApiClient };