@chainlesschain/personal-data-hub 0.4.6 → 0.4.18

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 (31) hide show
  1. package/__tests__/adapters/doc-baidu-netdisk.test.js +102 -0
  2. package/__tests__/adapters/doc-platforms.test.js +177 -0
  3. package/__tests__/adapters/music-kugou.test.js +187 -0
  4. package/__tests__/adapters/recruit-boss.test.js +180 -0
  5. package/__tests__/adapters/shopping-dianping.test.js +239 -0
  6. package/__tests__/adapters/social-csdn.test.js +175 -0
  7. package/__tests__/adapters/social-zhihu.test.js +246 -0
  8. package/__tests__/adapters/travel-12306.test.js +234 -1
  9. package/__tests__/adapters/travel-ctrip.test.js +175 -1
  10. package/__tests__/adapters/travel-didi.test.js +204 -0
  11. package/__tests__/adapters/travel-tongcheng.test.js +289 -0
  12. package/__tests__/adapters/video-platforms.test.js +152 -0
  13. package/lib/adapter-guide.js +13 -1
  14. package/lib/adapters/_document-base.js +370 -0
  15. package/lib/adapters/_video-base.js +331 -0
  16. package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
  17. package/lib/adapters/doc-tencent-docs/index.js +94 -0
  18. package/lib/adapters/doc-wps/index.js +77 -0
  19. package/lib/adapters/music-kugou/index.js +418 -0
  20. package/lib/adapters/recruit-boss/index.js +442 -0
  21. package/lib/adapters/shopping-dianping/index.js +473 -0
  22. package/lib/adapters/social-csdn/index.js +444 -0
  23. package/lib/adapters/social-zhihu/index.js +488 -0
  24. package/lib/adapters/travel-12306/index.js +279 -5
  25. package/lib/adapters/travel-ctrip/index.js +255 -40
  26. package/lib/adapters/travel-didi/index.js +327 -0
  27. package/lib/adapters/travel-tongcheng/index.js +393 -0
  28. package/lib/adapters/video-iqiyi/index.js +75 -0
  29. package/lib/adapters/video-tencent/index.js +78 -0
  30. package/lib/index.js +24 -0
  31. package/package.json +1 -1
@@ -0,0 +1,239 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const {
10
+ DianpingAdapter,
11
+ orderToRecord,
12
+ extractOrders,
13
+ NAME,
14
+ VERSION,
15
+ SNAPSHOT_SCHEMA_VERSION,
16
+ } = require("../../lib/adapters/shopping-dianping");
17
+
18
+ function writeTmp(content) {
19
+ const p = path.join(os.tmpdir(), `cc-dianping-${crypto.randomUUID()}.json`);
20
+ fs.writeFileSync(p, content, "utf-8");
21
+ return p;
22
+ }
23
+
24
+ async function collect(gen) {
25
+ const out = [];
26
+ for await (const x of gen) out.push(x);
27
+ return out;
28
+ }
29
+
30
+ const COOKIES = "dper=abc; _lxsdk=xyz; cy=1";
31
+
32
+ describe("constants", () => {
33
+ it("exposes name/version/schema", () => {
34
+ expect(NAME).toBe("shopping-dianping");
35
+ expect(VERSION).toBe("0.1.0");
36
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
37
+ });
38
+ });
39
+
40
+ describe("orderToRecord", () => {
41
+ it("maps a Dianping group-buy order (poi/deals/yuan amount)", () => {
42
+ const rec = orderToRecord({
43
+ orderId: "DP1",
44
+ shopName: "海底捞火锅(国贸店)",
45
+ dealList: [{ dealName: "双人套餐", quantity: 1, price: "238" }],
46
+ orderTime: 1716383021000,
47
+ payTime: 1716383040000,
48
+ statusText: "已消费",
49
+ totalPrice: "238.00",
50
+ });
51
+ expect(rec).toMatchObject({
52
+ vendorId: "dianping",
53
+ orderId: "DP1",
54
+ merchantName: "海底捞火锅(国贸店)",
55
+ status: "delivered",
56
+ });
57
+ expect(rec.totalAmount).toEqual({ value: 238, currency: "CNY" });
58
+ expect(rec.items[0]).toMatchObject({ name: "双人套餐", quantity: 1, unitPrice: 238 });
59
+ expect(rec.placedAt).toBe(1716383021000);
60
+ });
61
+
62
+ it("falls back to 分 (cents) field when no 元 field present", () => {
63
+ const rec = orderToRecord({ orderId: "DP2", shopName: "星巴克", totalFen: 4500 });
64
+ expect(rec.totalAmount.value).toBe(45);
65
+ });
66
+
67
+ it("maps refund / cancelled status", () => {
68
+ expect(orderToRecord({ orderId: "R", shopName: "x", statusText: "已退款" }).status).toBe("refunded");
69
+ expect(orderToRecord({ orderId: "C", shopName: "x", statusText: "已取消" }).status).toBe("cancelled");
70
+ });
71
+
72
+ it("drops id-less rows; default merchant 大众点评", () => {
73
+ expect(orderToRecord({ shopName: "x" })).toBe(null);
74
+ expect(orderToRecord({ orderId: "M" }).merchantName).toBe("大众点评");
75
+ });
76
+ });
77
+
78
+ describe("extractOrders", () => {
79
+ it("pulls list from common shapes", () => {
80
+ expect(extractOrders({ orders: [{ orderId: "A" }] })).toHaveLength(1);
81
+ expect(extractOrders({ data: { records: [{ orderId: "B" }] } })).toHaveLength(1);
82
+ expect(extractOrders({ orderList: [{ orderId: "C" }] })).toHaveLength(1);
83
+ expect(extractOrders({})).toEqual([]);
84
+ expect(extractOrders(null)).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe("DianpingAdapter snapshot mode", () => {
89
+ const SNAPSHOT = JSON.stringify({
90
+ schemaVersion: 1,
91
+ snapshottedAt: 1716383000000,
92
+ vendor: "dianping",
93
+ account: { userId: "u1", displayName: "我" },
94
+ events: [
95
+ {
96
+ kind: "order",
97
+ id: "order-DPS1",
98
+ orderId: "DPS1",
99
+ merchantName: "西贝莜面村",
100
+ platform: "groupbuy",
101
+ items: [{ name: "莜面套餐", quantity: 2, unitPrice: 88 }],
102
+ placedAt: 1716300000000,
103
+ status: "已完成",
104
+ totalAmount: { value: 176, currency: "CNY" },
105
+ },
106
+ ],
107
+ });
108
+
109
+ it("authenticate validates inputPath readability", async () => {
110
+ const p = writeTmp(SNAPSHOT);
111
+ try {
112
+ const a = new DianpingAdapter();
113
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
114
+ const bad = await a.authenticate({ inputPath: path.join(os.tmpdir(), "nope-dp.json") });
115
+ expect(bad.ok).toBe(false);
116
+ expect(bad.reason).toBe("INPUT_PATH_UNREADABLE");
117
+ } finally {
118
+ fs.unlinkSync(p);
119
+ }
120
+ });
121
+
122
+ it("sync yields order + normalize end-to-end", async () => {
123
+ const p = writeTmp(SNAPSHOT);
124
+ try {
125
+ const a = new DianpingAdapter();
126
+ const items = await collect(a.sync({ inputPath: p }));
127
+ expect(items).toHaveLength(1);
128
+ expect(items[0].originalId).toBe("dianping:order:order-DPS1");
129
+ const batch = a.normalize(items[0]);
130
+ expect(batch.events[0].subtype).toBe("order");
131
+ expect(batch.events[0].content.amount).toMatchObject({ value: 176, direction: "out" });
132
+ expect(batch.persons.find((x) => x.subtype === "merchant").names).toEqual(["西贝莜面村"]);
133
+ } finally {
134
+ fs.unlinkSync(p);
135
+ }
136
+ });
137
+
138
+ it("schemaVersion mismatch throws", async () => {
139
+ const p = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
140
+ try {
141
+ const a = new DianpingAdapter();
142
+ await expect(collect(a.sync({ inputPath: p }))).rejects.toThrow(/schemaVersion mismatch/);
143
+ } finally {
144
+ fs.unlinkSync(p);
145
+ }
146
+ });
147
+ });
148
+
149
+ describe("DianpingAdapter cookie-api mode", () => {
150
+ it("authenticate cookie mode requires userId", async () => {
151
+ const noId = new DianpingAdapter({ account: { cookies: COOKIES } });
152
+ expect((await noId.authenticate()).reason).toBe("NO_ACCOUNT_USER_ID");
153
+ const ok = new DianpingAdapter({ account: { cookies: COOKIES, userId: "u1" } });
154
+ expect(await ok.authenticate()).toEqual({ ok: true, account: "u1", mode: "cookie" });
155
+ });
156
+
157
+ it("sync fetches, paginates, maps + normalizes", async () => {
158
+ const pages = [
159
+ { orderList: [{ orderId: "CK1", shopName: "肯德基", payMoney: "39.9", orderTime: 1716383021000, statusText: "已消费" }] },
160
+ { orderList: [] },
161
+ ];
162
+ const calls = [];
163
+ const fetchFn = async ({ url, cookies, query, sign }) => {
164
+ calls.push({ url, cookies, page: query.page, sign });
165
+ return pages[query.page - 1] || { orderList: [] };
166
+ };
167
+ const a = new DianpingAdapter({ account: { cookies: COOKIES, userId: "u1" }, fetchFn });
168
+ const items = await collect(a.sync({ sinceWatermark: 0 }));
169
+ expect(items).toHaveLength(1);
170
+ expect(items[0].originalId).toBe("CK1");
171
+ expect(calls[0].cookies).toBe(COOKIES);
172
+ expect(calls[0].sign).toBe(null);
173
+ const batch = a.normalize(items[0]);
174
+ expect(batch.events[0].content.amount).toMatchObject({ value: 39.9, direction: "out" });
175
+ });
176
+
177
+ it("invokes signProvider when configured", async () => {
178
+ const signCalls = [];
179
+ const a = new DianpingAdapter({
180
+ account: { cookies: COOKIES, userId: "u1" },
181
+ fetchFn: async ({ query }) =>
182
+ query.page === 1 ? { orders: [{ orderId: "S1", shopName: "x" }] } : { orders: [] },
183
+ signProvider: async (ctx) => {
184
+ signCalls.push(ctx);
185
+ return "MTGSIG-TOKEN";
186
+ },
187
+ });
188
+ const items = await collect(a.sync({ sinceWatermark: 0 }));
189
+ expect(items).toHaveLength(1);
190
+ expect(signCalls).toHaveLength(1);
191
+ expect(signCalls[0].cookies).toBe(COOKIES);
192
+ });
193
+
194
+ it("stops at sinceWatermark", async () => {
195
+ const a = new DianpingAdapter({
196
+ account: { cookies: COOKIES, userId: "u1" },
197
+ fetchFn: async () => ({
198
+ orderList: [
199
+ { orderId: "NEW", shopName: "x", orderTime: 2_000_000_000_000 },
200
+ { orderId: "OLD", shopName: "x", orderTime: 1_000_000_000_000 },
201
+ ],
202
+ }),
203
+ });
204
+ const items = await collect(a.sync({ sinceWatermark: 1_500_000_000_000, pageSize: 2 }));
205
+ expect(items.map((x) => x.originalId)).toEqual(["NEW"]);
206
+ });
207
+
208
+ it("respects opts.limit", async () => {
209
+ const a = new DianpingAdapter({
210
+ account: { cookies: COOKIES, userId: "u1" },
211
+ fetchFn: async () => ({
212
+ orderList: [
213
+ { orderId: "L1", shopName: "x", orderTime: 2_000_000_000_000 },
214
+ { orderId: "L2", shopName: "x", orderTime: 2_000_000_000_001 },
215
+ ],
216
+ }),
217
+ });
218
+ const items = await collect(a.sync({ sinceWatermark: 0, limit: 1, pageSize: 2 }));
219
+ expect(items).toHaveLength(1);
220
+ });
221
+
222
+ it("empty/login-redirect response yields zero (no crash)", async () => {
223
+ const a = new DianpingAdapter({
224
+ account: { cookies: COOKIES, userId: "u1" },
225
+ fetchFn: async () => "<html>login</html>",
226
+ });
227
+ expect(await collect(a.sync({ sinceWatermark: 0 }))).toEqual([]);
228
+ });
229
+
230
+ it("default fetch throws when no fetchFn (wiring bug)", async () => {
231
+ const a = new DianpingAdapter({ account: { cookies: COOKIES, userId: "u1" } });
232
+ await expect(collect(a.sync({ sinceWatermark: 0 }))).rejects.toThrow(/no fetchFn configured/);
233
+ });
234
+
235
+ it("no input throws", async () => {
236
+ const a = new DianpingAdapter();
237
+ await expect(collect(a.sync({}))).rejects.toThrow(/needs opts.inputPath/);
238
+ });
239
+ });
@@ -0,0 +1,175 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const {
10
+ CsdnAdapter,
11
+ extractList,
12
+ NAME,
13
+ VERSION,
14
+ SNAPSHOT_SCHEMA_VERSION,
15
+ } = require("../../lib/adapters/social-csdn");
16
+
17
+ function writeTmp(content) {
18
+ const p = path.join(os.tmpdir(), `cc-csdn-${crypto.randomUUID()}.json`);
19
+ fs.writeFileSync(p, content, "utf-8");
20
+ return p;
21
+ }
22
+ async function collect(gen) {
23
+ const out = [];
24
+ for await (const x of gen) out.push(x);
25
+ return out;
26
+ }
27
+
28
+ const COOKIES = "UserName=alice; UserToken=xyz";
29
+
30
+ const SNAP = JSON.stringify({
31
+ schemaVersion: 1,
32
+ snapshottedAt: 1716383000000,
33
+ account: { username: "alice", name: "Alice" },
34
+ events: [
35
+ { kind: "article", id: "article-101", articleId: "101", title: "<p>Vue 源码解析</p>", viewCount: 999, collectCount: 50, createdTime: 1716300000, url: "https://blog.csdn.net/alice/article/details/101" },
36
+ { kind: "favourite", id: "fav-202", itemId: "202", title: "Rust 入门", url: "https://x/202", source: "blog", capturedAt: 1716310000000 },
37
+ { kind: "follow", id: "follow-bob", username: "bob", name: "Bob", capturedAt: 1716320000000 },
38
+ ],
39
+ });
40
+
41
+ describe("constants + extractList", () => {
42
+ it("name/version/schema", () => {
43
+ expect(NAME).toBe("social-csdn");
44
+ expect(VERSION).toBe("0.1.0");
45
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
46
+ });
47
+ it("extractList tolerant", () => {
48
+ expect(extractList({ list: [{ id: 1 }] })).toHaveLength(1);
49
+ expect(extractList({ data: { list: [{ id: 1 }] } })).toHaveLength(1);
50
+ expect(extractList({ data: { records: [{ id: 1 }] } })).toHaveLength(1);
51
+ expect(extractList({})).toEqual([]);
52
+ });
53
+ });
54
+
55
+ describe("CsdnAdapter snapshot mode", () => {
56
+ it("authenticate validates inputPath", async () => {
57
+ const p = writeTmp(SNAP);
58
+ try {
59
+ const a = new CsdnAdapter();
60
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
61
+ expect((await a.authenticate({ inputPath: path.join(os.tmpdir(), "no-csdn.json") })).reason).toBe("INPUT_PATH_UNREADABLE");
62
+ } finally {
63
+ fs.unlinkSync(p);
64
+ }
65
+ });
66
+
67
+ it("sync 3 kinds + normalize article→post(html-stripped)/favourite→like/follow→person", async () => {
68
+ const p = writeTmp(SNAP);
69
+ try {
70
+ const a = new CsdnAdapter();
71
+ const items = await collect(a.sync({ inputPath: p }));
72
+ expect(items.map((x) => x.kind)).toEqual(["article", "favourite", "follow"]);
73
+
74
+ const art = a.normalize(items[0]);
75
+ expect(art.events[0].subtype).toBe("post");
76
+ expect(art.events[0].content.text).toBe("Vue 源码解析"); // html stripped
77
+ expect(art.events[0].extra.viewCount).toBe(999);
78
+ expect(art.events[0].extra.collectCount).toBe(50);
79
+
80
+ const fav = a.normalize(items[1]);
81
+ expect(fav.events[0].subtype).toBe("like");
82
+ expect(fav.events[0].content.title).toBe("Rust 入门");
83
+
84
+ const fol = a.normalize(items[2]);
85
+ expect(fol.persons[0].subtype).toBe("contact");
86
+ expect(fol.persons[0].names).toEqual(["Bob"]);
87
+ expect(fol.persons[0].identifiers["csdn-username"]).toEqual(["bob"]);
88
+ } finally {
89
+ fs.unlinkSync(p);
90
+ }
91
+ });
92
+
93
+ it("include filter + limit + schema mismatch + unknown kind", async () => {
94
+ const p = writeTmp(SNAP);
95
+ try {
96
+ const a = new CsdnAdapter();
97
+ expect((await collect(a.sync({ inputPath: p, include: { article: false, follow: false } }))).map((x) => x.kind)).toEqual(["favourite"]);
98
+ expect(await collect(a.sync({ inputPath: p, limit: 2 }))).toHaveLength(2);
99
+ expect(() => a.normalize({ kind: "bogus", payload: {} })).toThrow(/unknown kind/);
100
+ } finally {
101
+ fs.unlinkSync(p);
102
+ }
103
+ const bad = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
104
+ try {
105
+ const a = new CsdnAdapter();
106
+ await expect(collect(a.sync({ inputPath: bad }))).rejects.toThrow(/schemaVersion mismatch/);
107
+ } finally {
108
+ fs.unlinkSync(bad);
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("CsdnAdapter cookie-api mode", () => {
114
+ it("authenticate requires username", async () => {
115
+ const noU = new CsdnAdapter({ account: { cookies: COOKIES } });
116
+ expect((await noU.authenticate()).reason).toBe("NO_ACCOUNT_USERNAME");
117
+ const ok = new CsdnAdapter({ account: { cookies: COOKIES, username: "alice" } });
118
+ expect(await ok.authenticate()).toEqual({ ok: true, account: "alice", mode: "cookie" });
119
+ });
120
+
121
+ it("sync fetches articles/favourites/followees, normalizes", async () => {
122
+ const byUrl = (u) => (u.includes("get-business-list") ? "articles" : u.includes("favorite") ? "favourites" : "followees");
123
+ const data = {
124
+ articles: [{ articleId: "A1", title: "Go 并发", viewCount: 10, createdTime: 1716300000 }],
125
+ favourites: [{ id: "F1", title: "K8s 实践", source: "blog", created_at: 1716310000 }],
126
+ followees: [{ username: "carol", name: "Carol" }],
127
+ };
128
+ const calls = [];
129
+ const a = new CsdnAdapter({
130
+ account: { cookies: COOKIES, username: "alice" },
131
+ fetchFn: async ({ url, cookies, query, sign }) => {
132
+ const k = byUrl(url);
133
+ calls.push({ k, cookies, page: query.page, sign });
134
+ return { data: { list: query.page === 1 ? data[k] : [] } };
135
+ },
136
+ });
137
+ const items = await collect(a.sync({}));
138
+ expect(items.map((x) => x.kind).sort()).toEqual(["article", "favourite", "follow"]);
139
+ expect(calls.every((c) => c.cookies === COOKIES && c.sign === null)).toBe(true);
140
+ const art = a.normalize(items.find((x) => x.kind === "article"));
141
+ expect(art.events[0].content.title).toBe("Go 并发");
142
+ const fol = a.normalize(items.find((x) => x.kind === "follow"));
143
+ expect(fol.persons[0].names).toEqual(["Carol"]);
144
+ });
145
+
146
+ it("invokes signProvider", async () => {
147
+ const signCalls = [];
148
+ const a = new CsdnAdapter({
149
+ account: { cookies: COOKIES, username: "alice" },
150
+ fetchFn: async ({ query }) => ({ list: query.page === 1 ? [{ articleId: "A1", title: "x" }] : [] }),
151
+ signProvider: async (ctx) => { signCalls.push(ctx); return "sig"; },
152
+ });
153
+ const items = await collect(a.sync({ include: { favourite: false, follow: false } }));
154
+ expect(items.length).toBeGreaterThan(0);
155
+ expect(signCalls.length).toBeGreaterThan(0);
156
+ expect(signCalls[0].cookies).toBe(COOKIES);
157
+ });
158
+
159
+ it("limit + empty/login + default fetch + no input", async () => {
160
+ const a1 = new CsdnAdapter({
161
+ account: { cookies: COOKIES, username: "alice" },
162
+ fetchFn: async ({ query }) => ({ list: query.page === 1 ? [{ articleId: "A1", title: "a" }, { articleId: "A2", title: "b" }] : [] }),
163
+ });
164
+ expect(await collect(a1.sync({ limit: 1 }))).toHaveLength(1);
165
+
166
+ const a2 = new CsdnAdapter({ account: { cookies: COOKIES, username: "alice" }, fetchFn: async () => "<html>login</html>" });
167
+ expect(await collect(a2.sync({}))).toEqual([]);
168
+
169
+ const a3 = new CsdnAdapter({ account: { cookies: COOKIES, username: "alice" } });
170
+ await expect(collect(a3.sync({}))).rejects.toThrow(/no fetchFn configured/);
171
+
172
+ const a4 = new CsdnAdapter();
173
+ await expect(collect(a4.sync({}))).rejects.toThrow(/needs opts.inputPath/);
174
+ });
175
+ });
@@ -0,0 +1,246 @@
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
+ const crypto = require("node:crypto");
8
+
9
+ const {
10
+ ZhihuAdapter,
11
+ extractData,
12
+ NAME,
13
+ VERSION,
14
+ SNAPSHOT_SCHEMA_VERSION,
15
+ } = require("../../lib/adapters/social-zhihu");
16
+
17
+ function writeTmp(content) {
18
+ const p = path.join(os.tmpdir(), `cc-zhihu-${crypto.randomUUID()}.json`);
19
+ fs.writeFileSync(p, content, "utf-8");
20
+ return p;
21
+ }
22
+
23
+ async function collect(gen) {
24
+ const out = [];
25
+ for await (const x of gen) out.push(x);
26
+ return out;
27
+ }
28
+
29
+ const COOKIES = "z_c0=abc; d_c0=xyz";
30
+
31
+ const SNAPSHOT = JSON.stringify({
32
+ schemaVersion: 1,
33
+ snapshottedAt: 1716383000000,
34
+ account: { urlToken: "alice", name: "Alice" },
35
+ events: [
36
+ {
37
+ kind: "answer",
38
+ id: "answer-101",
39
+ answerId: "101",
40
+ questionTitle: "如何评价 X?",
41
+ excerpt: "<p>我认为 X 很好</p>",
42
+ voteupCount: 42,
43
+ commentCount: 3,
44
+ createdTime: 1716300000,
45
+ url: "https://www.zhihu.com/answer/101",
46
+ },
47
+ {
48
+ kind: "favourite",
49
+ id: "fav-202",
50
+ itemId: "202",
51
+ title: "好文收藏",
52
+ collectionName: "技术",
53
+ capturedAt: 1716310000000,
54
+ },
55
+ {
56
+ kind: "follow",
57
+ id: "follow-bob",
58
+ memberToken: "bob",
59
+ name: "Bob",
60
+ headline: "工程师",
61
+ avatarUrl: "https://pic/bob.jpg",
62
+ capturedAt: 1716320000000,
63
+ },
64
+ ],
65
+ });
66
+
67
+ describe("constants", () => {
68
+ it("exposes name/version/schema", () => {
69
+ expect(NAME).toBe("social-zhihu");
70
+ expect(VERSION).toBe("0.1.0");
71
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
72
+ });
73
+ });
74
+
75
+ describe("extractData", () => {
76
+ it("pulls data/items arrays; tolerant of bad shapes", () => {
77
+ expect(extractData({ data: [{ id: 1 }] })).toHaveLength(1);
78
+ expect(extractData({ items: [{ id: 1 }] })).toHaveLength(1);
79
+ expect(extractData({})).toEqual([]);
80
+ expect(extractData(null)).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("ZhihuAdapter snapshot mode", () => {
85
+ it("authenticate validates inputPath readability", async () => {
86
+ const p = writeTmp(SNAPSHOT);
87
+ try {
88
+ const a = new ZhihuAdapter();
89
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
90
+ const bad = await a.authenticate({ inputPath: path.join(os.tmpdir(), "nope-z.json") });
91
+ expect(bad.ok).toBe(false);
92
+ expect(bad.reason).toBe("INPUT_PATH_UNREADABLE");
93
+ } finally {
94
+ fs.unlinkSync(p);
95
+ }
96
+ });
97
+
98
+ it("sync yields all 3 kinds + normalize answer→post, favourite→like, follow→person", async () => {
99
+ const p = writeTmp(SNAPSHOT);
100
+ try {
101
+ const a = new ZhihuAdapter();
102
+ const real = await collect(a.sync({ inputPath: p }));
103
+ expect(real).toHaveLength(3);
104
+ expect(real.map((x) => x.kind)).toEqual(["answer", "favourite", "follow"]);
105
+
106
+ const ans = a.normalize(real[0]);
107
+ expect(ans.events[0].subtype).toBe("post");
108
+ expect(ans.events[0].content.text).toBe("我认为 X 很好"); // html stripped
109
+ expect(ans.events[0].extra.voteupCount).toBe(42);
110
+ expect(ans.events[0].extra.questionTitle).toBe("如何评价 X?");
111
+
112
+ const fav = a.normalize(real[1]);
113
+ expect(fav.events[0].subtype).toBe("like");
114
+ expect(fav.events[0].content.title).toBe("好文收藏");
115
+
116
+ const fol = a.normalize(real[2]);
117
+ expect(fol.persons[0].subtype).toBe("contact");
118
+ expect(fol.persons[0].names).toEqual(["Bob"]);
119
+ expect(fol.persons[0].identifiers["zhihu-token"]).toEqual(["bob"]);
120
+ } finally {
121
+ fs.unlinkSync(p);
122
+ }
123
+ });
124
+
125
+ it("respects include filter + limit", async () => {
126
+ const p = writeTmp(SNAPSHOT);
127
+ try {
128
+ const a = new ZhihuAdapter();
129
+ const onlyFollow = await collect(a.sync({ inputPath: p, include: { answer: false, favourite: false } }));
130
+ expect(onlyFollow.map((x) => x.kind)).toEqual(["follow"]);
131
+ const limited = await collect(a.sync({ inputPath: p, limit: 1 }));
132
+ expect(limited).toHaveLength(1);
133
+ } finally {
134
+ fs.unlinkSync(p);
135
+ }
136
+ });
137
+
138
+ it("schemaVersion mismatch throws", async () => {
139
+ const p = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
140
+ try {
141
+ const a = new ZhihuAdapter();
142
+ await expect(collect(a.sync({ inputPath: p }))).rejects.toThrow(/schemaVersion mismatch/);
143
+ } finally {
144
+ fs.unlinkSync(p);
145
+ }
146
+ });
147
+
148
+ it("normalize throws on unknown kind", () => {
149
+ const a = new ZhihuAdapter();
150
+ expect(() => a.normalize({ kind: "bogus", payload: {} })).toThrow(/unknown kind/);
151
+ });
152
+ });
153
+
154
+ describe("ZhihuAdapter cookie-api mode", () => {
155
+ it("authenticate cookie mode requires urlToken", async () => {
156
+ const noTok = new ZhihuAdapter({ account: { cookies: COOKIES } });
157
+ expect((await noTok.authenticate()).reason).toBe("NO_ACCOUNT_URL_TOKEN");
158
+ const ok = new ZhihuAdapter({ account: { cookies: COOKIES, urlToken: "alice" } });
159
+ expect(await ok.authenticate()).toEqual({ ok: true, account: "alice", mode: "cookie" });
160
+ });
161
+
162
+ it("sync fetches answers/followees/collections, paginates, normalizes", async () => {
163
+ const byUrl = (url) => {
164
+ if (url.includes("/answers")) return "answers";
165
+ if (url.includes("/followees")) return "followees";
166
+ if (url.includes("/collections")) return "collections";
167
+ return "?";
168
+ };
169
+ const data = {
170
+ answers: [
171
+ { id: "A1", question: { title: "Q1" }, excerpt: "ans1", voteup_count: 9, created_time: 1716300000 },
172
+ ],
173
+ followees: [{ url_token: "bob", name: "Bob", headline: "eng" }],
174
+ collections: [{ id: "C1", title: "我的收藏", is_public: true }],
175
+ };
176
+ const calls = [];
177
+ const fetchFn = async ({ url, cookies, query, sign }) => {
178
+ const k = byUrl(url);
179
+ calls.push({ k, cookies, offset: query.offset, sign });
180
+ // single page each
181
+ return { data: query.offset === 0 ? data[k] : [], paging: { is_end: query.offset !== 0 } };
182
+ };
183
+ const a = new ZhihuAdapter({ account: { cookies: COOKIES, urlToken: "alice" }, fetchFn });
184
+ const items = await collect(a.sync({}));
185
+ expect(items.map((x) => x.kind).sort()).toEqual(["answer", "favourite", "follow"]);
186
+ expect(calls.every((c) => c.cookies === COOKIES)).toBe(true);
187
+ expect(calls.every((c) => c.sign === null)).toBe(true);
188
+
189
+ const ans = a.normalize(items.find((x) => x.kind === "answer"));
190
+ expect(ans.events[0].content.title).toBe("Q1");
191
+ expect(ans.events[0].extra.voteupCount).toBe(9);
192
+ const fol = a.normalize(items.find((x) => x.kind === "follow"));
193
+ expect(fol.persons[0].names).toEqual(["Bob"]);
194
+ expect(fol.persons[0].identifiers["zhihu-token"]).toEqual(["bob"]);
195
+ const fav = a.normalize(items.find((x) => x.kind === "favourite"));
196
+ expect(fav.events[0].content.title).toBe("我的收藏");
197
+ });
198
+
199
+ it("invokes signProvider when configured", async () => {
200
+ const signCalls = [];
201
+ const a = new ZhihuAdapter({
202
+ account: { cookies: COOKIES, urlToken: "alice" },
203
+ fetchFn: async ({ query }) =>
204
+ query.offset === 0 ? { data: [{ id: "A1", question: { title: "Q" }, excerpt: "x" }], paging: { is_end: true } } : { data: [], paging: { is_end: true } },
205
+ signProvider: async (ctx) => {
206
+ signCalls.push(ctx);
207
+ return "x-zse-96-value";
208
+ },
209
+ // only answers to keep it short
210
+ });
211
+ const items = await collect(a.sync({ include: { follow: false, favourite: false } }));
212
+ expect(items.length).toBeGreaterThan(0);
213
+ expect(signCalls.length).toBeGreaterThan(0);
214
+ expect(signCalls[0].cookies).toBe(COOKIES);
215
+ });
216
+
217
+ it("respects opts.limit across kinds", async () => {
218
+ const a = new ZhihuAdapter({
219
+ account: { cookies: COOKIES, urlToken: "alice" },
220
+ fetchFn: async ({ query }) =>
221
+ query.offset === 0
222
+ ? { data: [{ id: "A1", question: { title: "Q" }, excerpt: "x" }, { id: "A2", question: { title: "Q2" }, excerpt: "y" }], paging: { is_end: true } }
223
+ : { data: [], paging: { is_end: true } },
224
+ });
225
+ const items = await collect(a.sync({ limit: 1 }));
226
+ expect(items).toHaveLength(1);
227
+ });
228
+
229
+ it("is_end stops pagination; empty data yields zero (no crash)", async () => {
230
+ const a = new ZhihuAdapter({
231
+ account: { cookies: COOKIES, urlToken: "alice" },
232
+ fetchFn: async () => "not-json-login-redirect",
233
+ });
234
+ expect(await collect(a.sync({}))).toEqual([]);
235
+ });
236
+
237
+ it("default fetch throws when no fetchFn", async () => {
238
+ const a = new ZhihuAdapter({ account: { cookies: COOKIES, urlToken: "alice" } });
239
+ await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
240
+ });
241
+
242
+ it("sync with no input/cookie throws", async () => {
243
+ const a = new ZhihuAdapter();
244
+ await expect(collect(a.sync({}))).rejects.toThrow(/needs opts.inputPath/);
245
+ });
246
+ });