@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,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
|
+
});
|
|
@@ -430,3 +430,67 @@ describe("normalizeMs", () => {
|
|
|
430
430
|
expect(_internals.normalizeMs(-1)).toBe(0);
|
|
431
431
|
});
|
|
432
432
|
});
|
|
433
|
+
|
|
434
|
+
describe("api_ph base64 fallback (v0.3)", () => {
|
|
435
|
+
const profileJson = JSON.stringify({
|
|
436
|
+
user_id: "424242",
|
|
437
|
+
user_name: "B64User",
|
|
438
|
+
kuaishou_id: "b64_ks",
|
|
439
|
+
});
|
|
440
|
+
const b64 = Buffer.from(profileJson, "utf-8").toString("base64");
|
|
441
|
+
|
|
442
|
+
it("apiPhDecodeCandidates yields base64-decoded JSON as 2nd candidate", () => {
|
|
443
|
+
const cands = _internals.apiPhDecodeCandidates(encodeURIComponent(b64));
|
|
444
|
+
expect(cands).toHaveLength(2);
|
|
445
|
+
expect(cands[1]).toBe(profileJson);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("apiPhDecodeCandidates yields 1 candidate for plain JSON (no double decode)", () => {
|
|
449
|
+
const cands = _internals.apiPhDecodeCandidates(
|
|
450
|
+
encodeURIComponent(profileJson),
|
|
451
|
+
);
|
|
452
|
+
expect(cands).toEqual([profileJson]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("apiPhDecodeCandidates suppresses garbage base64 (decoded ≠ JSON)", () => {
|
|
456
|
+
expect(_internals.apiPhDecodeCandidates("base64junk")).toEqual([
|
|
457
|
+
"base64junk",
|
|
458
|
+
]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("apiPhDecodeCandidates handles url-safe base64 (- _ alphabet)", () => {
|
|
462
|
+
const urlSafe = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
463
|
+
const cands = _internals.apiPhDecodeCandidates(urlSafe);
|
|
464
|
+
expect(cands[1]).toBe(profileJson);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("fetchProfile parses base64-encoded api_ph", async () => {
|
|
468
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
469
|
+
const p = await c.fetchProfile(
|
|
470
|
+
`kuaishou.web.cp.api_ph=${encodeURIComponent(b64)}`,
|
|
471
|
+
);
|
|
472
|
+
expect(p).toMatchObject({
|
|
473
|
+
uid: "424242",
|
|
474
|
+
nickname: "B64User",
|
|
475
|
+
kuaishouId: "b64_ks",
|
|
476
|
+
});
|
|
477
|
+
expect(c.lastErrorCode).toBe(0);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("fetchProfile still rejects non-JSON non-base64 payload with -9", async () => {
|
|
481
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
482
|
+
expect(
|
|
483
|
+
await c.fetchProfile(
|
|
484
|
+
`kuaishou.web.cp.api_ph=${encodeURIComponent("%%not-b64%%")}`,
|
|
485
|
+
),
|
|
486
|
+
).toBe(null);
|
|
487
|
+
expect(c.lastErrorCode).toBe(-9);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("extractUid falls back to base64 api_ph nested user_id", () => {
|
|
491
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
492
|
+
expect(
|
|
493
|
+
c.extractUid(`kuaishou.web.cp.api_ph=${encodeURIComponent(b64)}`),
|
|
494
|
+
).toBe("424242");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -70,6 +70,17 @@ describe("pickUidFromCookieMap", () => {
|
|
|
70
70
|
const map = make([["did", "anonid"]]);
|
|
71
71
|
expect(_internals.pickUidFromCookieMap(map)).toBe(null);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("falls back to base64-encoded api_ph (v0.3 newer Kuaishou builds)", () => {
|
|
75
|
+
const b64 = Buffer.from(
|
|
76
|
+
JSON.stringify({ user_id: "424242" }),
|
|
77
|
+
"utf-8",
|
|
78
|
+
).toString("base64");
|
|
79
|
+
const map = make([
|
|
80
|
+
["kuaishou.web.cp.api_ph", encodeURIComponent(b64)],
|
|
81
|
+
]);
|
|
82
|
+
expect(_internals.pickUidFromCookieMap(map)).toBe("424242");
|
|
83
|
+
});
|
|
73
84
|
});
|
|
74
85
|
|
|
75
86
|
describe("assembleKuaishouCookieHeader", () => {
|
|
@@ -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
|
|
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
|
+
});
|