@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
TencentMapAdapter,
|
|
11
|
+
NAME,
|
|
12
|
+
VERSION,
|
|
13
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
14
|
+
} = require("../../lib/adapters/travel-tencent-map");
|
|
15
|
+
|
|
16
|
+
function writeTmp(content, ext = "json") {
|
|
17
|
+
const p = path.join(
|
|
18
|
+
os.tmpdir(),
|
|
19
|
+
`cc-tencentmap-test-${crypto.randomUUID()}.${ext}`,
|
|
20
|
+
);
|
|
21
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function collect(gen) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for await (const x of gen) out.push(x);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeFakeDriverFactory(tables, log = {}) {
|
|
32
|
+
return () =>
|
|
33
|
+
class FakeDb {
|
|
34
|
+
constructor(dbPath, opts) {
|
|
35
|
+
log.opened = { dbPath, opts };
|
|
36
|
+
}
|
|
37
|
+
prepare(sql) {
|
|
38
|
+
for (const [needle, rows] of Object.entries(tables)) {
|
|
39
|
+
if (sql.includes(needle)) return { all: () => rows };
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`no such table in: ${sql}`);
|
|
42
|
+
}
|
|
43
|
+
close() {
|
|
44
|
+
log.closed = true;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SNAPSHOT = {
|
|
50
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
51
|
+
snapshottedAt: 1716383021000,
|
|
52
|
+
vendor: "tencent-map",
|
|
53
|
+
account: { uid: "U1" },
|
|
54
|
+
events: [
|
|
55
|
+
{
|
|
56
|
+
kind: "favourite",
|
|
57
|
+
id: "fav-1",
|
|
58
|
+
capturedAt: 1716383021000,
|
|
59
|
+
name: "公司",
|
|
60
|
+
lat: 31.2,
|
|
61
|
+
lng: 121.44,
|
|
62
|
+
category: "company",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: "route",
|
|
66
|
+
id: "route-2",
|
|
67
|
+
capturedAt: 1716383021000,
|
|
68
|
+
from: { name: "公司" },
|
|
69
|
+
to: { name: "体育馆" },
|
|
70
|
+
mode: "bike",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe("constants", () => {
|
|
76
|
+
it("exposes name/version/schema", () => {
|
|
77
|
+
expect(NAME).toBe("travel-tencent-map");
|
|
78
|
+
expect(VERSION).toBe("0.2.0");
|
|
79
|
+
expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("authenticate", () => {
|
|
84
|
+
it("mirrors baidu: snapshot / sqlite-needs-deviceId / NO_INPUT", async () => {
|
|
85
|
+
const p = writeTmp("{}");
|
|
86
|
+
try {
|
|
87
|
+
const a = new TencentMapAdapter();
|
|
88
|
+
expect((await a.authenticate({ inputPath: p })).mode).toBe(
|
|
89
|
+
"snapshot-file",
|
|
90
|
+
);
|
|
91
|
+
expect(
|
|
92
|
+
(
|
|
93
|
+
await new TencentMapAdapter({ dbPath: "x.db" }).authenticate({})
|
|
94
|
+
).reason,
|
|
95
|
+
).toBe("NO_ACCOUNT_DEVICE_ID");
|
|
96
|
+
expect((await a.authenticate({})).reason).toBe("NO_INPUT");
|
|
97
|
+
} finally {
|
|
98
|
+
fs.unlinkSync(p);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("sync — snapshot mode", () => {
|
|
104
|
+
it("yields events with tencent-map: prefixed originalId", async () => {
|
|
105
|
+
const p = writeTmp(JSON.stringify(SNAPSHOT));
|
|
106
|
+
try {
|
|
107
|
+
const a = new TencentMapAdapter();
|
|
108
|
+
const items = await collect(a.sync({ inputPath: p }));
|
|
109
|
+
expect(items.map((i) => i.originalId)).toEqual([
|
|
110
|
+
"tencent-map:favourite:fav-1",
|
|
111
|
+
"tencent-map:route:route-2",
|
|
112
|
+
]);
|
|
113
|
+
expect(items[0].payload.account).toEqual({ uid: "U1" });
|
|
114
|
+
} finally {
|
|
115
|
+
fs.unlinkSync(p);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("throws on schemaVersion mismatch", async () => {
|
|
120
|
+
const p = writeTmp(JSON.stringify({ schemaVersion: 2, events: [] }));
|
|
121
|
+
try {
|
|
122
|
+
await expect(
|
|
123
|
+
collect(new TencentMapAdapter().sync({ inputPath: p })),
|
|
124
|
+
).rejects.toThrow(/schemaVersion mismatch/);
|
|
125
|
+
} finally {
|
|
126
|
+
fs.unlinkSync(p);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("sync — sqlite mode (fake driver)", () => {
|
|
132
|
+
it("yields rows + falls back to tencent_* legacy tables (keyword alias)", async () => {
|
|
133
|
+
const p = writeTmp("fake", "db");
|
|
134
|
+
const log = {};
|
|
135
|
+
try {
|
|
136
|
+
const a = new TencentMapAdapter({
|
|
137
|
+
dbPath: p,
|
|
138
|
+
account: { deviceId: "DEV1" },
|
|
139
|
+
dbDriverFactory: makeFakeDriverFactory(
|
|
140
|
+
{
|
|
141
|
+
tencent_route_history: [
|
|
142
|
+
{
|
|
143
|
+
_id: 5,
|
|
144
|
+
mode: "walk",
|
|
145
|
+
start_name: "家",
|
|
146
|
+
end_name: "菜场",
|
|
147
|
+
time: 1716383021,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
tencent_search_history: [
|
|
151
|
+
{ _id: 6, keyword: "奶茶", city: "深圳", time: 1716383021000 },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
log,
|
|
155
|
+
),
|
|
156
|
+
});
|
|
157
|
+
const items = await collect(a.sync({}));
|
|
158
|
+
expect(items).toHaveLength(2);
|
|
159
|
+
expect(items[0].payload.record).toMatchObject({
|
|
160
|
+
vendorId: "tencentmap",
|
|
161
|
+
recordId: "route-5",
|
|
162
|
+
vehicleType: "walk",
|
|
163
|
+
carrier: "腾讯地图",
|
|
164
|
+
departureMs: 1716383021 * 1000,
|
|
165
|
+
});
|
|
166
|
+
// searchRowToRecord accepts the tencent `keyword` alias
|
|
167
|
+
expect(items[1].payload.record.to).toMatchObject({
|
|
168
|
+
name: "奶茶",
|
|
169
|
+
city: "深圳",
|
|
170
|
+
});
|
|
171
|
+
expect(log.closed).toBe(true);
|
|
172
|
+
} finally {
|
|
173
|
+
fs.unlinkSync(p);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("requires account.deviceId at sync time", async () => {
|
|
178
|
+
const a = new TencentMapAdapter({ dbPath: "x.db" });
|
|
179
|
+
await expect(collect(a.sync({}))).rejects.toThrow(
|
|
180
|
+
/account\.deviceId required/,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("normalize", () => {
|
|
186
|
+
it("snapshot route → bike trip titled by place names", async () => {
|
|
187
|
+
const p = writeTmp(JSON.stringify(SNAPSHOT));
|
|
188
|
+
try {
|
|
189
|
+
const a = new TencentMapAdapter();
|
|
190
|
+
const [fav, route] = await collect(a.sync({ inputPath: p }));
|
|
191
|
+
expect(a.normalize(fav).events[0].content.title).toBe("visit: → 公司");
|
|
192
|
+
const batch = a.normalize(route);
|
|
193
|
+
expect(batch.events[0].content.title).toBe("bike: 公司 → 体育馆");
|
|
194
|
+
expect(
|
|
195
|
+
batch.persons.find((x) => x.subtype === "merchant").names,
|
|
196
|
+
).toEqual(["腾讯地图"]);
|
|
197
|
+
} finally {
|
|
198
|
+
fs.unlinkSync(p);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("throws on missing payload", () => {
|
|
203
|
+
expect(() => new TencentMapAdapter().normalize(null)).toThrow(
|
|
204
|
+
/payload missing/,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared field-extraction helpers for FAMILY-23 live JSON fetchers
|
|
3
|
+
* (edu-zuoyebang / edu-huawei-learning / finance-alipay).
|
|
4
|
+
*
|
|
5
|
+
* Same semantics as the locals in game-honor-of-kings/api-client.js — kept
|
|
6
|
+
* separate so existing clients (KOH / genshin) stay untouched; new live
|
|
7
|
+
* clients require from here instead of re-duplicating.
|
|
8
|
+
*/
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
/** First present, non-empty value among `keys` on `obj`. */
|
|
12
|
+
function pick(obj, keys, fallback = null) {
|
|
13
|
+
if (!obj || typeof obj !== "object") return fallback;
|
|
14
|
+
for (const k of keys) {
|
|
15
|
+
const v = obj[k];
|
|
16
|
+
if (v !== undefined && v !== null && v !== "") return v;
|
|
17
|
+
}
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Coerce a seconds-or-ms duration to ms. Session lengths separate cleanly:
|
|
23
|
+
* seconds form is ≤ ~7200 (2h), ms form is ≥ ~300000 (5min) — a 1e5 threshold
|
|
24
|
+
* disambiguates without overlap.
|
|
25
|
+
*/
|
|
26
|
+
function toDurationMs(v) {
|
|
27
|
+
if (!Number.isFinite(v)) {
|
|
28
|
+
const n = typeof v === "string" && /^\d+$/.test(v) ? parseInt(v, 10) : NaN;
|
|
29
|
+
if (!Number.isFinite(n)) return 0;
|
|
30
|
+
v = n;
|
|
31
|
+
}
|
|
32
|
+
if (v <= 0) return 0;
|
|
33
|
+
return v < 1e5 ? v * 1000 : v;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Coerce epoch seconds-or-ms (or date string) to ms, else null. */
|
|
37
|
+
function toEpochMs(v) {
|
|
38
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v * 1000;
|
|
39
|
+
if (typeof v === "string") {
|
|
40
|
+
if (/^\d+$/.test(v)) {
|
|
41
|
+
const n = parseInt(v, 10);
|
|
42
|
+
return n > 1e12 ? n : n * 1000;
|
|
43
|
+
}
|
|
44
|
+
const t = Date.parse(v);
|
|
45
|
+
return Number.isFinite(t) ? t : null;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { pick, toDurationMs, toEpochMs };
|
|
@@ -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);
|