@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.
- package/__tests__/adapters/bank-family.test.js +125 -0
- package/__tests__/adapters/car-mercedesme.test.js +74 -0
- package/__tests__/adapters/finance-dcep.test.js +74 -0
- package/__tests__/adapters/fitness-joyrun.test.js +82 -0
- package/__tests__/adapters/gov-12123.test.js +103 -0
- package/__tests__/adapters/music-qq.test.js +112 -0
- package/__tests__/adapters/reading-family.test.js +108 -0
- package/__tests__/adapters/travel-didi-consumer.test.js +66 -0
- package/__tests__/audio-ximalaya-snapshot.test.js +279 -0
- package/__tests__/fitness-keep-snapshot.test.js +224 -0
- package/__tests__/shopping-eleme-snapshot.test.js +454 -0
- package/__tests__/shopping-vipshop-snapshot.test.js +425 -0
- package/__tests__/shopping-xianyu-snapshot.test.js +451 -0
- package/__tests__/social-douban-snapshot.test.js +351 -0
- package/lib/adapter-guide.js +19 -1
- package/lib/adapters/_bank-base.js +405 -0
- package/lib/adapters/_reading-base.js +315 -0
- package/lib/adapters/audio-ximalaya/index.js +414 -0
- package/lib/adapters/bank-bankcomm/index.js +27 -0
- package/lib/adapters/bank-boc/index.js +26 -0
- package/lib/adapters/bank-cmbc/index.js +26 -0
- package/lib/adapters/bank-icbc/index.js +27 -0
- package/lib/adapters/car-mercedesme/index.js +225 -0
- package/lib/adapters/finance-dcep/index.js +302 -0
- package/lib/adapters/fitness-joyrun/index.js +295 -0
- package/lib/adapters/fitness-keep/index.js +343 -0
- package/lib/adapters/gov-12123/index.js +391 -0
- package/lib/adapters/music-qq/index.js +372 -0
- package/lib/adapters/reading-fanqie/index.js +61 -0
- package/lib/adapters/reading-qimao/index.js +61 -0
- package/lib/adapters/shopping-eleme/index.js +441 -0
- package/lib/adapters/shopping-vipshop/index.js +429 -0
- package/lib/adapters/shopping-xianyu/index.js +454 -0
- package/lib/adapters/social-douban/index.js +564 -0
- package/lib/adapters/travel-didi-consumer/index.js +148 -0
- package/lib/index.js +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
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 fanqie = require("../../lib/adapters/reading-fanqie");
|
|
10
|
+
const qimao = require("../../lib/adapters/reading-qimao");
|
|
11
|
+
|
|
12
|
+
function writeTmp(content) {
|
|
13
|
+
const p = path.join(os.tmpdir(), `cc-read-${crypto.randomUUID()}.json`);
|
|
14
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
async function collect(gen) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for await (const x of gen) out.push(x);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
const COOKIES = "novel_sid=abc; uid=1";
|
|
23
|
+
|
|
24
|
+
describe("reading wrappers identity + mappers", () => {
|
|
25
|
+
it("names", () => {
|
|
26
|
+
expect(fanqie.NAME).toBe("reading-fanqie");
|
|
27
|
+
expect(qimao.NAME).toBe("reading-qimao");
|
|
28
|
+
expect(new fanqie.FanqieReadingAdapter().name).toBe("reading-fanqie");
|
|
29
|
+
expect(new qimao.QimaoReadingAdapter().name).toBe("reading-qimao");
|
|
30
|
+
});
|
|
31
|
+
it("low sensitivity, no legalGate", () => {
|
|
32
|
+
for (const A of [new fanqie.FanqieReadingAdapter(), new qimao.QimaoReadingAdapter()]) {
|
|
33
|
+
expect(A.dataDisclosure.sensitivity).toBe("low");
|
|
34
|
+
expect(A.dataDisclosure.legalGate).toBe(false);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
it("fanqie mapItem (book_id / book_name / read_progress)", () => {
|
|
38
|
+
const r = fanqie.mapItem({ book_id: "B1", book_name: "诡秘之主", author: "爱潜水的乌贼", category: "玄幻", read_progress: "0.42", last_chapter_title: "第100章", read_time: 1716383000 });
|
|
39
|
+
expect(r).toMatchObject({ bookId: "B1", title: "诡秘之主", author: "爱潜水的乌贼", category: "玄幻", chapter: "第100章" });
|
|
40
|
+
expect(r.progress).toBeCloseTo(0.42, 2);
|
|
41
|
+
expect(r.occurredAt).toBe(1716383000000);
|
|
42
|
+
expect(r.url).toContain("fanqienovel.com/page/B1");
|
|
43
|
+
expect(fanqie.mapItem({ book_name: "x" })).toBe(null);
|
|
44
|
+
});
|
|
45
|
+
it("qimao mapItem (book_id / title / read_proportion)", () => {
|
|
46
|
+
const r = qimao.mapItem({ book_id: "Q1", title: "大奉打更人", author: "卖报小郎君", read_proportion: 0.8 });
|
|
47
|
+
expect(r).toMatchObject({ bookId: "Q1", title: "大奉打更人", author: "卖报小郎君" });
|
|
48
|
+
expect(r.progress).toBeCloseTo(0.8, 2);
|
|
49
|
+
expect(qimao.mapItem({ title: "x" })).toBe(null);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("FanqieReadingAdapter (snapshot + cookie-api via _reading-base)", () => {
|
|
54
|
+
const SNAP = JSON.stringify({
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
snapshottedAt: 1716383000000,
|
|
57
|
+
account: { userId: "u1" },
|
|
58
|
+
events: [
|
|
59
|
+
{ kind: "read", id: "read-B1", bookId: "B1", title: "诡秘之主", author: "乌贼", category: "玄幻", progress: 0.42 },
|
|
60
|
+
{ kind: "favourite", id: "fav-B2", bookId: "B2", title: "全职高手", author: "蝴蝶蓝" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("read → MEDIA event + DOCUMENT item; favourite → LIKE event", async () => {
|
|
65
|
+
const p = writeTmp(SNAP);
|
|
66
|
+
try {
|
|
67
|
+
const a = new fanqie.FanqieReadingAdapter();
|
|
68
|
+
expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
|
|
69
|
+
const items = await collect(a.sync({ inputPath: p }));
|
|
70
|
+
expect(items).toHaveLength(2);
|
|
71
|
+
const read = a.normalize(items[0]);
|
|
72
|
+
expect(read.events[0].subtype).toBe("media");
|
|
73
|
+
expect(read.events[0].content.title).toContain("读了: 诡秘之主");
|
|
74
|
+
expect(read.items[0].subtype).toBe("document");
|
|
75
|
+
expect(read.items[0].extra.platform).toBe("fanqie");
|
|
76
|
+
expect(read.events[0].extra.progress).toBeCloseTo(0.42, 2);
|
|
77
|
+
expect(items[0].originalId).toBe("fanqie:read:read-B1");
|
|
78
|
+
const fav = a.normalize(items[1]);
|
|
79
|
+
expect(fav.events[0].subtype).toBe("like");
|
|
80
|
+
expect(fav.events[0].content.title).toContain("收藏: 全职高手");
|
|
81
|
+
} finally {
|
|
82
|
+
fs.unlinkSync(p);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("cookie-api: read + favourite fetch + sign seam", async () => {
|
|
87
|
+
let signed = 0;
|
|
88
|
+
const a = new fanqie.FanqieReadingAdapter({
|
|
89
|
+
account: { cookies: COOKIES, userId: "u1" },
|
|
90
|
+
signProvider: async () => { signed += 1; return "sig"; },
|
|
91
|
+
fetchFn: async ({ url, query }) => {
|
|
92
|
+
if (query.page > 1) return { list: [] };
|
|
93
|
+
if (url.includes("/history")) return { data: { list: [{ book_id: "B9", book_name: "斗破", read_time: 1716383000 }] } };
|
|
94
|
+
return { data: { books: [{ book_id: "B8", book_name: "遮天" }] } };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(await a.authenticate()).toEqual({ ok: true, account: "u1", mode: "cookie" });
|
|
98
|
+
const items = await collect(a.sync({}));
|
|
99
|
+
expect(items).toHaveLength(2);
|
|
100
|
+
expect(items.map((i) => i.originalId).sort()).toEqual(["fanqie:favourite:B8", "fanqie:read:B9"]);
|
|
101
|
+
expect(signed).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("default fetch / no input throw", async () => {
|
|
105
|
+
await expect(collect(new fanqie.FanqieReadingAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
|
|
106
|
+
await expect(collect(new fanqie.FanqieReadingAdapter().sync({}))).rejects.toThrow(/needs opts.inputPath/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
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 dc = require("../../lib/adapters/travel-didi-consumer");
|
|
10
|
+
|
|
11
|
+
function writeTmp(content) {
|
|
12
|
+
const p = path.join(os.tmpdir(), `cc-didic-${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
|
+
const COOKIES = "didi_token=abc";
|
|
22
|
+
|
|
23
|
+
describe("travel-didi-consumer", () => {
|
|
24
|
+
it("name distinct from enterprise", () => {
|
|
25
|
+
expect(dc.NAME).toBe("travel-didi-consumer");
|
|
26
|
+
expect(new dc.DidiConsumerAdapter().name).toBe("travel-didi-consumer");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("snapshot ride → car travel event (via travel-base normalize)", async () => {
|
|
30
|
+
const SNAP = JSON.stringify([
|
|
31
|
+
{ orderId: "O1", fromAddress: "家", toAddress: "公司", departTime: 1716383000, arriveTime: 1716385000, fare: 2350, productName: "快车" },
|
|
32
|
+
]);
|
|
33
|
+
const p = writeTmp(SNAP);
|
|
34
|
+
try {
|
|
35
|
+
const a = new dc.DidiConsumerAdapter();
|
|
36
|
+
expect((await a.authenticate({ inputPath: p })).mode).toBe("snapshot-file");
|
|
37
|
+
const items = await collect(a.sync({ inputPath: p }));
|
|
38
|
+
expect(items).toHaveLength(1);
|
|
39
|
+
expect(items[0].originalId).toBe("O1");
|
|
40
|
+
const b = a.normalize(items[0]);
|
|
41
|
+
expect(b.events.length).toBeGreaterThan(0);
|
|
42
|
+
expect(b.source ? true : b.events[0].source.adapter).toBe("travel-didi-consumer");
|
|
43
|
+
} finally {
|
|
44
|
+
fs.unlinkSync(p);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cookie-api: unverified + sign seam + paginate", async () => {
|
|
49
|
+
let signed = 0;
|
|
50
|
+
const a = new dc.DidiConsumerAdapter({
|
|
51
|
+
account: { cookies: COOKIES, phone: "1" },
|
|
52
|
+
signProvider: async () => { signed += 1; return "sig"; },
|
|
53
|
+
fetchFn: async ({ query }) => (query.pageIndex > 1 ? { data: { list: [] } } : { data: { list: [{ orderId: "O9", fromAddress: "A", toAddress: "B", departTime: Date.now() }] } }),
|
|
54
|
+
});
|
|
55
|
+
expect(await a.authenticate()).toMatchObject({ ok: true, mode: "cookie", unverified: true });
|
|
56
|
+
const items = await collect(a.sync({}));
|
|
57
|
+
expect(items).toHaveLength(1);
|
|
58
|
+
expect(items[0].originalId).toBe("O9");
|
|
59
|
+
expect(signed).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("medium sensitivity; default fetch throws", async () => {
|
|
63
|
+
expect(new dc.DidiConsumerAdapter().dataDisclosure.sensitivity).toBe("medium");
|
|
64
|
+
await expect(collect(new dc.DidiConsumerAdapter({ account: { cookies: COOKIES } }).sync({}))).rejects.toThrow(/no fetchFn/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
XimalayaAdapter,
|
|
11
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
12
|
+
VALID_KINDS,
|
|
13
|
+
extractList,
|
|
14
|
+
trackItemToRecord,
|
|
15
|
+
albumItemToRecord,
|
|
16
|
+
} = require("../lib/adapters/audio-ximalaya");
|
|
17
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
18
|
+
const { validateBatch } = require("../lib/batch");
|
|
19
|
+
|
|
20
|
+
// 喜马拉雅 (Ximalaya) — 听书/播客; mirrors music-kugou's play/favorite/subscribe
|
|
21
|
+
// shape. signing injected (signProvider). pure-Node.
|
|
22
|
+
|
|
23
|
+
function writeSnapshot(dir, snapshot) {
|
|
24
|
+
const p = path.join(dir, "audio-ximalaya.json");
|
|
25
|
+
fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("XimalayaAdapter snapshot mode", () => {
|
|
30
|
+
let tmpDir;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "xmly-snap-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exports schema constants", () => {
|
|
36
|
+
expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
|
|
37
|
+
expect(VALID_KINDS).toEqual(["play", "favorite", "subscribe"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("authenticate(inputPath) ok when readable", async () => {
|
|
41
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: Date.now(), events: [] });
|
|
42
|
+
const a = new XimalayaAdapter();
|
|
43
|
+
const res = await a.authenticate({ inputPath: p });
|
|
44
|
+
expect(res.ok).toBe(true);
|
|
45
|
+
expect(res.mode).toBe("snapshot-file");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("authenticate() no input → NO_INPUT", async () => {
|
|
49
|
+
const a = new XimalayaAdapter();
|
|
50
|
+
const res = await a.authenticate({});
|
|
51
|
+
expect(res.ok).toBe(false);
|
|
52
|
+
expect(res.reason).toBe("NO_INPUT");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("sync() without input throws with signProvider hint", async () => {
|
|
56
|
+
const a = new XimalayaAdapter();
|
|
57
|
+
let threw = null;
|
|
58
|
+
try {
|
|
59
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
60
|
+
} catch (err) {
|
|
61
|
+
threw = err;
|
|
62
|
+
}
|
|
63
|
+
expect(String(threw.message)).toMatch(/signProvider/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects schemaVersion mismatch", async () => {
|
|
67
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 9, snapshottedAt: Date.now(), events: [] });
|
|
68
|
+
const a = new XimalayaAdapter();
|
|
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("play (收听节目) → MEDIA event + MEDIA item", async () => {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const p = writeSnapshot(tmpDir, {
|
|
81
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
82
|
+
account: { userId: "u1", name: "alice" },
|
|
83
|
+
events: [
|
|
84
|
+
{ kind: "play", id: "t1", trackId: "98765", title: "第1集 三体广播剧",
|
|
85
|
+
anchor: "729声工场", album: "三体", durationSec: 1800, capturedAt: now - 1000 },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
const a = new XimalayaAdapter();
|
|
89
|
+
const raws = [];
|
|
90
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
91
|
+
expect(raws.length).toBe(1);
|
|
92
|
+
expect(raws[0].originalId).toBe("ximalaya:play:t1");
|
|
93
|
+
const batch = a.normalize(raws[0]);
|
|
94
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
95
|
+
expect(batch.events[0].subtype).toBe("media");
|
|
96
|
+
expect(batch.events[0].content.title).toContain("收听: 第1集 三体广播剧 - 729声工场");
|
|
97
|
+
expect(batch.items[0].subtype).toBe("media");
|
|
98
|
+
expect(batch.items[0].name).toBe("第1集 三体广播剧 - 729声工场");
|
|
99
|
+
expect(batch.events[0].extra.album).toBe("三体");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("favorite → LIKE event", async () => {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const p = writeSnapshot(tmpDir, {
|
|
105
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
106
|
+
events: [{ kind: "favorite", id: "f1", trackId: "555", title: "收藏的播客", anchor: "主播X" }],
|
|
107
|
+
});
|
|
108
|
+
const a = new XimalayaAdapter();
|
|
109
|
+
const raws = [];
|
|
110
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
111
|
+
const batch = a.normalize(raws[0]);
|
|
112
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
113
|
+
expect(batch.events[0].subtype).toBe("like");
|
|
114
|
+
expect(batch.events[0].content.title).toContain("收藏: 收藏的播客");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("subscribe → TOPIC (album)", async () => {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const p = writeSnapshot(tmpDir, {
|
|
120
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
121
|
+
events: [{ kind: "subscribe", id: "s1", albumId: "30001", album: "得到·精英日课",
|
|
122
|
+
trackCount: 365, anchor: "罗振宇" }],
|
|
123
|
+
});
|
|
124
|
+
const a = new XimalayaAdapter();
|
|
125
|
+
const raws = [];
|
|
126
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
127
|
+
const batch = a.normalize(raws[0]);
|
|
128
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
129
|
+
expect(batch.events.length).toBe(0);
|
|
130
|
+
expect(batch.topics.length).toBe(1);
|
|
131
|
+
expect(batch.topics[0].id).toBe("topic-ximalaya-album-30001");
|
|
132
|
+
expect(batch.topics[0].name).toBe("得到·精英日课");
|
|
133
|
+
expect(batch.topics[0].extra.trackCount).toBe(365);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("respects include opt-out + limit", async () => {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const events = [
|
|
139
|
+
{ kind: "play", id: "p1", trackId: "1", title: "a", capturedAt: now },
|
|
140
|
+
{ kind: "favorite", id: "f1", trackId: "2", title: "b" },
|
|
141
|
+
{ kind: "subscribe", id: "s1", albumId: "3", album: "c" },
|
|
142
|
+
];
|
|
143
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
|
|
144
|
+
const a = new XimalayaAdapter();
|
|
145
|
+
const raws = [];
|
|
146
|
+
for await (const r of a.sync({ inputPath: p, include: { favorite: false, subscribe: false } })) raws.push(r);
|
|
147
|
+
expect(raws.length).toBe(1);
|
|
148
|
+
expect(raws[0].kind).toBe("play");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("filters unknown kinds", async () => {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const p = writeSnapshot(tmpDir, {
|
|
154
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
155
|
+
events: [
|
|
156
|
+
{ kind: "play", id: "p1", trackId: "1", title: "a", capturedAt: now },
|
|
157
|
+
{ kind: "comment", id: "c1" },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
const a = new XimalayaAdapter();
|
|
161
|
+
const raws = [];
|
|
162
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
163
|
+
expect(raws.length).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("advertises capabilities + passes assertAdapter", () => {
|
|
167
|
+
const a = new XimalayaAdapter();
|
|
168
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
169
|
+
expect(a.capabilities).toContain("sync:cookie-api");
|
|
170
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("XimalayaAdapter cookie-api mode", () => {
|
|
175
|
+
it("authenticate(cookie) ok with cookies (userId optional)", async () => {
|
|
176
|
+
const a = new XimalayaAdapter({ account: { cookies: "1&_token=ok" } });
|
|
177
|
+
const res = await a.authenticate();
|
|
178
|
+
expect(res.ok).toBe(true);
|
|
179
|
+
expect(res.mode).toBe("cookie");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fetches plays/favorites/subscribes and normalizes", async () => {
|
|
183
|
+
const byUrl = (url) => {
|
|
184
|
+
if (url.includes("/history")) {
|
|
185
|
+
return { data: { list: [{ trackId: 98765, title: "第1集", nickname: "声工场", albumTitle: "三体", duration: 1800, startedAt: 1700000000000 }] } };
|
|
186
|
+
}
|
|
187
|
+
if (url.includes("/favorite")) {
|
|
188
|
+
return { data: { tracks: [{ track_id: 555, track_title: "播客A", anchor_name: "主播X" }] } };
|
|
189
|
+
}
|
|
190
|
+
if (url.includes("/subscribe")) {
|
|
191
|
+
return { data: { albums: [{ albumId: 30001, albumTitle: "精英日课", includeTrackCount: 365, nickname: "罗振宇" }] } };
|
|
192
|
+
}
|
|
193
|
+
return {};
|
|
194
|
+
};
|
|
195
|
+
const fetchFn = async (opts) => byUrl(opts.url);
|
|
196
|
+
const a = new XimalayaAdapter({ account: { userId: "u1", cookies: "1&_token=ok" }, fetchFn });
|
|
197
|
+
const raws = [];
|
|
198
|
+
for await (const r of a.sync({})) raws.push(r);
|
|
199
|
+
expect(raws.map((r) => r.kind).sort()).toEqual(["favorite", "play", "subscribe"]);
|
|
200
|
+
|
|
201
|
+
const play = raws.find((r) => r.kind === "play");
|
|
202
|
+
const pb = a.normalize(play);
|
|
203
|
+
expect(validateBatch(pb).valid).toBe(true);
|
|
204
|
+
expect(pb.events[0].content.title).toContain("收听: 第1集 - 声工场");
|
|
205
|
+
expect(pb.items[0].name).toBe("第1集 - 声工场");
|
|
206
|
+
|
|
207
|
+
const sub = raws.find((r) => r.kind === "subscribe");
|
|
208
|
+
const sb = a.normalize(sub);
|
|
209
|
+
expect(sb.topics[0].name).toBe("精英日课");
|
|
210
|
+
expect(sb.topics[0].extra.trackCount).toBe(365);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("invokes signProvider, passes sign to fetchFn", async () => {
|
|
214
|
+
let seen = null;
|
|
215
|
+
const signProvider = async () => "SIG";
|
|
216
|
+
const fetchFn = async (opts) => {
|
|
217
|
+
seen = opts.sign;
|
|
218
|
+
return {};
|
|
219
|
+
};
|
|
220
|
+
const a = new XimalayaAdapter({ account: { cookies: "x=1" }, fetchFn, signProvider });
|
|
221
|
+
for await (const _r of a.sync({ include: { favorite: false, subscribe: false } })) { /* drain */ }
|
|
222
|
+
expect(seen).toBe("SIG");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("paginates plays until short page", async () => {
|
|
226
|
+
const all = Array.from({ length: 45 }, (_, i) => ({ trackId: i + 1, title: `t${i}`, startedAt: 1700000000000 }));
|
|
227
|
+
const seenPages = [];
|
|
228
|
+
const fetchFn = async (opts) => {
|
|
229
|
+
if (!opts.url.includes("/history")) return {};
|
|
230
|
+
const page = opts.query.page;
|
|
231
|
+
seenPages.push(page);
|
|
232
|
+
return { list: all.slice((page - 1) * 30, page * 30) };
|
|
233
|
+
};
|
|
234
|
+
const a = new XimalayaAdapter({ account: { cookies: "x=1" }, fetchFn });
|
|
235
|
+
const raws = [];
|
|
236
|
+
for await (const r of a.sync({ include: { favorite: false, subscribe: false } })) raws.push(r);
|
|
237
|
+
expect(raws.length).toBe(45);
|
|
238
|
+
expect(seenPages).toEqual([1, 2]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("extractList + item mappers tolerate shapes", () => {
|
|
242
|
+
expect(extractList({ list: [1] })).toEqual([1]);
|
|
243
|
+
expect(extractList({ data: { tracks: [2] } })).toEqual([2]);
|
|
244
|
+
expect(extractList({ data: { albums: [3] } })).toEqual([3]);
|
|
245
|
+
expect(extractList(null)).toEqual([]);
|
|
246
|
+
expect(trackItemToRecord({ trackId: 7, title: "x" }).trackId).toBe("7");
|
|
247
|
+
expect(trackItemToRecord({})).toBe(null);
|
|
248
|
+
expect(albumItemToRecord({ albumId: 9, albumTitle: "A" }).album).toBe("A");
|
|
249
|
+
expect(albumItemToRecord({})).toBe(null);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("uses opts.*Url overrides", async () => {
|
|
253
|
+
const seen = [];
|
|
254
|
+
const fetchFn = async (opts) => {
|
|
255
|
+
seen.push(opts.url);
|
|
256
|
+
return {};
|
|
257
|
+
};
|
|
258
|
+
const a = new XimalayaAdapter({
|
|
259
|
+
account: { cookies: "x=1" },
|
|
260
|
+
fetchFn,
|
|
261
|
+
playsUrl: "https://x/p",
|
|
262
|
+
favoritesUrl: "https://x/f",
|
|
263
|
+
subscribesUrl: "https://x/s",
|
|
264
|
+
});
|
|
265
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
266
|
+
expect(seen).toEqual(["https://x/p", "https://x/f", "https://x/s"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("default fetchFn throws legible error", async () => {
|
|
270
|
+
const a = new XimalayaAdapter({ account: { cookies: "x=1" } });
|
|
271
|
+
let threw = null;
|
|
272
|
+
try {
|
|
273
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
274
|
+
} catch (err) {
|
|
275
|
+
threw = err;
|
|
276
|
+
}
|
|
277
|
+
expect(String(threw.message)).toMatch(/no fetchFn configured/);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
KeepAdapter,
|
|
11
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
12
|
+
VALID_SNAPSHOT_KINDS,
|
|
13
|
+
mapWorkout,
|
|
14
|
+
extractList,
|
|
15
|
+
typeLabel,
|
|
16
|
+
} = require("../lib/adapters/fitness-keep");
|
|
17
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
18
|
+
const { validateBatch } = require("../lib/batch");
|
|
19
|
+
|
|
20
|
+
// Keep (健身) — mirrors fitness-joyrun but multi-type (workoutType). best-effort
|
|
21
|
+
// scaffold; signing injected. pure-Node.
|
|
22
|
+
|
|
23
|
+
function writeSnapshot(dir, snapshot) {
|
|
24
|
+
const p = path.join(dir, "fitness-keep.json");
|
|
25
|
+
fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("KeepAdapter snapshot mode", () => {
|
|
30
|
+
let tmpDir;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "keep-snap-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exports schema constants", () => {
|
|
36
|
+
expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
|
|
37
|
+
expect(VALID_SNAPSHOT_KINDS).toEqual(["workout"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("authenticate(inputPath) ok; authenticate() no input → NO_INPUT", async () => {
|
|
41
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: Date.now(), events: [] });
|
|
42
|
+
const a = new KeepAdapter();
|
|
43
|
+
expect((await a.authenticate({ inputPath: p })).ok).toBe(true);
|
|
44
|
+
const r = await a.authenticate({});
|
|
45
|
+
expect(r.ok).toBe(false);
|
|
46
|
+
expect(r.reason).toBe("NO_INPUT");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects schemaVersion mismatch", async () => {
|
|
50
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 9, snapshottedAt: Date.now(), events: [] });
|
|
51
|
+
const a = new KeepAdapter();
|
|
52
|
+
let threw = null;
|
|
53
|
+
try {
|
|
54
|
+
for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
|
|
55
|
+
} catch (err) {
|
|
56
|
+
threw = err;
|
|
57
|
+
}
|
|
58
|
+
expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("running workout → OTHER event '运动: 跑步 X km' + GPS-bearing extra", async () => {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const p = writeSnapshot(tmpDir, {
|
|
64
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
65
|
+
account: { userId: "u1" },
|
|
66
|
+
events: [
|
|
67
|
+
{ kind: "workout", id: "w1", workoutId: "9001", type: "running", name: "晨跑",
|
|
68
|
+
time: 1700000000, distanceMeters: 5230, durationSec: 1800, calories: 320, steps: 6400 },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
const a = new KeepAdapter();
|
|
72
|
+
const raws = [];
|
|
73
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
74
|
+
expect(raws.length).toBe(1);
|
|
75
|
+
expect(raws[0].originalId).toBe("keep:workout:9001");
|
|
76
|
+
const batch = a.normalize(raws[0]);
|
|
77
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
78
|
+
expect(batch.events[0].subtype).toBe("other");
|
|
79
|
+
expect(batch.events[0].content.title).toBe("运动: 跑步 5.23 km");
|
|
80
|
+
expect(batch.events[0].extra.workoutType).toBe("running");
|
|
81
|
+
expect(batch.events[0].extra.calories).toBe(320);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("non-distance workout (yoga) → '运动: 瑜伽 N 分钟'", async () => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const p = writeSnapshot(tmpDir, {
|
|
87
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
88
|
+
events: [
|
|
89
|
+
{ kind: "workout", id: "w2", workoutId: "9002", type: "yoga", name: "晚间瑜伽",
|
|
90
|
+
time: now, durationSec: 1500, calories: 120 },
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
const a = new KeepAdapter();
|
|
94
|
+
const raws = [];
|
|
95
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
96
|
+
const batch = a.normalize(raws[0]);
|
|
97
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
98
|
+
expect(batch.events[0].content.title).toBe("运动: 瑜伽 25 分钟");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("unknown type falls back to raw token; no metrics → bare '运动: <type>'", async () => {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const p = writeSnapshot(tmpDir, {
|
|
104
|
+
schemaVersion: 1, snapshottedAt: now,
|
|
105
|
+
events: [{ kind: "workout", id: "w3", workoutId: "9003", type: "parkour", time: now }],
|
|
106
|
+
});
|
|
107
|
+
const a = new KeepAdapter();
|
|
108
|
+
const raws = [];
|
|
109
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
110
|
+
const batch = a.normalize(raws[0]);
|
|
111
|
+
expect(batch.events[0].content.title).toBe("运动: parkour");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("respects include opt-out + limit", async () => {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const events = Array.from({ length: 4 }, (_, i) => ({
|
|
117
|
+
kind: "workout", id: `w${i}`, workoutId: String(100 + i), type: "running",
|
|
118
|
+
time: now - i * 1000, distanceMeters: 3000,
|
|
119
|
+
}));
|
|
120
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
|
|
121
|
+
const a = new KeepAdapter();
|
|
122
|
+
let raws = [];
|
|
123
|
+
for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
|
|
124
|
+
expect(raws.length).toBe(2);
|
|
125
|
+
raws = [];
|
|
126
|
+
for await (const r of a.sync({ inputPath: p, include: { workout: false } })) raws.push(r);
|
|
127
|
+
expect(raws.length).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("advertises capabilities + passes assertAdapter", () => {
|
|
131
|
+
const a = new KeepAdapter();
|
|
132
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
133
|
+
expect(a.capabilities).toContain("sync:cookie-api");
|
|
134
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("KeepAdapter cookie-api mode", () => {
|
|
139
|
+
it("authenticate(cookie) returns unverified:true (best-effort scaffold)", async () => {
|
|
140
|
+
const a = new KeepAdapter({ account: { cookies: "token=ok" } });
|
|
141
|
+
const res = await a.authenticate();
|
|
142
|
+
expect(res.ok).toBe(true);
|
|
143
|
+
expect(res.mode).toBe("cookie");
|
|
144
|
+
expect(res.unverified).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("fetches workouts via fetchFn and normalizes (km vs meters heuristic)", async () => {
|
|
148
|
+
const fetchFn = async () => ({
|
|
149
|
+
data: {
|
|
150
|
+
records: [
|
|
151
|
+
{ workoutId: 555, type: "cycling", name: "骑行", doneDate: 1700000000, distance: 12.5, duration: 2400, kcal: 410 },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const a = new KeepAdapter({ account: { userId: "u1", cookies: "token=ok" }, fetchFn });
|
|
156
|
+
const raws = [];
|
|
157
|
+
for await (const r of a.sync({})) raws.push(r);
|
|
158
|
+
expect(raws.length).toBe(1);
|
|
159
|
+
const batch = a.normalize(raws[0]);
|
|
160
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
161
|
+
// distance 12.5 (looks like km, no meter field) → 12500 m → 12.50 km
|
|
162
|
+
expect(batch.events[0].content.title).toBe("运动: 骑行 12.50 km");
|
|
163
|
+
expect(batch.events[0].extra.calories).toBe(410);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("invokes signProvider, passes sign to fetchFn", async () => {
|
|
167
|
+
let seen = null;
|
|
168
|
+
const signProvider = async () => "SIG";
|
|
169
|
+
const fetchFn = async (opts) => {
|
|
170
|
+
seen = opts.sign;
|
|
171
|
+
return { list: [] };
|
|
172
|
+
};
|
|
173
|
+
const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn, signProvider });
|
|
174
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
175
|
+
expect(seen).toBe("SIG");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("paginates until short page", async () => {
|
|
179
|
+
const all = Array.from({ length: 45 }, (_, i) => ({ workoutId: i + 1, type: "running", time: 1700000000, distanceMeters: 3000 }));
|
|
180
|
+
const seenPages = [];
|
|
181
|
+
const fetchFn = async (opts) => {
|
|
182
|
+
const page = opts.query.page;
|
|
183
|
+
seenPages.push(page);
|
|
184
|
+
return { list: all.slice((page - 1) * 30, page * 30) };
|
|
185
|
+
};
|
|
186
|
+
const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn });
|
|
187
|
+
const raws = [];
|
|
188
|
+
for await (const r of a.sync({})) raws.push(r);
|
|
189
|
+
expect(raws.length).toBe(45);
|
|
190
|
+
expect(seenPages).toEqual([1, 2]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("mapWorkout / extractList / typeLabel helpers", () => {
|
|
194
|
+
expect(mapWorkout({ workoutId: 7, type: "running", distanceMeters: 5000 }).distanceMeters).toBe(5000);
|
|
195
|
+
expect(mapWorkout({})).toBe(null);
|
|
196
|
+
expect(extractList({ data: { logs: [1] } })).toEqual([1]);
|
|
197
|
+
expect(extractList(null)).toEqual([]);
|
|
198
|
+
expect(typeLabel("yoga")).toBe("瑜伽");
|
|
199
|
+
expect(typeLabel("xyz")).toBe("xyz");
|
|
200
|
+
expect(typeLabel(null)).toBe("运动");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("uses opts.listUrl override", async () => {
|
|
204
|
+
let seenUrl = null;
|
|
205
|
+
const fetchFn = async (opts) => {
|
|
206
|
+
seenUrl = opts.url;
|
|
207
|
+
return { list: [] };
|
|
208
|
+
};
|
|
209
|
+
const a = new KeepAdapter({ account: { cookies: "x=1" }, fetchFn, listUrl: "https://x/w" });
|
|
210
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
211
|
+
expect(seenUrl).toBe("https://x/w");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("default fetchFn throws legible error", async () => {
|
|
215
|
+
const a = new KeepAdapter({ account: { cookies: "x=1" } });
|
|
216
|
+
let threw = null;
|
|
217
|
+
try {
|
|
218
|
+
for await (const _r of a.sync({})) { /* drain */ }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
threw = err;
|
|
221
|
+
}
|
|
222
|
+
expect(String(threw.message)).toMatch(/no fetchFn configured/);
|
|
223
|
+
});
|
|
224
|
+
});
|