@chainlesschain/personal-data-hub 0.3.1 → 0.3.7
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/email-adapter-snapshot.test.js +237 -0
- package/__tests__/adapters/email-adapter.test.js +1 -1
- package/__tests__/adapters/email-pdf-extractor.test.js +1 -1
- package/__tests__/adapters/email-retry-progress.test.js +1 -1
- package/__tests__/adapters/email-templates.test.js +1 -1
- package/__tests__/adapters/social-bilibili-adb-api-client.test.js +721 -0
- package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +346 -0
- package/__tests__/adapters/social-bilibili-adb-collector.test.js +284 -0
- package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +343 -0
- package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +296 -0
- package/__tests__/adapters/social-douyin-adb-collector.test.js +254 -0
- package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +304 -0
- package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +216 -0
- package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
- package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
- package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
- package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
- package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
- package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
- package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
- package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
- package/__tests__/adapters/social-weibo-adb-api-client.test.js +362 -0
- package/__tests__/adapters/social-weibo-adb-collector.test.js +201 -0
- package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +189 -0
- package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +207 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
- package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +130 -0
- package/__tests__/adapters/system-data-android.test.js +32 -1
- package/__tests__/longtail-adapters.test.js +15 -2
- package/__tests__/shopping-adapters.test.js +96 -0
- package/__tests__/sign-providers.test.js +62 -0
- package/__tests__/travel-adapters.test.js +66 -0
- package/__tests__/whatsapp-adapter.test.js +5 -2
- package/lib/adapters/browser-history-chrome/chrome-db-reader.js +11 -1
- package/lib/adapters/email-imap/email-adapter.js +224 -17
- package/lib/adapters/messaging-telegram/index.js +15 -12
- package/lib/adapters/messaging-whatsapp/index.js +15 -12
- package/lib/adapters/shopping-taobao/index.js +161 -21
- package/lib/adapters/social-bilibili-adb/api-client.js +555 -0
- package/lib/adapters/social-bilibili-adb/chromium-cookies-reader.js +296 -0
- package/lib/adapters/social-bilibili-adb/collector.js +190 -0
- package/lib/adapters/social-bilibili-adb/cookies-extension.js +250 -0
- package/lib/adapters/social-bilibili-adb/index.js +51 -0
- package/lib/adapters/social-bilibili-adb/snapshot-builder.js +197 -0
- package/lib/adapters/social-douyin/index.js +4 -0
- package/lib/adapters/social-douyin-adb/collector.js +165 -0
- package/lib/adapters/social-douyin-adb/db-extension.js +281 -0
- package/lib/adapters/social-douyin-adb/im-db-parser.js +287 -0
- package/lib/adapters/social-douyin-adb/index.js +57 -0
- package/lib/adapters/social-douyin-adb/snapshot-builder.js +174 -0
- package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
- package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
- package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
- package/lib/adapters/social-kuaishou-adb/index.js +53 -0
- package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
- package/lib/adapters/social-toutiao-adb/collector.js +200 -0
- package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
- package/lib/adapters/social-toutiao-adb/index.js +52 -0
- package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
- package/lib/adapters/social-weibo-adb/api-client.js +281 -0
- package/lib/adapters/social-weibo-adb/collector.js +169 -0
- package/lib/adapters/social-weibo-adb/cookies-extension.js +251 -0
- package/lib/adapters/social-weibo-adb/index.js +55 -0
- package/lib/adapters/social-weibo-adb/snapshot-builder.js +145 -0
- package/lib/adapters/social-xiaohongshu-adb/api-client.js +309 -0
- package/lib/adapters/social-xiaohongshu-adb/collector.js +209 -0
- package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +211 -0
- package/lib/adapters/social-xiaohongshu-adb/index.js +50 -0
- package/lib/adapters/social-xiaohongshu-adb/sign.js +90 -0
- package/lib/adapters/social-xiaohongshu-adb/snapshot-builder.js +126 -0
- package/lib/adapters/system-data-android/adapter.js +77 -3
- package/lib/adapters/travel-amap/index.js +16 -10
- package/lib/adapters/travel-ctrip/index.js +25 -9
- package/lib/adapters/vscode/vscode-reader.js +7 -1
- package/lib/sign-providers/index.js +20 -0
- package/lib/sign-providers/interface.js +82 -0
- package/lib/sign-providers/null-sign-provider.js +30 -0
- package/package.json +10 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 2a — Douyin snapshot-builder unit cover.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors social-bilibili-adb-snapshot-builder.test.js but with the
|
|
7
|
+
* Douyin event shapes (kind=message / kind=contact).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
buildSnapshot,
|
|
17
|
+
writeSnapshotJson,
|
|
18
|
+
cleanupSnapshotJson,
|
|
19
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
20
|
+
} = require("../../lib/adapters/social-douyin-adb/snapshot-builder");
|
|
21
|
+
|
|
22
|
+
let tmpDir;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = mkdtempSync(join(tmpdir(), "cc-douyin-snap-test-"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
try {
|
|
30
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
31
|
+
} catch (_e) {
|
|
32
|
+
// ignore
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("buildSnapshot — schema contract", () => {
|
|
37
|
+
it("returns documented schema shape", () => {
|
|
38
|
+
const snap = buildSnapshot({
|
|
39
|
+
uid: "1234567890123456789",
|
|
40
|
+
displayName: "alice",
|
|
41
|
+
messages: [],
|
|
42
|
+
contacts: [],
|
|
43
|
+
snapshottedAt: 1716383021000,
|
|
44
|
+
});
|
|
45
|
+
expect(snap.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION);
|
|
46
|
+
expect(snap.schemaVersion).toBe(1);
|
|
47
|
+
expect(snap.snapshottedAt).toBe(1716383021000);
|
|
48
|
+
expect(snap.account).toEqual({
|
|
49
|
+
secUid: null, // unknown via pure-db extraction
|
|
50
|
+
shortId: "1234567890123456789",
|
|
51
|
+
displayName: "alice",
|
|
52
|
+
});
|
|
53
|
+
expect(Array.isArray(snap.events)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects non-string / empty uid", () => {
|
|
57
|
+
expect(() => buildSnapshot({ uid: 123 })).toThrow(TypeError);
|
|
58
|
+
expect(() => buildSnapshot({ uid: "" })).toThrow(TypeError);
|
|
59
|
+
expect(() => buildSnapshot({})).toThrow(TypeError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("defaults snapshottedAt to Date.now() when omitted", () => {
|
|
63
|
+
const before = Date.now();
|
|
64
|
+
const snap = buildSnapshot({ uid: "1" });
|
|
65
|
+
const after = Date.now();
|
|
66
|
+
expect(snap.snapshottedAt).toBeGreaterThanOrEqual(before);
|
|
67
|
+
expect(snap.snapshottedAt).toBeLessThanOrEqual(after);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("defaults displayName to empty string", () => {
|
|
71
|
+
const snap = buildSnapshot({ uid: "1" });
|
|
72
|
+
expect(snap.account.displayName).toBe("");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("buildSnapshot — events", () => {
|
|
77
|
+
it("maps messages → kind=message events with composite id", () => {
|
|
78
|
+
const snap = buildSnapshot({
|
|
79
|
+
uid: "1",
|
|
80
|
+
messages: [
|
|
81
|
+
{
|
|
82
|
+
senderUid: "10001",
|
|
83
|
+
conversationId: "conv-A",
|
|
84
|
+
createdTimeMs: 1716383021000,
|
|
85
|
+
text: "hi",
|
|
86
|
+
readStatus: 1,
|
|
87
|
+
contentBlob: '{"text":"hi"}',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
expect(snap.events).toHaveLength(1);
|
|
92
|
+
expect(snap.events[0]).toMatchObject({
|
|
93
|
+
kind: "message",
|
|
94
|
+
id: "msg-conv-A-1716383021000",
|
|
95
|
+
capturedAt: 1716383021000,
|
|
96
|
+
senderUid: "10001",
|
|
97
|
+
conversationId: "conv-A",
|
|
98
|
+
text: "hi",
|
|
99
|
+
readStatus: 1,
|
|
100
|
+
contentBlob: '{"text":"hi"}',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("falls back to senderUid+time when conversationId absent", () => {
|
|
105
|
+
const snap = buildSnapshot({
|
|
106
|
+
uid: "1",
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
senderUid: "10001",
|
|
110
|
+
conversationId: null,
|
|
111
|
+
createdTimeMs: 1716383021000,
|
|
112
|
+
text: "hi",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
expect(snap.events[0].id).toBe("msg-10001-1716383021000");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to index when both conversationId + senderUid absent", () => {
|
|
120
|
+
const snap = buildSnapshot({
|
|
121
|
+
uid: "1",
|
|
122
|
+
messages: [{ text: "stray" }],
|
|
123
|
+
});
|
|
124
|
+
expect(snap.events[0].id).toBe("msg-msg-0");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("maps contacts → kind=contact events with uid-based id", () => {
|
|
128
|
+
const snap = buildSnapshot({
|
|
129
|
+
uid: "1",
|
|
130
|
+
contacts: [
|
|
131
|
+
{ uid: "111", shortId: "222", name: "Alice", avatarUrl: "https://a.png", followStatus: 1 },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
expect(snap.events[0]).toMatchObject({
|
|
135
|
+
kind: "contact",
|
|
136
|
+
id: "contact-111",
|
|
137
|
+
uid: "111",
|
|
138
|
+
shortId: "222",
|
|
139
|
+
name: "Alice",
|
|
140
|
+
followStatus: 1,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("preserves message → contact ordering", () => {
|
|
145
|
+
const snap = buildSnapshot({
|
|
146
|
+
uid: "1",
|
|
147
|
+
messages: [{ senderUid: "a", createdTimeMs: 1 }],
|
|
148
|
+
contacts: [{ uid: "b" }],
|
|
149
|
+
});
|
|
150
|
+
expect(snap.events.map((e) => e.kind)).toEqual(["message", "contact"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses snapshottedAt fallback when message has no createdTimeMs", () => {
|
|
154
|
+
const snap = buildSnapshot({
|
|
155
|
+
uid: "1",
|
|
156
|
+
snapshottedAt: 999,
|
|
157
|
+
messages: [{ senderUid: "x", text: "no time" }],
|
|
158
|
+
});
|
|
159
|
+
expect(snap.events[0].capturedAt).toBe(999);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("skips null / non-object items", () => {
|
|
163
|
+
const snap = buildSnapshot({
|
|
164
|
+
uid: "1",
|
|
165
|
+
messages: [null, { senderUid: "a", createdTimeMs: 1 }, "junk"],
|
|
166
|
+
contacts: [undefined, { uid: "b" }],
|
|
167
|
+
});
|
|
168
|
+
expect(snap.events).toHaveLength(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("handles non-array fields gracefully", () => {
|
|
172
|
+
const snap = buildSnapshot({
|
|
173
|
+
uid: "1",
|
|
174
|
+
messages: "not an array",
|
|
175
|
+
contacts: null,
|
|
176
|
+
});
|
|
177
|
+
expect(snap.events).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("writeSnapshotJson + cleanupSnapshotJson", () => {
|
|
182
|
+
it("writes JSON to default tmpdir", () => {
|
|
183
|
+
const snap = buildSnapshot({ uid: "1" });
|
|
184
|
+
const filePath = writeSnapshotJson(snap);
|
|
185
|
+
expect(existsSync(filePath)).toBe(true);
|
|
186
|
+
expect(filePath).toContain("cc-douyin-snapshot-");
|
|
187
|
+
expect(filePath).toMatch(/\.json$/);
|
|
188
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
189
|
+
expect(parsed.schemaVersion).toBe(1);
|
|
190
|
+
cleanupSnapshotJson(filePath);
|
|
191
|
+
expect(existsSync(filePath)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("respects custom dir + fileName", () => {
|
|
195
|
+
const snap = buildSnapshot({ uid: "1" });
|
|
196
|
+
const filePath = writeSnapshotJson(snap, {
|
|
197
|
+
dir: tmpDir,
|
|
198
|
+
fileName: "custom.json",
|
|
199
|
+
});
|
|
200
|
+
expect(filePath).toBe(join(tmpDir, "custom.json"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("rejects path separators in fileName", () => {
|
|
204
|
+
const snap = buildSnapshot({ uid: "1" });
|
|
205
|
+
expect(() => writeSnapshotJson(snap, { fileName: "../evil.json" })).toThrow(
|
|
206
|
+
/must be a basename/,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("cleanupSnapshotJson tolerates missing / null", () => {
|
|
211
|
+
cleanupSnapshotJson(null);
|
|
212
|
+
cleanupSnapshotJson(undefined);
|
|
213
|
+
cleanupSnapshotJson(join(tmpDir, "nonexistent.json"));
|
|
214
|
+
// does not throw
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
KuaishouApiClient,
|
|
7
|
+
_internals,
|
|
8
|
+
} = require("../../lib/adapters/social-kuaishou-adb/api-client");
|
|
9
|
+
const { NULL_SIGN_PROVIDER } = require("../../lib/sign-providers");
|
|
10
|
+
|
|
11
|
+
function makeFakeFetch(responses) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
const fakeFetch = async (urlStr, opts) => {
|
|
14
|
+
calls.push({ url: urlStr, opts });
|
|
15
|
+
for (const [pattern, payload] of responses) {
|
|
16
|
+
if (urlStr.includes(pattern)) {
|
|
17
|
+
const resolved =
|
|
18
|
+
typeof payload === "function" ? await payload(urlStr, opts) : payload;
|
|
19
|
+
return {
|
|
20
|
+
ok: resolved.status == null || resolved.status === 200,
|
|
21
|
+
status: resolved.status || 200,
|
|
22
|
+
text: async () => resolved.body,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error("fake fetch: no response for " + urlStr);
|
|
27
|
+
};
|
|
28
|
+
return { fakeFetch, calls };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HAPPY_GRAPHQL_RESPONSE = {
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
data: {
|
|
34
|
+
visionFeedRecommend: {
|
|
35
|
+
feeds: [
|
|
36
|
+
{
|
|
37
|
+
photo: {
|
|
38
|
+
id: "P1",
|
|
39
|
+
caption: "Watch 1",
|
|
40
|
+
timestamp: 1700000000,
|
|
41
|
+
duration: 30,
|
|
42
|
+
},
|
|
43
|
+
author: { id: "AUTH1", name: "AuthorOne" },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const apiPhPayload = encodeURIComponent(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
user_id: "12345",
|
|
54
|
+
user_name: "Alice",
|
|
55
|
+
kuaishou_id: "alice_ks",
|
|
56
|
+
headurl: "https://a.example/avatar.jpg",
|
|
57
|
+
sex: "F",
|
|
58
|
+
city: "Beijing",
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
describe("KuaishouApiClient — extractUid", () => {
|
|
63
|
+
it("prefers direct userId cookie", () => {
|
|
64
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
65
|
+
expect(c.extractUid("userId=12345; did=anon")).toBe("12345");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to api_ph nested user_id", () => {
|
|
69
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
70
|
+
expect(c.extractUid(`kuaishou.web.cp.api_ph=${apiPhPayload}`)).toBe(
|
|
71
|
+
"12345",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null when only anti-bot cookies present", () => {
|
|
76
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
77
|
+
expect(c.extractUid("did=anonid; ttwid=x")).toBe(null);
|
|
78
|
+
expect(c.lastErrorCode).toBe(-7);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null on empty cookie", () => {
|
|
82
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
83
|
+
expect(c.extractUid("")).toBe(null);
|
|
84
|
+
expect(c.lastErrorCode).toBe(-1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null on userId=0 sentinel", () => {
|
|
88
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
89
|
+
expect(c.extractUid("userId=0")).toBe(null);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("KuaishouApiClient — fetchProfile (cookie parse, no HTTP)", () => {
|
|
94
|
+
it("parses URL-encoded api_ph JSON", async () => {
|
|
95
|
+
// NO fetch needed — pure cookie parse
|
|
96
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
97
|
+
const p = await c.fetchProfile(
|
|
98
|
+
`userId=12345; kuaishou.web.cp.api_ph=${apiPhPayload}`,
|
|
99
|
+
);
|
|
100
|
+
expect(p).toMatchObject({
|
|
101
|
+
uid: "12345",
|
|
102
|
+
nickname: "Alice",
|
|
103
|
+
kuaishouId: "alice_ks",
|
|
104
|
+
avatarUrl: "https://a.example/avatar.jpg",
|
|
105
|
+
sex: "F",
|
|
106
|
+
city: "Beijing",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null when api_ph absent", async () => {
|
|
111
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
112
|
+
expect(await c.fetchProfile("userId=12345")).toBe(null);
|
|
113
|
+
expect(c.lastErrorCode).toBe(-8);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null on un-decodable api_ph (non-JSON)", async () => {
|
|
117
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
118
|
+
expect(
|
|
119
|
+
await c.fetchProfile(`kuaishou.web.cp.api_ph=${encodeURIComponent("base64junk")}`),
|
|
120
|
+
).toBe(null);
|
|
121
|
+
expect(c.lastErrorCode).toBe(-9);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns null when JSON lacks user_id", async () => {
|
|
125
|
+
const cookie = `kuaishou.web.cp.api_ph=${encodeURIComponent(
|
|
126
|
+
JSON.stringify({ user_name: "noUid" }),
|
|
127
|
+
)}`;
|
|
128
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
129
|
+
expect(await c.fetchProfile(cookie)).toBe(null);
|
|
130
|
+
expect(c.lastErrorCode).toBe(-7);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("normalizes user_id from number to string", async () => {
|
|
134
|
+
const cookie = `kuaishou.web.cp.api_ph=${encodeURIComponent(
|
|
135
|
+
JSON.stringify({ user_id: 98765, user_name: "Num" }),
|
|
136
|
+
)}`;
|
|
137
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
138
|
+
const p = await c.fetchProfile(cookie);
|
|
139
|
+
expect(p.uid).toBe("98765");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("treats user_id=0 as missing (guest)", async () => {
|
|
143
|
+
const cookie = `kuaishou.web.cp.api_ph=${encodeURIComponent(
|
|
144
|
+
JSON.stringify({ user_id: "0", user_name: "Guest" }),
|
|
145
|
+
)}`;
|
|
146
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
147
|
+
expect(await c.fetchProfile(cookie)).toBe(null);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("KuaishouApiClient — signProvider injection", () => {
|
|
152
|
+
it("defaults to NULL_SIGN_PROVIDER", () => {
|
|
153
|
+
const c = new KuaishouApiClient({ fetch: () => {} });
|
|
154
|
+
expect(c.signProvider).toBe(NULL_SIGN_PROVIDER);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("3 signed endpoints SHORT-CIRCUIT (-99) when NullSignProvider", async () => {
|
|
158
|
+
const { fakeFetch, calls } = makeFakeFetch([]);
|
|
159
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch });
|
|
160
|
+
const w = await c.fetchWatchHistory("userId=1");
|
|
161
|
+
const p = await c.fetchProfilePhotos("userId=1", "1");
|
|
162
|
+
const s = await c.fetchSearchHistory("userId=1");
|
|
163
|
+
expect(w).toEqual([]);
|
|
164
|
+
expect(p).toEqual([]);
|
|
165
|
+
expect(s).toEqual([]);
|
|
166
|
+
expect(c.lastErrorCode).toBe(-99);
|
|
167
|
+
expect(c._fallbackHits).toBe(3);
|
|
168
|
+
expect(c._bridgeHits).toBe(0);
|
|
169
|
+
// Critical: no HTTP traffic when bridge cold
|
|
170
|
+
expect(calls).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("sends GraphQL POST with __NS_sig3 query + kpf/kpn headers when bridge present", async () => {
|
|
174
|
+
const { fakeFetch, calls } = makeFakeFetch([
|
|
175
|
+
["graphql", HAPPY_GRAPHQL_RESPONSE],
|
|
176
|
+
]);
|
|
177
|
+
const sign = {
|
|
178
|
+
signUrl: vi.fn(async (url, _purpose) => {
|
|
179
|
+
const u = new URL(String(url));
|
|
180
|
+
u.searchParams.set("__NS_sig3", "BRIDGE_SIG");
|
|
181
|
+
return u;
|
|
182
|
+
}),
|
|
183
|
+
signedHeaders: vi.fn(async (_url, _purpose) => ({
|
|
184
|
+
kpf: "PC_WEB",
|
|
185
|
+
kpn: "KUAISHOU_VISION",
|
|
186
|
+
})),
|
|
187
|
+
};
|
|
188
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
189
|
+
const items = await c.fetchWatchHistory("userId=1", { limit: 5 });
|
|
190
|
+
expect(items).toHaveLength(1);
|
|
191
|
+
expect(items[0].photoId).toBe("P1");
|
|
192
|
+
expect(sign.signUrl).toHaveBeenCalledOnce();
|
|
193
|
+
expect(sign.signedHeaders).toHaveBeenCalledOnce();
|
|
194
|
+
expect(calls[0].url).toContain("__NS_sig3=BRIDGE_SIG");
|
|
195
|
+
expect(calls[0].opts.method).toBe("POST");
|
|
196
|
+
expect(calls[0].opts.headers.kpf).toBe("PC_WEB");
|
|
197
|
+
expect(calls[0].opts.headers.kpn).toBe("KUAISHOU_VISION");
|
|
198
|
+
// body MUST be valid JSON with operationName + variables + query
|
|
199
|
+
const body = JSON.parse(calls[0].opts.body);
|
|
200
|
+
expect(body.operationName).toBe("visionFeedRecommend");
|
|
201
|
+
expect(body.variables).toEqual({ pcursor: "", count: 5 });
|
|
202
|
+
expect(c._bridgeHits).toBe(1);
|
|
203
|
+
expect(c._fallbackHits).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("forwards <op>|<body> purpose to signUrl + signedHeaders", async () => {
|
|
207
|
+
const { fakeFetch } = makeFakeFetch([
|
|
208
|
+
["graphql", { body: JSON.stringify({ data: {} }) }],
|
|
209
|
+
]);
|
|
210
|
+
const sign = {
|
|
211
|
+
signUrl: vi.fn(async (url) => {
|
|
212
|
+
const u = new URL(String(url));
|
|
213
|
+
u.searchParams.set("__NS_sig3", "X");
|
|
214
|
+
return u;
|
|
215
|
+
}),
|
|
216
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
217
|
+
};
|
|
218
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
219
|
+
await c.fetchWatchHistory("userId=1");
|
|
220
|
+
const [, signPurpose] = sign.signUrl.mock.calls[0];
|
|
221
|
+
expect(signPurpose).toMatch(/^visionFeedRecommend\|/);
|
|
222
|
+
// purpose carries the exact body bytes for hash matching
|
|
223
|
+
expect(signPurpose).toContain('"pcursor"');
|
|
224
|
+
expect(signPurpose).toContain('"count"');
|
|
225
|
+
// signedHeaders receives same purpose
|
|
226
|
+
const [, hdrPurpose] = sign.signedHeaders.mock.calls[0];
|
|
227
|
+
expect(hdrPurpose).toBe(signPurpose);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("KuaishouApiClient — fetchWatchHistory parsing", () => {
|
|
232
|
+
const sign = {
|
|
233
|
+
signUrl: vi.fn(async (url) => {
|
|
234
|
+
const u = new URL(String(url));
|
|
235
|
+
u.searchParams.set("__NS_sig3", "X");
|
|
236
|
+
return u;
|
|
237
|
+
}),
|
|
238
|
+
signedHeaders: vi.fn(async () => ({ kpf: "PC_WEB", kpn: "KUAISHOU_VISION" })),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
it("parses nested photo + author", async () => {
|
|
242
|
+
const { fakeFetch } = makeFakeFetch([["graphql", HAPPY_GRAPHQL_RESPONSE]]);
|
|
243
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
244
|
+
const items = await c.fetchWatchHistory("userId=1");
|
|
245
|
+
expect(items[0]).toMatchObject({
|
|
246
|
+
photoId: "P1",
|
|
247
|
+
caption: "Watch 1",
|
|
248
|
+
authorName: "AuthorOne",
|
|
249
|
+
authorId: "AUTH1",
|
|
250
|
+
duration: 30,
|
|
251
|
+
});
|
|
252
|
+
expect(items[0].viewedAt).toBe(1700000000 * 1000);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("handles flat-shape items (no nested photo)", async () => {
|
|
256
|
+
const { fakeFetch } = makeFakeFetch([
|
|
257
|
+
[
|
|
258
|
+
"graphql",
|
|
259
|
+
{
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
data: {
|
|
262
|
+
visionFeedRecommend: {
|
|
263
|
+
feeds: [
|
|
264
|
+
{
|
|
265
|
+
id: "FLAT1",
|
|
266
|
+
caption: "Flat watch",
|
|
267
|
+
timestamp: 1700001000,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
]);
|
|
276
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
277
|
+
const items = await c.fetchWatchHistory("userId=1");
|
|
278
|
+
expect(items[0].photoId).toBe("FLAT1");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("returns [] on GraphQL errors response", async () => {
|
|
282
|
+
const { fakeFetch } = makeFakeFetch([
|
|
283
|
+
[
|
|
284
|
+
"graphql",
|
|
285
|
+
{
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
errors: [{ message: "401 unauthorized" }],
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
]);
|
|
292
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
293
|
+
const items = await c.fetchWatchHistory("userId=1");
|
|
294
|
+
expect(items).toEqual([]);
|
|
295
|
+
expect(c.lastErrorCode).toBe(-5);
|
|
296
|
+
expect(c.lastErrorMessage).toMatch(/401 unauthorized/);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("returns [] on HTTP 412 anti-bot", async () => {
|
|
300
|
+
const { fakeFetch } = makeFakeFetch([
|
|
301
|
+
["graphql", { status: 412, body: "<html>blocked" }],
|
|
302
|
+
]);
|
|
303
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
304
|
+
expect(await c.fetchWatchHistory("userId=1")).toEqual([]);
|
|
305
|
+
expect(c.lastErrorCode).toBe(412);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("KuaishouApiClient — fetchProfilePhotos parsing", () => {
|
|
310
|
+
const sign = {
|
|
311
|
+
signUrl: vi.fn(async (url) => {
|
|
312
|
+
const u = new URL(String(url));
|
|
313
|
+
u.searchParams.set("__NS_sig3", "X");
|
|
314
|
+
return u;
|
|
315
|
+
}),
|
|
316
|
+
signedHeaders: vi.fn(async () => ({ kpf: "PC_WEB", kpn: "KUAISHOU_VISION" })),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
it("parses own posted photos", async () => {
|
|
320
|
+
const { fakeFetch, calls } = makeFakeFetch([
|
|
321
|
+
[
|
|
322
|
+
"graphql",
|
|
323
|
+
{
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
data: {
|
|
326
|
+
visionProfilePhotoList: {
|
|
327
|
+
feeds: [
|
|
328
|
+
{
|
|
329
|
+
photo: {
|
|
330
|
+
id: "OWN1",
|
|
331
|
+
caption: "My photo",
|
|
332
|
+
timestamp: 1700002000,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
]);
|
|
342
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
343
|
+
const items = await c.fetchProfilePhotos("userId=1", "12345");
|
|
344
|
+
expect(items[0]).toMatchObject({
|
|
345
|
+
photoId: "OWN1",
|
|
346
|
+
caption: "My photo",
|
|
347
|
+
});
|
|
348
|
+
// body must carry userId variable
|
|
349
|
+
const body = JSON.parse(calls[0].opts.body);
|
|
350
|
+
expect(body.variables.userId).toBe("12345");
|
|
351
|
+
expect(body.variables.page).toBe("profile");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("KuaishouApiClient — fetchSearchHistory parsing", () => {
|
|
356
|
+
const sign = {
|
|
357
|
+
signUrl: vi.fn(async (url) => {
|
|
358
|
+
const u = new URL(String(url));
|
|
359
|
+
u.searchParams.set("__NS_sig3", "X");
|
|
360
|
+
return u;
|
|
361
|
+
}),
|
|
362
|
+
signedHeaders: vi.fn(async () => ({ kpf: "PC_WEB", kpn: "KUAISHOU_VISION" })),
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
it("parses recentSearchList object shape", async () => {
|
|
366
|
+
const { fakeFetch } = makeFakeFetch([
|
|
367
|
+
[
|
|
368
|
+
"graphql",
|
|
369
|
+
{
|
|
370
|
+
body: JSON.stringify({
|
|
371
|
+
data: {
|
|
372
|
+
visionSearchPhoto: {
|
|
373
|
+
recentSearchList: [
|
|
374
|
+
{ keyword: "AI", time: 1700003000 },
|
|
375
|
+
{ keyword: "rust", time: 1700004000 },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
]);
|
|
383
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
384
|
+
const items = await c.fetchSearchHistory("userId=1");
|
|
385
|
+
expect(items).toHaveLength(2);
|
|
386
|
+
expect(items[0]).toEqual({ keyword: "AI", searchedAt: 1700003000 * 1000 });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("falls back to data.visionSearchPhoto.history shape", async () => {
|
|
390
|
+
const { fakeFetch } = makeFakeFetch([
|
|
391
|
+
[
|
|
392
|
+
"graphql",
|
|
393
|
+
{
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
data: {
|
|
396
|
+
visionSearchPhoto: {
|
|
397
|
+
history: [{ keyword: "fallback", time: 1 }],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
]);
|
|
404
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
405
|
+
const items = await c.fetchSearchHistory("userId=1");
|
|
406
|
+
expect(items[0].keyword).toBe("fallback");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("returns [] when both shapes missing", async () => {
|
|
410
|
+
const { fakeFetch } = makeFakeFetch([
|
|
411
|
+
[
|
|
412
|
+
"graphql",
|
|
413
|
+
{
|
|
414
|
+
body: JSON.stringify({
|
|
415
|
+
data: { visionSearchPhoto: {} },
|
|
416
|
+
}),
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
]);
|
|
420
|
+
const c = new KuaishouApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
421
|
+
expect(await c.fetchSearchHistory("userId=1")).toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("normalizeMs", () => {
|
|
426
|
+
it("passes ms through, multiplies seconds, returns 0 for invalid", () => {
|
|
427
|
+
expect(_internals.normalizeMs(1700000000000)).toBe(1700000000000);
|
|
428
|
+
expect(_internals.normalizeMs(1700000000)).toBe(1700000000 * 1000);
|
|
429
|
+
expect(_internals.normalizeMs(0)).toBe(0);
|
|
430
|
+
expect(_internals.normalizeMs(-1)).toBe(0);
|
|
431
|
+
});
|
|
432
|
+
});
|