@chainlesschain/personal-data-hub 0.3.6 → 0.3.8
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/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-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
- package/__tests__/analysis.test.js +239 -14
- package/__tests__/query-parser.test.js +86 -0
- package/__tests__/vault.test.js +88 -0
- package/lib/adapters/ai-chat-history/health-checker.js +11 -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-xiaohongshu-adb/api-client.js +36 -5
- package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
- package/lib/analysis.js +154 -17
- package/lib/query-parser.js +93 -0
- package/lib/vault.js +64 -0
- package/package.json +5 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
collect,
|
|
8
|
+
collectAndSync,
|
|
9
|
+
} = require("../../lib/adapters/social-kuaishou-adb/collector");
|
|
10
|
+
const {
|
|
11
|
+
KuaishouApiClient,
|
|
12
|
+
} = require("../../lib/adapters/social-kuaishou-adb/api-client");
|
|
13
|
+
|
|
14
|
+
function makeFakeFetch(responses) {
|
|
15
|
+
const calls = [];
|
|
16
|
+
const fakeFetch = async (urlStr, opts) => {
|
|
17
|
+
calls.push({ url: urlStr, opts });
|
|
18
|
+
for (const [pattern, payload] of responses) {
|
|
19
|
+
if (urlStr.includes(pattern)) {
|
|
20
|
+
const resolved =
|
|
21
|
+
typeof payload === "function" ? await payload(urlStr, opts) : payload;
|
|
22
|
+
return {
|
|
23
|
+
ok: resolved.status == null || resolved.status === 200,
|
|
24
|
+
status: resolved.status || 200,
|
|
25
|
+
text: async () => resolved.body,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw new Error("fake fetch: no response for " + urlStr);
|
|
30
|
+
};
|
|
31
|
+
return { fakeFetch, calls };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function gqlBody(opName, dataFn) {
|
|
35
|
+
return async (urlStr, opts) => {
|
|
36
|
+
const reqBody = JSON.parse(opts.body);
|
|
37
|
+
if (reqBody.operationName !== opName) {
|
|
38
|
+
return { body: JSON.stringify({ data: {} }) };
|
|
39
|
+
}
|
|
40
|
+
return { body: JSON.stringify({ data: dataFn() }) };
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HAPPY_RESPONSES = [
|
|
45
|
+
// Single graphql endpoint — switch on operationName via body inspection
|
|
46
|
+
[
|
|
47
|
+
"graphql",
|
|
48
|
+
async (urlStr, opts) => {
|
|
49
|
+
const reqBody = JSON.parse(opts.body);
|
|
50
|
+
let data = {};
|
|
51
|
+
if (reqBody.operationName === "visionFeedRecommend") {
|
|
52
|
+
data = {
|
|
53
|
+
visionFeedRecommend: {
|
|
54
|
+
feeds: [
|
|
55
|
+
{
|
|
56
|
+
photo: { id: "W1", caption: "watched", timestamp: 1700000000 },
|
|
57
|
+
author: { id: "AUTH", name: "Author" },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
} else if (reqBody.operationName === "visionProfilePhotoList") {
|
|
63
|
+
data = {
|
|
64
|
+
visionProfilePhotoList: {
|
|
65
|
+
feeds: [
|
|
66
|
+
{
|
|
67
|
+
photo: { id: "OWN1", caption: "my", timestamp: 1700001000 },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
} else if (reqBody.operationName === "visionSearchPhoto") {
|
|
73
|
+
data = {
|
|
74
|
+
visionSearchPhoto: {
|
|
75
|
+
recentSearchList: [{ keyword: "kw", time: 1700002000 }],
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { body: JSON.stringify({ data }) };
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const apiPhPayload = encodeURIComponent(
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
user_id: "12345",
|
|
87
|
+
user_name: "Alice",
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const COOKIE_PAYLOAD = {
|
|
92
|
+
cookie: `userId=12345; kuaishou.web.cp.api_ph=${apiPhPayload}`,
|
|
93
|
+
uid: "12345",
|
|
94
|
+
diagnostic: { cookieCount: 5, hadEncrypted: false, cookieNames: [] },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function makeBridge(invokeResult) {
|
|
98
|
+
return {
|
|
99
|
+
invoke: vi.fn(async () => invokeResult),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("collect — happy path with signProvider", () => {
|
|
104
|
+
it("warmUp → 3 signed endpoints → shutdown", async () => {
|
|
105
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
106
|
+
const lifecycle = [];
|
|
107
|
+
const sign = {
|
|
108
|
+
warmUp: vi.fn(async (c) => lifecycle.push({ warmUp: c })),
|
|
109
|
+
signUrl: vi.fn(async (url) => {
|
|
110
|
+
const u = new URL(String(url));
|
|
111
|
+
u.searchParams.set("__NS_sig3", "BRIDGE_SIG");
|
|
112
|
+
return u;
|
|
113
|
+
}),
|
|
114
|
+
signedHeaders: vi.fn(async () => ({
|
|
115
|
+
kpf: "PC_WEB",
|
|
116
|
+
kpn: "KUAISHOU_VISION",
|
|
117
|
+
})),
|
|
118
|
+
shutdown: vi.fn(async () => lifecycle.push("shutdown")),
|
|
119
|
+
};
|
|
120
|
+
const client = new KuaishouApiClient({
|
|
121
|
+
fetch: fakeFetch,
|
|
122
|
+
signProvider: sign,
|
|
123
|
+
});
|
|
124
|
+
const r = await collect(makeBridge(COOKIE_PAYLOAD), {
|
|
125
|
+
apiClient: client,
|
|
126
|
+
signProvider: sign,
|
|
127
|
+
stagingDir: os.tmpdir(),
|
|
128
|
+
});
|
|
129
|
+
expect(sign.warmUp).toHaveBeenCalledWith(COOKIE_PAYLOAD.cookie);
|
|
130
|
+
expect(sign.shutdown).toHaveBeenCalledOnce();
|
|
131
|
+
expect(r.uid).toBe("12345");
|
|
132
|
+
expect(r.nickname).toBe("Alice");
|
|
133
|
+
expect(r.profileFetchFailed).toBe(false);
|
|
134
|
+
expect(r.eventCounts.profile).toBe(1);
|
|
135
|
+
expect(r.eventCounts.watch).toBe(1);
|
|
136
|
+
expect(r.eventCounts.collect).toBe(1);
|
|
137
|
+
expect(r.eventCounts.search).toBe(1);
|
|
138
|
+
expect(r.signProviderHits).toBe(3);
|
|
139
|
+
expect(r.signProviderFallbacks).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("collect — fallback path (no signProvider)", () => {
|
|
144
|
+
it("3 signed endpoints short-circuit; profile from cookie still emitted", async () => {
|
|
145
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
146
|
+
const client = new KuaishouApiClient({ fetch: fakeFetch });
|
|
147
|
+
const r = await collect(makeBridge(COOKIE_PAYLOAD), {
|
|
148
|
+
apiClient: client,
|
|
149
|
+
stagingDir: os.tmpdir(),
|
|
150
|
+
});
|
|
151
|
+
expect(r.uid).toBe("12345");
|
|
152
|
+
expect(r.profileFetchFailed).toBe(false);
|
|
153
|
+
expect(r.eventCounts.profile).toBe(1); // pure cookie parse
|
|
154
|
+
expect(r.eventCounts.watch).toBe(0); // short-circuit
|
|
155
|
+
expect(r.eventCounts.collect).toBe(0);
|
|
156
|
+
expect(r.eventCounts.search).toBe(0);
|
|
157
|
+
expect(r.signProviderUsed).toBe("none");
|
|
158
|
+
expect(r.signProviderHits).toBe(0);
|
|
159
|
+
expect(r.signProviderFallbacks).toBe(3);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("collect — profile fetch fails (no api_ph)", () => {
|
|
164
|
+
it("emits empty snapshot with cookie-derived uid + profileFetchFailed=true", async () => {
|
|
165
|
+
const cookiePayload = {
|
|
166
|
+
cookie: "userId=12345", // no api_ph → fetchProfile fails
|
|
167
|
+
uid: "12345",
|
|
168
|
+
diagnostic: {},
|
|
169
|
+
};
|
|
170
|
+
const { fakeFetch } = makeFakeFetch([]);
|
|
171
|
+
const client = new KuaishouApiClient({ fetch: fakeFetch });
|
|
172
|
+
const r = await collect(makeBridge(cookiePayload), {
|
|
173
|
+
apiClient: client,
|
|
174
|
+
stagingDir: os.tmpdir(),
|
|
175
|
+
});
|
|
176
|
+
expect(r.profileFetchFailed).toBe(true);
|
|
177
|
+
expect(r.uid).toBe("12345"); // cookie pre-extract
|
|
178
|
+
expect(r.eventCounts.total).toBe(0);
|
|
179
|
+
expect(r.lastErrorCode).toBe(-8);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("collect — bridge warmUp failure", () => {
|
|
184
|
+
it("tolerates warmUp throw; signed endpoints fall through to short-circuit", async () => {
|
|
185
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
186
|
+
const sign = {
|
|
187
|
+
warmUp: vi.fn(async () => {
|
|
188
|
+
throw new Error("kuaishou.com 403");
|
|
189
|
+
}),
|
|
190
|
+
signUrl: vi.fn(async () => null),
|
|
191
|
+
signedHeaders: vi.fn(async () => ({})),
|
|
192
|
+
shutdown: vi.fn(async () => {}),
|
|
193
|
+
};
|
|
194
|
+
const client = new KuaishouApiClient({
|
|
195
|
+
fetch: fakeFetch,
|
|
196
|
+
signProvider: sign,
|
|
197
|
+
});
|
|
198
|
+
const r = await collect(makeBridge(COOKIE_PAYLOAD), {
|
|
199
|
+
apiClient: client,
|
|
200
|
+
signProvider: sign,
|
|
201
|
+
stagingDir: os.tmpdir(),
|
|
202
|
+
});
|
|
203
|
+
expect(r.profileFetchFailed).toBe(false); // profile pure cookie parse
|
|
204
|
+
expect(r.eventCounts.watch).toBe(0);
|
|
205
|
+
expect(client._fallbackHits).toBe(3);
|
|
206
|
+
expect(sign.shutdown).toHaveBeenCalledOnce();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("collect — malformed bridge payload", () => {
|
|
211
|
+
it("throws when bridge returns no cookie", async () => {
|
|
212
|
+
const bridge = { invoke: vi.fn(async () => ({ uid: "1" })) };
|
|
213
|
+
await expect(
|
|
214
|
+
collect(bridge, { stagingDir: os.tmpdir() }),
|
|
215
|
+
).rejects.toThrow(/malformed payload/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("throws when bridge missing invoke", async () => {
|
|
219
|
+
await expect(collect({}, {})).rejects.toThrow(
|
|
220
|
+
/bridge must expose invoke/,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("collect — signProviderUsed diagnostic", () => {
|
|
226
|
+
it("reports class name when bridge present", async () => {
|
|
227
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
228
|
+
class KuaishouSignBridge {
|
|
229
|
+
constructor() {
|
|
230
|
+
this.warmUp = vi.fn(async () => {});
|
|
231
|
+
this.signUrl = vi.fn(async (url) => {
|
|
232
|
+
const u = new URL(String(url));
|
|
233
|
+
u.searchParams.set("__NS_sig3", "X");
|
|
234
|
+
return u;
|
|
235
|
+
});
|
|
236
|
+
this.signedHeaders = vi.fn(async () => ({
|
|
237
|
+
kpf: "PC_WEB",
|
|
238
|
+
kpn: "KUAISHOU_VISION",
|
|
239
|
+
}));
|
|
240
|
+
this.shutdown = vi.fn(async () => {});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const sign = new KuaishouSignBridge();
|
|
244
|
+
const client = new KuaishouApiClient({
|
|
245
|
+
fetch: fakeFetch,
|
|
246
|
+
signProvider: sign,
|
|
247
|
+
});
|
|
248
|
+
const r = await collect(makeBridge(COOKIE_PAYLOAD), {
|
|
249
|
+
apiClient: client,
|
|
250
|
+
signProvider: sign,
|
|
251
|
+
stagingDir: os.tmpdir(),
|
|
252
|
+
});
|
|
253
|
+
expect(r.signProviderUsed).toBe("KuaishouSignBridge");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("collectAndSync", () => {
|
|
258
|
+
it("orchestrates collect + registry.syncAdapter + cleanup", async () => {
|
|
259
|
+
const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
|
|
260
|
+
const client = new KuaishouApiClient({ fetch: fakeFetch });
|
|
261
|
+
const registry = {
|
|
262
|
+
syncAdapter: vi.fn(async (name) => ({ adapter: name, status: "ok" })),
|
|
263
|
+
};
|
|
264
|
+
const r = await collectAndSync(makeBridge(COOKIE_PAYLOAD), registry, {
|
|
265
|
+
apiClient: client,
|
|
266
|
+
stagingDir: os.tmpdir(),
|
|
267
|
+
});
|
|
268
|
+
expect(registry.syncAdapter).toHaveBeenCalledWith(
|
|
269
|
+
"social-kuaishou",
|
|
270
|
+
expect.objectContaining({ inputPath: expect.stringContaining(".json") }),
|
|
271
|
+
);
|
|
272
|
+
expect(r.adapter).toBe("social-kuaishou");
|
|
273
|
+
expect(r.kuaishou.uid).toBe("12345");
|
|
274
|
+
expect(r.kuaishou.eventCounts.profile).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
});
|