@chainlesschain/personal-data-hub 0.4.23 → 0.4.24

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 (37) hide show
  1. package/__tests__/adapters/bank-family.test.js +125 -0
  2. package/__tests__/adapters/car-mercedesme.test.js +74 -0
  3. package/__tests__/adapters/finance-dcep.test.js +74 -0
  4. package/__tests__/adapters/fitness-joyrun.test.js +82 -0
  5. package/__tests__/adapters/gov-12123.test.js +103 -0
  6. package/__tests__/adapters/music-qq.test.js +112 -0
  7. package/__tests__/adapters/reading-family.test.js +108 -0
  8. package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
  9. package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
  10. package/__tests__/fitness-keep-snapshot.test.js +224 -0
  11. package/__tests__/shopping-eleme-snapshot.test.js +454 -0
  12. package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
  13. package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
  14. package/__tests__/social-douban-snapshot.test.js +351 -0
  15. package/lib/adapter-guide.js +19 -1
  16. package/lib/adapters/_bank-base.js +405 -0
  17. package/lib/adapters/_reading-base.js +315 -0
  18. package/lib/adapters/audio-ximalaya/index.js +414 -0
  19. package/lib/adapters/bank-bankcomm/index.js +27 -0
  20. package/lib/adapters/bank-boc/index.js +26 -0
  21. package/lib/adapters/bank-cmbc/index.js +26 -0
  22. package/lib/adapters/bank-icbc/index.js +27 -0
  23. package/lib/adapters/car-mercedesme/index.js +225 -0
  24. package/lib/adapters/finance-dcep/index.js +302 -0
  25. package/lib/adapters/fitness-joyrun/index.js +295 -0
  26. package/lib/adapters/fitness-keep/index.js +343 -0
  27. package/lib/adapters/gov-12123/index.js +391 -0
  28. package/lib/adapters/music-qq/index.js +372 -0
  29. package/lib/adapters/reading-fanqie/index.js +61 -0
  30. package/lib/adapters/reading-qimao/index.js +61 -0
  31. package/lib/adapters/shopping-eleme/index.js +441 -0
  32. package/lib/adapters/shopping-vipshop/index.js +429 -0
  33. package/lib/adapters/shopping-xianyu/index.js +454 -0
  34. package/lib/adapters/social-douban/index.js +564 -0
  35. package/lib/adapters/travel-didi-consumer/index.js +148 -0
  36. package/lib/index.js +36 -0
  37. package/package.json +1 -1
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+
9
+ const {
10
+ DoubanAdapter,
11
+ SNAPSHOT_SCHEMA_VERSION,
12
+ VALID_SNAPSHOT_KINDS,
13
+ extractData,
14
+ } = require("../lib/adapters/social-douban");
15
+ const { assertAdapter } = require("../lib/adapter-spec");
16
+ const { validateBatch } = require("../lib/batch");
17
+
18
+ // 豆瓣 (Douban / Frodo) — 书影音 interest graph. Mirrors social-zhihu's two-mode
19
+ // custom-normalize shape + video-base MEDIA event+item for marks. Frodo signing
20
+ // is injected (signProvider) so the adapter stays pure-Node.
21
+
22
+ function writeSnapshot(dir, snapshot) {
23
+ const p = path.join(dir, "social-douban.json");
24
+ fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
25
+ return p;
26
+ }
27
+
28
+ describe("DoubanAdapter snapshot mode", () => {
29
+ let tmpDir;
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "douban-snap-"));
32
+ });
33
+
34
+ it("exports schema constants", () => {
35
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
36
+ expect(VALID_SNAPSHOT_KINDS).toEqual(["interest", "review", "follow"]);
37
+ });
38
+
39
+ it("authenticate(inputPath) ok when readable", async () => {
40
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: Date.now(), events: [] });
41
+ const a = new DoubanAdapter();
42
+ const res = await a.authenticate({ inputPath: p });
43
+ expect(res.ok).toBe(true);
44
+ expect(res.mode).toBe("snapshot-file");
45
+ });
46
+
47
+ it("authenticate() no input → NO_INPUT", async () => {
48
+ const a = new DoubanAdapter();
49
+ const res = await a.authenticate({});
50
+ expect(res.ok).toBe(false);
51
+ expect(res.reason).toBe("NO_INPUT");
52
+ });
53
+
54
+ it("sync() without input throws with signProvider hint", async () => {
55
+ const a = new DoubanAdapter();
56
+ let threw = null;
57
+ try {
58
+ for await (const _r of a.sync({})) { /* drain */ }
59
+ } catch (err) {
60
+ threw = err;
61
+ }
62
+ expect(threw).toBeTruthy();
63
+ expect(String(threw.message)).toMatch(/signProvider/);
64
+ });
65
+
66
+ it("rejects schemaVersion mismatch", async () => {
67
+ const p = writeSnapshot(tmpDir, { schemaVersion: 99, snapshottedAt: Date.now(), events: [] });
68
+ const a = new DoubanAdapter();
69
+ let threw = null;
70
+ try {
71
+ for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
72
+ } catch (err) {
73
+ threw = err;
74
+ }
75
+ expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
76
+ });
77
+
78
+ it("interest (看过电影) → MEDIA event + MEDIA item, normalizes cleanly", async () => {
79
+ const now = Date.now();
80
+ const p = writeSnapshot(tmpDir, {
81
+ schemaVersion: 1,
82
+ snapshottedAt: now,
83
+ account: { userId: "12345", name: "alice" },
84
+ events: [
85
+ {
86
+ kind: "interest", id: "interest-m1", subjectId: "26266893",
87
+ subjectType: "movie", title: "瞬息全宇宙", status: "done",
88
+ myRating: 5, comment: "好看", createdTime: 1700000000, url: "https://movie.douban.com/subject/26266893/",
89
+ },
90
+ ],
91
+ });
92
+ const a = new DoubanAdapter();
93
+ const raws = [];
94
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
95
+ expect(raws.length).toBe(1);
96
+ expect(raws[0].kind).toBe("interest");
97
+ expect(raws[0].originalId).toBe("douban:interest:interest-m1");
98
+
99
+ const batch = a.normalize(raws[0]);
100
+ expect(validateBatch(batch).valid).toBe(true);
101
+ expect(batch.events.length).toBe(1);
102
+ expect(batch.items.length).toBe(1);
103
+ expect(batch.events[0].subtype).toBe("media");
104
+ expect(batch.events[0].content.title).toContain("看过电影: 瞬息全宇宙");
105
+ expect(batch.events[0].extra.myRating).toBe(5);
106
+ expect(batch.items[0].name).toBe("瞬息全宇宙");
107
+ expect(JSON.stringify(batch)).toContain("好看");
108
+ });
109
+
110
+ it("interest status maps to verb (mark=想看, doing=在看, done=看过)", async () => {
111
+ const now = Date.now();
112
+ const cases = [
113
+ { status: "mark", verb: "想看" },
114
+ { status: "doing", verb: "在看" },
115
+ { status: "done", verb: "看过" },
116
+ ];
117
+ for (const c of cases) {
118
+ const p = writeSnapshot(tmpDir, {
119
+ schemaVersion: 1, snapshottedAt: now,
120
+ events: [
121
+ { kind: "interest", id: `i-${c.status}`, subjectId: "1", subjectType: "book",
122
+ title: "三体", status: c.status, createdTime: now },
123
+ ],
124
+ });
125
+ const a = new DoubanAdapter();
126
+ const raws = [];
127
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
128
+ const batch = a.normalize(raws[0]);
129
+ expect(batch.events[0].content.title).toContain(`${c.verb}图书: 三体`);
130
+ }
131
+ });
132
+
133
+ it("review → POST event", async () => {
134
+ const now = Date.now();
135
+ const p = writeSnapshot(tmpDir, {
136
+ schemaVersion: 1, snapshottedAt: now,
137
+ events: [
138
+ { kind: "review", id: "review-1", reviewId: "9001", title: "一篇影评",
139
+ abstract: "<p>写得不错</p>", subjectTitle: "瞬息全宇宙", rating: 4, createdTime: now },
140
+ ],
141
+ });
142
+ const a = new DoubanAdapter();
143
+ const raws = [];
144
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
145
+ const batch = a.normalize(raws[0]);
146
+ expect(validateBatch(batch).valid).toBe(true);
147
+ expect(batch.events[0].subtype).toBe("post");
148
+ expect(batch.events[0].content.title).toBe("一篇影评");
149
+ expect(batch.events[0].content.text).toBe("写得不错"); // html stripped
150
+ expect(batch.events[0].extra.rating).toBe(4);
151
+ });
152
+
153
+ it("follow → CONTACT person with douban-id identifier", async () => {
154
+ const now = Date.now();
155
+ const p = writeSnapshot(tmpDir, {
156
+ schemaVersion: 1, snapshottedAt: now,
157
+ events: [
158
+ { kind: "follow", id: "follow-u1", memberId: "67890", name: "豆友小张",
159
+ url: "https://www.douban.com/people/67890/", capturedAt: now },
160
+ ],
161
+ });
162
+ const a = new DoubanAdapter();
163
+ const raws = [];
164
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
165
+ const batch = a.normalize(raws[0]);
166
+ expect(validateBatch(batch).valid).toBe(true);
167
+ expect(batch.persons.length).toBe(1);
168
+ expect(batch.persons[0].id).toBe("person-douban-67890");
169
+ expect(batch.persons[0].names).toEqual(["豆友小张"]);
170
+ expect(batch.persons[0].identifiers["douban-id"]).toEqual(["67890"]);
171
+ });
172
+
173
+ it("respects per-kind include opt-out + limit", async () => {
174
+ const now = Date.now();
175
+ const events = [
176
+ { kind: "interest", id: "i1", subjectId: "1", subjectType: "movie", title: "a", status: "done", createdTime: now },
177
+ { kind: "review", id: "r1", reviewId: "2", title: "b", createdTime: now },
178
+ { kind: "follow", id: "f1", memberId: "3", name: "c", capturedAt: now },
179
+ ];
180
+ const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
181
+ const a = new DoubanAdapter();
182
+ const raws = [];
183
+ for await (const r of a.sync({ inputPath: p, include: { review: false, follow: false } })) raws.push(r);
184
+ expect(raws.length).toBe(1);
185
+ expect(raws[0].kind).toBe("interest");
186
+ });
187
+
188
+ it("filters unknown kinds (forward compat)", async () => {
189
+ const now = Date.now();
190
+ const p = writeSnapshot(tmpDir, {
191
+ schemaVersion: 1, snapshottedAt: now,
192
+ events: [
193
+ { kind: "interest", id: "i1", subjectId: "1", subjectType: "movie", title: "a", status: "done", createdTime: now },
194
+ { kind: "status", id: "s1" },
195
+ ],
196
+ });
197
+ const a = new DoubanAdapter();
198
+ const raws = [];
199
+ for await (const r of a.sync({ inputPath: p })) raws.push(r);
200
+ expect(raws.length).toBe(1);
201
+ });
202
+
203
+ it("advertises snapshot + cookie-api capabilities; passes assertAdapter", () => {
204
+ const a = new DoubanAdapter();
205
+ expect(a.capabilities).toContain("sync:snapshot");
206
+ expect(a.capabilities).toContain("sync:cookie-api");
207
+ expect(assertAdapter(a).ok).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe("DoubanAdapter cookie-api mode", () => {
212
+ it("authenticate(cookie) requires account.userId", async () => {
213
+ const a = new DoubanAdapter({ account: { cookies: "bid=ok" } });
214
+ const res = await a.authenticate();
215
+ expect(res.ok).toBe(false);
216
+ expect(res.reason).toBe("NO_ACCOUNT_USER_ID");
217
+ });
218
+
219
+ it("authenticate(cookie) ok when userId + cookies present", async () => {
220
+ const a = new DoubanAdapter({ account: { userId: "12345", cookies: "bid=ok" } });
221
+ const res = await a.authenticate();
222
+ expect(res.ok).toBe(true);
223
+ expect(res.mode).toBe("cookie");
224
+ expect(res.account).toBe("12345");
225
+ });
226
+
227
+ it("fetches interests/reviews/following across the plan and normalizes", async () => {
228
+ const byUrl = (url) => {
229
+ if (url.includes("/interests")) {
230
+ return {
231
+ interests: [
232
+ { id: "int1", status: "done", create_time: "2024-01-02 10:00:00",
233
+ rating: { value: 4 }, comment: "不错",
234
+ subject: { id: "26266893", type: "movie", title: "瞬息全宇宙", url: "u" } },
235
+ ],
236
+ total: 1,
237
+ };
238
+ }
239
+ if (url.includes("/reviews")) {
240
+ return { reviews: [{ id: "rev1", title: "影评", abstract: "好", create_time: "2024-01-03 10:00:00", subject: { title: "瞬息全宇宙" } }], total: 1 };
241
+ }
242
+ if (url.includes("/following")) {
243
+ return { users: [{ id: "u9", name: "豆友" }], total: 1 };
244
+ }
245
+ return { total: 0 };
246
+ };
247
+ const fetchFn = async (opts) => byUrl(opts.url);
248
+ const a = new DoubanAdapter({ account: { userId: "12345", cookies: "bid=ok" }, fetchFn });
249
+ const raws = [];
250
+ for await (const r of a.sync({})) raws.push(r);
251
+ expect(raws.map((r) => r.kind).sort()).toEqual(["follow", "interest", "review"]);
252
+
253
+ const interest = raws.find((r) => r.kind === "interest");
254
+ const ib = a.normalize(interest);
255
+ expect(validateBatch(ib).valid).toBe(true);
256
+ expect(ib.events[0].subtype).toBe("media");
257
+ expect(ib.events[0].content.title).toContain("看过电影: 瞬息全宇宙");
258
+ expect(ib.events[0].extra.myRating).toBe(4);
259
+ expect(ib.items[0].name).toBe("瞬息全宇宙");
260
+
261
+ const follow = raws.find((r) => r.kind === "follow");
262
+ const fb = a.normalize(follow);
263
+ expect(fb.persons[0].identifiers["douban-id"]).toEqual(["u9"]);
264
+ });
265
+
266
+ it("invokes signProvider and passes sign to fetchFn", async () => {
267
+ let seenSign = null;
268
+ const signProvider = async () => "SIG-1";
269
+ const fetchFn = async (opts) => {
270
+ seenSign = opts.sign;
271
+ return { total: 0 };
272
+ };
273
+ const a = new DoubanAdapter({
274
+ account: { userId: "12345", cookies: "bid=ok" },
275
+ fetchFn,
276
+ signProvider,
277
+ });
278
+ for await (const _r of a.sync({ include: { review: false, follow: false } })) { /* drain */ }
279
+ expect(seenSign).toBe("SIG-1");
280
+ });
281
+
282
+ it("passes sign: null when no signProvider", async () => {
283
+ let seen = "unset";
284
+ const fetchFn = async (opts) => {
285
+ seen = opts.sign;
286
+ return { total: 0 };
287
+ };
288
+ const a = new DoubanAdapter({ account: { userId: "12345", cookies: "bid=ok" }, fetchFn });
289
+ for await (const _r of a.sync({ include: { review: false, follow: false } })) { /* drain */ }
290
+ expect(seen).toBe(null);
291
+ });
292
+
293
+ it("paginates interests with start cursor until total reached", async () => {
294
+ const seenStarts = [];
295
+ const all = Array.from({ length: 25 }, (_, i) => ({
296
+ id: `int${i}`, status: "done", create_time: "2024-01-01 00:00:00",
297
+ subject: { id: String(i), type: "book", title: `书${i}` },
298
+ }));
299
+ const fetchFn = async (opts) => {
300
+ if (!opts.url.includes("/interests")) return { total: 0 };
301
+ const start = opts.query.start;
302
+ seenStarts.push(start);
303
+ return { interests: all.slice(start, start + 20), total: all.length };
304
+ };
305
+ const a = new DoubanAdapter({ account: { userId: "12345", cookies: "bid=ok" }, fetchFn });
306
+ const raws = [];
307
+ for await (const r of a.sync({ include: { review: false, follow: false } })) raws.push(r);
308
+ expect(raws.length).toBe(25);
309
+ expect(seenStarts).toEqual([0, 20]);
310
+ });
311
+
312
+ it("extractData tolerates Frodo response shapes", () => {
313
+ expect(extractData({ interests: [1] }, "interest")).toEqual([1]);
314
+ expect(extractData({ reviews: [2] }, "review")).toEqual([2]);
315
+ expect(extractData({ users: [3] }, "follow")).toEqual([3]);
316
+ expect(extractData({ items: [4] })).toEqual([4]);
317
+ expect(extractData([5])).toEqual([5]);
318
+ expect(extractData({})).toEqual([]);
319
+ expect(extractData(null)).toEqual([]);
320
+ });
321
+
322
+ it("uses opts.*Url overrides", async () => {
323
+ const seen = [];
324
+ const fetchFn = async (opts) => {
325
+ seen.push(opts.url);
326
+ return { total: 0 };
327
+ };
328
+ const a = new DoubanAdapter({
329
+ account: { userId: "12345", cookies: "bid=ok" },
330
+ fetchFn,
331
+ interestsUrl: "https://x/i/{id}",
332
+ reviewsUrl: "https://x/r/{id}",
333
+ followingUrl: "https://x/f/{id}",
334
+ });
335
+ for await (const _r of a.sync({})) { /* drain */ }
336
+ expect(seen).toContain("https://x/i/12345");
337
+ expect(seen).toContain("https://x/r/12345");
338
+ expect(seen).toContain("https://x/f/12345");
339
+ });
340
+
341
+ it("default fetchFn throws legible error in cookie mode without injection", async () => {
342
+ const a = new DoubanAdapter({ account: { userId: "12345", cookies: "bid=ok" } });
343
+ let threw = null;
344
+ try {
345
+ for await (const _r of a.sync({})) { /* drain */ }
346
+ } catch (err) {
347
+ threw = err;
348
+ }
349
+ expect(String(threw.message)).toMatch(/no fetchFn configured/);
350
+ });
351
+ });
@@ -33,6 +33,7 @@ const DISPLAY_NAMES = Object.freeze({
33
33
  "social-bilibili": "哔哩哔哩",
34
34
  "social-weibo": "微博",
35
35
  "social-zhihu": "知乎",
36
+ "social-douban": "豆瓣",
36
37
  "recruit-boss": "BOSS 直聘",
37
38
  "social-csdn": "CSDN",
38
39
  "social-dongchedi": "懂车帝",
@@ -56,8 +57,11 @@ const DISPLAY_NAMES = Object.freeze({
56
57
  "shopping-taobao": "淘宝",
57
58
  "shopping-jd": "京东",
58
59
  "shopping-meituan": "美团",
60
+ "shopping-eleme": "饿了么",
59
61
  "shopping-pinduoduo": "拼多多",
60
62
  "shopping-dianping": "大众点评",
63
+ "shopping-xianyu": "闲鱼",
64
+ "shopping-vipshop": "唯品会",
61
65
  "travel-12306": "12306 铁路",
62
66
  "travel-ctrip": "携程",
63
67
  "travel-tongcheng": "同程旅行",
@@ -73,6 +77,14 @@ const DISPLAY_NAMES = Object.freeze({
73
77
  "apple-health": "Apple 健康",
74
78
  "netease-music": "网易云音乐",
75
79
  "music-kugou": "酷狗音乐",
80
+ "music-qq": "QQ音乐",
81
+ "audio-ximalaya": "喜马拉雅",
82
+ "reading-fanqie": "番茄小说",
83
+ "reading-qimao": "七猫小说",
84
+ "fitness-joyrun": "悦跑圈",
85
+ "fitness-keep": "Keep",
86
+ "travel-didi-consumer": "滴滴出行",
87
+ "car-mercedesme": "奔驰 Mercedes me",
76
88
  "video-iqiyi": "爱奇艺",
77
89
  "video-tencent": "腾讯视频",
78
90
  "video-xigua": "西瓜视频",
@@ -84,6 +96,12 @@ const DISPLAY_NAMES = Object.freeze({
84
96
  "gov-ixiamen": "i厦门",
85
97
  "health-meiyou": "美柚",
86
98
  "gov-tax": "个人所得税",
99
+ "bank-cmbc": "民生银行",
100
+ "bank-boc": "中国银行",
101
+ "bank-bankcomm": "交通银行",
102
+ "bank-icbc": "工商银行",
103
+ "finance-dcep": "数字人民币",
104
+ "gov-12123": "交管12123",
87
105
  "browser-history-chrome": "Chrome 浏览历史",
88
106
  "browser-history-edge": "Edge 浏览历史",
89
107
  "vscode": "VS Code",
@@ -555,7 +573,7 @@ function getAdapterGuide(name, category) {
555
573
  // usable standalone, e.g. CLI without a live readiness probe).
556
574
  function _inferCategory(name) {
557
575
  if (ADAPTER_OVERRIDES[name] && name === "wechat") return READINESS_CATEGORY.DEVICE;
558
- if (/^(email-imap|finance-alipay|alipay-bill|ai-chat-history|weread|doc-wps|doc-tencent-docs|doc-baidu-netdisk|doc-camscanner|recruit-boss|social-csdn|social-dongchedi|biz-tianyancha|gov-ixiamen|health-meiyou|gov-tax)$/.test(name))
576
+ if (/^(email-imap|finance-alipay|alipay-bill|ai-chat-history|weread|doc-wps|doc-tencent-docs|doc-baidu-netdisk|doc-camscanner|recruit-boss|social-csdn|social-douban|social-dongchedi|biz-tianyancha|gov-ixiamen|health-meiyou|gov-tax|bank-cmbc|bank-boc|bank-bankcomm|finance-dcep|gov-12123|bank-icbc)$/.test(name))
559
577
  return READINESS_CATEGORY.CREDENTIAL;
560
578
  if (/^(messaging-(telegram|whatsapp)|wechat|wechat-pc|messaging-qq|qq-pc|dingtalk-pc|feishu-pc|wework-pc|travel-amap)$/.test(name))
561
579
  return READINESS_CATEGORY.DEVICE;