@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.
- 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/netease-music-live.test.js +244 -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-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-cookies-extension.test.js +0 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +182 -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/shopping-pinduoduo/index.js +241 -33
- 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-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/index.js +1 -1
- package/package.json +1 -1
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ZuoyebangApiClient — FAMILY-23
|
|
2
|
+
* ZuoyebangApiClient — FAMILY-23 作业帮采集客户端。
|
|
3
3
|
*
|
|
4
4
|
* 作业帮 session 主键是 ZYBUSS(不透明 token);数字 uid 走 uid / student_id /
|
|
5
|
-
* passport_uid。v0.1 仅 extractUid
|
|
6
|
-
*
|
|
5
|
+
* passport_uid。v0.1 仅 cookie-scrape(extractUid)。**v0.2 接通 live HTTP
|
|
6
|
+
* fetcher**:cookie(ZYBUSS 会话)直拉 用户信息 + 学习/搜题记录。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **best-effort**:作业帮 web 接口无公开稳定文档,下方端点/字段按其 web 端
|
|
9
|
+
* 常见形态实现(envelope `{errNo, errstr, data}`),做了多字段名兼容(pick
|
|
10
|
+
* 回退),**未经真实登录态实地验证** — 端点/字段漂移时改常量 / opts 覆盖
|
|
11
|
+
* (同 SignProvider 轮转思路)。
|
|
12
|
+
*
|
|
13
|
+
* Cookie key 优先级 (extractUid): uid > student_id > passport_uid。
|
|
14
|
+
* live 模式仅需 ZYBUSS 在场即可(uid 由 user-info 接口返回)。
|
|
7
15
|
*/
|
|
8
16
|
"use strict";
|
|
9
17
|
|
|
18
|
+
const { pick, toDurationMs, toEpochMs } = require("../_live-json-helpers");
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASE_URL = "https://www.zuoyebang.com";
|
|
21
|
+
// 端点(best-effort,可经 opts 覆盖)。
|
|
22
|
+
const PATH_USER_INFO = "/session/pc/getuserinfo";
|
|
23
|
+
const PATH_STUDY_RECORDS = "/study/pc/record/list";
|
|
24
|
+
|
|
25
|
+
const BROWSER_UA =
|
|
26
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
27
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
28
|
+
|
|
10
29
|
class ZuoyebangApiClient {
|
|
11
|
-
constructor() {
|
|
30
|
+
constructor(opts = {}) {
|
|
12
31
|
this._lastErrorCode = 0;
|
|
13
32
|
this._lastErrorMsg = "";
|
|
33
|
+
this._fetch =
|
|
34
|
+
opts.fetch || (typeof globalThis.fetch === "function" ? globalThis.fetch : null);
|
|
35
|
+
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
36
|
+
this.userInfoPath = opts.userInfoPath || PATH_USER_INFO;
|
|
37
|
+
this.studyRecordsPath = opts.studyRecordsPath || PATH_STUDY_RECORDS;
|
|
14
38
|
}
|
|
15
39
|
_setLastError(code, msg) {
|
|
16
40
|
this._lastErrorCode = code;
|
|
@@ -39,10 +63,161 @@ class ZuoyebangApiClient {
|
|
|
39
63
|
}
|
|
40
64
|
this._setLastError(
|
|
41
65
|
-7,
|
|
42
|
-
"cookie 缺 uid / student_id / passport_uid — 作业帮未登录 (仅 ZYBUSS 不透明 token,
|
|
66
|
+
"cookie 缺 uid / student_id / passport_uid — 作业帮未登录 (仅 ZYBUSS 不透明 token, extractUid 不解)",
|
|
43
67
|
);
|
|
44
68
|
return null;
|
|
45
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* live 模式会话探测:ZYBUSS 在场(uid 可由接口拿)或数字 uid 可抽即视为有会话。
|
|
73
|
+
* @param {string} cookie @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
hasSession(cookie) {
|
|
76
|
+
if (typeof cookie !== "string" || cookie.length === 0) return false;
|
|
77
|
+
if (/(?:^|; ?)ZYBUSS=[^;\s]+/.test(cookie)) return true;
|
|
78
|
+
const uid = this.extractUid(cookie);
|
|
79
|
+
if (uid) return true;
|
|
80
|
+
this._setLastError(-7, "cookie 缺 ZYBUSS 且无数字 uid — 作业帮未登录");
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_headers(cookie) {
|
|
85
|
+
return {
|
|
86
|
+
Cookie: cookie,
|
|
87
|
+
"User-Agent": BROWSER_UA,
|
|
88
|
+
Referer: `${this.baseUrl}/`,
|
|
89
|
+
Accept: "application/json",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET <url> with cookie. Parses the zuoyebang web envelope
|
|
95
|
+
* `{ errNo, errstr, data }` (errNo 0 = ok). Returns `data` on success,
|
|
96
|
+
* null on transport / API error (sets lastError).
|
|
97
|
+
*/
|
|
98
|
+
async _doGetJson(url, cookie) {
|
|
99
|
+
if (typeof this._fetch !== "function") {
|
|
100
|
+
this._setLastError(
|
|
101
|
+
-2,
|
|
102
|
+
"ZuoyebangApiClient: fetch not available — pass opts.fetch or run on Node 18+",
|
|
103
|
+
);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
let resp;
|
|
107
|
+
try {
|
|
108
|
+
resp = await this._fetch(url, { method: "GET", headers: this._headers(cookie) });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
this._setLastError(-4, "network: " + (e && e.message ? e.message : String(e)));
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const txt = await resp.text();
|
|
114
|
+
if (!resp.ok) {
|
|
115
|
+
this._setLastError(resp.status, `HTTP ${resp.status}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
let obj;
|
|
119
|
+
try {
|
|
120
|
+
obj = JSON.parse(txt);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
this._setLastError(-3, "parse: " + (e && e.message ? e.message : String(e)));
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const code = pick(obj, ["errNo", "errno", "code"], 0);
|
|
126
|
+
if (Number(code) !== 0) {
|
|
127
|
+
this._setLastError(
|
|
128
|
+
Number(code),
|
|
129
|
+
pick(obj, ["errstr", "errStr", "errmsg", "errMsg", "msg"], `errNo ${code}`).toString(),
|
|
130
|
+
);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
this._clearLastError();
|
|
134
|
+
return obj.data !== undefined && obj.data !== null ? obj.data : obj;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 用户信息 → { uid, nickname, grade } or null. */
|
|
138
|
+
async getUserInfo(cookie) {
|
|
139
|
+
const data = await this._doGetJson(`${this.baseUrl}${this.userInfoPath}`, cookie);
|
|
140
|
+
if (data === null) return null;
|
|
141
|
+
// web 端常把用户体包在 data.user / data.userInfo 下,或直接平铺。
|
|
142
|
+
const u = pick(data, ["user", "userInfo", "loginUser"], data);
|
|
143
|
+
const uid = pick(u, ["uid", "userId", "studentUid", "cuid"]);
|
|
144
|
+
return {
|
|
145
|
+
uid: uid != null ? String(uid) : null,
|
|
146
|
+
nickname: pick(u, ["uname", "nickName", "nickname", "userName", "name"]),
|
|
147
|
+
grade: pick(u, ["gradeName", "grade", "gradeId"]),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 学习/搜题记录 → [{ recordId, subject, durationMs, startAt }]. null on error.
|
|
153
|
+
* @param {string} cookie
|
|
154
|
+
* @param {object} [opts] { limit, offset }
|
|
155
|
+
*/
|
|
156
|
+
async getStudyRecords(cookie, opts = {}) {
|
|
157
|
+
const rn = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 20;
|
|
158
|
+
const pn = Number.isInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
|
|
159
|
+
const url = `${this.baseUrl}${this.studyRecordsPath}?pn=${pn}&rn=${rn}`;
|
|
160
|
+
const data = await this._doGetJson(url, cookie);
|
|
161
|
+
if (data === null) return null;
|
|
162
|
+
const list = pick(data, ["list", "records", "items"], Array.isArray(data) ? data : []);
|
|
163
|
+
if (!Array.isArray(list)) return [];
|
|
164
|
+
return list.map((r) => ({
|
|
165
|
+
recordId: pick(r, ["recordId", "logId", "id"]),
|
|
166
|
+
subject: pick(r, ["subjectName", "subject", "courseName", "course"]),
|
|
167
|
+
durationMs: toDurationMs(pick(r, ["studyTime", "duration", "learnTime", "durationMs"], 0)),
|
|
168
|
+
startAt: toEpochMs(pick(r, ["startTime", "beginTime", "createTime", "time"])),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* High-level: user info + study records → snapshot-shaped { account, events }
|
|
174
|
+
* so the adapter normalize path is unchanged.
|
|
175
|
+
* @returns {Promise<{account, events}|null>}
|
|
176
|
+
*/
|
|
177
|
+
async fetchSnapshot(cookie, opts = {}) {
|
|
178
|
+
if (!this.hasSession(cookie)) return null; // lastError already set
|
|
179
|
+
const include = opts.include || {};
|
|
180
|
+
const events = [];
|
|
181
|
+
let account = null;
|
|
182
|
+
|
|
183
|
+
if (include.profile !== false) {
|
|
184
|
+
const user = await this.getUserInfo(cookie);
|
|
185
|
+
if (user === null) return null;
|
|
186
|
+
account = { uid: user.uid, displayName: user.nickname };
|
|
187
|
+
events.push({
|
|
188
|
+
kind: "profile",
|
|
189
|
+
id: user.uid ? `profile-${user.uid}` : null,
|
|
190
|
+
uid: user.uid,
|
|
191
|
+
nickname: user.nickname,
|
|
192
|
+
grade: user.grade,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (include.study !== false) {
|
|
197
|
+
const records = await this.getStudyRecords(cookie, {
|
|
198
|
+
limit: opts.limit,
|
|
199
|
+
offset: opts.offset,
|
|
200
|
+
});
|
|
201
|
+
if (records === null) return null;
|
|
202
|
+
for (const r of records) {
|
|
203
|
+
events.push({
|
|
204
|
+
kind: "study",
|
|
205
|
+
id: r.recordId ? `study-${r.recordId}` : null,
|
|
206
|
+
subject: r.subject,
|
|
207
|
+
durationMs: r.durationMs,
|
|
208
|
+
startAt: r.startAt,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this._clearLastError();
|
|
214
|
+
return { account, events };
|
|
215
|
+
}
|
|
46
216
|
}
|
|
47
217
|
|
|
48
|
-
module.exports = {
|
|
218
|
+
module.exports = {
|
|
219
|
+
ZuoyebangApiClient,
|
|
220
|
+
// Exported for tests / endpoint introspection.
|
|
221
|
+
PATH_USER_INFO,
|
|
222
|
+
PATH_STUDY_RECORDS,
|
|
223
|
+
};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FAMILY-23
|
|
2
|
+
* FAMILY-23 — 作业帮 (Zuoyebang) adapter.
|
|
3
3
|
*
|
|
4
|
-
* 家庭守护 telemetry
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 家庭守护 telemetry:家长看孩子的学习/搜题情况。两路互补:
|
|
5
|
+
* - snapshot 模式(inputPath):手机端 collector 快照 (profile + study-session)。
|
|
6
|
+
* - **live 模式(cookie,v0.2 接通)**:[ZuoyebangApiClient.fetchSnapshot] 经
|
|
7
|
+
* 作业帮 web 接口(ZYBUSS 会话 cookie)拉 用户信息 + 学习/搜题记录。端点/
|
|
8
|
+
* 字段无公开稳定文档,按 web 端常见形态实现 + 多字段名兼容,**未实地验证**,
|
|
9
|
+
* 漂移时按 api-client 常量/pick 列表调整。
|
|
10
|
+
* 无 inputPath 且无 cookie 时 sync 抛错。
|
|
8
11
|
*
|
|
9
12
|
* Snapshot schema (v1):
|
|
10
13
|
* { schemaVersion:1, snapshottedAt, account:{uid,displayName}, events:[
|
|
@@ -26,7 +29,7 @@ const {
|
|
|
26
29
|
const { ZuoyebangApiClient } = require("./api-client");
|
|
27
30
|
|
|
28
31
|
const NAME = "edu-zuoyebang";
|
|
29
|
-
const VERSION = "0.
|
|
32
|
+
const VERSION = "0.2.0";
|
|
30
33
|
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
31
34
|
const KIND_PROFILE = "profile";
|
|
32
35
|
const KIND_STUDY = "study";
|
|
@@ -60,6 +63,7 @@ class ZuoyebangAdapter {
|
|
|
60
63
|
this.version = VERSION;
|
|
61
64
|
this.capabilities = [
|
|
62
65
|
"sync:snapshot",
|
|
66
|
+
"sync:cookie",
|
|
63
67
|
"parse:zuoyebang-profile",
|
|
64
68
|
"parse:zuoyebang-study-session",
|
|
65
69
|
];
|
|
@@ -74,7 +78,10 @@ class ZuoyebangAdapter {
|
|
|
74
78
|
legalGate: false,
|
|
75
79
|
defaultInclude: { profile: true, study: true },
|
|
76
80
|
};
|
|
77
|
-
this.apiClient = new ZuoyebangApiClient();
|
|
81
|
+
this.apiClient = new ZuoyebangApiClient(opts);
|
|
82
|
+
// Test seam: override how the live client is built per-sync (inject fetch).
|
|
83
|
+
this._apiClientFactory =
|
|
84
|
+
typeof opts.apiClientFactory === "function" ? opts.apiClientFactory : null;
|
|
78
85
|
this._deps = { fs };
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -91,11 +98,21 @@ class ZuoyebangAdapter {
|
|
|
91
98
|
}
|
|
92
99
|
return { ok: true, mode: "snapshot-file" };
|
|
93
100
|
}
|
|
101
|
+
if (ctx && typeof ctx.cookie === "string" && ctx.cookie.length > 0) {
|
|
102
|
+
if (!this.apiClient.hasSession(ctx.cookie)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "INVALID_COOKIE",
|
|
106
|
+
message: `edu-zuoyebang.authenticate: ${this.apiClient.lastError.message}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, mode: "cookie" };
|
|
110
|
+
}
|
|
94
111
|
return {
|
|
95
112
|
ok: false,
|
|
96
113
|
reason: "NO_INPUT",
|
|
97
114
|
message:
|
|
98
|
-
"edu-zuoyebang.authenticate:
|
|
115
|
+
"edu-zuoyebang.authenticate: needs opts.inputPath (snapshot mode) or opts.cookie (ZYBUSS 会话, live fetch)",
|
|
99
116
|
};
|
|
100
117
|
}
|
|
101
118
|
|
|
@@ -108,11 +125,68 @@ class ZuoyebangAdapter {
|
|
|
108
125
|
yield* this._syncViaSnapshot(opts);
|
|
109
126
|
return;
|
|
110
127
|
}
|
|
128
|
+
if (typeof opts.cookie === "string" && opts.cookie.length > 0) {
|
|
129
|
+
yield* this._syncViaLive(opts);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
111
132
|
throw new Error(
|
|
112
|
-
"edu-zuoyebang.sync:
|
|
133
|
+
"edu-zuoyebang.sync: needs opts.inputPath (snapshot mode) or opts.cookie (ZYBUSS 会话, 学习/搜题记录 live fetch)",
|
|
113
134
|
);
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
async *_syncViaLive(opts) {
|
|
138
|
+
const client = this._apiClientFactory
|
|
139
|
+
? this._apiClientFactory(opts)
|
|
140
|
+
: new ZuoyebangApiClient({
|
|
141
|
+
fetch: opts.fetch,
|
|
142
|
+
baseUrl: opts.baseUrl,
|
|
143
|
+
userInfoPath: opts.userInfoPath,
|
|
144
|
+
studyRecordsPath: opts.studyRecordsPath,
|
|
145
|
+
});
|
|
146
|
+
const emit = (phase, extra) => {
|
|
147
|
+
if (typeof opts.onProgress === "function") {
|
|
148
|
+
try {
|
|
149
|
+
opts.onProgress({ phase, adapter: NAME, ...extra });
|
|
150
|
+
} catch (_e) {
|
|
151
|
+
/* progress callback errors are best-effort */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const result = await client.fetchSnapshot(opts.cookie, {
|
|
156
|
+
include: opts.include || {},
|
|
157
|
+
limit: opts.limit,
|
|
158
|
+
offset: opts.offset,
|
|
159
|
+
});
|
|
160
|
+
if (result === null) {
|
|
161
|
+
const e = client.lastError;
|
|
162
|
+
throw new Error(
|
|
163
|
+
`edu-zuoyebang.sync (live): ${e.message || "fetch failed"} (code ${e.code})`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const account = result.account || null;
|
|
167
|
+
emit("fetched", { count: result.events.length });
|
|
168
|
+
const capturedAt = Date.now();
|
|
169
|
+
const limit =
|
|
170
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
171
|
+
const include = opts.include || {};
|
|
172
|
+
let emitted = 0;
|
|
173
|
+
for (const ev of result.events) {
|
|
174
|
+
if (emitted >= limit) return;
|
|
175
|
+
if (!ev || !VALID_SNAPSHOT_KINDS.includes(ev.kind)) continue;
|
|
176
|
+
if (include[ev.kind] === false) continue;
|
|
177
|
+
const id =
|
|
178
|
+
(typeof ev.id === "string" && ev.id.length > 0 && ev.id) || ev.uid || null;
|
|
179
|
+
yield {
|
|
180
|
+
adapter: NAME,
|
|
181
|
+
kind: ev.kind,
|
|
182
|
+
originalId: stableOriginalId(ev.kind, id),
|
|
183
|
+
capturedAt,
|
|
184
|
+
payload: { ...ev, capturedAt, account },
|
|
185
|
+
};
|
|
186
|
+
emitted += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
116
190
|
async *_syncViaSnapshot(opts) {
|
|
117
191
|
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
118
192
|
const snapshot = JSON.parse(raw);
|
|
@@ -1,16 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AlipayApiClient — FAMILY-23
|
|
2
|
+
* AlipayApiClient — FAMILY-23 支付宝采集客户端。
|
|
3
3
|
*
|
|
4
|
-
* 支付宝 web cookie uid 不易直取(多走 session token);v0.1
|
|
5
|
-
* alipay_uid / userId / loginUserId 抽数字 uid
|
|
6
|
-
*
|
|
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 = {
|
|
303
|
+
module.exports = {
|
|
304
|
+
AlipayApiClient,
|
|
305
|
+
// Exported for tests / endpoint introspection.
|
|
306
|
+
parseAmountYuan,
|
|
307
|
+
deriveDirection,
|
|
308
|
+
PATH_MGW,
|
|
309
|
+
OP_BILL_LIST,
|
|
310
|
+
};
|