@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.
Files changed (41) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/netease-music-live.test.js +244 -0
  9. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  10. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  11. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  12. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  13. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  14. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  15. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  16. package/lib/adapters/_live-json-helpers.js +50 -0
  17. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  18. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  19. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  20. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  21. package/lib/adapters/finance-alipay/api-client.js +268 -6
  22. package/lib/adapters/finance-alipay/index.js +85 -9
  23. package/lib/adapters/game-genshin/api-client.js +207 -6
  24. package/lib/adapters/game-genshin/index.js +90 -9
  25. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  26. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  27. package/lib/adapters/netease-music/api-client.js +284 -0
  28. package/lib/adapters/netease-music/index.js +85 -9
  29. package/lib/adapters/social-douyin/index.js +2 -0
  30. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  31. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  32. package/lib/adapters/social-douyin-adb/index.js +18 -1
  33. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  34. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  35. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  36. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  37. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  38. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  39. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  40. package/lib/index.js +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Douyin watch-history local-DB reader + collector tests (real-device-driven
3
+ * 2026-06-11, device 5lhyaqu8lbwstc6x: video_record.db record_<uid> = 900 rows).
4
+ *
5
+ * Validates the plaintext video_record.db path that sidesteps the encrypted IM
6
+ * db + X-Bogus signing. adb + sqlite injected (no device / native driver).
7
+ */
8
+ "use strict";
9
+
10
+ import { describe, it, expect, vi } from "vitest";
11
+
12
+ const {
13
+ createDouyinWatchExtension,
14
+ VIDEO_RECORD_DB_REMOTE_PATH,
15
+ _internals,
16
+ } = require("../../lib/adapters/social-douyin-adb/watch-history-reader");
17
+ const {
18
+ collectWatchHistory,
19
+ } = require("../../lib/adapters/social-douyin-adb/collector");
20
+ const { DouyinAdapter } = require("../../lib/adapters/social-douyin");
21
+ const { partitionBatch } = require("../../lib/batch");
22
+
23
+ // ── readDouyinWatchHistory (injected Database) ────────────────────────
24
+ function makeFakeDb(tablesToRows) {
25
+ return class FakeDb {
26
+ constructor() {}
27
+ prepare(sql) {
28
+ return {
29
+ all: () => {
30
+ if (/sqlite_master/.test(sql)) {
31
+ return Object.keys(tablesToRows).map((name) => ({ name }));
32
+ }
33
+ const tm = /FROM "([^"]+)"/.exec(sql);
34
+ if (tm && /table_info/.test(sql) === false && /COUNT/.test(sql) === false) {
35
+ return tablesToRows[tm[1]] || [];
36
+ }
37
+ if (/table_info/.test(sql)) {
38
+ return [
39
+ { name: "aid" },
40
+ { name: "view_time_timestamp" },
41
+ { name: "enter_from" },
42
+ ];
43
+ }
44
+ return [];
45
+ },
46
+ get: () => {
47
+ const tm = /FROM "([^"]+)"/.exec(sql);
48
+ if (/COUNT/.test(sql) && tm) return { c: (tablesToRows[tm[1]] || []).length };
49
+ return undefined;
50
+ },
51
+ };
52
+ }
53
+ close() {}
54
+ };
55
+ }
56
+
57
+ describe("readDouyinWatchHistory", () => {
58
+ it("picks the largest record_<uid> table (skips record_0), parses rows → ms", () => {
59
+ const Db = makeFakeDb({
60
+ record_0: [{ aid: "x", view_time_timestamp: 1, enter_from: "a" }],
61
+ record_92585448288: [
62
+ { aid: "7480000000000000001", view_time_timestamp: 1717800000, enter_from: "homepage_hot" },
63
+ { aid: "7480000000000000002", view_time_timestamp: 1717800600, enter_from: "homepage_follow" },
64
+ ],
65
+ });
66
+ const r = _internals.readDouyinWatchHistory("x.db", { _databaseClass: Db });
67
+ expect(r.uid).toBe("92585448288");
68
+ expect(r.records).toHaveLength(2);
69
+ expect(r.records[0]).toEqual({
70
+ awemeId: "7480000000000000001",
71
+ capturedAt: 1717800000 * 1000, // seconds → ms
72
+ enterFrom: "homepage_hot",
73
+ });
74
+ });
75
+
76
+ it("returns {uid:null, records:[]} when only the anonymous record_0 exists", () => {
77
+ const Db = makeFakeDb({ record_0: [{ aid: "x", view_time_timestamp: 1 }] });
78
+ const r = _internals.readDouyinWatchHistory("x.db", { _databaseClass: Db });
79
+ expect(r.uid).toBe(null);
80
+ expect(r.records).toEqual([]);
81
+ });
82
+
83
+ it("toEpochMs treats >1e12 as ms, else seconds; rejects junk", () => {
84
+ expect(_internals.toEpochMs(1717800000)).toBe(1717800000000);
85
+ expect(_internals.toEpochMs(1717800000000)).toBe(1717800000000);
86
+ expect(_internals.toEpochMs(0)).toBe(null);
87
+ expect(_internals.toEpochMs("nope")).toBe(null);
88
+ });
89
+ });
90
+
91
+ // ── pullVideoRecordDbViaSu (injected adb) ─────────────────────────────
92
+ function makeAdb({ ls, pm, id, b64 }) {
93
+ return async (args) => {
94
+ const cmd = args.join(" ");
95
+ if (cmd.includes("pm list packages")) return pm || "";
96
+ if (cmd.includes("ls ")) return ls;
97
+ if (cmd.includes("id -u")) return id;
98
+ if (cmd.includes("base64 ")) return b64;
99
+ throw new Error("fake adb: unexpected " + cmd);
100
+ };
101
+ }
102
+
103
+ describe("pullVideoRecordDbViaSu — diagnosis", () => {
104
+ it("path constant points at video_record.db", () => {
105
+ expect(VIDEO_RECORD_DB_REMOTE_PATH).toBe(
106
+ "/data/data/com.ss.android.ugc.aweme/databases/video_record.db",
107
+ );
108
+ });
109
+
110
+ it("missing db + installed → DOUYIN_VIDEO_RECORD_MISSING", async () => {
111
+ const adb = makeAdb({ ls: "NOT_FOUND\r\n", pm: "package:com.ss.android.ugc.aweme\r\n" });
112
+ await expect(_internals.pullVideoRecordDbViaSu(adb, "s", {})).rejects.toThrow(
113
+ /DOUYIN_VIDEO_RECORD_MISSING/,
114
+ );
115
+ });
116
+
117
+ it("missing db + not installed → DOUYIN_NOT_INSTALLED", async () => {
118
+ const adb = makeAdb({ ls: "NOT_FOUND\r\n", pm: "" });
119
+ await expect(_internals.pullVideoRecordDbViaSu(adb, "s", {})).rejects.toThrow(
120
+ /DOUYIN_NOT_INSTALLED/,
121
+ );
122
+ });
123
+
124
+ it("non-root → DOUYIN_NO_ROOT", async () => {
125
+ const adb = makeAdb({ ls: VIDEO_RECORD_DB_REMOTE_PATH, id: "2000\r\n" });
126
+ await expect(_internals.pullVideoRecordDbViaSu(adb, "s", {})).rejects.toThrow(/DOUYIN_NO_ROOT/);
127
+ });
128
+
129
+ it("non-sqlite payload → DOUYIN_VIDEO_RECORD_NOT_SQLITE", async () => {
130
+ const buf = Buffer.alloc(2048, 0x41);
131
+ const adb = makeAdb({ ls: VIDEO_RECORD_DB_REMOTE_PATH, id: "uid=0(root)", b64: buf.toString("base64") });
132
+ await expect(_internals.pullVideoRecordDbViaSu(adb, "s", {})).rejects.toThrow(
133
+ /DOUYIN_VIDEO_RECORD_NOT_SQLITE/,
134
+ );
135
+ });
136
+ });
137
+
138
+ // ── collectWatchHistory + normalize round-trip ────────────────────────
139
+ describe("collectWatchHistory → social-douyin history events", () => {
140
+ it("builds a snapshot of history events; normalize → valid BROWSE batch with enterFrom", async () => {
141
+ const fs = require("node:fs");
142
+ const os = require("node:os");
143
+ const bridge = {
144
+ invoke: vi.fn(async (m) => {
145
+ if (m === "douyin.watch-history") {
146
+ return {
147
+ uid: "92585448288",
148
+ records: [
149
+ { awemeId: "7480000000000000001", capturedAt: 1717800000000, enterFrom: "homepage_hot" },
150
+ { awemeId: "7480000000000000002", capturedAt: 1717800600000, enterFrom: "homepage_follow" },
151
+ ],
152
+ };
153
+ }
154
+ throw new Error("unknown " + m);
155
+ }),
156
+ };
157
+ const r = await collectWatchHistory(bridge, { stagingDir: os.tmpdir(), now: () => 1717900000000 });
158
+ expect(r.uid).toBe("92585448288");
159
+ expect(r.eventCounts.history).toBe(2);
160
+ // The written snapshot ingests through the real adapter normalize.
161
+ const snap = JSON.parse(fs.readFileSync(r.snapshotPath, "utf-8"));
162
+ try {
163
+ expect(snap.events).toHaveLength(2);
164
+ expect(snap.events[0].kind).toBe("history");
165
+ const a = new DouyinAdapter();
166
+ const batch = a.normalize({
167
+ adapter: "social-douyin",
168
+ kind: "history",
169
+ originalId: "douyin:history:1",
170
+ capturedAt: 1717800000000,
171
+ payload: { ...snap.events[0], account: snap.account },
172
+ });
173
+ expect(partitionBatch(batch).invalidReasons).toHaveLength(0);
174
+ expect(batch.events[0].subtype).toBe("browse");
175
+ expect(batch.events[0].extra.awemeId).toBe("7480000000000000001");
176
+ expect(batch.events[0].extra.enterFrom).toBe("homepage_hot");
177
+ } finally {
178
+ fs.unlinkSync(r.snapshotPath);
179
+ }
180
+ });
181
+
182
+ it("throws on malformed bridge payload (no records array)", async () => {
183
+ const bridge = { invoke: vi.fn(async () => ({ uid: "1" })) };
184
+ await expect(collectWatchHistory(bridge, {})).rejects.toThrow(/malformed payload/);
185
+ });
186
+ });
187
+
188
+ describe("createDouyinWatchExtension contract", () => {
189
+ it("rejects when ctx lacks {adb, pickDevice}", async () => {
190
+ await expect(createDouyinWatchExtension()({}, {})).rejects.toThrow(/ctx must provide/);
191
+ });
192
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Toutiao on-device account_db reader tests (real-device-driven 2026-06-11).
3
+ *
4
+ * Validates the path that unblocks Toutiao when the web profile endpoint is
5
+ * permission-denied (error_code 16) and the cookie jar has no numeric uid:
6
+ * read uid+nickname+sec_uid from the plaintext local account_db.login_info.
7
+ * adb + sqlite are injected (no device / no native driver needed).
8
+ */
9
+ "use strict";
10
+
11
+ import { describe, it, expect } from "vitest";
12
+
13
+ const {
14
+ createToutiaoAccountExtension,
15
+ ACCOUNT_DB_REMOTE_PATH,
16
+ _internals,
17
+ } = require("../../lib/adapters/social-toutiao-adb/account-reader");
18
+
19
+ // ── readToutiaoAccount (injected Database class) ──────────────────────
20
+ function makeFakeDb(rows, { hasTime = true } = {}) {
21
+ return class FakeDb {
22
+ constructor() {}
23
+ prepare(sql) {
24
+ return {
25
+ all: () => {
26
+ if (/table_info\(login_info\)/.test(sql)) {
27
+ const cols = ["uid", "screen_name", "sec_uid", "platform_screen_name"];
28
+ if (hasTime) cols.push("time");
29
+ return cols.map((name) => ({ name }));
30
+ }
31
+ if (/FROM login_info/.test(sql)) return rows;
32
+ return [];
33
+ },
34
+ get: () => undefined,
35
+ };
36
+ }
37
+ close() {}
38
+ };
39
+ }
40
+
41
+ describe("readToutiaoAccount", () => {
42
+ it("picks the numeric-uid row → {uid, nickname, secUid}", () => {
43
+ const Db = makeFakeDb([
44
+ { uid: "92585279158", screen_name: "小明", sec_uid: "MS4wLjAB", time: 200 },
45
+ ]);
46
+ const r = _internals.readToutiaoAccount("x.db", { _databaseClass: Db });
47
+ expect(r).toEqual({ uid: "92585279158", nickname: "小明", secUid: "MS4wLjAB" });
48
+ });
49
+
50
+ it("skips guest/zero/non-numeric uid rows, takes first valid", () => {
51
+ const Db = makeFakeDb([
52
+ { uid: "0", screen_name: "guest", time: 300 },
53
+ { uid: "abc", screen_name: "bad", time: 250 },
54
+ { uid: "555", screen_name: "real", sec_uid: null, time: 200 },
55
+ ]);
56
+ const r = _internals.readToutiaoAccount("x.db", { _databaseClass: Db });
57
+ expect(r.uid).toBe("555");
58
+ expect(r.nickname).toBe("real");
59
+ expect(r.secUid).toBe(null);
60
+ });
61
+
62
+ it("falls back to platform_screen_name when screen_name empty", () => {
63
+ const Db = makeFakeDb([
64
+ { uid: "7", screen_name: "", platform_screen_name: "plat", time: 1 },
65
+ ]);
66
+ expect(_internals.readToutiaoAccount("x.db", { _databaseClass: Db }).nickname).toBe("plat");
67
+ });
68
+
69
+ it("returns null when no numeric-uid row exists", () => {
70
+ const Db = makeFakeDb([{ uid: "0" }, { uid: "" }]);
71
+ expect(_internals.readToutiaoAccount("x.db", { _databaseClass: Db })).toBe(null);
72
+ });
73
+ });
74
+
75
+ // ── pullAccountDbViaSu (injected adb) ─────────────────────────────────
76
+ function makeAdb({ ls, pm, id, b64 }) {
77
+ return async (args) => {
78
+ const cmd = args.join(" ");
79
+ if (cmd.includes("pm list packages")) return pm || "";
80
+ if (cmd.includes("ls ")) return ls;
81
+ if (cmd.includes("id -u")) return id;
82
+ if (cmd.includes("base64 ")) return b64;
83
+ throw new Error("fake adb: unexpected " + cmd);
84
+ };
85
+ }
86
+
87
+ describe("pullAccountDbViaSu — diagnosis", () => {
88
+ it("constant points at the toutiao account_db", () => {
89
+ expect(ACCOUNT_DB_REMOTE_PATH).toBe(
90
+ "/data/data/com.ss.android.article.news/databases/account_db",
91
+ );
92
+ });
93
+
94
+ it("missing db + package installed → TOUTIAO_ACCOUNT_DB_MISSING", async () => {
95
+ const adb = makeAdb({ ls: "NOT_FOUND\r\n", pm: "package:com.ss.android.article.news\r\n" });
96
+ await expect(_internals.pullAccountDbViaSu(adb, "s", {})).rejects.toThrow(
97
+ /TOUTIAO_ACCOUNT_DB_MISSING/,
98
+ );
99
+ });
100
+
101
+ it("missing db + package not installed → TOUTIAO_NOT_INSTALLED", async () => {
102
+ const adb = makeAdb({ ls: "NOT_FOUND\r\n", pm: "" });
103
+ await expect(_internals.pullAccountDbViaSu(adb, "s", {})).rejects.toThrow(
104
+ /TOUTIAO_NOT_INSTALLED/,
105
+ );
106
+ });
107
+
108
+ it("non-root su → TOUTIAO_NO_ROOT", async () => {
109
+ const adb = makeAdb({ ls: ACCOUNT_DB_REMOTE_PATH, id: "2000\r\n" });
110
+ await expect(_internals.pullAccountDbViaSu(adb, "s", {})).rejects.toThrow(/TOUTIAO_NO_ROOT/);
111
+ });
112
+
113
+ it("empty base64 stream → TOUTIAO_ACCOUNT_DB_EMPTY", async () => {
114
+ const adb = makeAdb({ ls: ACCOUNT_DB_REMOTE_PATH, id: "0", b64: " \r\n" });
115
+ await expect(_internals.pullAccountDbViaSu(adb, "s", {})).rejects.toThrow(
116
+ /TOUTIAO_ACCOUNT_DB_EMPTY/,
117
+ );
118
+ });
119
+
120
+ it("non-sqlite payload → TOUTIAO_ACCOUNT_DB_NOT_SQLITE", async () => {
121
+ const buf = Buffer.alloc(2048, 0x41); // "AAAA…"
122
+ const adb = makeAdb({ ls: ACCOUNT_DB_REMOTE_PATH, id: "uid=0(root)", b64: buf.toString("base64") });
123
+ await expect(_internals.pullAccountDbViaSu(adb, "s", {})).rejects.toThrow(
124
+ /TOUTIAO_ACCOUNT_DB_NOT_SQLITE/,
125
+ );
126
+ });
127
+ });
128
+
129
+ // ── extension contract ────────────────────────────────────────────────
130
+ describe("createToutiaoAccountExtension", () => {
131
+ it("rejects when ctx lacks {adb, pickDevice}", async () => {
132
+ const ext = createToutiaoAccountExtension();
133
+ await expect(ext({}, {})).rejects.toThrow(/ctx must provide/);
134
+ });
135
+ });
@@ -280,6 +280,56 @@ describe("ToutiaoApiClient — fetchProfile", () => {
280
280
  expect(await c.fetchProfile("sessionid=abc")).toBe(null);
281
281
  expect(c.lastErrorCode).toBe(-4);
282
282
  });
283
+
284
+ // ── passport v2 envelope (real-device 2026-06-11, no status_code) ──
285
+ it("parses passport-v2 success envelope { message:'success', data } (no status_code)", async () => {
286
+ const { fakeFetch } = makeFakeFetch([
287
+ [
288
+ "passport/account/info/v2",
289
+ {
290
+ body: JSON.stringify({
291
+ message: "success",
292
+ data: { user_id: "555", screen_name: "v2user" },
293
+ }),
294
+ },
295
+ ],
296
+ ]);
297
+ const c = new ToutiaoApiClient({ fetch: fakeFetch });
298
+ const p = await c.fetchProfile("sessionid=abc");
299
+ expect(p).not.toBe(null);
300
+ expect(p.uid).toBe("555");
301
+ expect(p.nickname).toBe("v2user");
302
+ });
303
+
304
+ it("surfaces passport-v2 error envelope error_code + 中文 description (error_code 16 该应用无权限)", async () => {
305
+ // Verified on device 5lhyaqu8lbwstc6x with a fully logged-in Toutiao:
306
+ // the endpoint now returns this even with valid sessionid cookies. The
307
+ // old code mis-reported it as "missing status_code".
308
+ const { fakeFetch } = makeFakeFetch([
309
+ [
310
+ "passport/account/info/v2",
311
+ {
312
+ body: JSON.stringify({
313
+ message: "error",
314
+ data: { error_code: 16, description: "该应用无权限", captcha: "" },
315
+ }),
316
+ },
317
+ ],
318
+ ]);
319
+ const c = new ToutiaoApiClient({ fetch: fakeFetch });
320
+ expect(await c.fetchProfile("sessionid=abc")).toBe(null);
321
+ expect(c.lastErrorCode).toBe(16);
322
+ expect(c.lastErrorMessage).toBe("该应用无权限");
323
+ });
324
+
325
+ it("unrecognized envelope (no status_code, no message) → -5 with key list", async () => {
326
+ const { fakeFetch } = makeFakeFetch([
327
+ ["passport/account/info/v2", { body: JSON.stringify({ foo: "bar" }) }],
328
+ ]);
329
+ const c = new ToutiaoApiClient({ fetch: fakeFetch });
330
+ expect(await c.fetchProfile("sessionid=abc")).toBe(null);
331
+ expect(c.lastErrorCode).toBe(-5);
332
+ });
283
333
  });
284
334
 
285
335
  describe("ToutiaoApiClient — fetchFeed", () => {
@@ -535,3 +585,42 @@ describe("normalizeMs", () => {
535
585
  expect(_internals.normalizeMs(NaN)).toBe(0);
536
586
  });
537
587
  });
588
+
589
+ describe("err_no surfacing (HTTP 200 + err_no!=0 must NOT mask as empty)", () => {
590
+ it("fetchCollection: {err_no:1,'params illegal',data:[]} → [] + lastErrorCode 1", async () => {
591
+ // Real-device 2026-06-11: tab_comments returned this; the old code saw
592
+ // data:[] and reported 0 results with errCode 0, hiding the real failure.
593
+ const { fakeFetch } = makeFakeFetch([
594
+ [
595
+ "article/v2/tab_comments",
596
+ { body: JSON.stringify({ message: "params illegal", err_no: 1, data: [] }) },
597
+ ],
598
+ ]);
599
+ const sign = {
600
+ signUrl: vi.fn(async (url) => {
601
+ const u = new URL(String(url));
602
+ u.searchParams.set("_signature", "X");
603
+ return u;
604
+ }),
605
+ };
606
+ const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
607
+ const items = await c.fetchCollection("sessionid=abc");
608
+ expect(items).toEqual([]);
609
+ expect(c.lastErrorCode).toBe(1);
610
+ expect(c.lastErrorMessage).toBe("params illegal");
611
+ });
612
+
613
+ it("err_no:0 is treated as success (not masked)", async () => {
614
+ const { fakeFetch } = makeFakeFetch([
615
+ [
616
+ "article/v2/tab_comments",
617
+ { body: JSON.stringify({ err_no: 0, data: [{ group_id: "C1", title: "Saved", behot_time: 1700000000 }] }) },
618
+ ],
619
+ ]);
620
+ const sign = { signUrl: vi.fn(async (url) => { const u = new URL(String(url)); u.searchParams.set("_signature", "X"); return u; }) };
621
+ const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
622
+ const items = await c.fetchCollection("sessionid=abc");
623
+ expect(items).toHaveLength(1);
624
+ expect(items[0].itemId).toBe("C1");
625
+ });
626
+ });
@@ -71,9 +71,19 @@ const HAPPY_RESPONSES = [
71
71
  ],
72
72
  ];
73
73
 
74
- function makeBridge(invokeResult) {
74
+ function makeBridge(invokeResult, accountResult) {
75
75
  return {
76
- invoke: vi.fn(async (method, params) => invokeResult),
76
+ invoke: vi.fn(async (method) => {
77
+ if (method === "toutiao.account") {
78
+ // Mirror real wiring: a separate extension. Tests that don't wire it
79
+ // get a throw (collector falls through gracefully).
80
+ if (accountResult === undefined) {
81
+ throw new Error("toutiao.account not wired in this test");
82
+ }
83
+ return accountResult;
84
+ }
85
+ return invokeResult;
86
+ }),
77
87
  };
78
88
  }
79
89
 
@@ -182,6 +192,89 @@ describe("collect — profile fetch fails", () => {
182
192
  expect(r.uid).toBe(null);
183
193
  expect(r.profileFetchFailed).toBe(true);
184
194
  });
195
+
196
+ it("profile permission-denied (error_code 16) BUT cookie uid + signer → feed/collection/search still collect", async () => {
197
+ // Real-device 2026-06-11: logged-in Toutiao returns passport error_code 16
198
+ // 该应用无权限. We must NOT abort — feed is cookie-identified, so with a
199
+ // SignBridge the signed endpoints still flow. Profile event is skipped, but
200
+ // the headline error (16) is surfaced and feed/collection/search collect.
201
+ const { fakeFetch } = makeFakeFetch([
202
+ [
203
+ "passport/account/info/v2",
204
+ {
205
+ body: JSON.stringify({
206
+ message: "error",
207
+ data: { error_code: 16, description: "该应用无权限" },
208
+ }),
209
+ },
210
+ ],
211
+ ...HAPPY_RESPONSES.slice(1), // feed / comments / search responses
212
+ ]);
213
+ const sign = {
214
+ warmUp: vi.fn(async () => {}),
215
+ signUrl: vi.fn(async (url) => {
216
+ const u = new URL(String(url));
217
+ u.searchParams.set("_signature", "BRIDGE_SIG");
218
+ return u;
219
+ }),
220
+ shutdown: vi.fn(async () => {}),
221
+ };
222
+ const client = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
223
+ const r = await collect(makeBridge(COOKIE_PAYLOAD), {
224
+ apiClient: client,
225
+ signProvider: sign,
226
+ stagingDir: os.tmpdir(),
227
+ });
228
+ expect(r.profileFetchFailed).toBe(true);
229
+ expect(r.uid).toBe("12345"); // cookie-derived
230
+ expect(r.lastErrorCode).toBe(16); // headline profile error preserved
231
+ expect(r.lastErrorMessage).toBe("该应用无权限");
232
+ expect(r.eventCounts.profile).toBe(0); // no profile event
233
+ expect(r.eventCounts.feed).toBe(1); // ← previously 0 (aborted before signing)
234
+ expect(r.eventCounts.collection).toBe(1);
235
+ expect(r.eventCounts.search).toBe(1);
236
+ expect(r.eventCounts.total).toBe(3);
237
+ });
238
+
239
+ it("profile error_code 16 + NO cookie uid → recovers uid from local account_db, collects signed endpoints", async () => {
240
+ // Real-device 2026-06-11: web profile permission-denied AND the WebView
241
+ // cookie jar has no numeric uid. The collector asks the bridge for
242
+ // 'toutiao.account' (local account_db) and proceeds with that uid.
243
+ const { fakeFetch } = makeFakeFetch([
244
+ [
245
+ "passport/account/info/v2",
246
+ { body: JSON.stringify({ message: "error", data: { error_code: 16, description: "该应用无权限" } }) },
247
+ ],
248
+ ...HAPPY_RESPONSES.slice(1),
249
+ ]);
250
+ const sign = {
251
+ warmUp: vi.fn(async () => {}),
252
+ signUrl: vi.fn(async (url) => {
253
+ const u = new URL(String(url));
254
+ u.searchParams.set("_signature", "BRIDGE_SIG");
255
+ return u;
256
+ }),
257
+ shutdown: vi.fn(async () => {}),
258
+ };
259
+ const bridge = {
260
+ invoke: vi.fn(async (m) => {
261
+ if (m === "toutiao.cookies") return { cookie: "sessionid=abc", uid: null, diagnostic: {} };
262
+ if (m === "toutiao.account") return { uid: "92585279158", nickname: "小明", secUid: "MS4w" };
263
+ throw new Error("unknown " + m);
264
+ }),
265
+ };
266
+ const client = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
267
+ const r = await collect(bridge, { apiClient: client, signProvider: sign, stagingDir: os.tmpdir() });
268
+ expect(r.profileFetchFailed).toBe(true);
269
+ expect(r.profileSource).toBe("local-account-db");
270
+ expect(r.uid).toBe("92585279158");
271
+ expect(r.nickname).toBe("小明");
272
+ expect(r.lastErrorCode).toBe(16); // headline web error preserved
273
+ expect(r.eventCounts.profile).toBe(1); // profile event from local account
274
+ expect(r.eventCounts.feed).toBe(1);
275
+ expect(r.eventCounts.collection).toBe(1);
276
+ expect(r.eventCounts.search).toBe(1);
277
+ });
185
278
  });
186
279
 
187
280
  describe("collect — bridge warmUp failure", () => {
@@ -161,3 +161,33 @@ describe("createToutiaoCookiesExtension", () => {
161
161
  ).rejects.toThrow(/ctx must provide/);
162
162
  });
163
163
  });
164
+
165
+ describe("pullCookiesViaSu — installed-vs-not-installed diagnosis", () => {
166
+ function makeAdb({ ls, pm }) {
167
+ return async (args) => {
168
+ const cmd = args.join(" ");
169
+ if (cmd.includes("pm list packages")) return pm || "";
170
+ if (cmd.includes("ls ")) return ls;
171
+ throw new Error("fake adb: unexpected command " + cmd);
172
+ };
173
+ }
174
+
175
+ it("throws TOUTIAO_NOT_INSTALLED when cookies absent AND package not installed", async () => {
176
+ const adb = makeAdb({ ls: "NOT_FOUND\r\n", pm: "" });
177
+ await expect(
178
+ _internals.pullCookiesViaSu(adb, "serial", {}),
179
+ ).rejects.toThrow(/TOUTIAO_NOT_INSTALLED/);
180
+ });
181
+
182
+ it("throws TOUTIAO_NO_WEBVIEW_COOKIES when cookies absent but package installed (real-device 2026-06-11)", async () => {
183
+ // Verified on device 5lhyaqu8lbwstc6x: com.ss.android.article.news
184
+ // installed but no webview cookie store → must NOT say NOT_INSTALLED.
185
+ const adb = makeAdb({
186
+ ls: "NOT_FOUND\r\n",
187
+ pm: "package:com.ss.android.article.news\r\n",
188
+ });
189
+ await expect(
190
+ _internals.pullCookiesViaSu(adb, "serial", {}),
191
+ ).rejects.toThrow(/TOUTIAO_NO_WEBVIEW_COOKIES/);
192
+ });
193
+ });
@@ -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 };