@chainlesschain/personal-data-hub 0.4.4 → 0.4.5

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 (41) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/netease-music-live.test.js +244 -0
  9. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  10. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  11. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  12. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  13. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  14. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  15. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  16. package/lib/adapters/_live-json-helpers.js +50 -0
  17. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  18. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  19. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  20. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  21. package/lib/adapters/finance-alipay/api-client.js +268 -6
  22. package/lib/adapters/finance-alipay/index.js +85 -9
  23. package/lib/adapters/game-genshin/api-client.js +207 -6
  24. package/lib/adapters/game-genshin/index.js +90 -9
  25. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  26. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  27. package/lib/adapters/netease-music/api-client.js +284 -0
  28. package/lib/adapters/netease-music/index.js +85 -9
  29. package/lib/adapters/social-douyin/index.js +2 -0
  30. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  31. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  32. package/lib/adapters/social-douyin-adb/index.js +18 -1
  33. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  34. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  35. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  36. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  37. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  38. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  39. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  40. package/lib/index.js +1 -1
  41. package/package.json +1 -1
@@ -1,16 +1,83 @@
1
1
  /**
2
- * AlipayApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
2
+ * AlipayApiClient — FAMILY-23 支付宝采集客户端。
3
3
  *
4
- * 支付宝 web cookie uid 不易直取(多走 session token);v0.1 best-effort 从
5
- * alipay_uid / userId / loginUserId 抽数字 uid。账单/交易明细 走 v0.2(mobilegw
6
- * 接口 + 签名)。**高敏感**(涉资金)— 上行受 telemetry level + quiet hours 闸。
4
+ * 支付宝 web cookie uid 不易直取(多走 session token);v0.1 仅 cookie-scrape
5
+ * (extractUid,best-effort 从 alipay_uid / userId / loginUserId 抽数字 uid)。
6
+ * **v0.2 接通 live 账单 fetcher**:cookie(支付宝会话)经 mobilegw(mgw.htm)
7
+ * 拉账单/交易明细(商户 / 金额 / 收支方向 / 时间)。
8
+ *
9
+ * ⚠️ **best-effort**:mobilegw 接口无公开稳定文档,且生产环境多数 operationType
10
+ * 需要 app 级签名 — 本客户端留 `signProvider` seam(`buildHeaders({url,
11
+ * operationType, body}) → headers`,镜像仓内 SignProvider 模式),未注入时
12
+ * 发未签名请求(服务端可能拒,错误经 lastError 透出)。端点/operationType/
13
+ * 字段名按社区逆向常见形态实现 + 多字段名兼容(pick 回退),**未经真实
14
+ * 支付宝登录态实地验证**,漂移时改常量 / opts 覆盖。
15
+ * ⚠️ **高敏感**(涉资金)— 上行受 telemetry level + quiet hours 闸(adapter 层
16
+ * sensitivity: "high")。
17
+ *
18
+ * profile 不走接口:live 模式 uid 直接由 extractUid 从 cookie 抽(抽不到则只
19
+ * 出账单不出 profile)— 避免再引入一个未经验证的 user-info operationType。
7
20
  */
8
21
  "use strict";
9
22
 
23
+ const { pick, toEpochMs } = require("../_live-json-helpers");
24
+
25
+ const DEFAULT_BASE_URL = "https://mobilegw.alipay.com";
26
+ // 端点 / operationType(best-effort,可经 opts 覆盖)。
27
+ const PATH_MGW = "/mgw.htm";
28
+ const OP_BILL_LIST = "alipay.mobile.bill.list";
29
+
30
+ const BROWSER_UA =
31
+ "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) " +
32
+ "Chrome/114.0.0.0 Mobile Safari/537.36 AlipayClient/10.5.0";
33
+
34
+ /**
35
+ * 金额(元,number 或 "12.50"/"-12.50"/"+12.50" 字符串)→ { amountFen, sign }。
36
+ * amountFen 取绝对值整数分;sign -1/0/1(账单负数=支出)。解析失败返 null。
37
+ */
38
+ function parseAmountYuan(v) {
39
+ let n;
40
+ if (Number.isFinite(v)) {
41
+ n = v;
42
+ } else if (typeof v === "string") {
43
+ const cleaned = v.replace(/[¥¥,\s]/g, "");
44
+ if (!/^[+-]?\d+(\.\d+)?$/.test(cleaned)) return null;
45
+ n = parseFloat(cleaned);
46
+ } else {
47
+ return null;
48
+ }
49
+ if (!Number.isFinite(n)) return null;
50
+ return {
51
+ amountFen: Math.round(Math.abs(n) * 100),
52
+ sign: n > 0 ? 1 : n < 0 ? -1 : 0,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 收支方向:显式字段("收入"/"in"/"INCOME" → in;"支出"/"out"/"EXPENSE" → out)
58
+ * 优先,否则按金额符号(负=out 正=in),都没有默认 "out"(家长关心消费)。
59
+ */
60
+ function deriveDirection(explicit, sign) {
61
+ if (typeof explicit === "string" && explicit.length > 0) {
62
+ const s = explicit.toLowerCase();
63
+ if (s === "in" || s === "income" || explicit.includes("收入")) return "in";
64
+ if (s === "out" || s === "expense" || explicit.includes("支出")) return "out";
65
+ }
66
+ if (sign === 1) return "in";
67
+ return "out";
68
+ }
69
+
10
70
  class AlipayApiClient {
11
- constructor() {
71
+ constructor(opts = {}) {
12
72
  this._lastErrorCode = 0;
13
73
  this._lastErrorMsg = "";
74
+ this._fetch =
75
+ opts.fetch || (typeof globalThis.fetch === "function" ? globalThis.fetch : null);
76
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
77
+ this.mgwPath = opts.mgwPath || PATH_MGW;
78
+ this.billListOp = opts.billListOp || OP_BILL_LIST;
79
+ // 签名 seam:{ buildHeaders({url, operationType, body}) → object }。
80
+ this.signProvider = opts.signProvider || null;
14
81
  }
15
82
  _setLastError(code, msg) {
16
83
  this._lastErrorCode = code;
@@ -43,6 +110,201 @@ class AlipayApiClient {
43
110
  );
44
111
  return null;
45
112
  }
113
+
114
+ /**
115
+ * live 模式会话探测:数字 uid 可抽,或常见会话 token key(ALIPAYJSESSIONID /
116
+ * JSESSIONID / ctoken / zone)在场即放行,真伪交给服务端校验。
117
+ * @param {string} cookie @returns {boolean}
118
+ */
119
+ hasSession(cookie) {
120
+ if (typeof cookie !== "string" || cookie.length === 0) {
121
+ this._setLastError(-7, "cookie 为空 — 支付宝未登录");
122
+ return false;
123
+ }
124
+ if (/(?:^|; ?)(ALIPAYJSESSIONID|JSESSIONID|ctoken|zone)=[^;\s]+/.test(cookie)) {
125
+ this._clearLastError();
126
+ return true;
127
+ }
128
+ if (this.extractUid(cookie)) return true;
129
+ this._setLastError(
130
+ -7,
131
+ "cookie 缺会话 token(ALIPAYJSESSIONID / JSESSIONID / ctoken)且无数字 uid — 支付宝未登录",
132
+ );
133
+ return false;
134
+ }
135
+
136
+ /**
137
+ * POST mgw.htm:form 体 `operationType=<op>&requestData=<json-array>`,带
138
+ * cookie + 可选 signProvider 头。mgw envelope:`{ resultStatus, memo, result }`
139
+ * (resultStatus 1000 = ok,result 可能是 JSON 字符串),或直接平铺业务体
140
+ * (`{ success, ... }`)。成功返业务体,失败返 null(设 lastError)。
141
+ */
142
+ async _postMgw(operationType, requestData, cookie) {
143
+ if (typeof this._fetch !== "function") {
144
+ this._setLastError(
145
+ -2,
146
+ "AlipayApiClient: fetch not available — pass opts.fetch or run on Node 18+",
147
+ );
148
+ return null;
149
+ }
150
+ const url = `${this.baseUrl}${this.mgwPath}`;
151
+ const body =
152
+ `operationType=${encodeURIComponent(operationType)}` +
153
+ `&requestData=${encodeURIComponent(JSON.stringify([requestData]))}`;
154
+ let headers = {
155
+ Cookie: cookie,
156
+ "Content-Type": "application/x-www-form-urlencoded",
157
+ "User-Agent": BROWSER_UA,
158
+ };
159
+ if (this.signProvider && typeof this.signProvider.buildHeaders === "function") {
160
+ try {
161
+ const extra = await this.signProvider.buildHeaders({ url, operationType, body });
162
+ if (extra && typeof extra === "object") headers = { ...headers, ...extra };
163
+ } catch (e) {
164
+ this._setLastError(-5, "sign: " + (e && e.message ? e.message : String(e)));
165
+ return null;
166
+ }
167
+ }
168
+ let resp;
169
+ try {
170
+ resp = await this._fetch(url, { method: "POST", headers, body });
171
+ } catch (e) {
172
+ this._setLastError(-4, "network: " + (e && e.message ? e.message : String(e)));
173
+ return null;
174
+ }
175
+ const txt = await resp.text();
176
+ if (!resp.ok) {
177
+ this._setLastError(resp.status, `HTTP ${resp.status}`);
178
+ return null;
179
+ }
180
+ let obj;
181
+ try {
182
+ obj = JSON.parse(txt);
183
+ } catch (e) {
184
+ this._setLastError(-3, "parse: " + (e && e.message ? e.message : String(e)));
185
+ return null;
186
+ }
187
+ // mgw 网关层错误(resultStatus != 1000)。
188
+ if (obj.resultStatus !== undefined && Number(obj.resultStatus) !== 1000) {
189
+ this._setLastError(
190
+ Number(obj.resultStatus),
191
+ pick(obj, ["memo", "tips", "message"], `resultStatus ${obj.resultStatus}`).toString(),
192
+ );
193
+ return null;
194
+ }
195
+ let result = obj.result !== undefined ? obj.result : obj;
196
+ if (typeof result === "string") {
197
+ try {
198
+ result = JSON.parse(result);
199
+ } catch (e) {
200
+ this._setLastError(-3, "parse(result): " + (e && e.message ? e.message : String(e)));
201
+ return null;
202
+ }
203
+ }
204
+ // 业务层错误(success === false)。
205
+ if (result && result.success === false) {
206
+ this._setLastError(
207
+ -6,
208
+ pick(result, ["resultMessage", "memo", "message", "msg"], "业务失败").toString(),
209
+ );
210
+ return null;
211
+ }
212
+ this._clearLastError();
213
+ return result;
214
+ }
215
+
216
+ /**
217
+ * 账单列表 → [{ orderId, merchant, amountFen, direction, startAt }].
218
+ * null on error.
219
+ * @param {string} cookie
220
+ * @param {object} [opts] { limit, offset }
221
+ */
222
+ async getBillList(cookie, opts = {}) {
223
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 20;
224
+ const offset = Number.isInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
225
+ const result = await this._postMgw(
226
+ this.billListOp,
227
+ { pageSize: limit, pageNum: Math.floor(offset / limit) + 1, offset },
228
+ cookie,
229
+ );
230
+ if (result === null) return null;
231
+ const list = pick(
232
+ result,
233
+ ["billList", "list", "records", "items"],
234
+ Array.isArray(result) ? result : [],
235
+ );
236
+ if (!Array.isArray(list)) return [];
237
+ return list.map((b) => {
238
+ const amount = parseAmountYuan(pick(b, ["amount", "tradeAmount", "money"]));
239
+ return {
240
+ orderId: pick(b, ["billId", "tradeNo", "bizInNo", "alipayOrderNo", "id"]),
241
+ merchant: pick(b, [
242
+ "displayName",
243
+ "merchantName",
244
+ "shopName",
245
+ "goodsTitle",
246
+ "title",
247
+ ]),
248
+ amountFen: amount ? amount.amountFen : null,
249
+ direction: deriveDirection(
250
+ pick(b, ["direction", "inOut", "incomeOrExpense"]),
251
+ amount ? amount.sign : 0,
252
+ ),
253
+ startAt: toEpochMs(pick(b, ["gmtCreate", "createTime", "tradeTime", "payTime"])),
254
+ };
255
+ });
256
+ }
257
+
258
+ /**
259
+ * High-level: cookie-scraped uid (profile) + bill list (orders) →
260
+ * snapshot-shaped { account, events } so the adapter normalize path is
261
+ * unchanged. uid 抽不到时仅出账单(account null、无 profile 事件)。
262
+ * @returns {Promise<{account, events}|null>}
263
+ */
264
+ async fetchSnapshot(cookie, opts = {}) {
265
+ if (!this.hasSession(cookie)) return null; // lastError already set
266
+ const include = opts.include || {};
267
+ const events = [];
268
+ let account = null;
269
+
270
+ if (include.profile !== false) {
271
+ const uid = this.extractUid(cookie);
272
+ if (uid) {
273
+ account = { uid, displayName: null };
274
+ events.push({ kind: "profile", id: `profile-${uid}`, uid, nickname: null });
275
+ }
276
+ // uid 抽不到不是硬错 — 账单仍可拉。
277
+ this._clearLastError();
278
+ }
279
+
280
+ if (include.order !== false) {
281
+ const bills = await this.getBillList(cookie, {
282
+ limit: opts.limit,
283
+ offset: opts.offset,
284
+ });
285
+ if (bills === null) return null;
286
+ for (const b of bills) {
287
+ events.push({
288
+ kind: "order",
289
+ id: b.orderId ? `order-${b.orderId}` : null,
290
+ merchant: b.merchant,
291
+ amountFen: b.amountFen,
292
+ direction: b.direction,
293
+ startAt: b.startAt,
294
+ });
295
+ }
296
+ }
297
+
298
+ this._clearLastError();
299
+ return { account, events };
300
+ }
46
301
  }
47
302
 
48
- module.exports = { AlipayApiClient };
303
+ module.exports = {
304
+ AlipayApiClient,
305
+ // Exported for tests / endpoint introspection.
306
+ parseAmountYuan,
307
+ deriveDirection,
308
+ PATH_MGW,
309
+ OP_BILL_LIST,
310
+ };
@@ -1,11 +1,15 @@
1
1
  /**
2
- * FAMILY-23 v0.1 — 支付宝 (Alipay) adapter, snapshot mode.
2
+ * FAMILY-23 — 支付宝 (Alipay) adapter.
3
3
  *
4
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。
5
+ * telemetry level + quiet hours 闸(FAMILY-24/25)。两路互补:
6
+ * - snapshot 模式(inputPath):手机端 collector 快照 (profile + order)。
7
+ * - **live 模式(cookie,v0.2 接通)**:[AlipayApiClient.fetchSnapshot] 经
8
+ * mobilegw(mgw.htm)拉账单/交易明细;多数 operationType app 级签名 —
9
+ * opts.signProvider seam 注入,未注入发未签名请求(服务端可能拒)。端点/
10
+ * 字段无公开稳定文档,按社区逆向常见形态实现 + 多字段名兼容,**未实地
11
+ * 验证**,漂移时按 api-client 常量/pick 列表调整。
12
+ * 无 inputPath 且无 cookie 时 sync 抛错。
9
13
  *
10
14
  * Snapshot schema (v1):
11
15
  * { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
@@ -28,7 +32,7 @@ const {
28
32
  const { AlipayApiClient } = require("./api-client");
29
33
 
30
34
  const NAME = "finance-alipay";
31
- const VERSION = "0.1.0";
35
+ const VERSION = "0.2.0";
32
36
  const SNAPSHOT_SCHEMA_VERSION = 1;
33
37
  const KIND_PROFILE = "profile";
34
38
  const KIND_ORDER = "order";
@@ -62,6 +66,7 @@ class AlipayAdapter {
62
66
  this.version = VERSION;
63
67
  this.capabilities = [
64
68
  "sync:snapshot",
69
+ "sync:cookie",
65
70
  "parse:alipay-profile",
66
71
  "parse:alipay-order",
67
72
  ];
@@ -76,7 +81,10 @@ class AlipayAdapter {
76
81
  legalGate: false,
77
82
  defaultInclude: { profile: true, order: true },
78
83
  };
79
- this.apiClient = new AlipayApiClient();
84
+ this.apiClient = new AlipayApiClient(opts);
85
+ // Test seam: override how the live client is built per-sync (inject fetch).
86
+ this._apiClientFactory =
87
+ typeof opts.apiClientFactory === "function" ? opts.apiClientFactory : null;
80
88
  this._deps = { fs };
81
89
  }
82
90
 
@@ -93,11 +101,21 @@ class AlipayAdapter {
93
101
  }
94
102
  return { ok: true, mode: "snapshot-file" };
95
103
  }
104
+ if (ctx && typeof ctx.cookie === "string" && ctx.cookie.length > 0) {
105
+ if (!this.apiClient.hasSession(ctx.cookie)) {
106
+ return {
107
+ ok: false,
108
+ reason: "INVALID_COOKIE",
109
+ message: `finance-alipay.authenticate: ${this.apiClient.lastError.message}`,
110
+ };
111
+ }
112
+ return { ok: true, mode: "cookie" };
113
+ }
96
114
  return {
97
115
  ok: false,
98
116
  reason: "NO_INPUT",
99
117
  message:
100
- "finance-alipay.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher v0.2",
118
+ "finance-alipay.authenticate: needs opts.inputPath (snapshot mode) or opts.cookie (支付宝会话, mobilegw live fetch)",
101
119
  };
102
120
  }
103
121
 
@@ -110,11 +128,69 @@ class AlipayAdapter {
110
128
  yield* this._syncViaSnapshot(opts);
111
129
  return;
112
130
  }
131
+ if (typeof opts.cookie === "string" && opts.cookie.length > 0) {
132
+ yield* this._syncViaLive(opts);
133
+ return;
134
+ }
113
135
  throw new Error(
114
- "finance-alipay.sync: v0.1 needs opts.inputPath (snapshot mode); 账单 HTTP fetcher (mobilegw + 签名) 待 v0.2",
136
+ "finance-alipay.sync: needs opts.inputPath (snapshot mode) or opts.cookie (支付宝会话, 账单 mobilegw live fetch)",
115
137
  );
116
138
  }
117
139
 
140
+ async *_syncViaLive(opts) {
141
+ const client = this._apiClientFactory
142
+ ? this._apiClientFactory(opts)
143
+ : new AlipayApiClient({
144
+ fetch: opts.fetch,
145
+ baseUrl: opts.baseUrl,
146
+ mgwPath: opts.mgwPath,
147
+ billListOp: opts.billListOp,
148
+ signProvider: opts.signProvider,
149
+ });
150
+ const emit = (phase, extra) => {
151
+ if (typeof opts.onProgress === "function") {
152
+ try {
153
+ opts.onProgress({ phase, adapter: NAME, ...extra });
154
+ } catch (_e) {
155
+ /* progress callback errors are best-effort */
156
+ }
157
+ }
158
+ };
159
+ const result = await client.fetchSnapshot(opts.cookie, {
160
+ include: opts.include || {},
161
+ limit: opts.limit,
162
+ offset: opts.offset,
163
+ });
164
+ if (result === null) {
165
+ const e = client.lastError;
166
+ throw new Error(
167
+ `finance-alipay.sync (live): ${e.message || "fetch failed"} (code ${e.code})`,
168
+ );
169
+ }
170
+ const account = result.account || null;
171
+ emit("fetched", { count: result.events.length });
172
+ const capturedAt = Date.now();
173
+ const limit =
174
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
175
+ const include = opts.include || {};
176
+ let emitted = 0;
177
+ for (const ev of result.events) {
178
+ if (emitted >= limit) return;
179
+ if (!ev || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
180
+ if (include[ev.kind] === false) continue;
181
+ const id =
182
+ (typeof ev.id === "string" && ev.id.length > 0 && ev.id) || ev.uid || null;
183
+ yield {
184
+ adapter: NAME,
185
+ kind: ev.kind,
186
+ originalId: stableOriginalId(ev.kind, id),
187
+ capturedAt,
188
+ payload: { ...ev, capturedAt, account },
189
+ };
190
+ emitted += 1;
191
+ }
192
+ }
193
+
118
194
  async *_syncViaSnapshot(opts) {
119
195
  const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
120
196
  const snapshot = JSON.parse(raw);
@@ -1,19 +1,78 @@
1
1
  /**
2
- * GenshinApiClient — FAMILY-23 v0.1 cookie-scrape(无签名)。
2
+ * GenshinApiClient — FAMILY-23 米哈游通行证 (HoYoLAB / 米游社) 采集客户端。
3
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 形态。
4
+ * v0.1 cookie-scrape(extractUid)。**v0.2 接通 live HTTP fetcher**:
5
+ * 通过米游社 takumi 接口 + DS(dynamic secret)v1 签名拉取角色档案
6
+ * (nickname / level / region / 活跃天数)。原神 web game-record API 不暴露
7
+ * 单次游戏时长("玩多久" 仍走手机端 collector 快照),但 "玩什么 / 等级 /
8
+ * 活跃天数" 可经合法 cookie 获取——两路互补。
9
+ *
10
+ * DS v1 算法是固定的;轮转的只有 `x-rpc-app_version` ↔ salt 配对(约每隔
11
+ * 几个客户端版本变一次)。配对作为**可覆盖常量**钉在这里(镜像仓内
12
+ * SignProvider 轮转模式)——轮转时改 DEFAULT_APP_VERSION / DEFAULT_DS_SALT,
13
+ * 或在 opts 里传 appVersion / salt。每次轮转后必须用真实已登录 cookie 验证。
7
14
  *
8
15
  * Cookie key 优先级 (米游社 2023+ → 旧版):
9
16
  * account_id_v2 > ltuid_v2 > account_id > ltuid
10
17
  */
11
18
  "use strict";
12
19
 
20
+ const crypto = require("node:crypto");
21
+
22
+ // ─── Endpoints (CN / 国服) ─────────────────────────────────────────────
23
+ const DEFAULT_TAKUMI_API = "https://api-takumi.mihoyo.com";
24
+ const DEFAULT_RECORD_API = "https://api-takumi-record.mihoyo.com";
25
+ // 原神国服 game_biz。海外 (hk4e_global) 走不同 host,本采集器仅国服。
26
+ const GAME_BIZ_GENSHIN_CN = "hk4e_cn";
27
+
28
+ // ─── DS v1 (rotatable) ─────────────────────────────────────────────────
29
+ // 与下方 salt 配对的客户端版本号;DS 校验时 server 比对该版本与 salt。
30
+ // 轮转点:客户端大版本更新后 salt 失效 → 同步改这两个常量。
31
+ const DEFAULT_APP_VERSION = "2.71.1";
32
+ // LK2 / game-record salt(与 DEFAULT_APP_VERSION 配对)。已知会随版本轮转。
33
+ const DEFAULT_DS_SALT = "dWCcEenpFEKWPk7FQzfztReDfGdvR9D9";
34
+
35
+ const BROWSER_UA =
36
+ "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) " +
37
+ "Version/4.0 Chrome/103.0.5060.129 Mobile Safari/537.36 miHoYoBBS/2.71.1";
38
+
39
+ /** md5 hex digest of a utf-8 string. */
40
+ function md5Hex(input) {
41
+ return crypto.createHash("md5").update(String(input), "utf8").digest("hex");
42
+ }
43
+
44
+ /**
45
+ * Generate a DS v1 dynamic-secret header value: `${t},${r},${c}` where
46
+ * t = floor(epoch_ms / 1000)
47
+ * r = random integer in [100001, 200000) (6-digit)
48
+ * c = md5(`salt=${salt}&t=${t}&r=${r}`)
49
+ *
50
+ * @param {string} salt
51
+ * @param {() => number} nowFn epoch-ms clock (test seam)
52
+ * @param {() => number} randFn [0,1) source (test seam)
53
+ */
54
+ function genDs(salt, nowFn, randFn) {
55
+ const t = Math.floor(nowFn() / 1000);
56
+ const span = 200000 - 100001;
57
+ const r = 100001 + Math.floor(randFn() * span);
58
+ const c = md5Hex(`salt=${salt}&t=${t}&r=${r}`);
59
+ return `${t},${r},${c}`;
60
+ }
61
+
13
62
  class GenshinApiClient {
14
- constructor() {
63
+ constructor(opts = {}) {
15
64
  this._lastErrorCode = 0;
16
65
  this._lastErrorMsg = "";
66
+ // Network deps — resolved lazily so offline callers (extractUid) and
67
+ // tests can construct without a fetch impl. Network methods check.
68
+ this._fetch =
69
+ opts.fetch || (typeof globalThis.fetch === "function" ? globalThis.fetch : null);
70
+ this._now = opts.now || Date.now;
71
+ this._rand = opts.rand || Math.random;
72
+ this.takumiApi = (opts.takumiApi || DEFAULT_TAKUMI_API).replace(/\/+$/, "");
73
+ this.recordApi = (opts.recordApi || DEFAULT_RECORD_API).replace(/\/+$/, "");
74
+ this.appVersion = opts.appVersion || DEFAULT_APP_VERSION;
75
+ this.salt = opts.salt || DEFAULT_DS_SALT;
17
76
  }
18
77
 
19
78
  _setLastError(code, msg) {
@@ -54,6 +113,148 @@ class GenshinApiClient {
54
113
  );
55
114
  return null;
56
115
  }
116
+
117
+ _headers(cookie, ds) {
118
+ return {
119
+ Cookie: cookie,
120
+ DS: ds,
121
+ "x-rpc-app_version": this.appVersion,
122
+ "x-rpc-client_type": "5",
123
+ "User-Agent": BROWSER_UA,
124
+ Referer: "https://webstatic.mihoyo.com/",
125
+ Origin: "https://webstatic.mihoyo.com",
126
+ };
127
+ }
128
+
129
+ /**
130
+ * GET <url> with DS-signed mihoyo headers. Returns parsed `data` field on
131
+ * success (retcode 0), null on transport / API error (sets lastError).
132
+ * @param {string} url
133
+ * @param {string} cookie
134
+ */
135
+ async _doGetJson(url, cookie) {
136
+ if (typeof this._fetch !== "function") {
137
+ this._setLastError(
138
+ -2,
139
+ "GenshinApiClient: fetch not available — pass opts.fetch or run on Node 18+",
140
+ );
141
+ return null;
142
+ }
143
+ let resp;
144
+ try {
145
+ const ds = genDs(this.salt, this._now, this._rand);
146
+ resp = await this._fetch(url, { method: "GET", headers: this._headers(cookie, ds) });
147
+ } catch (e) {
148
+ this._setLastError(-4, "network: " + (e && e.message ? e.message : String(e)));
149
+ return null;
150
+ }
151
+ const body = await resp.text();
152
+ if (!resp.ok) {
153
+ this._setLastError(resp.status, `HTTP ${resp.status}`);
154
+ return null;
155
+ }
156
+ let obj;
157
+ try {
158
+ obj = JSON.parse(body);
159
+ } catch (e) {
160
+ this._setLastError(-3, "parse: " + (e && e.message ? e.message : String(e)));
161
+ return null;
162
+ }
163
+ // mihoyo envelope: { retcode, message, data }
164
+ const retcode = typeof obj.retcode === "number" ? obj.retcode : 0;
165
+ if (retcode !== 0) {
166
+ this._setLastError(retcode, (obj.message || "").toString() || `retcode ${retcode}`);
167
+ return null;
168
+ }
169
+ this._clearLastError();
170
+ return obj.data;
171
+ }
172
+
173
+ /**
174
+ * Fetch the user's bound Genshin (国服) game roles via cookie.
175
+ * @param {string} cookie
176
+ * @returns {Promise<Array<{game_uid,nickname,level,region,region_name}>|null>}
177
+ */
178
+ async getGameRoles(cookie) {
179
+ const url = `${this.takumiApi}/binding/api/getUserGameRolesByCookie?game_biz=${GAME_BIZ_GENSHIN_CN}`;
180
+ const data = await this._doGetJson(url, cookie);
181
+ if (data === null) return null;
182
+ const list = Array.isArray(data.list) ? data.list : [];
183
+ return list;
184
+ }
185
+
186
+ /**
187
+ * Fetch game-record index stats for one role (active_day_number etc.).
188
+ * @param {string} cookie
189
+ * @param {string} roleId in-game uid (game_uid)
190
+ * @param {string} server region code (cn_gf01 / cn_qd01)
191
+ * @returns {Promise<object|null>} the `stats` object or null
192
+ */
193
+ async getRecordStats(cookie, roleId, server) {
194
+ const url = `${this.recordApi}/game_record/app/genshin/api/index?role_id=${encodeURIComponent(
195
+ roleId,
196
+ )}&server=${encodeURIComponent(server)}`;
197
+ const data = await this._doGetJson(url, cookie);
198
+ if (data === null) return null;
199
+ return data.stats && typeof data.stats === "object" ? data.stats : {};
200
+ }
201
+
202
+ /**
203
+ * High-level: resolve all bound Genshin roles into profile records shaped
204
+ * to match the snapshot `profile` event (so adapter.normalize is unchanged).
205
+ * When `fetchStats` is true also folds in active_day_number per role.
206
+ *
207
+ * @param {string} cookie
208
+ * @param {object} [opts]
209
+ * @param {boolean} [opts.fetchStats=true]
210
+ * @param {number} [opts.limit]
211
+ * @returns {Promise<Array<object>|null>} profile records, or null on hard error
212
+ */
213
+ async fetchProfiles(cookie, opts = {}) {
214
+ if (typeof cookie !== "string" || cookie.length === 0) {
215
+ this._setLastError(-1, "cookie 为空");
216
+ return null;
217
+ }
218
+ const roles = await this.getGameRoles(cookie);
219
+ if (roles === null) return null; // lastError already set
220
+ const fetchStats = opts.fetchStats !== false;
221
+ const limit =
222
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
223
+ const profiles = [];
224
+ for (const role of roles) {
225
+ if (profiles.length >= limit) break;
226
+ if (!role || typeof role !== "object") continue;
227
+ const uid = role.game_uid != null ? String(role.game_uid) : null;
228
+ if (!uid) continue;
229
+ const region = role.region || null;
230
+ let activeDayNumber = null;
231
+ if (fetchStats && region) {
232
+ const stats = await this.getRecordStats(cookie, uid, region);
233
+ // A per-role stats failure is non-fatal — still emit the profile.
234
+ if (stats && Number.isFinite(stats.active_day_number)) {
235
+ activeDayNumber = stats.active_day_number;
236
+ }
237
+ }
238
+ profiles.push({
239
+ uid,
240
+ nickname: role.nickname || null,
241
+ level: Number.isFinite(role.level) ? role.level : null,
242
+ region,
243
+ regionName: role.region_name || null,
244
+ activeDayNumber,
245
+ });
246
+ }
247
+ this._clearLastError();
248
+ return profiles;
249
+ }
57
250
  }
58
251
 
59
- module.exports = { GenshinApiClient };
252
+ module.exports = {
253
+ GenshinApiClient,
254
+ // Exported for tests / live-pair rotation introspection.
255
+ genDs,
256
+ md5Hex,
257
+ DEFAULT_APP_VERSION,
258
+ DEFAULT_DS_SALT,
259
+ GAME_BIZ_GENSHIN_CN,
260
+ };