@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,244 @@
1
+ /**
2
+ * 网易云音乐 LIVE cookie web-API (weapi) fetcher tests.
3
+ * weapi crypto determinism (fixed secKey) + account/play/playlist parsing +
4
+ * adapter cookie-mode sync + error mapping. All network via injected fetch.
5
+ */
6
+ "use strict";
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ const { NeteaseMusicAdapter } = require("../../lib/adapters/netease-music");
11
+ const {
12
+ NeteaseMusicApiClient,
13
+ weapiEncrypt,
14
+ aesEncrypt,
15
+ rsaEncrypt,
16
+ modpow,
17
+ } = require("../../lib/adapters/netease-music/api-client");
18
+ const { partitionBatch } = require("../../lib/batch");
19
+
20
+ const FIXED_SECKEY = "0123456789abcdef"; // 16 chars → deterministic weapi
21
+ const COOKIE = "MUSIC_U=abcdef0123456789; __csrf=zzz";
22
+
23
+ /** Map endpoint path substrings → JSON bodies; record posted form bodies. */
24
+ function makeFetch(routes, calls) {
25
+ return async (url, init) => {
26
+ calls.push({ url, body: init && init.body, headers: (init && init.headers) || {} });
27
+ const route = routes.find((r) => url.includes(r.match));
28
+ if (!route) return { ok: false, status: 404, text: async () => "not mapped" };
29
+ return {
30
+ ok: route.status ? route.status >= 200 && route.status < 300 : true,
31
+ status: route.status || 200,
32
+ text: async () =>
33
+ typeof route.body === "string" ? route.body : JSON.stringify(route.body),
34
+ };
35
+ };
36
+ }
37
+
38
+ async function collect(iter) {
39
+ const out = [];
40
+ for await (const r of iter) out.push(r);
41
+ return out;
42
+ }
43
+
44
+ describe("weapi crypto primitives", () => {
45
+ it("modpow matches BigInt ** % for small values", () => {
46
+ expect(modpow(4n, 13n, 497n)).toBe(445n); // textbook RSA example
47
+ expect(modpow(7n, 0n, 13n)).toBe(1n);
48
+ });
49
+
50
+ it("aesEncrypt is deterministic (fixed key + IV) and base64", () => {
51
+ const a = aesEncrypt("hello", "0CoJUm6Qyw8W8jud");
52
+ const b = aesEncrypt("hello", "0CoJUm6Qyw8W8jud");
53
+ expect(a).toBe(b);
54
+ expect(a).toMatch(/^[A-Za-z0-9+/=]+$/);
55
+ });
56
+
57
+ it("rsaEncrypt yields zero-padded 256-hex", () => {
58
+ const enc = rsaEncrypt("test", "010001", "e0b5");
59
+ expect(enc).toHaveLength(256);
60
+ expect(enc).toMatch(/^[0-9a-f]{256}$/);
61
+ });
62
+
63
+ it("weapiEncrypt produces stable { params, encSecKey } for a fixed secKey", () => {
64
+ const a = weapiEncrypt({ uid: 1 }, FIXED_SECKEY);
65
+ const b = weapiEncrypt({ uid: 1 }, FIXED_SECKEY);
66
+ expect(a).toEqual(b);
67
+ expect(a.encSecKey).toHaveLength(256);
68
+ expect(a.params).toMatch(/^[A-Za-z0-9+/=]+$/);
69
+ });
70
+ });
71
+
72
+ describe("NeteaseMusicApiClient.fetchSnapshot — live (mocked fetch)", () => {
73
+ it("resolves account, play record, playlists into snapshot-shaped events", async () => {
74
+ const calls = [];
75
+ const fetch = makeFetch(
76
+ [
77
+ {
78
+ match: "/weapi/w/nuser/account/get",
79
+ body: { code: 200, profile: { userId: 42, nickname: "听歌的人" }, account: { id: 42 } },
80
+ },
81
+ {
82
+ match: "/weapi/v1/play/record",
83
+ body: {
84
+ code: 200,
85
+ weekData: [
86
+ { playCount: 50, song: { id: 186016, name: "晴天", ar: [{ name: "周杰伦" }], al: { name: "叶惠美" } } },
87
+ ],
88
+ },
89
+ },
90
+ {
91
+ match: "/weapi/user/playlist",
92
+ body: {
93
+ code: 200,
94
+ playlist: [{ id: 999, name: "我喜欢的音乐", trackCount: 200, creator: { nickname: "听歌的人" } }],
95
+ },
96
+ },
97
+ ],
98
+ calls,
99
+ );
100
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
101
+ const result = await client.fetchSnapshot(COOKIE);
102
+ expect(result.account).toEqual({ uid: "42", nickname: "听歌的人" });
103
+ expect(result.events).toHaveLength(2);
104
+ const play = result.events.find((e) => e.kind === "play");
105
+ expect(play).toMatchObject({ songId: "186016", song: "晴天", artist: "周杰伦", album: "叶惠美", playCount: 50 });
106
+ const pl = result.events.find((e) => e.kind === "playlist");
107
+ expect(pl).toMatchObject({ playlistId: "999", name: "我喜欢的音乐", trackCount: 200, creator: "听歌的人" });
108
+ // every POST is form-encoded weapi (params + encSecKey) with the cookie.
109
+ for (const c of calls) {
110
+ expect(c.body).toMatch(/^params=.+&encSecKey=.+$/);
111
+ expect(c.headers.Cookie).toBe(COOKIE);
112
+ expect(c.headers["Content-Type"]).toContain("x-www-form-urlencoded");
113
+ }
114
+ });
115
+
116
+ it("multi-artist joined with ' / '; falls back to allData when weekData empty", async () => {
117
+ const calls = [];
118
+ const fetch = makeFetch(
119
+ [
120
+ { match: "account/get", body: { code: 200, profile: { userId: 7 } } },
121
+ {
122
+ match: "play/record",
123
+ body: {
124
+ code: 200,
125
+ weekData: [],
126
+ allData: [{ playCount: 3, song: { id: 1, name: "S", ar: [{ name: "A" }, { name: "B" }], al: { name: "Al" } } }],
127
+ },
128
+ },
129
+ { match: "user/playlist", body: { code: 200, playlist: [] } },
130
+ ],
131
+ calls,
132
+ );
133
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
134
+ const result = await client.fetchSnapshot(COOKIE);
135
+ const play = result.events.find((e) => e.kind === "play");
136
+ expect(play.artist).toBe("A / B");
137
+ });
138
+
139
+ it("include flags skip endpoints", async () => {
140
+ const calls = [];
141
+ const fetch = makeFetch(
142
+ [
143
+ { match: "account/get", body: { code: 200, profile: { userId: 7 } } },
144
+ { match: "user/playlist", body: { code: 200, playlist: [] } },
145
+ ],
146
+ calls,
147
+ );
148
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
149
+ const result = await client.fetchSnapshot(COOKIE, { include: { play: false } });
150
+ expect(calls.some((c) => c.url.includes("play/record"))).toBe(false);
151
+ expect(result.events.every((e) => e.kind !== "play")).toBe(true);
152
+ });
153
+
154
+ it("maps API code != 200 to null + lastError (e.g. -460 risk-control)", async () => {
155
+ const calls = [];
156
+ const fetch = makeFetch(
157
+ [{ match: "account/get", body: { code: -460, message: "Cheating" } }],
158
+ calls,
159
+ );
160
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
161
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
162
+ expect(client.lastError.code).toBe(-460);
163
+ expect(client.lastError.message).toContain("Cheating");
164
+ });
165
+
166
+ it("account with no userId → null + lastError -7", async () => {
167
+ const calls = [];
168
+ const fetch = makeFetch([{ match: "account/get", body: { code: 200, profile: null, account: null } }], calls);
169
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
170
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
171
+ expect(client.lastError.code).toBe(-7);
172
+ });
173
+
174
+ it("empty cookie → null + lastError -1 (no network)", async () => {
175
+ const calls = [];
176
+ const client = new NeteaseMusicApiClient({ fetch: makeFetch([], calls), secKey: FIXED_SECKEY });
177
+ expect(await client.fetchSnapshot("")).toBeNull();
178
+ expect(client.lastError.code).toBe(-1);
179
+ expect(calls).toHaveLength(0);
180
+ });
181
+
182
+ it("HTTP non-2xx → null + lastError with status", async () => {
183
+ const calls = [];
184
+ const fetch = makeFetch([{ match: "account/get", status: 502, body: "bad gw" }], calls);
185
+ const client = new NeteaseMusicApiClient({ fetch, secKey: FIXED_SECKEY });
186
+ expect(await client.fetchSnapshot(COOKIE)).toBeNull();
187
+ expect(client.lastError.code).toBe(502);
188
+ });
189
+ });
190
+
191
+ describe("NeteaseMusicAdapter — cookie (live) sync mode", () => {
192
+ it("authenticate accepts a cookie with MUSIC_U; rejects without it", async () => {
193
+ const a = new NeteaseMusicAdapter();
194
+ expect((await a.authenticate({ cookie: COOKIE })).mode).toBe("cookie");
195
+ const bad = await a.authenticate({ cookie: "foo=bar" });
196
+ expect(bad.ok).toBe(false);
197
+ expect(bad.reason).toBe("INVALID_COOKIE");
198
+ });
199
+
200
+ it("capabilities/version reflect v0.2 live mode", () => {
201
+ const a = new NeteaseMusicAdapter();
202
+ expect(a.version).toBe("0.2.0");
203
+ expect(a.capabilities).toContain("sync:cookie");
204
+ });
205
+
206
+ it("sync via cookie yields play+playlist raws → valid normalized batch", async () => {
207
+ const calls = [];
208
+ const fetch = makeFetch(
209
+ [
210
+ { match: "account/get", body: { code: 200, profile: { userId: 42, nickname: "me" } } },
211
+ {
212
+ match: "play/record",
213
+ body: { code: 200, weekData: [{ playCount: 9, song: { id: 5, name: "夜曲", ar: [{ name: "周杰伦" }], al: { name: "11月的萧邦" } } }] },
214
+ },
215
+ { match: "user/playlist", body: { code: 200, playlist: [{ id: 8, name: "练歌", trackCount: 12, creator: { nickname: "me" } }] } },
216
+ ],
217
+ calls,
218
+ );
219
+ const a = new NeteaseMusicAdapter();
220
+ const raws = await collect(a.sync({ cookie: COOKIE, fetch, secKey: FIXED_SECKEY }));
221
+ expect(raws.map((r) => r.kind).sort()).toEqual(["play", "playlist"]);
222
+ const playRaw = raws.find((r) => r.kind === "play");
223
+ expect(playRaw.originalId).toBe("netease-music:play:play-5");
224
+
225
+ // normalize → vault-valid batch for both kinds.
226
+ for (const raw of raws) {
227
+ const batch = a.normalize(raw);
228
+ const { invalidReasons } = partitionBatch(batch);
229
+ expect(invalidReasons).toHaveLength(0);
230
+ }
231
+ const playBatch = a.normalize(playRaw);
232
+ expect(playBatch.items[0].extra.songId).toBe("5");
233
+ expect(playBatch.events[0].content.title).toContain("夜曲");
234
+ });
235
+
236
+ it("sync via cookie throws (mapped lastError) on risk-control", async () => {
237
+ const calls = [];
238
+ const fetch = makeFetch([{ match: "account/get", body: { code: -460, message: "Cheating" } }], calls);
239
+ const a = new NeteaseMusicAdapter();
240
+ await expect(collect(a.sync({ cookie: COOKIE, fetch, secKey: FIXED_SECKEY }))).rejects.toThrow(
241
+ /Cheating|code -460/,
242
+ );
243
+ });
244
+ });
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const {
6
+ normalizeOrderRecord,
7
+ mapStatusToSubtype,
8
+ CookieAuth,
9
+ } = require("../../lib/adapters/shopping-base");
10
+
11
+ describe("normalizeOrderRecord", () => {
12
+ const ORDER = {
13
+ vendorId: "jd",
14
+ orderId: "JD123456",
15
+ placedAt: 1716383021000,
16
+ paidAt: 1716383100000,
17
+ status: "delivered",
18
+ merchantName: "京东自营旗舰店",
19
+ totalAmount: { value: 299.9, currency: "CNY" },
20
+ items: [
21
+ { name: "机械键盘", quantity: 1, unitPrice: 249.9, sku: "SKU1" },
22
+ { name: "键帽", quantity: 2, unitPrice: 25 },
23
+ ],
24
+ recipient: "张三",
25
+ shippingAddress: "上海市幸福路1号",
26
+ trackingNumber: "SF123",
27
+ extras: { shopId: "S1" },
28
+ };
29
+
30
+ it("throws on missing rec / orderId / merchantName", () => {
31
+ expect(() => normalizeOrderRecord(null)).toThrow(/rec required/);
32
+ expect(() => normalizeOrderRecord({})).toThrow(/orderId required/);
33
+ expect(() => normalizeOrderRecord({ orderId: "X" })).toThrow(
34
+ /merchantName required/,
35
+ );
36
+ });
37
+
38
+ it("emits order event with amount out + items text + cross-source link extras", () => {
39
+ const batch = normalizeOrderRecord(ORDER, {
40
+ adapterName: "shopping-jd",
41
+ adapterVersion: "0.5.0",
42
+ });
43
+ expect(batch.events).toHaveLength(1);
44
+ const ev = batch.events[0];
45
+ expect(ev.subtype).toBe("order");
46
+ expect(ev.occurredAt).toBe(1716383021000);
47
+ expect(ev.actor).toBe("person-self");
48
+ expect(ev.content.title).toBe("京东自营旗舰店 订单 JD123456");
49
+ expect(ev.content.amount).toEqual({
50
+ value: 299.9,
51
+ currency: "CNY",
52
+ direction: "out",
53
+ });
54
+ expect(ev.content.text).toBe("机械键盘 x1; 键帽 x2");
55
+ expect(ev.extra).toMatchObject({
56
+ vendorId: "jd",
57
+ orderId: "JD123456",
58
+ merchantOrderNumber: "JD123456", // Email/Alipay cross-source link key
59
+ orderStatus: "delivered",
60
+ itemCount: 2,
61
+ recipient: "张三",
62
+ trackingNumber: "SF123",
63
+ paidAt: 1716383100000,
64
+ vendorExtras: { shopId: "S1" },
65
+ });
66
+ expect(ev.source).toMatchObject({
67
+ adapter: "shopping-jd",
68
+ adapterVersion: "0.5.0",
69
+ originalId: "JD123456",
70
+ capturedBy: "api",
71
+ });
72
+ });
73
+
74
+ it("emits merchant person + per-SKU item entities inheriting order currency", () => {
75
+ const batch = normalizeOrderRecord(ORDER, { adapterName: "shopping-jd" });
76
+ expect(batch.persons).toHaveLength(1);
77
+ expect(batch.persons[0]).toMatchObject({
78
+ subtype: "merchant",
79
+ names: ["京东自营旗舰店"],
80
+ });
81
+ expect(batch.items).toHaveLength(2);
82
+ expect(batch.items[0]).toMatchObject({
83
+ type: "item",
84
+ subtype: "product",
85
+ name: "机械键盘",
86
+ merchant: batch.persons[0].id,
87
+ price: { value: 249.9, currency: "CNY" },
88
+ });
89
+ expect(batch.items[0].extra).toMatchObject({ quantity: 1, sku: "SKU1" });
90
+ // nameless item entries are dropped
91
+ const withJunk = normalizeOrderRecord(
92
+ { ...ORDER, items: [{ quantity: 3 }, { name: "ok" }] },
93
+ {},
94
+ );
95
+ expect(withJunk.items).toHaveLength(1);
96
+ });
97
+
98
+ it("refund order → refund subtype with money direction IN", () => {
99
+ const batch = normalizeOrderRecord(
100
+ { ...ORDER, status: "refunded" },
101
+ { adapterName: "shopping-jd" },
102
+ );
103
+ expect(batch.events[0].subtype).toBe("refund");
104
+ expect(batch.events[0].content.amount.direction).toBe("in");
105
+ });
106
+
107
+ it("omits amount when totalAmount missing; occurredAt falls back to now", () => {
108
+ const before = Date.now();
109
+ const batch = normalizeOrderRecord({
110
+ orderId: "X1",
111
+ merchantName: "店",
112
+ });
113
+ expect(batch.events[0].content.amount).toBeUndefined();
114
+ expect(batch.events[0].occurredAt).toBeGreaterThanOrEqual(before);
115
+ expect(batch.events[0].extra.orderStatus).toBe("placed");
116
+ });
117
+ });
118
+
119
+ describe("mapStatusToSubtype", () => {
120
+ it("maps refund variants (en + 中文)", () => {
121
+ expect(mapStatusToSubtype("refunded")).toBe("refund");
122
+ expect(mapStatusToSubtype("REFUND_PENDING")).toBe("refund");
123
+ expect(mapStatusToSubtype("退款中")).toBe("refund");
124
+ });
125
+
126
+ it("maps cancel variants (en + 中文)", () => {
127
+ expect(mapStatusToSubtype("cancelled")).toBe("cancelled");
128
+ expect(mapStatusToSubtype("closed")).toBe("cancelled");
129
+ expect(mapStatusToSubtype("已取消")).toBe("cancelled");
130
+ expect(mapStatusToSubtype("已关闭")).toBe("cancelled");
131
+ });
132
+
133
+ it("everything else (placed/shipped/delivered/blank) → order", () => {
134
+ expect(mapStatusToSubtype("placed")).toBe("order");
135
+ expect(mapStatusToSubtype("shipped")).toBe("order");
136
+ expect(mapStatusToSubtype("delivered")).toBe("order");
137
+ expect(mapStatusToSubtype(undefined)).toBe("order");
138
+ });
139
+ });
140
+
141
+ describe("CookieAuth", () => {
142
+ it("requires platform; setCookies type-checks", () => {
143
+ expect(() => new CookieAuth({})).toThrow(/platform required/);
144
+ const c = new CookieAuth({ platform: "jd" });
145
+ expect(() => c.setCookies(42)).toThrow(/string required/);
146
+ });
147
+
148
+ it("toHeader returns raw string or null when empty", () => {
149
+ const c = new CookieAuth({ platform: "jd" });
150
+ expect(c.toHeader()).toBe(null);
151
+ c.setCookies("pt_key=abc; pt_pin=u1");
152
+ expect(c.toHeader()).toBe("pt_key=abc; pt_pin=u1");
153
+ });
154
+
155
+ it("validate: false on empty, true on non-empty, defers to injected validator", async () => {
156
+ const empty = new CookieAuth({ platform: "jd" });
157
+ expect(await empty.validate()).toBe(false);
158
+ const plain = new CookieAuth({ platform: "jd", cookies: "k=v" });
159
+ expect(await plain.validate()).toBe(true);
160
+ const probed = new CookieAuth({
161
+ platform: "jd",
162
+ cookies: "k=v",
163
+ validator: async (raw) => raw.includes("pt_key"),
164
+ });
165
+ expect(await probed.validate()).toBe(false);
166
+ });
167
+
168
+ it("getCookieValue: case-insensitive, URI-decodes, escapes regex metachars", () => {
169
+ const c = new CookieAuth({
170
+ platform: "taobao",
171
+ cookies: "Name=%E5%BC%A0%E4%B8%89; a.b+c=literal; last=v",
172
+ });
173
+ expect(c.getCookieValue("name")).toBe("张三");
174
+ // dot/plus in cookie name must be treated literally, not as regex
175
+ expect(c.getCookieValue("a.b+c")).toBe("literal");
176
+ expect(c.getCookieValue("missing")).toBe(null);
177
+ expect(c.getCookieValue("")).toBe(null);
178
+ });
179
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Douyin aweme-detail title resolver tests (real-device-driven 2026-06-11:
3
+ * web detail endpoint returns aweme_detail{desc,author,duration} with no signing).
4
+ * fetch injected — no network.
5
+ */
6
+ "use strict";
7
+
8
+ import { describe, it, expect, vi } from "vitest";
9
+
10
+ const {
11
+ AwemeDetailClient,
12
+ } = require("../../lib/adapters/social-douyin-adb/aweme-detail-client");
13
+ const {
14
+ collectWatchHistory,
15
+ } = require("../../lib/adapters/social-douyin-adb/collector");
16
+ const { DouyinAdapter } = require("../../lib/adapters/social-douyin");
17
+ const { partitionBatch } = require("../../lib/batch");
18
+
19
+ function makeFetch(byAid) {
20
+ return async (url) => {
21
+ const m = /aweme_id=(\d+)/.exec(url);
22
+ const aid = m && m[1];
23
+ const payload = byAid[aid];
24
+ if (payload === undefined) return { ok: false, status: 404, text: async () => "nf" };
25
+ return {
26
+ ok: payload.status ? payload.status >= 200 && payload.status < 300 : true,
27
+ status: payload.status || 200,
28
+ text: async () => (typeof payload.body === "string" ? payload.body : JSON.stringify(payload.body)),
29
+ };
30
+ };
31
+ }
32
+ const noSleep = () => Promise.resolve();
33
+
34
+ describe("AwemeDetailClient.fetchDetail", () => {
35
+ it("parses aweme_detail → {desc, author, durationMs, createTime}; sends aweme_id + webapp params", async () => {
36
+ let seenUrl;
37
+ const fetch = async (url) => {
38
+ seenUrl = url;
39
+ return { ok: true, status: 200, text: async () => JSON.stringify({
40
+ status_code: 0,
41
+ aweme_detail: { desc: "洋气婆婆和她的土狗儿媳", author: { nickname: "任集" }, duration: 9200, create_time: 1780112750 },
42
+ }) };
43
+ };
44
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
45
+ const d = await c.fetchDetail("7645526043227334246");
46
+ expect(d).toEqual({ awemeId: "7645526043227334246", desc: "洋气婆婆和她的土狗儿媳", author: "任集", durationMs: 9200, createTime: 1780112750 });
47
+ expect(seenUrl).toContain("aweme_id=7645526043227334246");
48
+ expect(seenUrl).toContain("device_platform=webapp");
49
+ expect(seenUrl).toContain("aid=6383");
50
+ });
51
+
52
+ it("status_code != 0 → null + lastError", async () => {
53
+ const fetch = makeFetch({ 1: { body: { status_code: 8, status_msg: "risk" } } });
54
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
55
+ expect(await c.fetchDetail("1")).toBe(null);
56
+ expect(c.lastErrorCode).toBe(8);
57
+ });
58
+
59
+ it("missing aweme_detail (deleted/private) → null -5", async () => {
60
+ const fetch = makeFetch({ 1: { body: { status_code: 0 } } });
61
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
62
+ expect(await c.fetchDetail("1")).toBe(null);
63
+ expect(c.lastErrorCode).toBe(-5);
64
+ });
65
+
66
+ it("HTTP non-2xx → null with status", async () => {
67
+ const fetch = makeFetch({ 1: { status: 444, body: "blocked" } });
68
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
69
+ expect(await c.fetchDetail("1")).toBe(null);
70
+ expect(c.lastErrorCode).toBe(444);
71
+ });
72
+ });
73
+
74
+ describe("AwemeDetailClient.resolveMany", () => {
75
+ it("dedups ids, caps at limit, skips per-id failures", async () => {
76
+ const calls = [];
77
+ const fetch = async (url) => {
78
+ const aid = /aweme_id=(\d+)/.exec(url)[1];
79
+ calls.push(aid);
80
+ const ok = { 11: true, 22: true }[aid];
81
+ return { ok: true, status: 200, text: async () => JSON.stringify(
82
+ ok ? { status_code: 0, aweme_detail: { desc: "d" + aid, author: { nickname: "a" } } }
83
+ : { status_code: 0 }, // 33 → no aweme_detail → skipped
84
+ ) };
85
+ };
86
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
87
+ const map = await c.resolveMany(["11", "11", "22", "33"], { limit: 10 });
88
+ expect(calls).toEqual(["11", "22", "33"]); // deduped (one 11)
89
+ expect([...map.keys()].sort()).toEqual(["11", "22"]); // 33 skipped
90
+ expect(map.get("11").desc).toBe("d11");
91
+ });
92
+
93
+ it("respects limit (stops early)", async () => {
94
+ const calls = [];
95
+ const fetch = async (url) => {
96
+ calls.push(/aweme_id=(\d+)/.exec(url)[1]);
97
+ return { ok: true, status: 200, text: async () => JSON.stringify({ status_code: 0, aweme_detail: { desc: "x" } }) };
98
+ };
99
+ const c = new AwemeDetailClient({ fetch, sleep: noSleep });
100
+ await c.resolveMany(["1", "2", "3", "4", "5"], { limit: 2 });
101
+ expect(calls).toEqual(["1", "2"]);
102
+ });
103
+ });
104
+
105
+ describe("collectWatchHistory --resolve-titles integration", () => {
106
+ it("attaches title/author/duration → normalizeHistory shows real content", async () => {
107
+ const fs = require("node:fs");
108
+ const os = require("node:os");
109
+ const bridge = {
110
+ invoke: vi.fn(async (m) =>
111
+ m === "douyin.watch-history"
112
+ ? {
113
+ uid: "92585448288",
114
+ records: [
115
+ { awemeId: "7645526043227334246", capturedAt: 1780112750000, enterFrom: "homepage_hot" },
116
+ ],
117
+ }
118
+ : (() => { throw new Error("unknown " + m); })(),
119
+ ),
120
+ };
121
+ const detailClient = new AwemeDetailClient({
122
+ sleep: () => Promise.resolve(),
123
+ fetch: makeFetch({
124
+ "7645526043227334246": { body: { status_code: 0, aweme_detail: { desc: "洋气婆婆和她的土狗儿媳", author: { nickname: "任集" }, duration: 9200 } } },
125
+ }),
126
+ });
127
+ const r = await collectWatchHistory(bridge, {
128
+ stagingDir: os.tmpdir(),
129
+ now: () => 1781000000000,
130
+ resolveTitles: true,
131
+ _detailClient: detailClient,
132
+ });
133
+ expect(r.titlesResolved).toBe(1);
134
+ const snap = JSON.parse(fs.readFileSync(r.snapshotPath, "utf-8"));
135
+ try {
136
+ expect(snap.events[0].title).toBe("洋气婆婆和她的土狗儿媳");
137
+ const a = new DouyinAdapter();
138
+ const batch = a.normalize({
139
+ adapter: "social-douyin",
140
+ kind: "history",
141
+ originalId: "douyin:history:1",
142
+ capturedAt: 1780112750000,
143
+ payload: { ...snap.events[0], account: snap.account },
144
+ });
145
+ expect(partitionBatch(batch).invalidReasons).toHaveLength(0);
146
+ expect(batch.events[0].content.title).toBe("洋气婆婆和她的土狗儿媳");
147
+ expect(batch.events[0].extra.author).toBe("任集");
148
+ expect(batch.events[0].extra.duration).toBe(9200);
149
+ expect(batch.events[0].extra.enterFrom).toBe("homepage_hot");
150
+ } finally {
151
+ fs.unlinkSync(r.snapshotPath);
152
+ }
153
+ });
154
+
155
+ it("without resolveTitles, no network + title stays unresolved", async () => {
156
+ const os = require("node:os");
157
+ const fs = require("node:fs");
158
+ const bridge = { invoke: vi.fn(async () => ({ uid: "1", records: [{ awemeId: "999", capturedAt: 1, enterFrom: "x" }] })) };
159
+ const r = await collectWatchHistory(bridge, { stagingDir: os.tmpdir(), now: () => 1, resolveTitles: false });
160
+ expect(r.titlesResolved).toBe(0);
161
+ const snap = JSON.parse(fs.readFileSync(r.snapshotPath, "utf-8"));
162
+ fs.unlinkSync(r.snapshotPath);
163
+ expect(snap.events[0].title).toBeUndefined();
164
+ });
165
+ });