@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.
- 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/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-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,15 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HuaweiLearningApiClient — FAMILY-23
|
|
2
|
+
* HuaweiLearningApiClient — FAMILY-23 华为学习中心采集客户端。
|
|
3
3
|
*
|
|
4
|
-
* 华为学习中心走华为账号登录;v0.1
|
|
5
|
-
*
|
|
4
|
+
* 华为学习中心走华为账号登录;v0.1 仅 cookie-scrape(extractUid,从 accountId /
|
|
5
|
+
* userId / huaweiUid 抽数字 uid)。**v0.2 接通 live HTTP fetcher**:cookie(华为
|
|
6
|
+
* 账号 web 会话)直拉 用户信息 + 课程学习记录(课程名 / 学习时长)。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **best-effort**:学习中心接口无公开稳定文档,下方端点/字段按 hicloud 教育
|
|
9
|
+
* 服务常见形态实现(envelope `{code|resultCode, message, data}`,0 = ok),
|
|
10
|
+
* 做了多字段名兼容(pick 回退),**未经真实华为账号登录态实地验证** —
|
|
11
|
+
* 端点/字段漂移时改常量 / opts 覆盖(同 SignProvider 轮转思路)。
|
|
6
12
|
*/
|
|
7
13
|
"use strict";
|
|
8
14
|
|
|
15
|
+
const { pick, toDurationMs, toEpochMs } = require("../_live-json-helpers");
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BASE_URL = "https://educenter.hicloud.com";
|
|
18
|
+
// 端点(best-effort,可经 opts 覆盖)。
|
|
19
|
+
const PATH_USER_INFO = "/edu/api/user/v1/info";
|
|
20
|
+
const PATH_STUDY_RECORDS = "/edu/api/study/v1/records";
|
|
21
|
+
|
|
22
|
+
const BROWSER_UA =
|
|
23
|
+
"Mozilla/5.0 (Linux; Android 12; HarmonyOS) AppleWebKit/537.36 " +
|
|
24
|
+
"(KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36";
|
|
25
|
+
|
|
9
26
|
class HuaweiLearningApiClient {
|
|
10
|
-
constructor() {
|
|
27
|
+
constructor(opts = {}) {
|
|
11
28
|
this._lastErrorCode = 0;
|
|
12
29
|
this._lastErrorMsg = "";
|
|
30
|
+
this._fetch =
|
|
31
|
+
opts.fetch || (typeof globalThis.fetch === "function" ? globalThis.fetch : null);
|
|
32
|
+
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
33
|
+
this.userInfoPath = opts.userInfoPath || PATH_USER_INFO;
|
|
34
|
+
this.studyRecordsPath = opts.studyRecordsPath || PATH_STUDY_RECORDS;
|
|
13
35
|
}
|
|
14
36
|
_setLastError(code, msg) {
|
|
15
37
|
this._lastErrorCode = code;
|
|
@@ -42,6 +64,157 @@ class HuaweiLearningApiClient {
|
|
|
42
64
|
);
|
|
43
65
|
return null;
|
|
44
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* live 模式会话探测:华为账号 web 会话 key 形态不稳定(CAS / OAuth 多变体),
|
|
70
|
+
* 宽松判定 — 有任意 `k=v` 形态的非空 cookie 即放行,真伪交给服务端校验。
|
|
71
|
+
* @param {string} cookie @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
hasSession(cookie) {
|
|
74
|
+
if (typeof cookie !== "string" || !/[^=;\s]+=[^;\s]+/.test(cookie)) {
|
|
75
|
+
this._setLastError(-7, "cookie 为空或非法 — 华为账号未登录");
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
this._clearLastError();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_headers(cookie) {
|
|
83
|
+
return {
|
|
84
|
+
Cookie: cookie,
|
|
85
|
+
"User-Agent": BROWSER_UA,
|
|
86
|
+
Referer: `${this.baseUrl}/`,
|
|
87
|
+
Accept: "application/json",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* GET <url> with cookie. Parses `{ code|resultCode, message, data }`
|
|
93
|
+
* (0 = ok). Returns `data` on success, null on error (sets lastError).
|
|
94
|
+
*/
|
|
95
|
+
async _doGetJson(url, cookie) {
|
|
96
|
+
if (typeof this._fetch !== "function") {
|
|
97
|
+
this._setLastError(
|
|
98
|
+
-2,
|
|
99
|
+
"HuaweiLearningApiClient: fetch not available — pass opts.fetch or run on Node 18+",
|
|
100
|
+
);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
let resp;
|
|
104
|
+
try {
|
|
105
|
+
resp = await this._fetch(url, { method: "GET", headers: this._headers(cookie) });
|
|
106
|
+
} catch (e) {
|
|
107
|
+
this._setLastError(-4, "network: " + (e && e.message ? e.message : String(e)));
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const txt = await resp.text();
|
|
111
|
+
if (!resp.ok) {
|
|
112
|
+
this._setLastError(resp.status, `HTTP ${resp.status}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
let obj;
|
|
116
|
+
try {
|
|
117
|
+
obj = JSON.parse(txt);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
this._setLastError(-3, "parse: " + (e && e.message ? e.message : String(e)));
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const code = pick(obj, ["code", "resultCode", "errorCode"], 0);
|
|
123
|
+
if (Number(code) !== 0) {
|
|
124
|
+
this._setLastError(
|
|
125
|
+
Number(code),
|
|
126
|
+
pick(obj, ["message", "msg", "desc", "errorMsg"], `code ${code}`).toString(),
|
|
127
|
+
);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
this._clearLastError();
|
|
131
|
+
const data = pick(obj, ["data", "result", "value"]);
|
|
132
|
+
return data !== null ? data : obj;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 用户信息 → { uid, nickname } or null. */
|
|
136
|
+
async getUserInfo(cookie) {
|
|
137
|
+
const data = await this._doGetJson(`${this.baseUrl}${this.userInfoPath}`, cookie);
|
|
138
|
+
if (data === null) return null;
|
|
139
|
+
const u = pick(data, ["user", "userInfo", "account"], data);
|
|
140
|
+
const uid = pick(u, ["uid", "userId", "accountId"]);
|
|
141
|
+
return {
|
|
142
|
+
uid: uid != null ? String(uid) : null,
|
|
143
|
+
nickname: pick(u, ["nickName", "nickname", "displayName", "userName", "name"]),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 课程学习记录 → [{ recordId, course, durationMs, startAt }]. null on error.
|
|
149
|
+
* @param {string} cookie
|
|
150
|
+
* @param {object} [opts] { limit, offset }
|
|
151
|
+
*/
|
|
152
|
+
async getStudyRecords(cookie, opts = {}) {
|
|
153
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 20;
|
|
154
|
+
const offset = Number.isInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
|
|
155
|
+
const url = `${this.baseUrl}${this.studyRecordsPath}?offset=${offset}&limit=${limit}`;
|
|
156
|
+
const data = await this._doGetJson(url, cookie);
|
|
157
|
+
if (data === null) return null;
|
|
158
|
+
const list = pick(data, ["records", "list", "items"], Array.isArray(data) ? data : []);
|
|
159
|
+
if (!Array.isArray(list)) return [];
|
|
160
|
+
return list.map((r) => ({
|
|
161
|
+
recordId: pick(r, ["recordId", "id", "logId"]),
|
|
162
|
+
course: pick(r, ["courseName", "course", "title", "name"]),
|
|
163
|
+
durationMs: toDurationMs(
|
|
164
|
+
pick(r, ["studyDuration", "duration", "learnTime", "durationMs"], 0),
|
|
165
|
+
),
|
|
166
|
+
startAt: toEpochMs(pick(r, ["startTime", "studyTime", "createTime", "beginTime"])),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* High-level: user info + study records → snapshot-shaped { account, events }
|
|
172
|
+
* so the adapter normalize path is unchanged.
|
|
173
|
+
* @returns {Promise<{account, events}|null>}
|
|
174
|
+
*/
|
|
175
|
+
async fetchSnapshot(cookie, opts = {}) {
|
|
176
|
+
if (!this.hasSession(cookie)) return null; // lastError already set
|
|
177
|
+
const include = opts.include || {};
|
|
178
|
+
const events = [];
|
|
179
|
+
let account = null;
|
|
180
|
+
|
|
181
|
+
if (include.profile !== false) {
|
|
182
|
+
const user = await this.getUserInfo(cookie);
|
|
183
|
+
if (user === null) return null;
|
|
184
|
+
account = { uid: user.uid, displayName: user.nickname };
|
|
185
|
+
events.push({
|
|
186
|
+
kind: "profile",
|
|
187
|
+
id: user.uid ? `profile-${user.uid}` : null,
|
|
188
|
+
uid: user.uid,
|
|
189
|
+
nickname: user.nickname,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (include.study !== false) {
|
|
194
|
+
const records = await this.getStudyRecords(cookie, {
|
|
195
|
+
limit: opts.limit,
|
|
196
|
+
offset: opts.offset,
|
|
197
|
+
});
|
|
198
|
+
if (records === null) return null;
|
|
199
|
+
for (const r of records) {
|
|
200
|
+
events.push({
|
|
201
|
+
kind: "study",
|
|
202
|
+
id: r.recordId ? `study-${r.recordId}` : null,
|
|
203
|
+
course: r.course,
|
|
204
|
+
durationMs: r.durationMs,
|
|
205
|
+
startAt: r.startAt,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this._clearLastError();
|
|
211
|
+
return { account, events };
|
|
212
|
+
}
|
|
45
213
|
}
|
|
46
214
|
|
|
47
|
-
module.exports = {
|
|
215
|
+
module.exports = {
|
|
216
|
+
HuaweiLearningApiClient,
|
|
217
|
+
// Exported for tests / endpoint introspection.
|
|
218
|
+
PATH_USER_INFO,
|
|
219
|
+
PATH_STUDY_RECORDS,
|
|
220
|
+
};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FAMILY-23
|
|
2
|
+
* FAMILY-23 — 华为学习中心 (Huawei Learning Center) adapter.
|
|
3
3
|
*
|
|
4
|
-
* 家庭守护 telemetry
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 家庭守护 telemetry:家长看孩子的课程/学习时长。两路互补:
|
|
5
|
+
* - snapshot 模式(inputPath):手机端 collector 快照 (profile + study-session)。
|
|
6
|
+
* - **live 模式(cookie,v0.2 接通)**:[HuaweiLearningApiClient.fetchSnapshot]
|
|
7
|
+
* 经学习中心接口(华为账号 web 会话 cookie)拉 用户信息 + 课程学习记录。
|
|
8
|
+
* 端点/字段无公开稳定文档,按 hicloud 教育服务常见形态实现 + 多字段名兼容,
|
|
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 { HuaweiLearningApiClient } = require("./api-client");
|
|
27
30
|
|
|
28
31
|
const NAME = "edu-huawei-learning";
|
|
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 HuaweiLearningAdapter {
|
|
|
60
63
|
this.version = VERSION;
|
|
61
64
|
this.capabilities = [
|
|
62
65
|
"sync:snapshot",
|
|
66
|
+
"sync:cookie",
|
|
63
67
|
"parse:huawei-learning-profile",
|
|
64
68
|
"parse:huawei-learning-study-session",
|
|
65
69
|
];
|
|
@@ -74,7 +78,10 @@ class HuaweiLearningAdapter {
|
|
|
74
78
|
legalGate: false,
|
|
75
79
|
defaultInclude: { profile: true, study: true },
|
|
76
80
|
};
|
|
77
|
-
this.apiClient = new HuaweiLearningApiClient();
|
|
81
|
+
this.apiClient = new HuaweiLearningApiClient(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 HuaweiLearningAdapter {
|
|
|
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-huawei-learning.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-huawei-learning.authenticate:
|
|
115
|
+
"edu-huawei-learning.authenticate: needs opts.inputPath (snapshot mode) or opts.cookie (华为账号会话, live fetch)",
|
|
99
116
|
};
|
|
100
117
|
}
|
|
101
118
|
|
|
@@ -108,11 +125,68 @@ class HuaweiLearningAdapter {
|
|
|
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-huawei-learning.sync:
|
|
133
|
+
"edu-huawei-learning.sync: needs opts.inputPath (snapshot mode) or opts.cookie (华为账号会话, 课程学习记录 live fetch)",
|
|
113
134
|
);
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
async *_syncViaLive(opts) {
|
|
138
|
+
const client = this._apiClientFactory
|
|
139
|
+
? this._apiClientFactory(opts)
|
|
140
|
+
: new HuaweiLearningApiClient({
|
|
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-huawei-learning.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,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);
|