@chainlesschain/personal-data-hub 0.3.6 → 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/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/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/package.json +5 -1
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
ToutiaoApiClient,
|
|
7
|
+
_internals,
|
|
8
|
+
} = require("../../lib/adapters/social-toutiao-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
|
+
describe("ToutiaoApiClient — extractUid", () => {
|
|
32
|
+
it("prefers passport_uid", () => {
|
|
33
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
34
|
+
expect(c.extractUid("passport_uid=12345; sessionid=abc")).toBe("12345");
|
|
35
|
+
expect(c.lastErrorCode).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to multi_sids first segment", () => {
|
|
39
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
40
|
+
expect(c.extractUid("multi_sids=67890:abcd;11111:efgh")).toBe("67890");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to __ac_uid", () => {
|
|
44
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
45
|
+
expect(c.extractUid("__ac_uid=555")).toBe("555");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to tt_uid legacy", () => {
|
|
49
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
50
|
+
expect(c.extractUid("tt_uid=777")).toBe("777");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null on empty cookie", () => {
|
|
54
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
55
|
+
expect(c.extractUid("")).toBe(null);
|
|
56
|
+
expect(c.lastErrorCode).toBe(-1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null on '0' sentinels (anonymous)", () => {
|
|
60
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
61
|
+
expect(c.extractUid("passport_uid=0; __ac_uid=0")).toBe(null);
|
|
62
|
+
expect(c.lastErrorCode).toBe(-7);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null when no uid candidate present", () => {
|
|
66
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
67
|
+
expect(c.extractUid("sessionid=abc; ttwid=def")).toBe(null);
|
|
68
|
+
expect(c.lastErrorCode).toBe(-7);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("ToutiaoApiClient — signProvider injection", () => {
|
|
73
|
+
it("defaults to NULL_SIGN_PROVIDER", () => {
|
|
74
|
+
const c = new ToutiaoApiClient({ fetch: () => {} });
|
|
75
|
+
expect(c.signProvider).toBe(NULL_SIGN_PROVIDER);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("uses opts.signProvider verbatim", () => {
|
|
79
|
+
const fake = { signUrl: vi.fn(async () => null) };
|
|
80
|
+
const c = new ToutiaoApiClient({ fetch: () => {}, signProvider: fake });
|
|
81
|
+
expect(c.signProvider).toBe(fake);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("fetchProfile does NOT call signUrl (no _sig required)", async () => {
|
|
85
|
+
const { fakeFetch } = makeFakeFetch([
|
|
86
|
+
[
|
|
87
|
+
"passport/account/info/v2",
|
|
88
|
+
{
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
status_code: 0,
|
|
91
|
+
data: { user_id: "12345", screen_name: "Alice" },
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
]);
|
|
96
|
+
const sign = { signUrl: vi.fn(async () => null) };
|
|
97
|
+
const c = new ToutiaoApiClient({
|
|
98
|
+
fetch: fakeFetch,
|
|
99
|
+
signProvider: sign,
|
|
100
|
+
});
|
|
101
|
+
await c.fetchProfile("sessionid=abc");
|
|
102
|
+
expect(sign.signUrl).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("3 signed endpoints SHORT-CIRCUIT (-99) when NullSignProvider", async () => {
|
|
106
|
+
const { fakeFetch, calls } = makeFakeFetch([]);
|
|
107
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
108
|
+
const f = await c.fetchFeed("sessionid=abc");
|
|
109
|
+
const co = await c.fetchCollection("sessionid=abc");
|
|
110
|
+
const s = await c.fetchSearchHistory("sessionid=abc");
|
|
111
|
+
expect(f).toEqual([]);
|
|
112
|
+
expect(co).toEqual([]);
|
|
113
|
+
expect(s).toEqual([]);
|
|
114
|
+
expect(c.lastErrorCode).toBe(-99);
|
|
115
|
+
expect(c._fallbackHits).toBe(3);
|
|
116
|
+
expect(c._bridgeHits).toBe(0);
|
|
117
|
+
// Critical: NO HTTP requests went out (signed endpoints short-
|
|
118
|
+
// circuit BEFORE calling fetch — saves bandwidth on cold bridge).
|
|
119
|
+
expect(calls).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("signed endpoint sends mutated URL when signProvider returns one", async () => {
|
|
123
|
+
const { fakeFetch, calls } = makeFakeFetch([
|
|
124
|
+
[
|
|
125
|
+
"api/news/feed/v90",
|
|
126
|
+
{
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
data: [
|
|
129
|
+
{
|
|
130
|
+
group_id: "G1",
|
|
131
|
+
title: "T",
|
|
132
|
+
behot_time: 1700000000,
|
|
133
|
+
category: "tech",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
]);
|
|
140
|
+
const sign = {
|
|
141
|
+
signUrl: vi.fn(async (url) => {
|
|
142
|
+
const u = new URL(String(url));
|
|
143
|
+
u.searchParams.set("_signature", "BRIDGE_SIG_123");
|
|
144
|
+
return u;
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
148
|
+
const items = await c.fetchFeed("sessionid=abc", { limit: 5 });
|
|
149
|
+
expect(items).toHaveLength(1);
|
|
150
|
+
expect(items[0].itemId).toBe("G1");
|
|
151
|
+
expect(sign.signUrl).toHaveBeenCalledOnce();
|
|
152
|
+
expect(calls[0].url).toContain("_signature=BRIDGE_SIG_123");
|
|
153
|
+
expect(c._bridgeHits).toBe(1);
|
|
154
|
+
expect(c._fallbackHits).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("forwards purpose string to signUrl", async () => {
|
|
158
|
+
const { fakeFetch } = makeFakeFetch([
|
|
159
|
+
["api/news/feed/v90", { body: JSON.stringify({ data: [] }) }],
|
|
160
|
+
["article/v2/tab_comments", { body: JSON.stringify({ data: [] }) }],
|
|
161
|
+
["api/search/content", { body: JSON.stringify({ data: {} }) }],
|
|
162
|
+
]);
|
|
163
|
+
const sign = {
|
|
164
|
+
signUrl: vi.fn(async (url) => {
|
|
165
|
+
const u = new URL(String(url));
|
|
166
|
+
u.searchParams.set("_signature", "X");
|
|
167
|
+
return u;
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
171
|
+
await c.fetchFeed("sessionid=abc");
|
|
172
|
+
await c.fetchCollection("sessionid=abc");
|
|
173
|
+
await c.fetchSearchHistory("sessionid=abc");
|
|
174
|
+
expect(sign.signUrl.mock.calls[0][1]).toBe("feed");
|
|
175
|
+
expect(sign.signUrl.mock.calls[1][1]).toBe("comments");
|
|
176
|
+
expect(sign.signUrl.mock.calls[2][1]).toBe("search");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("ToutiaoApiClient — fetchProfile", () => {
|
|
181
|
+
it("parses status_code=0 + data.user_id (string)", async () => {
|
|
182
|
+
const { fakeFetch, calls } = makeFakeFetch([
|
|
183
|
+
[
|
|
184
|
+
"passport/account/info/v2",
|
|
185
|
+
{
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
status_code: 0,
|
|
188
|
+
data: {
|
|
189
|
+
user_id: "12345",
|
|
190
|
+
screen_name: "Alice",
|
|
191
|
+
avatar_url: "https://a/x.jpg",
|
|
192
|
+
mobile: "138****",
|
|
193
|
+
description: "bio",
|
|
194
|
+
following_count: 10,
|
|
195
|
+
followers_count: 99,
|
|
196
|
+
media_id: "5678",
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
]);
|
|
202
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
203
|
+
const p = await c.fetchProfile("sessionid=abc");
|
|
204
|
+
expect(p).toMatchObject({
|
|
205
|
+
uid: "12345",
|
|
206
|
+
nickname: "Alice",
|
|
207
|
+
avatarUrl: "https://a/x.jpg",
|
|
208
|
+
mobile: "138****",
|
|
209
|
+
description: "bio",
|
|
210
|
+
followingCount: 10,
|
|
211
|
+
followerCount: 99,
|
|
212
|
+
mediaId: "5678",
|
|
213
|
+
});
|
|
214
|
+
// aid=24 is in the URL
|
|
215
|
+
expect(calls[0].url).toContain("aid=24");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns null on status_code != 0", async () => {
|
|
219
|
+
const { fakeFetch } = makeFakeFetch([
|
|
220
|
+
[
|
|
221
|
+
"passport/account/info/v2",
|
|
222
|
+
{
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
status_code: 1,
|
|
225
|
+
status_msg: "token expired",
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
]);
|
|
230
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
231
|
+
const p = await c.fetchProfile("sessionid=abc");
|
|
232
|
+
expect(p).toBe(null);
|
|
233
|
+
expect(c.lastErrorCode).toBe(1);
|
|
234
|
+
expect(c.lastErrorMessage).toBe("token expired");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns null on missing data object", async () => {
|
|
238
|
+
const { fakeFetch } = makeFakeFetch([
|
|
239
|
+
[
|
|
240
|
+
"passport/account/info/v2",
|
|
241
|
+
{ body: JSON.stringify({ status_code: 0 }) },
|
|
242
|
+
],
|
|
243
|
+
]);
|
|
244
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
245
|
+
expect(await c.fetchProfile("sessionid=abc")).toBe(null);
|
|
246
|
+
expect(c.lastErrorCode).toBe(-6);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("returns null on missing user_id in data", async () => {
|
|
250
|
+
const { fakeFetch } = makeFakeFetch([
|
|
251
|
+
[
|
|
252
|
+
"passport/account/info/v2",
|
|
253
|
+
{
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
status_code: 0,
|
|
256
|
+
data: { screen_name: "noUidShown" },
|
|
257
|
+
}),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
]);
|
|
261
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
262
|
+
expect(await c.fetchProfile("sessionid=abc")).toBe(null);
|
|
263
|
+
expect(c.lastErrorCode).toBe(-7);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("HTTP 412 anti-bot returns null with status code", async () => {
|
|
267
|
+
const { fakeFetch } = makeFakeFetch([
|
|
268
|
+
["passport/account/info/v2", { status: 412, body: "<html>blocked" }],
|
|
269
|
+
]);
|
|
270
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
271
|
+
expect(await c.fetchProfile("sessionid=abc")).toBe(null);
|
|
272
|
+
expect(c.lastErrorCode).toBe(412);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("non-JSON response returns null with -4", async () => {
|
|
276
|
+
const { fakeFetch } = makeFakeFetch([
|
|
277
|
+
["passport/account/info/v2", { body: "<html>login redirect" }],
|
|
278
|
+
]);
|
|
279
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch });
|
|
280
|
+
expect(await c.fetchProfile("sessionid=abc")).toBe(null);
|
|
281
|
+
expect(c.lastErrorCode).toBe(-4);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("ToutiaoApiClient — fetchFeed", () => {
|
|
286
|
+
it("parses items + top-level raw fields", async () => {
|
|
287
|
+
const { fakeFetch } = makeFakeFetch([
|
|
288
|
+
[
|
|
289
|
+
"api/news/feed/v90",
|
|
290
|
+
{
|
|
291
|
+
body: JSON.stringify({
|
|
292
|
+
data: [
|
|
293
|
+
{
|
|
294
|
+
group_id: "G1",
|
|
295
|
+
title: "Title 1",
|
|
296
|
+
category: "tech",
|
|
297
|
+
source: "WeRead",
|
|
298
|
+
behot_time: 1700000000, // seconds → ms via normalizeMs
|
|
299
|
+
read_duration: 30,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
]);
|
|
306
|
+
const sign = {
|
|
307
|
+
signUrl: vi.fn(async (url) => {
|
|
308
|
+
const u = new URL(String(url));
|
|
309
|
+
u.searchParams.set("_signature", "X");
|
|
310
|
+
return u;
|
|
311
|
+
}),
|
|
312
|
+
};
|
|
313
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
314
|
+
const items = await c.fetchFeed("sessionid=abc");
|
|
315
|
+
expect(items).toHaveLength(1);
|
|
316
|
+
expect(items[0]).toMatchObject({
|
|
317
|
+
itemId: "G1",
|
|
318
|
+
title: "Title 1",
|
|
319
|
+
category: "tech",
|
|
320
|
+
readDuration: 30,
|
|
321
|
+
});
|
|
322
|
+
expect(items[0].publishedAt).toBe(1700000000 * 1000);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("decodes nested raw_data JSON string", async () => {
|
|
326
|
+
const { fakeFetch } = makeFakeFetch([
|
|
327
|
+
[
|
|
328
|
+
"api/news/feed/v90",
|
|
329
|
+
{
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
data: [
|
|
332
|
+
{
|
|
333
|
+
category: "outer-cat",
|
|
334
|
+
raw_data: JSON.stringify({
|
|
335
|
+
group_id: "INNER1",
|
|
336
|
+
title: "From nested",
|
|
337
|
+
behot_time: 1700000000,
|
|
338
|
+
}),
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
}),
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
]);
|
|
345
|
+
const sign = {
|
|
346
|
+
signUrl: vi.fn(async (url) => {
|
|
347
|
+
const u = new URL(String(url));
|
|
348
|
+
u.searchParams.set("_signature", "X");
|
|
349
|
+
return u;
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
353
|
+
const items = await c.fetchFeed("sessionid=abc");
|
|
354
|
+
expect(items[0].itemId).toBe("INNER1");
|
|
355
|
+
expect(items[0].title).toBe("From nested");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("falls back to outer category when nested item lacks one", async () => {
|
|
359
|
+
const { fakeFetch } = makeFakeFetch([
|
|
360
|
+
[
|
|
361
|
+
"api/news/feed/v90",
|
|
362
|
+
{
|
|
363
|
+
body: JSON.stringify({
|
|
364
|
+
data: [
|
|
365
|
+
{
|
|
366
|
+
category: "outer-cat",
|
|
367
|
+
raw_data: JSON.stringify({ group_id: "G", title: "T" }),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
}),
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
]);
|
|
374
|
+
const sign = {
|
|
375
|
+
signUrl: vi.fn(async (url) => {
|
|
376
|
+
const u = new URL(String(url));
|
|
377
|
+
u.searchParams.set("_signature", "X");
|
|
378
|
+
return u;
|
|
379
|
+
}),
|
|
380
|
+
};
|
|
381
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
382
|
+
const items = await c.fetchFeed("sessionid=abc");
|
|
383
|
+
expect(items[0].category).toBe("outer-cat");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("returns [] on empty data array", async () => {
|
|
387
|
+
const { fakeFetch } = makeFakeFetch([
|
|
388
|
+
["api/news/feed/v90", { body: JSON.stringify({ data: [] }) }],
|
|
389
|
+
]);
|
|
390
|
+
const sign = {
|
|
391
|
+
signUrl: vi.fn(async (url) => {
|
|
392
|
+
const u = new URL(String(url));
|
|
393
|
+
u.searchParams.set("_signature", "X");
|
|
394
|
+
return u;
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
398
|
+
expect(await c.fetchFeed("sessionid=abc")).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("ToutiaoApiClient — fetchCollection", () => {
|
|
403
|
+
it("parses saved articles", async () => {
|
|
404
|
+
const { fakeFetch } = makeFakeFetch([
|
|
405
|
+
[
|
|
406
|
+
"article/v2/tab_comments",
|
|
407
|
+
{
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
data: [
|
|
410
|
+
{
|
|
411
|
+
group_id: "C1",
|
|
412
|
+
title: "Saved 1",
|
|
413
|
+
category: "news",
|
|
414
|
+
behot_time: 1700000000,
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
]);
|
|
421
|
+
const sign = {
|
|
422
|
+
signUrl: vi.fn(async (url) => {
|
|
423
|
+
const u = new URL(String(url));
|
|
424
|
+
u.searchParams.set("_signature", "X");
|
|
425
|
+
return u;
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
429
|
+
const items = await c.fetchCollection("sessionid=abc");
|
|
430
|
+
expect(items[0]).toMatchObject({
|
|
431
|
+
itemId: "C1",
|
|
432
|
+
title: "Saved 1",
|
|
433
|
+
category: "news",
|
|
434
|
+
});
|
|
435
|
+
expect(items[0].savedAt).toBe(1700000000 * 1000);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("ToutiaoApiClient — fetchSearchHistory", () => {
|
|
440
|
+
it("parses object-shape history with timestamps", async () => {
|
|
441
|
+
const { fakeFetch } = makeFakeFetch([
|
|
442
|
+
[
|
|
443
|
+
"api/search/content",
|
|
444
|
+
{
|
|
445
|
+
body: JSON.stringify({
|
|
446
|
+
data: {
|
|
447
|
+
user_search_history: [
|
|
448
|
+
{ keyword: "AI", time: 1700000000 },
|
|
449
|
+
{ keyword: "rust", time: 1700001000 },
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
]);
|
|
456
|
+
const sign = {
|
|
457
|
+
signUrl: vi.fn(async (url) => {
|
|
458
|
+
const u = new URL(String(url));
|
|
459
|
+
u.searchParams.set("_signature", "X");
|
|
460
|
+
return u;
|
|
461
|
+
}),
|
|
462
|
+
};
|
|
463
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
464
|
+
const items = await c.fetchSearchHistory("sessionid=abc");
|
|
465
|
+
expect(items).toHaveLength(2);
|
|
466
|
+
expect(items[0]).toEqual({ keyword: "AI", searchedAt: 1700000000 * 1000 });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("parses bare-string legacy history (no timestamps)", async () => {
|
|
470
|
+
const { fakeFetch } = makeFakeFetch([
|
|
471
|
+
[
|
|
472
|
+
"api/search/content",
|
|
473
|
+
{
|
|
474
|
+
body: JSON.stringify({
|
|
475
|
+
data: { user_search_history: ["AI", "rust", "kotlin"] },
|
|
476
|
+
}),
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
]);
|
|
480
|
+
const sign = {
|
|
481
|
+
signUrl: vi.fn(async (url) => {
|
|
482
|
+
const u = new URL(String(url));
|
|
483
|
+
u.searchParams.set("_signature", "X");
|
|
484
|
+
return u;
|
|
485
|
+
}),
|
|
486
|
+
};
|
|
487
|
+
const fixedNow = () => 1716383021000;
|
|
488
|
+
const c = new ToutiaoApiClient({
|
|
489
|
+
fetch: fakeFetch,
|
|
490
|
+
signProvider: sign,
|
|
491
|
+
now: fixedNow,
|
|
492
|
+
});
|
|
493
|
+
const items = await c.fetchSearchHistory("sessionid=abc");
|
|
494
|
+
expect(items).toHaveLength(3);
|
|
495
|
+
// Latest first ordering — index 0 gets fixedNow
|
|
496
|
+
expect(items[0]).toEqual({ keyword: "AI", searchedAt: 1716383021000 });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("falls back to data.search_history shape", async () => {
|
|
500
|
+
const { fakeFetch } = makeFakeFetch([
|
|
501
|
+
[
|
|
502
|
+
"api/search/content",
|
|
503
|
+
{
|
|
504
|
+
body: JSON.stringify({
|
|
505
|
+
data: { search_history: [{ keyword: "fallback", time: 1 }] },
|
|
506
|
+
}),
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
]);
|
|
510
|
+
const sign = {
|
|
511
|
+
signUrl: vi.fn(async (url) => {
|
|
512
|
+
const u = new URL(String(url));
|
|
513
|
+
u.searchParams.set("_signature", "X");
|
|
514
|
+
return u;
|
|
515
|
+
}),
|
|
516
|
+
};
|
|
517
|
+
const c = new ToutiaoApiClient({ fetch: fakeFetch, signProvider: sign });
|
|
518
|
+
const items = await c.fetchSearchHistory("sessionid=abc");
|
|
519
|
+
expect(items[0].keyword).toBe("fallback");
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("normalizeMs", () => {
|
|
524
|
+
it("passes through ms (>1e12)", () => {
|
|
525
|
+
expect(_internals.normalizeMs(1700000000000)).toBe(1700000000000);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("multiplies seconds (<1e12) by 1000", () => {
|
|
529
|
+
expect(_internals.normalizeMs(1700000000)).toBe(1700000000 * 1000);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("returns 0 for invalid / negative", () => {
|
|
533
|
+
expect(_internals.normalizeMs(0)).toBe(0);
|
|
534
|
+
expect(_internals.normalizeMs(-1)).toBe(0);
|
|
535
|
+
expect(_internals.normalizeMs(NaN)).toBe(0);
|
|
536
|
+
});
|
|
537
|
+
});
|