@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.
Files changed (58) hide show
  1. package/__tests__/adapters/edu-huawei-learning-live.test.js +198 -0
  2. package/__tests__/adapters/edu-zuoyebang-live.test.js +226 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +5 -1
  4. package/__tests__/adapters/finance-alipay-live.test.js +258 -0
  5. package/__tests__/adapters/game-genshin-live.test.js +238 -0
  6. package/__tests__/adapters/game-genshin-scaffold.test.js +4 -3
  7. package/__tests__/adapters/game-honor-of-kings-live.test.js +230 -0
  8. package/__tests__/adapters/messaging-whatsapp.test.js +289 -0
  9. package/__tests__/adapters/netease-music-live.test.js +244 -0
  10. package/__tests__/adapters/shopping-base.test.js +179 -0
  11. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +165 -0
  12. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +192 -0
  13. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +64 -0
  14. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +11 -0
  15. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +135 -0
  16. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +89 -0
  17. package/__tests__/adapters/social-toutiao-adb-collector.test.js +95 -2
  18. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +30 -0
  19. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +431 -0
  20. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  21. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +200 -0
  22. package/__tests__/adapters/travel-12306.test.js +279 -0
  23. package/__tests__/adapters/travel-amap.test.js +219 -0
  24. package/__tests__/adapters/travel-baidu-map.test.js +305 -0
  25. package/__tests__/adapters/travel-base.test.js +205 -0
  26. package/__tests__/adapters/travel-ctrip.test.js +203 -0
  27. package/__tests__/adapters/travel-tencent-map.test.js +207 -0
  28. package/lib/adapters/_live-json-helpers.js +50 -0
  29. package/lib/adapters/edu-huawei-learning/api-client.js +178 -5
  30. package/lib/adapters/edu-huawei-learning/index.js +83 -9
  31. package/lib/adapters/edu-zuoyebang/api-client.js +181 -6
  32. package/lib/adapters/edu-zuoyebang/index.js +83 -9
  33. package/lib/adapters/finance-alipay/api-client.js +268 -6
  34. package/lib/adapters/finance-alipay/index.js +85 -9
  35. package/lib/adapters/game-genshin/api-client.js +207 -6
  36. package/lib/adapters/game-genshin/index.js +90 -9
  37. package/lib/adapters/game-honor-of-kings/api-client.js +235 -12
  38. package/lib/adapters/game-honor-of-kings/index.js +80 -9
  39. package/lib/adapters/netease-music/api-client.js +284 -0
  40. package/lib/adapters/netease-music/index.js +85 -9
  41. package/lib/adapters/social-douyin/index.js +2 -0
  42. package/lib/adapters/social-douyin-adb/aweme-detail-client.js +119 -0
  43. package/lib/adapters/social-douyin-adb/collector.js +114 -0
  44. package/lib/adapters/social-douyin-adb/index.js +18 -1
  45. package/lib/adapters/social-douyin-adb/watch-history-reader.js +188 -0
  46. package/lib/adapters/social-kuaishou/index.js +7 -2
  47. package/lib/adapters/social-kuaishou-adb/api-client.js +38 -18
  48. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +16 -15
  49. package/lib/adapters/social-toutiao/index.js +8 -4
  50. package/lib/adapters/social-toutiao-adb/account-reader.js +179 -0
  51. package/lib/adapters/social-toutiao-adb/api-client.js +41 -17
  52. package/lib/adapters/social-toutiao-adb/collector.js +55 -19
  53. package/lib/adapters/social-toutiao-adb/cookies-extension.js +21 -1
  54. package/lib/adapters/social-toutiao-adb/index.js +6 -0
  55. package/lib/adapters/social-xiaohongshu-adb/cookies-extension.js +19 -1
  56. package/lib/adapters/travel-base/index.js +9 -2
  57. package/lib/index.js +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,431 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+
5
+ const {
6
+ XhsApiClient,
7
+ _internals,
8
+ } = require("../../lib/adapters/social-xiaohongshu-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 ME_OK = {
32
+ body: JSON.stringify({
33
+ code: 0,
34
+ success: true,
35
+ data: { user_id: "5e8c8f7e000000000100abcd", nickname: "Alice" },
36
+ }),
37
+ };
38
+
39
+ describe("parseCount", () => {
40
+ it("parses 万 suffix (1.2万 → 12000)", () => {
41
+ expect(_internals.parseCount("1.2万")).toBe(12000);
42
+ });
43
+
44
+ it("parses w+ / W+ suffix (10w+ → 100000)", () => {
45
+ expect(_internals.parseCount("10w+")).toBe(100000);
46
+ expect(_internals.parseCount("10W+")).toBe(100000);
47
+ });
48
+
49
+ it("parses bare w / W suffix (3w → 30000)", () => {
50
+ expect(_internals.parseCount("3w")).toBe(30000);
51
+ expect(_internals.parseCount("3W")).toBe(30000);
52
+ });
53
+
54
+ it("parses 亿 suffix (1.5亿 → 150000000)", () => {
55
+ expect(_internals.parseCount("1.5亿")).toBe(150000000);
56
+ });
57
+
58
+ it("parses plain integer string", () => {
59
+ expect(_internals.parseCount("234")).toBe(234);
60
+ });
61
+
62
+ it("returns 0 for empty / non-string / non-numeric", () => {
63
+ expect(_internals.parseCount("")).toBe(0);
64
+ expect(_internals.parseCount(null)).toBe(0);
65
+ expect(_internals.parseCount(42)).toBe(0);
66
+ expect(_internals.parseCount("abc")).toBe(0);
67
+ expect(_internals.parseCount("x万")).toBe(0);
68
+ });
69
+ });
70
+
71
+ describe("normalizeMs", () => {
72
+ it("passes ms through, multiplies seconds, returns 0 for invalid", () => {
73
+ expect(_internals.normalizeMs(1700000000000)).toBe(1700000000000);
74
+ expect(_internals.normalizeMs(1700000000)).toBe(1700000000 * 1000);
75
+ expect(_internals.normalizeMs(0)).toBe(0);
76
+ expect(_internals.normalizeMs(-1)).toBe(0);
77
+ expect(_internals.normalizeMs("1700000000")).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe("XhsApiClient — construction", () => {
82
+ it("defaults to NULL_SIGN_PROVIDER", () => {
83
+ const c = new XhsApiClient({ fetch: () => {} });
84
+ expect(c.signProvider).toBe(NULL_SIGN_PROVIDER);
85
+ });
86
+
87
+ it("normalizes baseUrl to trailing slash", () => {
88
+ const c = new XhsApiClient({
89
+ fetch: () => {},
90
+ baseUrl: "https://edith.example.com",
91
+ });
92
+ expect(c.baseUrl).toBe("https://edith.example.com/");
93
+ });
94
+
95
+ it("throws when fetch unavailable", () => {
96
+ vi.stubGlobal("fetch", undefined);
97
+ try {
98
+ expect(() => new XhsApiClient({})).toThrow(/fetch not available/);
99
+ } finally {
100
+ vi.unstubAllGlobals();
101
+ }
102
+ });
103
+ });
104
+
105
+ describe("XhsApiClient — fetchMe (cookies-only, no X-S)", () => {
106
+ it("returns {userId, nickname} and sends desktop-Chrome headers without X-S", async () => {
107
+ const { fakeFetch, calls } = makeFakeFetch([["user/me", ME_OK]]);
108
+ const c = new XhsApiClient({ fetch: fakeFetch });
109
+ const me = await c.fetchMe("a1=fp; web_session=tok");
110
+ expect(me).toEqual({
111
+ userId: "5e8c8f7e000000000100abcd",
112
+ nickname: "Alice",
113
+ });
114
+ expect(calls).toHaveLength(1);
115
+ const headers = calls[0].opts.headers;
116
+ expect(headers.Cookie).toBe("a1=fp; web_session=tok");
117
+ // xhs web is desktop-tuned — UA must NOT be mobile
118
+ expect(headers["User-Agent"]).toContain("Windows NT 10.0");
119
+ expect(headers.Referer).toBe("https://www.xiaohongshu.com/");
120
+ // /user/me is the unsigned endpoint
121
+ expect(headers["X-S"]).toBeUndefined();
122
+ expect(headers["X-T"]).toBeUndefined();
123
+ expect(c.lastErrorCode).toBe(0);
124
+ });
125
+
126
+ it("returns null with -7 when user_id blank (web_session missing)", async () => {
127
+ const { fakeFetch } = makeFakeFetch([
128
+ [
129
+ "user/me",
130
+ { body: JSON.stringify({ code: 0, data: { nickname: "Ghost" } }) },
131
+ ],
132
+ ]);
133
+ const c = new XhsApiClient({ fetch: fakeFetch });
134
+ expect(await c.fetchMe("a1=fp")).toBe(null);
135
+ expect(c.lastErrorCode).toBe(-7);
136
+ expect(c.lastErrorMessage).toMatch(/web_session/);
137
+ });
138
+
139
+ it("returns null on HTTP error status (461 anti-bot)", async () => {
140
+ const { fakeFetch } = makeFakeFetch([
141
+ ["user/me", { status: 461, body: "blocked" }],
142
+ ]);
143
+ const c = new XhsApiClient({ fetch: fakeFetch });
144
+ expect(await c.fetchMe("a1=fp")).toBe(null);
145
+ expect(c.lastErrorCode).toBe(461);
146
+ });
147
+
148
+ it("returns null with -4 on non-JSON body (login redirect HTML)", async () => {
149
+ const { fakeFetch } = makeFakeFetch([
150
+ ["user/me", { body: "<html>login</html>" }],
151
+ ]);
152
+ const c = new XhsApiClient({ fetch: fakeFetch });
153
+ expect(await c.fetchMe("a1=fp")).toBe(null);
154
+ expect(c.lastErrorCode).toBe(-4);
155
+ });
156
+
157
+ it("returns null with -3 on truncated JSON", async () => {
158
+ const { fakeFetch } = makeFakeFetch([["user/me", { body: '{"code":0,' }]]);
159
+ const c = new XhsApiClient({ fetch: fakeFetch });
160
+ expect(await c.fetchMe("a1=fp")).toBe(null);
161
+ expect(c.lastErrorCode).toBe(-3);
162
+ });
163
+
164
+ it("returns null with -5 on success=false", async () => {
165
+ const { fakeFetch } = makeFakeFetch([
166
+ ["user/me", { body: JSON.stringify({ success: false, data: {} }) }],
167
+ ]);
168
+ const c = new XhsApiClient({ fetch: fakeFetch });
169
+ expect(await c.fetchMe("a1=fp")).toBe(null);
170
+ expect(c.lastErrorCode).toBe(-5);
171
+ });
172
+
173
+ it("surfaces xhs business error code + msg", async () => {
174
+ const { fakeFetch } = makeFakeFetch([
175
+ [
176
+ "user/me",
177
+ { body: JSON.stringify({ code: -100, msg: "登录已过期" }) },
178
+ ],
179
+ ]);
180
+ const c = new XhsApiClient({ fetch: fakeFetch });
181
+ expect(await c.fetchMe("a1=fp")).toBe(null);
182
+ expect(c.lastErrorCode).toBe(-100);
183
+ expect(c.lastErrorMessage).toBe("登录已过期");
184
+ });
185
+
186
+ it("returns null with -2 on network throw", async () => {
187
+ const c = new XhsApiClient({
188
+ fetch: async () => {
189
+ throw new Error("ECONNRESET");
190
+ },
191
+ });
192
+ expect(await c.fetchMe("a1=fp")).toBe(null);
193
+ expect(c.lastErrorCode).toBe(-2);
194
+ expect(c.lastErrorMessage).toMatch(/ECONNRESET/);
195
+ });
196
+ });
197
+
198
+ describe("XhsApiClient — signProvider injection (bridge vs fallback)", () => {
199
+ const NOTES_OK = {
200
+ body: JSON.stringify({
201
+ code: 0,
202
+ data: {
203
+ notes: [
204
+ {
205
+ note_id: "N1",
206
+ display_title: "Note one",
207
+ type: "normal",
208
+ time: 1700000000,
209
+ interact_info: {
210
+ liked_count: "1.2万",
211
+ collected_count: "10w+",
212
+ comment_count: "234",
213
+ },
214
+ },
215
+ ],
216
+ },
217
+ }),
218
+ };
219
+
220
+ it("uses bridge headers verbatim when bridge produces them", async () => {
221
+ const { fakeFetch, calls } = makeFakeFetch([["user_posted", NOTES_OK]]);
222
+ const sign = {
223
+ signedHeaders: vi.fn(async (_url, _purpose) => ({
224
+ "X-s": "XYW_BRIDGE",
225
+ "X-t": "1716383021000",
226
+ "X-s-common": "common",
227
+ })),
228
+ };
229
+ const c = new XhsApiClient({ fetch: fakeFetch, signProvider: sign });
230
+ const notes = await c.fetchNotes("a1=fp; web_session=t", "fp", "U1");
231
+ expect(notes).toHaveLength(1);
232
+ expect(sign.signedHeaders).toHaveBeenCalledOnce();
233
+ // purpose = "<path+query>|" (GET — empty body after pipe)
234
+ const [, purpose] = sign.signedHeaders.mock.calls[0];
235
+ expect(purpose).toMatch(/^\/api\/sns\/web\/v2\/user_posted\?/);
236
+ expect(purpose.endsWith("|")).toBe(true);
237
+ expect(calls[0].opts.headers["X-s"]).toBe("XYW_BRIDGE");
238
+ // bridge path must NOT also attach the fallback md5 X-S
239
+ expect(calls[0].opts.headers["X-S"]).toBeUndefined();
240
+ expect(c._bridgeHits).toBe(1);
241
+ expect(c._fallbackHits).toBe(0);
242
+ });
243
+
244
+ it("falls back to in-process computeXsXt when bridge returns {} (NULL provider)", async () => {
245
+ const { fakeFetch, calls } = makeFakeFetch([["user_posted", NOTES_OK]]);
246
+ const c = new XhsApiClient({
247
+ fetch: fakeFetch,
248
+ now: () => 1716383021000,
249
+ });
250
+ const notes = await c.fetchNotes("a1=fp; web_session=t", "fp", "U1");
251
+ // unlike Kuaishou (-99 short-circuit), xhs degrades to best-effort md5
252
+ // and the HTTP request IS made
253
+ expect(notes).toHaveLength(1);
254
+ expect(calls).toHaveLength(1);
255
+ expect(calls[0].opts.headers["X-S"]).toMatch(/^XYW_/);
256
+ expect(calls[0].opts.headers["X-T"]).toBe("1716383021000");
257
+ expect(c._fallbackHits).toBe(1);
258
+ expect(c._bridgeHits).toBe(0);
259
+ });
260
+
261
+ it("skips signing entirely when a1 missing", async () => {
262
+ const { fakeFetch, calls } = makeFakeFetch([["user_posted", NOTES_OK]]);
263
+ const sign = { signedHeaders: vi.fn(async () => ({ "X-s": "X" })) };
264
+ const c = new XhsApiClient({ fetch: fakeFetch, signProvider: sign });
265
+ await c.fetchNotes("web_session=t", null, "U1");
266
+ expect(sign.signedHeaders).not.toHaveBeenCalled();
267
+ expect(calls[0].opts.headers["X-S"]).toBeUndefined();
268
+ expect(calls[0].opts.headers["X-s"]).toBeUndefined();
269
+ });
270
+ });
271
+
272
+ describe("XhsApiClient — fetchNotes parsing", () => {
273
+ it("parses notes with count strings + seconds→ms timestamp", async () => {
274
+ const { fakeFetch, calls } = makeFakeFetch([
275
+ [
276
+ "user_posted",
277
+ {
278
+ body: JSON.stringify({
279
+ code: 0,
280
+ data: {
281
+ notes: [
282
+ {
283
+ note_id: "N1",
284
+ display_title: "Note one",
285
+ desc: "desc",
286
+ type: "video",
287
+ time: 1700000000,
288
+ interact_info: {
289
+ liked_count: "1.2万",
290
+ collected_count: "10w+",
291
+ comment_count: "234",
292
+ },
293
+ },
294
+ ],
295
+ },
296
+ }),
297
+ },
298
+ ],
299
+ ]);
300
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
301
+ const notes = await c.fetchNotes("a1=fp", "fp", "U1");
302
+ expect(notes[0]).toEqual({
303
+ noteId: "N1",
304
+ title: "Note one",
305
+ desc: "desc",
306
+ type: "video",
307
+ createdAt: 1700000000 * 1000,
308
+ likedCount: 12000,
309
+ collectedCount: 100000,
310
+ commentCount: 234,
311
+ });
312
+ // request carries user_id param
313
+ expect(calls[0].url).toContain("user_id=U1");
314
+ });
315
+
316
+ it("accepts id alias, skips entries without any id, applies limit", async () => {
317
+ const { fakeFetch } = makeFakeFetch([
318
+ [
319
+ "user_posted",
320
+ {
321
+ body: JSON.stringify({
322
+ code: 0,
323
+ data: {
324
+ notes: [
325
+ { id: "ALIAS", title: "t" },
326
+ { title: "no id — skipped" },
327
+ { note_id: "N2" },
328
+ { note_id: "N3-over-limit" },
329
+ ],
330
+ },
331
+ }),
332
+ },
333
+ ],
334
+ ]);
335
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
336
+ const notes = await c.fetchNotes("a1=fp", "fp", "U1", { limit: 3 });
337
+ // limit caps the SCAN window (first 3 entries), so the skipped
338
+ // no-id entry leaves 2 results
339
+ expect(notes.map((n) => n.noteId)).toEqual(["ALIAS", "N2"]);
340
+ expect(notes[0].title).toBe("t");
341
+ expect(notes[1].title).toBe("(no title)");
342
+ });
343
+
344
+ it("returns [] on endpoint failure", async () => {
345
+ const { fakeFetch } = makeFakeFetch([
346
+ ["user_posted", { status: 461, body: "sig rejected" }],
347
+ ]);
348
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
349
+ expect(await c.fetchNotes("a1=fp", "fp", "U1")).toEqual([]);
350
+ expect(c.lastErrorCode).toBe(461);
351
+ });
352
+ });
353
+
354
+ describe("XhsApiClient — fetchLiked parsing", () => {
355
+ it("parses liked notes (likedAt left 0 for collector to fill)", async () => {
356
+ const { fakeFetch } = makeFakeFetch([
357
+ [
358
+ "note/like/page",
359
+ {
360
+ body: JSON.stringify({
361
+ code: 0,
362
+ data: {
363
+ notes: [
364
+ {
365
+ note_id: "L1",
366
+ display_title: "Liked one",
367
+ user: { nickname: "AuthorX" },
368
+ },
369
+ { title: "no note_id — skipped" },
370
+ ],
371
+ },
372
+ }),
373
+ },
374
+ ],
375
+ ]);
376
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
377
+ const liked = await c.fetchLiked("a1=fp", "fp");
378
+ expect(liked).toHaveLength(1);
379
+ expect(liked[0]).toEqual({
380
+ noteId: "L1",
381
+ title: "Liked one",
382
+ likedAt: 0,
383
+ authorNickname: "AuthorX",
384
+ });
385
+ });
386
+
387
+ it("returns [] when data.notes missing", async () => {
388
+ const { fakeFetch } = makeFakeFetch([
389
+ ["note/like/page", { body: JSON.stringify({ code: 0, data: {} }) }],
390
+ ]);
391
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
392
+ expect(await c.fetchLiked("a1=fp", "fp")).toEqual([]);
393
+ });
394
+ });
395
+
396
+ describe("XhsApiClient — fetchFollows parsing", () => {
397
+ it("parses follow list with userId passthrough", async () => {
398
+ const { fakeFetch, calls } = makeFakeFetch([
399
+ [
400
+ "follow/list",
401
+ {
402
+ body: JSON.stringify({
403
+ code: 0,
404
+ data: {
405
+ users: [
406
+ {
407
+ user_id: "F1",
408
+ nickname: "FollowOne",
409
+ image: "https://a/f1.jpg",
410
+ },
411
+ { nickname: "no user_id — skipped" },
412
+ { user_id: "F2" },
413
+ ],
414
+ },
415
+ }),
416
+ },
417
+ ],
418
+ ]);
419
+ const c = new XhsApiClient({ fetch: fakeFetch, now: () => 1 });
420
+ const follows = await c.fetchFollows("a1=fp", "fp", "U1");
421
+ expect(follows).toHaveLength(2);
422
+ expect(follows[0]).toEqual({
423
+ userId: "F1",
424
+ nickname: "FollowOne",
425
+ image: "https://a/f1.jpg",
426
+ followedAt: 0,
427
+ });
428
+ expect(follows[1].nickname).toBe("(unnamed)");
429
+ expect(calls[0].url).toContain("user_id=U1");
430
+ });
431
+ });
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+
8
+ const {
9
+ buildSnapshot,
10
+ writeSnapshotJson,
11
+ cleanupSnapshotJson,
12
+ SNAPSHOT_SCHEMA_VERSION,
13
+ } = require("../../lib/adapters/social-xiaohongshu-adb/snapshot-builder");
14
+
15
+ describe("SNAPSHOT_SCHEMA_VERSION", () => {
16
+ it("is 1 (matches existing social-xiaohongshu adapter)", () => {
17
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
18
+ });
19
+ });
20
+
21
+ describe("buildSnapshot", () => {
22
+ it("throws on missing / empty userId", () => {
23
+ expect(() => buildSnapshot({})).toThrow(/userId must be a non-empty string/);
24
+ expect(() => buildSnapshot({ userId: "" })).toThrow(
25
+ /userId must be a non-empty string/,
26
+ );
27
+ expect(() => buildSnapshot(null)).toThrow(TypeError);
28
+ });
29
+
30
+ it("keeps account.userId as string passthrough (xhs uid is NOT numeric)", () => {
31
+ const snap = buildSnapshot({
32
+ userId: "5e8c8f7e000000000100abcd",
33
+ nickname: "Alice",
34
+ snapshottedAt: 1716383021000,
35
+ });
36
+ expect(snap.schemaVersion).toBe(1);
37
+ expect(snap.snapshottedAt).toBe(1716383021000);
38
+ expect(snap.account).toEqual({
39
+ userId: "5e8c8f7e000000000100abcd",
40
+ nickname: "Alice",
41
+ });
42
+ expect(snap.events).toEqual([]);
43
+ });
44
+
45
+ it("defaults nickname to empty string", () => {
46
+ const snap = buildSnapshot({ userId: "U1" });
47
+ expect(snap.account.nickname).toBe("");
48
+ });
49
+
50
+ it("emits note events with metadata + createdAt", () => {
51
+ const snap = buildSnapshot({
52
+ userId: "U1",
53
+ snapshottedAt: 1716000000000,
54
+ notes: [
55
+ {
56
+ noteId: "N1",
57
+ title: "Note one",
58
+ desc: "d",
59
+ type: "video",
60
+ createdAt: 1700000000000,
61
+ likedCount: 12000,
62
+ collectedCount: 100000,
63
+ commentCount: 234,
64
+ },
65
+ ],
66
+ });
67
+ const n = snap.events.filter((e) => e.kind === "note");
68
+ expect(n).toHaveLength(1);
69
+ expect(n[0]).toEqual({
70
+ kind: "note",
71
+ id: "note-N1",
72
+ capturedAt: 1700000000000,
73
+ noteId: "N1",
74
+ title: "Note one",
75
+ desc: "d",
76
+ type: "video",
77
+ likedCount: 12000,
78
+ collectedCount: 100000,
79
+ commentCount: 234,
80
+ });
81
+ });
82
+
83
+ it("falls back to index id + snapshottedAt when note lacks noteId/createdAt", () => {
84
+ const snap = buildSnapshot({
85
+ userId: "U1",
86
+ snapshottedAt: 1716000000000,
87
+ notes: [{ title: "no id" }],
88
+ });
89
+ const n = snap.events[0];
90
+ expect(n.id).toBe("note-0");
91
+ expect(n.noteId).toBe(null);
92
+ expect(n.capturedAt).toBe(1716000000000);
93
+ expect(n.type).toBe("normal");
94
+ expect(n.likedCount).toBe(0);
95
+ });
96
+
97
+ it("emits liked events pinned to snapshottedAt (xhs has no liked_at)", () => {
98
+ const snap = buildSnapshot({
99
+ userId: "U1",
100
+ snapshottedAt: 1716100000000,
101
+ liked: [
102
+ { noteId: "L1", title: "Liked one", authorNickname: "AuthorX" },
103
+ ],
104
+ });
105
+ const l = snap.events.filter((e) => e.kind === "liked");
106
+ expect(l).toHaveLength(1);
107
+ expect(l[0]).toEqual({
108
+ kind: "liked",
109
+ id: "liked-L1",
110
+ capturedAt: 1716100000000,
111
+ noteId: "L1",
112
+ title: "Liked one",
113
+ authorNickname: "AuthorX",
114
+ });
115
+ });
116
+
117
+ it("emits follow events keyed by followed userId", () => {
118
+ const snap = buildSnapshot({
119
+ userId: "U1",
120
+ snapshottedAt: 1716200000000,
121
+ follows: [
122
+ { userId: "F1", nickname: "FollowOne", image: "https://a/f1.jpg" },
123
+ ],
124
+ });
125
+ const f = snap.events.filter((e) => e.kind === "follow");
126
+ expect(f).toHaveLength(1);
127
+ expect(f[0]).toEqual({
128
+ kind: "follow",
129
+ id: "follow-F1",
130
+ capturedAt: 1716200000000,
131
+ userId: "F1",
132
+ nickname: "FollowOne",
133
+ image: "https://a/f1.jpg",
134
+ });
135
+ });
136
+
137
+ it("skips non-object entries but keeps the rest", () => {
138
+ const snap = buildSnapshot({
139
+ userId: "U1",
140
+ notes: [null, "junk", { noteId: "OK" }],
141
+ liked: [undefined],
142
+ follows: [42],
143
+ });
144
+ expect(snap.events).toHaveLength(1);
145
+ expect(snap.events[0].id).toBe("note-OK");
146
+ });
147
+
148
+ it("emits all 3 kinds at once", () => {
149
+ const snap = buildSnapshot({
150
+ userId: "U1",
151
+ notes: [{ noteId: "N1" }],
152
+ liked: [{ noteId: "L1" }],
153
+ follows: [{ userId: "F1" }],
154
+ });
155
+ expect(snap.events.map((e) => e.kind).sort()).toEqual([
156
+ "follow",
157
+ "liked",
158
+ "note",
159
+ ]);
160
+ });
161
+
162
+ it("defaults snapshottedAt to now when invalid", () => {
163
+ const before = Date.now();
164
+ const snap = buildSnapshot({ userId: "U1", snapshottedAt: -5 });
165
+ expect(snap.snapshottedAt).toBeGreaterThanOrEqual(before);
166
+ });
167
+ });
168
+
169
+ describe("writeSnapshotJson + cleanupSnapshotJson", () => {
170
+ it("writes valid JSON roundtrip", () => {
171
+ const snap = buildSnapshot({ userId: "U1", nickname: "A" });
172
+ const full = writeSnapshotJson(snap);
173
+ try {
174
+ expect(fs.existsSync(full)).toBe(true);
175
+ const round = JSON.parse(fs.readFileSync(full, "utf-8"));
176
+ expect(round.account.userId).toBe("U1");
177
+ expect(round.schemaVersion).toBe(1);
178
+ } finally {
179
+ cleanupSnapshotJson(full);
180
+ expect(fs.existsSync(full)).toBe(false);
181
+ }
182
+ });
183
+
184
+ it("rejects fileName with path separator", () => {
185
+ const snap = buildSnapshot({ userId: "U1" });
186
+ expect(() =>
187
+ writeSnapshotJson(snap, { fileName: "../escape.json" }),
188
+ ).toThrow(/basename, not a path/);
189
+ expect(() =>
190
+ writeSnapshotJson(snap, { fileName: "sub\\escape.json" }),
191
+ ).toThrow(/basename, not a path/);
192
+ });
193
+
194
+ it("cleanup tolerates null / missing file", () => {
195
+ expect(() => cleanupSnapshotJson(null)).not.toThrow();
196
+ expect(() =>
197
+ cleanupSnapshotJson(path.join(os.tmpdir(), "nonexistent-xhs.json")),
198
+ ).not.toThrow();
199
+ });
200
+ });