@chainlesschain/personal-data-hub 0.4.4 → 0.4.6

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 (43) 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/__tests__/shopping-pinduoduo-snapshot.test.js +182 -0
  17. package/lib/adapters/_live-json-helpers.js +50 -0
  18. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  19. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  20. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  21. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  22. package/lib/adapters/finance-alipay/api-client.js +268 -6
  23. package/lib/adapters/finance-alipay/index.js +85 -9
  24. package/lib/adapters/game-genshin/api-client.js +207 -6
  25. package/lib/adapters/game-genshin/index.js +90 -9
  26. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  27. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  28. package/lib/adapters/netease-music/api-client.js +284 -0
  29. package/lib/adapters/netease-music/index.js +85 -9
  30. package/lib/adapters/shopping-pinduoduo/index.js +241 -33
  31. package/lib/adapters/social-douyin/index.js +2 -0
  32. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  33. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  34. package/lib/adapters/social-douyin-adb/index.js +18 -1
  35. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  36. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  37. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  38. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  39. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  40. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  41. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  42. package/lib/index.js +1 -1
  43. package/package.json +1 -1
@@ -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
+ };
@@ -1,10 +1,14 @@
1
1
  /**
2
- * FAMILY-23 v0.1 — Genshin Impact (原神 / 米哈游) adapter, snapshot mode.
2
+ * FAMILY-23 — Genshin Impact (原神 / 米哈游) adapter.
3
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)。
4
+ * 家庭守护 telemetry:家长想看孩子玩什么游戏 / 玩多久。两路互补:
5
+ * - snapshot 模式(inputPath):消费手机端 collector 产的快照 JSON
6
+ * (profile + play-session 事件,含精确游戏时长)。
7
+ * - **live 模式(cookie,v0.2 接通)**:[GenshinApiClient.fetchProfiles]
8
+ * 米游社 takumi 接口 + DS v1 签名拉取角色档案(nickname / level / region /
9
+ * 活跃天数)。web game-record API 不暴露单次时长,故 live 仅出 profile;
10
+ * "玩多久" 仍依赖 snapshot。
11
+ * 无 inputPath 且无 cookie 时 sync 抛错。
8
12
  *
9
13
  * Snapshot schema (v1):
10
14
  * {
@@ -34,7 +38,7 @@ const {
34
38
  const { GenshinApiClient } = require("./api-client");
35
39
 
36
40
  const NAME = "game-genshin";
37
- const VERSION = "0.1.0";
41
+ const VERSION = "0.2.0";
38
42
  const SNAPSHOT_SCHEMA_VERSION = 1;
39
43
 
40
44
  const KIND_PROFILE = "profile";
@@ -69,6 +73,7 @@ class GenshinAdapter {
69
73
  this.version = VERSION;
70
74
  this.capabilities = [
71
75
  "sync:snapshot",
76
+ "sync:cookie",
72
77
  "parse:genshin-profile",
73
78
  "parse:genshin-play-session",
74
79
  ];
@@ -83,7 +88,10 @@ class GenshinAdapter {
83
88
  legalGate: false,
84
89
  defaultInclude: { profile: true, play: true },
85
90
  };
86
- this.apiClient = new GenshinApiClient();
91
+ this.apiClient = new GenshinApiClient(opts);
92
+ // Test seam: override how the live client is built per-sync (inject fetch).
93
+ this._apiClientFactory =
94
+ typeof opts.apiClientFactory === "function" ? opts.apiClientFactory : null;
87
95
  this._deps = { fs };
88
96
  }
89
97
 
@@ -100,11 +108,22 @@ class GenshinAdapter {
100
108
  }
101
109
  return { ok: true, mode: "snapshot-file" };
102
110
  }
111
+ if (ctx && typeof ctx.cookie === "string" && ctx.cookie.length > 0) {
112
+ const uid = this.apiClient.extractUid(ctx.cookie);
113
+ if (!uid) {
114
+ return {
115
+ ok: false,
116
+ reason: "INVALID_COOKIE",
117
+ message: `game-genshin.authenticate: ${this.apiClient.lastError.message}`,
118
+ };
119
+ }
120
+ return { ok: true, mode: "cookie" };
121
+ }
103
122
  return {
104
123
  ok: false,
105
124
  reason: "NO_INPUT",
106
125
  message:
107
- "game-genshin.authenticate: v0.1 needs opts.inputPath (snapshot mode); live HTTP fetcher v0.2",
126
+ "game-genshin.authenticate: needs opts.inputPath (snapshot mode) or opts.cookie (live HoYoLAB fetch)",
108
127
  };
109
128
  }
110
129
 
@@ -117,11 +136,68 @@ class GenshinAdapter {
117
136
  yield* this._syncViaSnapshot(opts);
118
137
  return;
119
138
  }
139
+ if (typeof opts.cookie === "string" && opts.cookie.length > 0) {
140
+ yield* this._syncViaLive(opts);
141
+ return;
142
+ }
120
143
  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",
144
+ "game-genshin.sync: needs opts.inputPath (snapshot mode, Android in-APK cc) or opts.cookie (live HoYoLAB fetch via takumi + DS 签名)",
122
145
  );
123
146
  }
124
147
 
148
+ async *_syncViaLive(opts) {
149
+ const client = this._apiClientFactory
150
+ ? this._apiClientFactory(opts)
151
+ : new GenshinApiClient({
152
+ fetch: opts.fetch,
153
+ now: opts.now,
154
+ rand: opts.rand,
155
+ appVersion: opts.appVersion,
156
+ salt: opts.salt,
157
+ takumiApi: opts.takumiApi,
158
+ recordApi: opts.recordApi,
159
+ });
160
+ const emit = (phase, extra) => {
161
+ if (typeof opts.onProgress === "function") {
162
+ try {
163
+ opts.onProgress({ phase, adapter: NAME, ...extra });
164
+ } catch (_e) {
165
+ /* progress callback errors are best-effort */
166
+ }
167
+ }
168
+ };
169
+ const profiles = await client.fetchProfiles(opts.cookie, {
170
+ fetchStats: opts.fetchStats !== false,
171
+ limit: opts.limit,
172
+ });
173
+ if (profiles === null) {
174
+ const e = client.lastError;
175
+ throw new Error(
176
+ `game-genshin.sync (live): ${e.message || "fetch failed"} (code ${e.code})`,
177
+ );
178
+ }
179
+ emit("roles", { count: profiles.length });
180
+ const capturedAt = Date.now();
181
+ const limit =
182
+ Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
183
+ let emitted = 0;
184
+ for (const prof of profiles) {
185
+ if (emitted >= limit) return;
186
+ yield {
187
+ adapter: NAME,
188
+ kind: KIND_PROFILE,
189
+ originalId: stableOriginalId(KIND_PROFILE, prof.uid),
190
+ capturedAt,
191
+ payload: {
192
+ kind: KIND_PROFILE,
193
+ ...prof,
194
+ account: { uid: prof.uid, displayName: prof.nickname },
195
+ },
196
+ };
197
+ emitted += 1;
198
+ }
199
+ }
200
+
125
201
  async *_syncViaSnapshot(opts) {
126
202
  const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
127
203
  const snapshot = JSON.parse(raw);
@@ -220,6 +296,11 @@ function normalizeProfile(p, raw, ingestedAt) {
220
296
  platform: "genshin",
221
297
  level: p.level != null ? p.level : null,
222
298
  avatarUrl: p.avatarUrl || null,
299
+ region: p.region || null,
300
+ regionName: p.regionName || null,
301
+ activeDayNumber: Number.isFinite(p.activeDayNumber)
302
+ ? p.activeDayNumber
303
+ : null,
223
304
  snapshottedAt: occurredAt,
224
305
  },
225
306
  },