@chainlesschain/personal-data-hub 0.4.3 → 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.
- package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
- package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
- package/__tests__/adapters/finance-alipay-live.test.js +258 -0
- package/__tests__/adapters/game-genshin-live.test.js +238 -0
- package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
- package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
- package/__tests__/adapters/messaging-whatsapp.test.js +289 -0
- package/__tests__/adapters/netease-music-live.test.js +244 -0
- package/__tests__/adapters/shopping-base.test.js +179 -0
- package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
- package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
- package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +64 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +11 -0
- package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
- package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +431 -0
- package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
- package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +200 -0
- package/__tests__/adapters/travel-12306.test.js +279 -0
- package/__tests__/adapters/travel-amap.test.js +219 -0
- package/__tests__/adapters/travel-baidu-map.test.js +305 -0
- package/__tests__/adapters/travel-base.test.js +205 -0
- package/__tests__/adapters/travel-ctrip.test.js +203 -0
- package/__tests__/adapters/travel-tencent-map.test.js +207 -0
- package/lib/adapters/_live-json-helpers.js +50 -0
- package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
- package/lib/adapters/edu-huawei-learning/index.js +83 -9
- package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
- package/lib/adapters/edu-zuoyebang/index.js +83 -9
- package/lib/adapters/finance-alipay/api-client.js +268 -6
- package/lib/adapters/finance-alipay/index.js +85 -9
- package/lib/adapters/game-genshin/api-client.js +207 -6
- package/lib/adapters/game-genshin/index.js +90 -9
- package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
- package/lib/adapters/game-honor-of-kings/index.js +80 -9
- package/lib/adapters/netease-music/api-client.js +284 -0
- package/lib/adapters/netease-music/index.js +85 -9
- package/lib/adapters/social-douyin/index.js +2 -0
- package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
- package/lib/adapters/social-douyin-adb/collector.js +114 -0
- package/lib/adapters/social-douyin-adb/index.js +18 -1
- package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
- package/lib/adapters/social-kuaishou/index.js +7 -2
- package/lib/adapters/social-kuaishou-adb/api-client.js +38 -18
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +16 -15
- package/lib/adapters/social-toutiao/index.js +8 -4
- package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
- package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
- package/lib/adapters/social-toutiao-adb/collector.js +55 -19
- package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
- package/lib/adapters/social-toutiao-adb/index.js +6 -0
- package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
- package/lib/adapters/travel-base/index.js +9 -2
- package/lib/index.js +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FAMILY-23
|
|
2
|
+
* FAMILY-23 — 支付宝 (Alipay) adapter.
|
|
3
3
|
*
|
|
4
4
|
* 家庭守护 telemetry:家长看孩子的消费情况。**高敏感**(涉资金)— 上行受
|
|
5
|
-
* telemetry level + quiet hours 闸(FAMILY-24/25
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
2
|
+
* GenshinApiClient — FAMILY-23 米哈游通行证 (HoYoLAB / 米游社) 采集客户端。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 = {
|
|
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
|
|
2
|
+
* FAMILY-23 — Genshin Impact (原神 / 米哈游) adapter.
|
|
3
3
|
*
|
|
4
|
-
* 家庭守护 telemetry:家长想看孩子玩什么游戏 /
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
},
|