@chainlesschain/personal-data-hub 0.4.7 → 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 (29) 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-ctrip.test.js +175 -1
  9. package/__tests__/adapters/travel-didi.test.js +204 -0
  10. package/__tests__/adapters/travel-tongcheng.test.js +289 -0
  11. package/__tests__/adapters/video-platforms.test.js +152 -0
  12. package/lib/adapter-guide.js +13 -1
  13. package/lib/adapters/_document-base.js +370 -0
  14. package/lib/adapters/_video-base.js +331 -0
  15. package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
  16. package/lib/adapters/doc-tencent-docs/index.js +94 -0
  17. package/lib/adapters/doc-wps/index.js +77 -0
  18. package/lib/adapters/music-kugou/index.js +418 -0
  19. package/lib/adapters/recruit-boss/index.js +442 -0
  20. package/lib/adapters/shopping-dianping/index.js +473 -0
  21. package/lib/adapters/social-csdn/index.js +444 -0
  22. package/lib/adapters/social-zhihu/index.js +488 -0
  23. package/lib/adapters/travel-ctrip/index.js +255 -40
  24. package/lib/adapters/travel-didi/index.js +327 -0
  25. package/lib/adapters/travel-tongcheng/index.js +393 -0
  26. package/lib/adapters/video-iqiyi/index.js +75 -0
  27. package/lib/adapters/video-tencent/index.js +78 -0
  28. package/lib/index.js +24 -0
  29. package/package.json +1 -1
@@ -0,0 +1,102 @@
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 nd = require("../../lib/adapters/doc-baidu-netdisk");
10
+
11
+ function writeTmp(content) {
12
+ const p = path.join(os.tmpdir(), `cc-nd-${crypto.randomUUID()}.json`);
13
+ fs.writeFileSync(p, content, "utf-8");
14
+ return p;
15
+ }
16
+ async function collect(gen) {
17
+ const out = [];
18
+ for await (const x of gen) out.push(x);
19
+ return out;
20
+ }
21
+
22
+ const COOKIES = "BDUSS=abc; STOKEN=xyz";
23
+
24
+ describe("doc-baidu-netdisk mappers", () => {
25
+ it("name/version", () => {
26
+ expect(nd.NAME).toBe("doc-baidu-netdisk");
27
+ expect(nd.VERSION).toBe("0.1.0");
28
+ });
29
+ it("mapDoc maps netdisk fields; category + isdir → docType", () => {
30
+ const rec = nd.mapDoc({ fs_id: 123, server_filename: "电影.mp4", category: 1, size: 999, server_mtime: 1716383000, path: "/影视/电影.mp4" });
31
+ expect(rec).toMatchObject({ docId: "123", title: "电影.mp4", docType: "video" });
32
+ expect(rec.updatedMs).toBe(1716383000000);
33
+ expect(rec.extra.path).toBe("/影视/电影.mp4");
34
+ expect(nd.mapDoc({ fs_id: 1, isdir: 1, server_filename: "我的资料" }).docType).toBe("folder");
35
+ expect(nd.mapDoc({ fs_id: 2, server_filename: "报告.pdf" }).docType).toBe("doc"); // extension fallback
36
+ expect(nd.mapDoc({ server_filename: "noid" })).toBe(null);
37
+ });
38
+ it("extractDocs tolerant", () => {
39
+ expect(nd.extractDocs({ list: [{ fs_id: 1 }] })).toHaveLength(1);
40
+ expect(nd.extractDocs({ data: { list: [{ fs_id: 1 }] } })).toHaveLength(1);
41
+ expect(nd.extractDocs({})).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe("BaiduNetdiskAdapter (via _document-base)", () => {
46
+ const SNAP = JSON.stringify({
47
+ schemaVersion: 1,
48
+ snapshottedAt: 1716383000000,
49
+ account: { userId: "u1" },
50
+ events: [
51
+ { kind: "document", id: "doc-F1", docId: "F1", title: "合同.pdf", docType: "doc", updatedTime: 1716383000, url: "/工作/合同.pdf" },
52
+ ],
53
+ });
54
+
55
+ it("snapshot sync + normalize → event(post)+item(document)", async () => {
56
+ const p = writeTmp(SNAP);
57
+ try {
58
+ const a = new nd.BaiduNetdiskAdapter();
59
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
60
+ const items = await collect(a.sync({ inputPath: p }));
61
+ expect(items).toHaveLength(1);
62
+ const batch = a.normalize(items[0]);
63
+ expect(batch.events[0].subtype).toBe("post");
64
+ expect(batch.items[0].subtype).toBe("document");
65
+ expect(batch.items[0].name).toBe("合同.pdf");
66
+ expect(batch.items[0].extra.platform).toBe("baidu-netdisk");
67
+ } finally {
68
+ fs.unlinkSync(p);
69
+ }
70
+ });
71
+
72
+ it("cookie-api: fetch + paginate + normalize", async () => {
73
+ const pages = [
74
+ { list: [{ fs_id: 7, server_filename: "照片.jpg", category: 3, server_mtime: 1716383000 }] },
75
+ { list: [] },
76
+ ];
77
+ const calls = [];
78
+ const a = new nd.BaiduNetdiskAdapter({
79
+ account: { cookies: COOKIES, userId: "u1" },
80
+ fetchFn: async ({ url, cookies, query, sign }) => {
81
+ calls.push({ url, cookies, offset: query.offset, sign });
82
+ return query.offset === 0 ? pages[0] : pages[1];
83
+ },
84
+ });
85
+ expect(await a.authenticate()).toEqual({ ok: true, account: "u1", mode: "cookie" });
86
+ const items = await collect(a.sync({}));
87
+ expect(items).toHaveLength(1);
88
+ expect(items[0].originalId).toBe("baidu-netdisk:document:7");
89
+ expect(calls[0].cookies).toBe(COOKIES);
90
+ expect(calls[0].sign).toBe(null);
91
+ const batch = a.normalize(items[0]);
92
+ expect(batch.items[0].name).toBe("照片.jpg");
93
+ expect(batch.items[0].extra.docType).toBe("image");
94
+ });
95
+
96
+ it("default fetch throws; no input throws", async () => {
97
+ const a = new nd.BaiduNetdiskAdapter({ account: { cookies: COOKIES } });
98
+ await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
99
+ const b = new nd.BaiduNetdiskAdapter();
100
+ await expect(collect(b.sync({}))).rejects.toThrow(/needs opts.inputPath/);
101
+ });
102
+ });
@@ -0,0 +1,177 @@
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 wps = require("../../lib/adapters/doc-wps");
10
+ const tdocs = require("../../lib/adapters/doc-tencent-docs");
11
+
12
+ function writeTmp(content) {
13
+ const p = path.join(os.tmpdir(), `cc-doc-${crypto.randomUUID()}.json`);
14
+ fs.writeFileSync(p, content, "utf-8");
15
+ return p;
16
+ }
17
+
18
+ async function collect(gen) {
19
+ const out = [];
20
+ for await (const x of gen) out.push(x);
21
+ return out;
22
+ }
23
+
24
+ const COOKIES = "wps_sid=abc; uid=1";
25
+
26
+ describe("doc-wps constants + mappers", () => {
27
+ it("exposes name/version", () => {
28
+ expect(wps.NAME).toBe("doc-wps");
29
+ expect(wps.VERSION).toBe("0.1.0");
30
+ });
31
+ it("mapDoc maps WPS fields + infers docType from extension", () => {
32
+ const rec = wps.mapDoc({ id: "F1", fname: "预算.xlsx", ctime: 1716300000, mtime: 1716383000 });
33
+ expect(rec).toMatchObject({ docId: "F1", title: "预算.xlsx", docType: "sheet" });
34
+ expect(rec.createdMs).toBe(1716300000000);
35
+ expect(rec.updatedMs).toBe(1716383000000);
36
+ expect(rec.url).toContain("kdocs.cn");
37
+ expect(wps.mapDoc({ id: "F2", fname: "方案.pptx" }).docType).toBe("slide");
38
+ expect(wps.mapDoc({ id: "F3", fname: "说明.docx" }).docType).toBe("doc");
39
+ expect(wps.mapDoc({ fname: "noid" })).toBe(null);
40
+ });
41
+ it("extractDocs tolerant of shapes", () => {
42
+ expect(wps.extractDocs({ files: [{ id: 1 }] })).toHaveLength(1);
43
+ expect(wps.extractDocs({ data: { files: [{ id: 1 }] } })).toHaveLength(1);
44
+ expect(wps.extractDocs({})).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("doc-tencent-docs constants + mappers", () => {
49
+ it("exposes name/version", () => {
50
+ expect(tdocs.NAME).toBe("doc-tencent-docs");
51
+ expect(tdocs.VERSION).toBe("0.1.0");
52
+ });
53
+ it("mapDoc maps Tencent fields + type codes", () => {
54
+ const rec = tdocs.mapDoc({ id: "T1", title: "周报", type: "sheet", createTime: 1716300000, lastModifyTime: 1716383000 });
55
+ expect(rec).toMatchObject({ docId: "T1", title: "周报", docType: "sheet" });
56
+ expect(rec.url).toContain("docs.qq.com");
57
+ expect(tdocs.mapDoc({ id: "T2", type: 2 }).docType).toBe("sheet");
58
+ expect(tdocs.mapDoc({ id: "T3", type: "presentation" }).docType).toBe("slide");
59
+ expect(tdocs.mapDoc({ title: "noid" })).toBe(null);
60
+ });
61
+ });
62
+
63
+ describe("WpsDocAdapter snapshot mode", () => {
64
+ const SNAP = JSON.stringify({
65
+ schemaVersion: 1,
66
+ snapshottedAt: 1716383000000,
67
+ account: { userId: "u1" },
68
+ events: [
69
+ { kind: "document", id: "doc-D1", docId: "D1", title: "我的文档", docType: "doc", createdTime: 1716300000, updatedTime: 1716383000, url: "https://kdocs.cn/p/D1" },
70
+ ],
71
+ });
72
+
73
+ it("authenticate validates inputPath", async () => {
74
+ const p = writeTmp(SNAP);
75
+ try {
76
+ const a = new wps.WpsDocAdapter();
77
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
78
+ expect((await a.authenticate({ inputPath: path.join(os.tmpdir(), "nope.json") })).reason).toBe("INPUT_PATH_UNREADABLE");
79
+ } finally {
80
+ fs.unlinkSync(p);
81
+ }
82
+ });
83
+
84
+ it("sync yields doc + normalize → event(post)+item(document)", async () => {
85
+ const p = writeTmp(SNAP);
86
+ try {
87
+ const a = new wps.WpsDocAdapter();
88
+ const items = await collect(a.sync({ inputPath: p }));
89
+ expect(items).toHaveLength(1);
90
+ expect(items[0].originalId).toBe("wps:document:doc-D1");
91
+ const batch = a.normalize(items[0]);
92
+ expect(batch.events[0].subtype).toBe("post");
93
+ expect(batch.events[0].content.title).toBe("文档: 我的文档");
94
+ expect(batch.items[0].subtype).toBe("document");
95
+ expect(batch.items[0].name).toBe("我的文档");
96
+ expect(batch.items[0].extra.platform).toBe("wps");
97
+ // event references the item
98
+ expect(batch.events[0].extra.itemRef).toBe(batch.items[0].id);
99
+ } finally {
100
+ fs.unlinkSync(p);
101
+ }
102
+ });
103
+
104
+ it("schemaVersion mismatch throws; normalize missing record throws", async () => {
105
+ const p = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
106
+ try {
107
+ const a = new wps.WpsDocAdapter();
108
+ await expect(collect(a.sync({ inputPath: p }))).rejects.toThrow(/schemaVersion mismatch/);
109
+ expect(() => a.normalize({ payload: {} })).toThrow(/payload.record missing/);
110
+ } finally {
111
+ fs.unlinkSync(p);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("TencentDocsAdapter cookie-api mode", () => {
117
+ it("authenticate cookie mode (userId optional)", async () => {
118
+ const a = new tdocs.TencentDocsAdapter({ account: { cookies: COOKIES } });
119
+ expect(await a.authenticate()).toEqual({ ok: true, account: null, mode: "cookie" });
120
+ });
121
+
122
+ it("sync fetches, paginates, normalizes", async () => {
123
+ const pages = [
124
+ { data: { files: [{ id: "T1", title: "项目计划", type: "doc", lastModifyTime: 1716383000 }] } },
125
+ { data: { files: [] } },
126
+ ];
127
+ const calls = [];
128
+ const fetchFn = async ({ url, cookies, query, sign }) => {
129
+ calls.push({ url, cookies, offset: query.offset, sign });
130
+ return query.offset === 0 ? pages[0] : pages[1];
131
+ };
132
+ const a = new tdocs.TencentDocsAdapter({ account: { cookies: COOKIES, userId: "u1" }, fetchFn });
133
+ const items = await collect(a.sync({}));
134
+ expect(items).toHaveLength(1);
135
+ expect(items[0].originalId).toBe("tencent-docs:document:T1");
136
+ expect(calls[0].cookies).toBe(COOKIES);
137
+ expect(calls[0].sign).toBe(null);
138
+ const batch = a.normalize(items[0]);
139
+ expect(batch.items[0].name).toBe("项目计划");
140
+ expect(batch.items[0].extra.platform).toBe("tencent-docs");
141
+ });
142
+
143
+ it("invokes signProvider when configured", async () => {
144
+ const signCalls = [];
145
+ const a = new tdocs.TencentDocsAdapter({
146
+ account: { cookies: COOKIES },
147
+ fetchFn: async ({ query }) => (query.offset === 0 ? { files: [{ id: "S1", title: "x" }] } : { files: [] }),
148
+ signProvider: async (ctx) => { signCalls.push(ctx); return "SIG"; },
149
+ });
150
+ const items = await collect(a.sync({}));
151
+ expect(items).toHaveLength(1);
152
+ expect(signCalls.length).toBeGreaterThan(0);
153
+ expect(signCalls[0].cookies).toBe(COOKIES);
154
+ });
155
+
156
+ it("sinceWatermark + limit + empty response", async () => {
157
+ const a1 = new tdocs.TencentDocsAdapter({
158
+ account: { cookies: COOKIES },
159
+ fetchFn: async ({ query }) => query.offset === 0 ? { files: [
160
+ { id: "NEW", title: "n", lastModifyTime: 2_000_000_000 },
161
+ { id: "OLD", title: "o", lastModifyTime: 1_000_000_000 },
162
+ ] } : { files: [] },
163
+ });
164
+ const got = await collect(a1.sync({ sinceWatermark: 1_500_000_000_000 }));
165
+ expect(got.map((x) => x.originalId)).toEqual(["tencent-docs:document:NEW"]);
166
+
167
+ const a2 = new tdocs.TencentDocsAdapter({ account: { cookies: COOKIES }, fetchFn: async () => "<html>login</html>" });
168
+ expect(await collect(a2.sync({}))).toEqual([]);
169
+ });
170
+
171
+ it("default fetch throws; no input throws", async () => {
172
+ const a = new tdocs.TencentDocsAdapter({ account: { cookies: COOKIES } });
173
+ await expect(collect(a.sync({}))).rejects.toThrow(/no fetchFn configured/);
174
+ const b = new tdocs.TencentDocsAdapter();
175
+ await expect(collect(b.sync({}))).rejects.toThrow(/needs opts.inputPath/);
176
+ });
177
+ });
@@ -0,0 +1,187 @@
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
+ KugouMusicAdapter,
11
+ extractList,
12
+ songItemToRecord,
13
+ playlistItemToRecord,
14
+ NAME,
15
+ VERSION,
16
+ SNAPSHOT_SCHEMA_VERSION,
17
+ } = require("../../lib/adapters/music-kugou");
18
+
19
+ function writeTmp(content) {
20
+ const p = path.join(os.tmpdir(), `cc-kg-${crypto.randomUUID()}.json`);
21
+ fs.writeFileSync(p, content, "utf-8");
22
+ return p;
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 = "KuGoo=abc; kg_mid=xyz";
31
+
32
+ const SNAP = JSON.stringify({
33
+ schemaVersion: 1,
34
+ snapshottedAt: 1716383000000,
35
+ account: { userId: "u1" },
36
+ events: [
37
+ { kind: "play", id: "p1", songId: "S1", song: "晴天", artist: "周杰伦", album: "叶惠美", playCount: 12, capturedAt: 1716300000000 },
38
+ { kind: "favorite", id: "f1", songId: "S2", song: "稻香", artist: "周杰伦" },
39
+ { kind: "playlist", id: "pl1", playlistId: "L1", name: "华语经典", trackCount: 88, creator: "我" },
40
+ ],
41
+ });
42
+
43
+ describe("constants + item mappers", () => {
44
+ it("name/version", () => {
45
+ expect(NAME).toBe("music-kugou");
46
+ expect(VERSION).toBe("0.1.0");
47
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
48
+ });
49
+ it("songItemToRecord: discrete fields + filename split fallback", () => {
50
+ const r1 = songItemToRecord({ hash: "H1", songname: "夜曲", singername: "周杰伦", album_name: "十一月的萧邦", addtime: 1716300000 });
51
+ expect(r1).toMatchObject({ id: "H1", song: "夜曲", artist: "周杰伦", album: "十一月的萧邦" });
52
+ expect(r1.occurredAt).toBe(1716300000000);
53
+ const r2 = songItemToRecord({ mixsongid: 9, filename: "林俊杰 - 江南" });
54
+ expect(r2).toMatchObject({ song: "江南", artist: "林俊杰" });
55
+ expect(songItemToRecord({ songname: "noid" })).toBe(null);
56
+ });
57
+ it("playlistItemToRecord", () => {
58
+ const r = playlistItemToRecord({ listid: "L9", name: "睡前", count: 30, nickname: "我" });
59
+ expect(r).toMatchObject({ id: "L9", playlistId: "L9", name: "睡前", trackCount: 30, creator: "我" });
60
+ });
61
+ it("extractList tolerant", () => {
62
+ expect(extractList({ list: [{ hash: 1 }] })).toHaveLength(1);
63
+ expect(extractList({ data: { info: [{ hash: 1 }] } })).toHaveLength(1);
64
+ expect(extractList({})).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("KugouMusicAdapter snapshot mode", () => {
69
+ it("authenticate validates inputPath", async () => {
70
+ const p = writeTmp(SNAP);
71
+ try {
72
+ const a = new KugouMusicAdapter();
73
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
74
+ expect((await a.authenticate({ inputPath: path.join(os.tmpdir(), "no-kg.json") })).reason).toBe("INPUT_PATH_UNREADABLE");
75
+ } finally {
76
+ fs.unlinkSync(p);
77
+ }
78
+ });
79
+
80
+ it("sync 3 kinds + normalize play→media+item / favorite→like / playlist→topic", async () => {
81
+ const p = writeTmp(SNAP);
82
+ try {
83
+ const a = new KugouMusicAdapter();
84
+ const items = await collect(a.sync({ inputPath: p }));
85
+ expect(items.map((x) => x.kind)).toEqual(["play", "favorite", "playlist"]);
86
+
87
+ const play = a.normalize(items[0]);
88
+ expect(play.events[0].subtype).toBe("media");
89
+ expect(play.events[0].content.title).toBe("听了: 晴天 - 周杰伦");
90
+ expect(play.items[0].subtype).toBe("media");
91
+ expect(play.items[0].extra.platform).toBe("kugou");
92
+ expect(play.events[0].extra.itemRef).toBe(play.items[0].id);
93
+
94
+ const fav = a.normalize(items[1]);
95
+ expect(fav.events[0].subtype).toBe("like");
96
+ expect(fav.events[0].content.title).toBe("收藏: 稻香 - 周杰伦");
97
+
98
+ const pl = a.normalize(items[2]);
99
+ expect(pl.topics[0].name).toBe("华语经典");
100
+ expect(pl.topics[0].extra.trackCount).toBe(88);
101
+ } finally {
102
+ fs.unlinkSync(p);
103
+ }
104
+ });
105
+
106
+ it("include + limit + schema mismatch + unknown kind", async () => {
107
+ const p = writeTmp(SNAP);
108
+ try {
109
+ const a = new KugouMusicAdapter();
110
+ expect((await collect(a.sync({ inputPath: p, include: { play: false, favorite: false } }))).map((x) => x.kind)).toEqual(["playlist"]);
111
+ expect(await collect(a.sync({ inputPath: p, limit: 1 }))).toHaveLength(1);
112
+ expect(() => a.normalize({ kind: "bogus", payload: {} })).toThrow(/unknown kind/);
113
+ } finally {
114
+ fs.unlinkSync(p);
115
+ }
116
+ const bad = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
117
+ try {
118
+ const a = new KugouMusicAdapter();
119
+ await expect(collect(a.sync({ inputPath: bad }))).rejects.toThrow(/schemaVersion mismatch/);
120
+ } finally {
121
+ fs.unlinkSync(bad);
122
+ }
123
+ });
124
+ });
125
+
126
+ describe("KugouMusicAdapter cookie-api mode", () => {
127
+ it("authenticate cookie mode (userId optional)", async () => {
128
+ const a = new KugouMusicAdapter({ account: { cookies: COOKIES } });
129
+ expect(await a.authenticate()).toEqual({ ok: true, account: null, mode: "cookie" });
130
+ });
131
+
132
+ it("sync fetches plays/favorites/playlists, normalizes", async () => {
133
+ const byUrl = (u) => (u.includes("listen") ? "play" : u.includes("favorite") ? "favorite" : "playlist");
134
+ const data = {
135
+ play: [{ hash: "H1", songname: "七里香", singername: "周杰伦", addtime: 1716300000 }],
136
+ favorite: [{ hash: "H2", filename: "陈奕迅 - 浮夸" }],
137
+ playlist: [{ listid: "L1", name: "粤语", count: 50 }],
138
+ };
139
+ const calls = [];
140
+ const a = new KugouMusicAdapter({
141
+ account: { cookies: COOKIES, userId: "u1" },
142
+ fetchFn: async ({ url, cookies, query, sign }) => {
143
+ const k = byUrl(url);
144
+ calls.push({ k, cookies, page: query.page, sign });
145
+ return { data: { list: query.page === 1 ? data[k] : [] } };
146
+ },
147
+ });
148
+ const items = await collect(a.sync({}));
149
+ expect(items.map((x) => x.kind).sort()).toEqual(["favorite", "play", "playlist"]);
150
+ expect(calls.every((c) => c.cookies === COOKIES && c.sign === null)).toBe(true);
151
+ const play = a.normalize(items.find((x) => x.kind === "play"));
152
+ expect(play.events[0].content.title).toBe("听了: 七里香 - 周杰伦");
153
+ const fav = a.normalize(items.find((x) => x.kind === "favorite"));
154
+ expect(fav.events[0].content.title).toBe("收藏: 浮夸 - 陈奕迅"); // filename split
155
+ const pl = a.normalize(items.find((x) => x.kind === "playlist"));
156
+ expect(pl.topics[0].name).toBe("粤语");
157
+ });
158
+
159
+ it("invokes signProvider", async () => {
160
+ const signCalls = [];
161
+ const a = new KugouMusicAdapter({
162
+ account: { cookies: COOKIES },
163
+ fetchFn: async ({ query }) => ({ list: query.page === 1 ? [{ hash: "H1", songname: "x" }] : [] }),
164
+ signProvider: async (ctx) => { signCalls.push(ctx); return "sig"; },
165
+ });
166
+ const items = await collect(a.sync({ include: { favorite: false, playlist: false } }));
167
+ expect(items.length).toBeGreaterThan(0);
168
+ expect(signCalls[0].cookies).toBe(COOKIES);
169
+ });
170
+
171
+ it("limit + empty/login + default fetch + no input", async () => {
172
+ const a1 = new KugouMusicAdapter({
173
+ account: { cookies: COOKIES },
174
+ fetchFn: async ({ query }) => ({ list: query.page === 1 ? [{ hash: "H1", songname: "a" }, { hash: "H2", songname: "b" }] : [] }),
175
+ });
176
+ expect(await collect(a1.sync({ limit: 1 }))).toHaveLength(1);
177
+
178
+ const a2 = new KugouMusicAdapter({ account: { cookies: COOKIES }, fetchFn: async () => "<html>login</html>" });
179
+ expect(await collect(a2.sync({}))).toEqual([]);
180
+
181
+ const a3 = new KugouMusicAdapter({ account: { cookies: COOKIES } });
182
+ await expect(collect(a3.sync({}))).rejects.toThrow(/no fetchFn configured/);
183
+
184
+ const a4 = new KugouMusicAdapter();
185
+ await expect(collect(a4.sync({}))).rejects.toThrow(/needs opts.inputPath/);
186
+ });
187
+ });
@@ -0,0 +1,180 @@
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
+ BossZhipinAdapter,
11
+ extractData,
12
+ chatItemToRecord,
13
+ applicationItemToRecord,
14
+ NAME,
15
+ VERSION,
16
+ SNAPSHOT_SCHEMA_VERSION,
17
+ } = require("../../lib/adapters/recruit-boss");
18
+
19
+ function writeTmp(content) {
20
+ const p = path.join(os.tmpdir(), `cc-boss-${crypto.randomUUID()}.json`);
21
+ fs.writeFileSync(p, content, "utf-8");
22
+ return p;
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 = "wt2=abc; __zp_stoken__=xyz";
31
+
32
+ const SNAP = JSON.stringify({
33
+ schemaVersion: 1,
34
+ snapshottedAt: 1716383000000,
35
+ account: { userId: "u1", name: "我" },
36
+ events: [
37
+ { kind: "chat", id: "chat-J1", jobId: "J1", jobTitle: "前端工程师", company: "字节跳动", hrName: "李 HR", hrId: "HR9", salary: "25-40K", city: "北京", lastChatTime: 1716300000 },
38
+ { kind: "application", id: "apply-J2", jobId: "J2", jobTitle: "后端工程师", company: "腾讯", status: "已查看", deliverTime: 1716310000 },
39
+ ],
40
+ });
41
+
42
+ describe("constants + item mappers", () => {
43
+ it("name/version/schema", () => {
44
+ expect(NAME).toBe("recruit-boss");
45
+ expect(VERSION).toBe("0.1.0");
46
+ expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
47
+ });
48
+ it("chatItemToRecord maps BOSS chat fields", () => {
49
+ const r = chatItemToRecord({ jobId: "J1", jobName: "前端", brandName: "字节", bossName: "王HR", bossId: "B1", salaryDesc: "30K", cityName: "深圳", lastChatTime: 1716300000 });
50
+ expect(r).toMatchObject({ id: "J1", jobTitle: "前端", company: "字节", hrName: "王HR", hrId: "B1", salary: "30K", city: "深圳" });
51
+ expect(r.occurredAt).toBe(1716300000000);
52
+ expect(chatItemToRecord({ jobName: "noid" })).toBe(null);
53
+ });
54
+ it("applicationItemToRecord maps delivery fields", () => {
55
+ const r = applicationItemToRecord({ jobId: "J2", jobName: "后端", brandName: "腾讯", statusDesc: "已查看", deliverTime: 1716310000 });
56
+ expect(r).toMatchObject({ id: "J2", jobTitle: "后端", company: "腾讯", status: "已查看" });
57
+ expect(r.occurredAt).toBe(1716310000000);
58
+ });
59
+ it("extractData tolerant (data/list/zpData.list)", () => {
60
+ expect(extractData({ data: [{ jobId: 1 }] })).toHaveLength(1);
61
+ expect(extractData({ zpData: { list: [{ jobId: 1 }] } })).toHaveLength(1);
62
+ expect(extractData({})).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe("BossZhipinAdapter snapshot mode", () => {
67
+ it("authenticate validates inputPath", async () => {
68
+ const p = writeTmp(SNAP);
69
+ try {
70
+ const a = new BossZhipinAdapter();
71
+ expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
72
+ expect((await a.authenticate({ inputPath: path.join(os.tmpdir(), "no-boss.json") })).reason).toBe("INPUT_PATH_UNREADABLE");
73
+ } finally {
74
+ fs.unlinkSync(p);
75
+ }
76
+ });
77
+
78
+ it("sync yields chat+application; normalize → interaction event + HR person", async () => {
79
+ const p = writeTmp(SNAP);
80
+ try {
81
+ const a = new BossZhipinAdapter();
82
+ const items = await collect(a.sync({ inputPath: p }));
83
+ expect(items.map((x) => x.kind)).toEqual(["chat", "application"]);
84
+
85
+ const chat = a.normalize(items[0]);
86
+ expect(chat.events[0].subtype).toBe("interaction");
87
+ expect(chat.events[0].content.title).toBe("沟通职位: 前端工程师 @ 字节跳动");
88
+ expect(chat.persons[0].subtype).toBe("contact");
89
+ expect(chat.persons[0].names).toEqual(["李 HR"]);
90
+ expect(chat.persons[0].identifiers["boss-hr-id"]).toEqual(["HR9"]);
91
+ expect(chat.events[0].participants).toContain(chat.persons[0].id);
92
+
93
+ const app = a.normalize(items[1]);
94
+ expect(app.events[0].content.title).toBe("投递简历: 后端工程师 @ 腾讯");
95
+ expect(app.events[0].extra.status).toBe("已查看");
96
+ expect(app.persons).toEqual([]);
97
+ } finally {
98
+ fs.unlinkSync(p);
99
+ }
100
+ });
101
+
102
+ it("include filter + limit + schema mismatch + unknown kind", async () => {
103
+ const p = writeTmp(SNAP);
104
+ try {
105
+ const a = new BossZhipinAdapter();
106
+ expect((await collect(a.sync({ inputPath: p, include: { chat: false } }))).map((x) => x.kind)).toEqual(["application"]);
107
+ expect(await collect(a.sync({ inputPath: p, limit: 1 }))).toHaveLength(1);
108
+ expect(() => a.normalize({ kind: "bogus", payload: {} })).toThrow(/unknown kind/);
109
+ } finally {
110
+ fs.unlinkSync(p);
111
+ }
112
+ const bad = writeTmp(JSON.stringify({ schemaVersion: 9, events: [] }));
113
+ try {
114
+ const a = new BossZhipinAdapter();
115
+ await expect(collect(a.sync({ inputPath: bad }))).rejects.toThrow(/schemaVersion mismatch/);
116
+ } finally {
117
+ fs.unlinkSync(bad);
118
+ }
119
+ });
120
+ });
121
+
122
+ describe("BossZhipinAdapter cookie-api mode", () => {
123
+ it("authenticate cookie mode (userId optional)", async () => {
124
+ const a = new BossZhipinAdapter({ account: { cookies: COOKIES } });
125
+ expect(await a.authenticate()).toEqual({ ok: true, account: null, mode: "cookie" });
126
+ });
127
+
128
+ it("sync fetches chats + deliveries, normalizes", async () => {
129
+ const byUrl = (url) => (url.includes("contactList") ? "chats" : "deliveries");
130
+ const data = {
131
+ chats: [{ jobId: "C1", jobName: "全栈", brandName: "小米", bossName: "赵HR", bossId: "B2", lastChatTime: 1716300000 }],
132
+ deliveries: [{ jobId: "D1", jobName: "测试", brandName: "美团", statusDesc: "已投递", deliverTime: 1716310000 }],
133
+ };
134
+ const calls = [];
135
+ const fetchFn = async ({ url, cookies, query, sign }) => {
136
+ const k = byUrl(url);
137
+ calls.push({ k, cookies, page: query.page, sign });
138
+ return { zpData: { list: query.page === 1 ? data[k] : [] } };
139
+ };
140
+ const a = new BossZhipinAdapter({ account: { cookies: COOKIES }, fetchFn });
141
+ const items = await collect(a.sync({}));
142
+ expect(items.map((x) => x.kind).sort()).toEqual(["application", "chat"]);
143
+ expect(calls.every((c) => c.cookies === COOKIES && c.sign === null)).toBe(true);
144
+ const chat = a.normalize(items.find((x) => x.kind === "chat"));
145
+ expect(chat.events[0].content.title).toBe("沟通职位: 全栈 @ 小米");
146
+ expect(chat.persons[0].names).toEqual(["赵HR"]);
147
+ const app = a.normalize(items.find((x) => x.kind === "application"));
148
+ expect(app.events[0].content.title).toBe("投递简历: 测试 @ 美团");
149
+ });
150
+
151
+ it("invokes signProvider when configured", async () => {
152
+ const signCalls = [];
153
+ const a = new BossZhipinAdapter({
154
+ account: { cookies: COOKIES },
155
+ fetchFn: async ({ query }) => ({ zpData: { list: query.page === 1 ? [{ jobId: "C1", jobName: "x" }] : [] } }),
156
+ signProvider: async (ctx) => { signCalls.push(ctx); return "__zp_stoken__sig"; },
157
+ });
158
+ const items = await collect(a.sync({ include: { application: false } }));
159
+ expect(items.length).toBeGreaterThan(0);
160
+ expect(signCalls.length).toBeGreaterThan(0);
161
+ expect(signCalls[0].cookies).toBe(COOKIES);
162
+ });
163
+
164
+ it("limit + empty/login response + default fetch + no input", async () => {
165
+ const a1 = new BossZhipinAdapter({
166
+ account: { cookies: COOKIES },
167
+ fetchFn: async ({ query }) => ({ data: query.page === 1 ? [{ jobId: "C1", jobName: "a" }, { jobId: "C2", jobName: "b" }] : [] }),
168
+ });
169
+ expect(await collect(a1.sync({ limit: 1 }))).toHaveLength(1);
170
+
171
+ const a2 = new BossZhipinAdapter({ account: { cookies: COOKIES }, fetchFn: async () => "<html>login</html>" });
172
+ expect(await collect(a2.sync({}))).toEqual([]);
173
+
174
+ const a3 = new BossZhipinAdapter({ account: { cookies: COOKIES } });
175
+ await expect(collect(a3.sync({}))).rejects.toThrow(/no fetchFn configured/);
176
+
177
+ const a4 = new BossZhipinAdapter();
178
+ await expect(collect(a4.sync({}))).rejects.toThrow(/needs opts.inputPath/);
179
+ });
180
+ });