@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.
- package/__tests__/adapters/doc-baidu-netdisk.test.js +102 -0
- package/__tests__/adapters/doc-platforms.test.js +177 -0
- package/__tests__/adapters/music-kugou.test.js +187 -0
- package/__tests__/adapters/recruit-boss.test.js +180 -0
- package/__tests__/adapters/shopping-dianping.test.js +239 -0
- package/__tests__/adapters/social-csdn.test.js +175 -0
- package/__tests__/adapters/social-zhihu.test.js +246 -0
- package/__tests__/adapters/travel-12306.test.js +234 -1
- package/__tests__/adapters/travel-ctrip.test.js +175 -1
- package/__tests__/adapters/travel-didi.test.js +204 -0
- package/__tests__/adapters/travel-tongcheng.test.js +289 -0
- package/__tests__/adapters/video-platforms.test.js +152 -0
- package/lib/adapter-guide.js +13 -1
- package/lib/adapters/_document-base.js +370 -0
- package/lib/adapters/_video-base.js +331 -0
- package/lib/adapters/doc-baidu-netdisk/index.js +91 -0
- package/lib/adapters/doc-tencent-docs/index.js +94 -0
- package/lib/adapters/doc-wps/index.js +77 -0
- package/lib/adapters/music-kugou/index.js +418 -0
- package/lib/adapters/recruit-boss/index.js +442 -0
- package/lib/adapters/shopping-dianping/index.js +473 -0
- package/lib/adapters/social-csdn/index.js +444 -0
- package/lib/adapters/social-zhihu/index.js +488 -0
- package/lib/adapters/travel-12306/index.js +279 -5
- package/lib/adapters/travel-ctrip/index.js +255 -40
- package/lib/adapters/travel-didi/index.js +327 -0
- package/lib/adapters/travel-tongcheng/index.js +393 -0
- package/lib/adapters/video-iqiyi/index.js +75 -0
- package/lib/adapters/video-tencent/index.js +78 -0
- package/lib/index.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §13+ — 百度网盘 (Baidu Netdisk, com.baidu.netdisk) adapter. §12.1 ROI ⭐⭐⭐
|
|
3
|
+
* "文件 + 外链".
|
|
4
|
+
*
|
|
5
|
+
* Thin wrapper over _document-base — a cloud-drive file list is the same shape
|
|
6
|
+
* as the doc-wps / doc-tencent-docs "own-document list". Baidu Netdisk exposes
|
|
7
|
+
* the owner's files via pan.baidu.com/api/list (BDUSS cookie); this adapter
|
|
8
|
+
* supplies the endpoint + field mapping, the base handles snapshot + cookie-api
|
|
9
|
+
* orchestration + normalize (event POST + item DOCUMENT). Endpoint best-effort +
|
|
10
|
+
* overridable via opts.listUrl (not field-verified — FAMILY-23 playbook).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const { createDocumentAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_document-base");
|
|
16
|
+
|
|
17
|
+
const NAME = "doc-baidu-netdisk";
|
|
18
|
+
const VERSION = "0.1.0";
|
|
19
|
+
|
|
20
|
+
// Best-effort Baidu Netdisk file-list endpoint. Overridable via opts.listUrl.
|
|
21
|
+
const NETDISK_LIST_URL = "https://pan.baidu.com/api/list";
|
|
22
|
+
|
|
23
|
+
// Baidu Netdisk `category` codes → normalized docType.
|
|
24
|
+
const CATEGORY_MAP = {
|
|
25
|
+
1: "video",
|
|
26
|
+
2: "audio",
|
|
27
|
+
3: "image",
|
|
28
|
+
4: "doc",
|
|
29
|
+
5: "app",
|
|
30
|
+
6: "other",
|
|
31
|
+
7: "seed",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mapNetdiskType(d) {
|
|
35
|
+
if (d.isdir === 1 || d.isdir === true) return "folder";
|
|
36
|
+
const cat = d.category != null ? d.category : d.file_category;
|
|
37
|
+
if (cat != null && CATEGORY_MAP[cat]) return CATEGORY_MAP[cat];
|
|
38
|
+
const name = String(d.server_filename || d.filename || "").toLowerCase();
|
|
39
|
+
if (/\.(mp4|mkv|avi|mov)$/.test(name)) return "video";
|
|
40
|
+
if (/\.(mp3|flac|wav|m4a)$/.test(name)) return "audio";
|
|
41
|
+
if (/\.(jpg|jpeg|png|gif|webp)$/.test(name)) return "image";
|
|
42
|
+
if (/\.(docx?|xlsx?|pptx?|pdf|txt)$/.test(name)) return "doc";
|
|
43
|
+
return "file";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractDocs(resp) {
|
|
47
|
+
if (!resp || typeof resp !== "object") return [];
|
|
48
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
49
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
50
|
+
if (resp.data && Array.isArray(resp.data.list)) return resp.data.list;
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mapDoc(d) {
|
|
55
|
+
if (!d || typeof d !== "object") return null;
|
|
56
|
+
const docId = d.fs_id || d.fsId || d.id || d.path;
|
|
57
|
+
if (!docId) return null;
|
|
58
|
+
return {
|
|
59
|
+
docId: String(docId),
|
|
60
|
+
title: d.server_filename || d.filename || d.title || "(未命名)",
|
|
61
|
+
docType: mapNetdiskType(d),
|
|
62
|
+
url: d.path || d.dlink || null,
|
|
63
|
+
createdMs: parseTime(d.server_ctime || d.local_ctime || d.ctime),
|
|
64
|
+
updatedMs: parseTime(d.server_mtime || d.local_mtime || d.mtime),
|
|
65
|
+
extra: {
|
|
66
|
+
size: d.size != null ? d.size : null,
|
|
67
|
+
isDir: d.isdir === 1 || d.isdir === true ? true : false,
|
|
68
|
+
path: d.path || null,
|
|
69
|
+
category: d.category != null ? d.category : null,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const BaiduNetdiskAdapter = createDocumentAdapter({
|
|
75
|
+
NAME,
|
|
76
|
+
VERSION,
|
|
77
|
+
platform: "baidu-netdisk",
|
|
78
|
+
defaultListUrl: NETDISK_LIST_URL,
|
|
79
|
+
extractDocs,
|
|
80
|
+
mapDoc,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
BaiduNetdiskAdapter,
|
|
85
|
+
extractDocs,
|
|
86
|
+
mapDoc,
|
|
87
|
+
CATEGORY_MAP,
|
|
88
|
+
NAME,
|
|
89
|
+
VERSION,
|
|
90
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
91
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §13+ — 腾讯文档 (Tencent Docs) adapter. "自创文档列表" (§12.1, ROI ⭐⭐⭐).
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over _document-base. 腾讯文档 exposes the owner's documents via
|
|
5
|
+
* docs.qq.com dop-api; this adapter supplies the endpoint + field mapping, the
|
|
6
|
+
* base handles snapshot + cookie-api orchestration + normalize. Endpoint is
|
|
7
|
+
* best-effort + overridable via opts.listUrl (docs.qq.com rotates its dop-api;
|
|
8
|
+
* some calls need a sign — opts.signProvider seam — FAMILY-23 playbook, not
|
|
9
|
+
* field-verified).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const { createDocumentAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_document-base");
|
|
15
|
+
|
|
16
|
+
const NAME = "doc-tencent-docs";
|
|
17
|
+
const VERSION = "0.1.0";
|
|
18
|
+
|
|
19
|
+
// Best-effort Tencent Docs "my documents" list. Overridable via opts.listUrl.
|
|
20
|
+
const TENCENT_DOCS_LIST_URL = "https://docs.qq.com/dop-api/get/personal/files";
|
|
21
|
+
|
|
22
|
+
// Tencent Docs type codes → normalized docType.
|
|
23
|
+
const TYPE_MAP = {
|
|
24
|
+
doc: "doc",
|
|
25
|
+
document: "doc",
|
|
26
|
+
sheet: "sheet",
|
|
27
|
+
spreadsheet: "sheet",
|
|
28
|
+
slide: "slide",
|
|
29
|
+
presentation: "slide",
|
|
30
|
+
pdf: "pdf",
|
|
31
|
+
form: "form",
|
|
32
|
+
mind: "mind",
|
|
33
|
+
1: "doc",
|
|
34
|
+
2: "sheet",
|
|
35
|
+
3: "slide",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function mapTencentType(d) {
|
|
39
|
+
const raw = d.type != null ? d.type : d.docType != null ? d.docType : d.fileType;
|
|
40
|
+
const key = String(raw == null ? "" : raw).toLowerCase();
|
|
41
|
+
return TYPE_MAP[key] || TYPE_MAP[raw] || "doc";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractDocs(resp) {
|
|
45
|
+
if (!resp || typeof resp !== "object") return [];
|
|
46
|
+
if (Array.isArray(resp.files)) return resp.files;
|
|
47
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
48
|
+
const data = resp.data && typeof resp.data === "object" ? resp.data : null;
|
|
49
|
+
if (data) {
|
|
50
|
+
if (Array.isArray(data.files)) return data.files;
|
|
51
|
+
if (Array.isArray(data.list)) return data.list;
|
|
52
|
+
if (Array.isArray(data.records)) return data.records;
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mapDoc(d) {
|
|
58
|
+
if (!d || typeof d !== "object") return null;
|
|
59
|
+
const docId = d.id || d.fileId || d.file_id || d.docId || d.url;
|
|
60
|
+
if (!docId) return null;
|
|
61
|
+
return {
|
|
62
|
+
docId: String(docId),
|
|
63
|
+
title: d.title || d.name || d.fileName || "(无标题)",
|
|
64
|
+
docType: mapTencentType(d),
|
|
65
|
+
url:
|
|
66
|
+
d.url ||
|
|
67
|
+
(d.id ? `https://docs.qq.com/doc/${d.id}` : null),
|
|
68
|
+
createdMs: parseTime(d.createTime || d.create_time || d.gmtCreate),
|
|
69
|
+
updatedMs: parseTime(d.lastModifyTime || d.modifyTime || d.updateTime || d.gmtModify),
|
|
70
|
+
extra: {
|
|
71
|
+
ownerName: d.ownerName || d.creatorName || null,
|
|
72
|
+
starred: d.isStar != null ? d.isStar : undefined,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TencentDocsAdapter = createDocumentAdapter({
|
|
78
|
+
NAME,
|
|
79
|
+
VERSION,
|
|
80
|
+
platform: "tencent-docs",
|
|
81
|
+
defaultListUrl: TENCENT_DOCS_LIST_URL,
|
|
82
|
+
extractDocs,
|
|
83
|
+
mapDoc,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
TencentDocsAdapter,
|
|
88
|
+
extractDocs,
|
|
89
|
+
mapDoc,
|
|
90
|
+
TYPE_MAP,
|
|
91
|
+
NAME,
|
|
92
|
+
VERSION,
|
|
93
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
94
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §13+ — WPS 云文档 (Kingsoft Office) adapter. "自创文档列表" (§12.1, ROI ⭐⭐⭐).
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over _document-base. WPS exposes the owner's cloud documents via
|
|
5
|
+
* drive.wps.cn; this adapter supplies the endpoint + field mapping, the base
|
|
6
|
+
* handles snapshot + cookie-api orchestration + normalize (event POST + item
|
|
7
|
+
* DOCUMENT). Endpoint is best-effort + overridable via opts.listUrl (WPS rotates
|
|
8
|
+
* its drive API — FAMILY-23 playbook, not field-verified).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const { createDocumentAdapter, parseTime, SNAPSHOT_SCHEMA_VERSION } = require("../_document-base");
|
|
14
|
+
|
|
15
|
+
const NAME = "doc-wps";
|
|
16
|
+
const VERSION = "0.1.0";
|
|
17
|
+
|
|
18
|
+
// Best-effort WPS cloud-drive file list. Overridable via opts.listUrl.
|
|
19
|
+
const WPS_LIST_URL = "https://drive.wps.cn/api/v5/groups/special/files";
|
|
20
|
+
|
|
21
|
+
// WPS doc-type codes/names → normalized docType.
|
|
22
|
+
function mapWpsType(d) {
|
|
23
|
+
const t = String(d.fname || d.name || "").toLowerCase();
|
|
24
|
+
if (d.ftype && typeof d.ftype === "string") return d.ftype;
|
|
25
|
+
if (/\.(xlsx?|et|csv)$/.test(t)) return "sheet";
|
|
26
|
+
if (/\.(pptx?|dps)$/.test(t)) return "slide";
|
|
27
|
+
if (/\.pdf$/.test(t)) return "pdf";
|
|
28
|
+
if (/\.(docx?|wps)$/.test(t)) return "doc";
|
|
29
|
+
return "doc";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractDocs(resp) {
|
|
33
|
+
if (!resp || typeof resp !== "object") return [];
|
|
34
|
+
if (Array.isArray(resp.files)) return resp.files;
|
|
35
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
36
|
+
if (resp.data && Array.isArray(resp.data.files)) return resp.data.files;
|
|
37
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapDoc(d) {
|
|
42
|
+
if (!d || typeof d !== "object") return null;
|
|
43
|
+
const docId = d.id || d.fileid || d.file_id || d.fid;
|
|
44
|
+
if (!docId) return null;
|
|
45
|
+
return {
|
|
46
|
+
docId: String(docId),
|
|
47
|
+
title: d.fname || d.name || d.title || "(无标题)",
|
|
48
|
+
docType: mapWpsType(d),
|
|
49
|
+
url:
|
|
50
|
+
d.url ||
|
|
51
|
+
(d.id ? `https://www.kdocs.cn/p/${d.id}` : null),
|
|
52
|
+
createdMs: parseTime(d.ctime || d.create_time || d.created),
|
|
53
|
+
updatedMs: parseTime(d.mtime || d.modify_time || d.updated || d.utime),
|
|
54
|
+
extra: {
|
|
55
|
+
size: d.fsize || d.size || null,
|
|
56
|
+
groupId: d.group_id || d.groupid || null,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const WpsDocAdapter = createDocumentAdapter({
|
|
62
|
+
NAME,
|
|
63
|
+
VERSION,
|
|
64
|
+
platform: "wps",
|
|
65
|
+
defaultListUrl: WPS_LIST_URL,
|
|
66
|
+
extractDocs,
|
|
67
|
+
mapDoc,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
WpsDocAdapter,
|
|
72
|
+
extractDocs,
|
|
73
|
+
mapDoc,
|
|
74
|
+
NAME,
|
|
75
|
+
VERSION,
|
|
76
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
77
|
+
};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §A12 — Kugou 酷狗音乐 (com.kugou.android) adapter, dual-mode (snapshot +
|
|
3
|
+
* cookie-api). Phase 13+ §12.1 line-781 ROI ⭐⭐ "听歌历史".
|
|
4
|
+
*
|
|
5
|
+
* Mirrors netease-music's three-kind shape (play / favorite / playlist) so the
|
|
6
|
+
* vault treats music listening uniformly across providers. Unlike netease (which
|
|
7
|
+
* needs weapi signing via a dedicated api-client), Kugou's web endpoints are
|
|
8
|
+
* fetched through a generic injected `fetchFn` + optional signProvider seam,
|
|
9
|
+
* keeping this module a pure-Node parser/orchestrator.
|
|
10
|
+
*
|
|
11
|
+
* 1. snapshot mode (opts.inputPath): JSON schemaVersion 1, stateless.
|
|
12
|
+
* 2. cookie-api mode (opts.account.cookies): fetch play history / favourites /
|
|
13
|
+
* playlists from kugou web via the injected fetchFn, paginate; sign seam
|
|
14
|
+
* (opts.signProvider) for any anti-bot token; endpoints overridable via
|
|
15
|
+
* opts.*Url (best-effort, not field-verified — FAMILY-23 playbook).
|
|
16
|
+
*
|
|
17
|
+
* Snapshot schema (schemaVersion 1, mirrors netease-music):
|
|
18
|
+
* {
|
|
19
|
+
* "schemaVersion": 1, "snapshottedAt": <ms>,
|
|
20
|
+
* "account": { "userId": "...", "name": "..." },
|
|
21
|
+
* "events": [
|
|
22
|
+
* { "kind": "play", "id": "...", "songId": "...", "song": "...",
|
|
23
|
+
* "artist": "...", "album": "...", "playCount": N, "capturedAt": <ms> },
|
|
24
|
+
* { "kind": "favorite", "id": "...", "songId": "...", "song": "...", "artist": "..." },
|
|
25
|
+
* { "kind": "playlist", "id": "...", "playlistId": "...", "name": "...",
|
|
26
|
+
* "trackCount": N, "creator": "..." }
|
|
27
|
+
* ]
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
const fs = require("node:fs");
|
|
34
|
+
const { newId } = require("../../ids");
|
|
35
|
+
const {
|
|
36
|
+
ENTITY_TYPES,
|
|
37
|
+
EVENT_SUBTYPES,
|
|
38
|
+
ITEM_SUBTYPES,
|
|
39
|
+
CAPTURED_BY,
|
|
40
|
+
} = require("../../constants");
|
|
41
|
+
const { CookieAuth } = require("../shopping-base");
|
|
42
|
+
|
|
43
|
+
const NAME = "music-kugou";
|
|
44
|
+
const VERSION = "0.1.0";
|
|
45
|
+
const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
const KIND_PLAY = "play";
|
|
48
|
+
const KIND_FAVORITE = "favorite";
|
|
49
|
+
const KIND_PLAYLIST = "playlist";
|
|
50
|
+
const VALID_KINDS = Object.freeze([KIND_PLAY, KIND_FAVORITE, KIND_PLAYLIST]);
|
|
51
|
+
|
|
52
|
+
// Best-effort Kugou web endpoints. Overridable via opts.*Url.
|
|
53
|
+
const PLAYS_URL = "https://www.kugou.com/api/v3/user/listen/list";
|
|
54
|
+
const FAVORITES_URL = "https://www.kugou.com/api/v3/user/favorite/list";
|
|
55
|
+
const PLAYLISTS_URL = "https://www.kugou.com/api/v3/user/playlist/list";
|
|
56
|
+
const PAGE_SIZE = 30;
|
|
57
|
+
|
|
58
|
+
function parseTime(v) {
|
|
59
|
+
if (Number.isFinite(v)) return v > 1e12 ? v : v >= 1e9 ? v * 1000 : v;
|
|
60
|
+
if (typeof v === "string") {
|
|
61
|
+
if (/^\d+$/.test(v)) {
|
|
62
|
+
const n = parseInt(v, 10);
|
|
63
|
+
return n > 1e12 ? n : n >= 1e9 ? n * 1000 : n;
|
|
64
|
+
}
|
|
65
|
+
const t = Date.parse(v);
|
|
66
|
+
return Number.isFinite(t) ? t : null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stableOriginalId(kind, id) {
|
|
72
|
+
const safe =
|
|
73
|
+
(typeof id === "string" && id.length > 0 && id) ||
|
|
74
|
+
(typeof id === "number" && Number.isFinite(id) && String(id)) ||
|
|
75
|
+
`unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
76
|
+
return `kugou:${kind}:${safe}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Split Kugou's "歌手 - 歌名" filename into {song, artist} when discrete
|
|
80
|
+
* fields are absent. */
|
|
81
|
+
function splitFilename(name) {
|
|
82
|
+
if (typeof name !== "string" || !name.includes(" - ")) return null;
|
|
83
|
+
const idx = name.indexOf(" - ");
|
|
84
|
+
return { artist: name.slice(0, idx).trim(), song: name.slice(idx + 3).trim() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class KugouMusicAdapter {
|
|
88
|
+
constructor(opts = {}) {
|
|
89
|
+
this.account = opts.account || null;
|
|
90
|
+
this._cookieAuth =
|
|
91
|
+
opts.account && opts.account.cookies
|
|
92
|
+
? new CookieAuth({ platform: "kugou", cookies: opts.account.cookies })
|
|
93
|
+
: null;
|
|
94
|
+
this._fetchFn = typeof opts.fetchFn === "function" ? opts.fetchFn : defaultFetch;
|
|
95
|
+
this._signProvider =
|
|
96
|
+
typeof opts.signProvider === "function" ? opts.signProvider : null;
|
|
97
|
+
this._urls = {
|
|
98
|
+
play: opts.playsUrl || PLAYS_URL,
|
|
99
|
+
favorite: opts.favoritesUrl || FAVORITES_URL,
|
|
100
|
+
playlist: opts.playlistsUrl || PLAYLISTS_URL,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
this.name = NAME;
|
|
104
|
+
this.version = VERSION;
|
|
105
|
+
this.capabilities = [
|
|
106
|
+
"sync:snapshot",
|
|
107
|
+
"sync:cookie-api",
|
|
108
|
+
"parse:kugou-play",
|
|
109
|
+
"parse:kugou-favorite",
|
|
110
|
+
"parse:kugou-playlist",
|
|
111
|
+
];
|
|
112
|
+
this.extractMode = "web-api";
|
|
113
|
+
this.rateLimits = {};
|
|
114
|
+
this.dataDisclosure = {
|
|
115
|
+
fields: [
|
|
116
|
+
"kugou:play (歌名 / 歌手 / 专辑)",
|
|
117
|
+
"kugou:favorite (收藏的歌)",
|
|
118
|
+
"kugou:playlist (歌单名 / 曲目数)",
|
|
119
|
+
],
|
|
120
|
+
sensitivity: "low",
|
|
121
|
+
legalGate: false,
|
|
122
|
+
defaultInclude: { play: true, favorite: true, playlist: true },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this._deps = { fs };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async authenticate(ctx = {}) {
|
|
129
|
+
if (ctx && typeof ctx.inputPath === "string" && ctx.inputPath.length > 0) {
|
|
130
|
+
try {
|
|
131
|
+
this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "INPUT_PATH_UNREADABLE",
|
|
136
|
+
message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, mode: "snapshot-file" };
|
|
140
|
+
}
|
|
141
|
+
if (this._cookieAuth) {
|
|
142
|
+
const ok = await this._cookieAuth.validate();
|
|
143
|
+
if (!ok) return { ok: false, reason: "INVALID_COOKIE", error: "cookies missing" };
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
account: (this.account && this.account.userId) || null,
|
|
147
|
+
mode: "cookie",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: "NO_INPUT",
|
|
153
|
+
message: "music-kugou.authenticate: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async healthCheck() {
|
|
158
|
+
if (this._cookieAuth) {
|
|
159
|
+
const r = await this.authenticate();
|
|
160
|
+
return r.ok ? { ok: true, lastChecked: Date.now() } : { ok: false, reason: r.reason, error: r.error };
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, lastChecked: Date.now() };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async *sync(opts = {}) {
|
|
166
|
+
if (typeof opts.inputPath === "string" && opts.inputPath.length > 0) {
|
|
167
|
+
yield* this._syncViaSnapshot(opts);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (this._cookieAuth) {
|
|
171
|
+
yield* this._syncViaCookie(opts);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
throw new Error(
|
|
175
|
+
"music-kugou.sync: needs opts.inputPath (snapshot mode) OR opts.account.cookies (cookie-api mode)",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async *_syncViaSnapshot(opts) {
|
|
180
|
+
const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
|
|
181
|
+
const snapshot = JSON.parse(raw);
|
|
182
|
+
if (!snapshot || typeof snapshot !== "object" || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`music-kugou.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const fallback =
|
|
188
|
+
Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
|
|
189
|
+
? Math.floor(snapshot.snapshottedAt)
|
|
190
|
+
: Date.now();
|
|
191
|
+
const account = snapshot.account && typeof snapshot.account === "object" ? snapshot.account : null;
|
|
192
|
+
const include = opts.include || {};
|
|
193
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
194
|
+
const events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
195
|
+
let emitted = 0;
|
|
196
|
+
for (const ev of events) {
|
|
197
|
+
if (emitted >= limit) return;
|
|
198
|
+
if (!ev || typeof ev !== "object" || !VALID_KINDS.includes(ev.kind)) continue;
|
|
199
|
+
if (include[ev.kind] === false) continue;
|
|
200
|
+
const id = (typeof ev.id === "string" && ev.id) || ev.songId || ev.playlistId || null;
|
|
201
|
+
yield {
|
|
202
|
+
adapter: NAME,
|
|
203
|
+
kind: ev.kind,
|
|
204
|
+
originalId: stableOriginalId(ev.kind, id),
|
|
205
|
+
capturedAt: parseTime(ev.capturedAt) || fallback,
|
|
206
|
+
payload: { ...ev, account },
|
|
207
|
+
};
|
|
208
|
+
emitted += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async *_syncViaCookie(opts = {}) {
|
|
213
|
+
if (!(await this._cookieAuth.validate())) return;
|
|
214
|
+
const cookies = this._cookieAuth.toHeader();
|
|
215
|
+
const include = opts.include || {};
|
|
216
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
|
|
217
|
+
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : 10;
|
|
218
|
+
|
|
219
|
+
const plan = [
|
|
220
|
+
{ kind: KIND_PLAY, url: this._urls.play, map: songItemToRecord },
|
|
221
|
+
{ kind: KIND_FAVORITE, url: this._urls.favorite, map: songItemToRecord },
|
|
222
|
+
{ kind: KIND_PLAYLIST, url: this._urls.playlist, map: playlistItemToRecord },
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
let emitted = 0;
|
|
226
|
+
for (const step of plan) {
|
|
227
|
+
if (include[step.kind] === false) continue;
|
|
228
|
+
let page = 1;
|
|
229
|
+
while (page <= maxPages) {
|
|
230
|
+
const query = { page, pagesize: PAGE_SIZE };
|
|
231
|
+
let sign = null;
|
|
232
|
+
if (this._signProvider) {
|
|
233
|
+
sign = await this._signProvider({ url: step.url, query, cookies });
|
|
234
|
+
}
|
|
235
|
+
const resp = await this._fetchFn({ url: step.url, cookies, query, sign });
|
|
236
|
+
const items = extractList(resp);
|
|
237
|
+
if (!items.length) break;
|
|
238
|
+
for (const it of items) {
|
|
239
|
+
const rec = step.map(it);
|
|
240
|
+
if (!rec) continue;
|
|
241
|
+
if (emitted >= limit) return;
|
|
242
|
+
yield {
|
|
243
|
+
adapter: NAME,
|
|
244
|
+
kind: step.kind,
|
|
245
|
+
originalId: stableOriginalId(step.kind, rec.id),
|
|
246
|
+
capturedAt: rec.occurredAt || Date.now(),
|
|
247
|
+
payload: { ...rec, kind: step.kind, cookie: true },
|
|
248
|
+
};
|
|
249
|
+
emitted += 1;
|
|
250
|
+
}
|
|
251
|
+
if (items.length < PAGE_SIZE) break;
|
|
252
|
+
page += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
normalize(raw) {
|
|
258
|
+
if (!raw || !raw.payload) throw new Error("KugouMusicAdapter.normalize: payload missing");
|
|
259
|
+
const kind = raw.kind || raw.payload.kind;
|
|
260
|
+
const ingestedAt = Date.now();
|
|
261
|
+
if (kind === KIND_PLAY) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.MEDIA, "听了");
|
|
262
|
+
if (kind === KIND_FAVORITE) return normalizeSong(raw.payload, raw, ingestedAt, EVENT_SUBTYPES.LIKE, "收藏");
|
|
263
|
+
if (kind === KIND_PLAYLIST) return normalizePlaylist(raw.payload, raw, ingestedAt);
|
|
264
|
+
throw new Error(`KugouMusicAdapter.normalize: unknown kind ${kind}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── cookie response → intermediate record ───────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function extractList(resp) {
|
|
271
|
+
if (!resp || typeof resp !== "object") return [];
|
|
272
|
+
if (Array.isArray(resp.list)) return resp.list;
|
|
273
|
+
if (Array.isArray(resp.data)) return resp.data;
|
|
274
|
+
const d = resp.data;
|
|
275
|
+
if (d && typeof d === "object") {
|
|
276
|
+
if (Array.isArray(d.list)) return d.list;
|
|
277
|
+
if (Array.isArray(d.info)) return d.info;
|
|
278
|
+
if (Array.isArray(d.songs)) return d.songs;
|
|
279
|
+
}
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function songItemToRecord(it) {
|
|
284
|
+
if (!it || typeof it !== "object") return null;
|
|
285
|
+
const id = it.hash || it.mixsongid || it.songid || it.audio_id || it.id;
|
|
286
|
+
if (!id) return null;
|
|
287
|
+
let song = it.songname || it.song_name || it.name || null;
|
|
288
|
+
let artist = it.singername || it.singer_name || it.author_name || it.singer || null;
|
|
289
|
+
if ((!song || !artist) && it.filename) {
|
|
290
|
+
const split = splitFilename(it.filename);
|
|
291
|
+
if (split) {
|
|
292
|
+
song = song || split.song;
|
|
293
|
+
artist = artist || split.artist;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
id: String(id),
|
|
298
|
+
songId: String(id),
|
|
299
|
+
song: song || "(未知歌曲)",
|
|
300
|
+
artist: artist || "",
|
|
301
|
+
album: it.album_name || it.albumname || it.album || null,
|
|
302
|
+
occurredAt: parseTime(it.addtime || it.playtime || it.update_time || it.timestamp),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function playlistItemToRecord(it) {
|
|
307
|
+
if (!it || typeof it !== "object") return null;
|
|
308
|
+
const id = it.listid || it.global_collection_id || it.specialid || it.id;
|
|
309
|
+
if (!id) return null;
|
|
310
|
+
return {
|
|
311
|
+
id: String(id),
|
|
312
|
+
playlistId: String(id),
|
|
313
|
+
name: it.name || it.list_name || it.specialname || "(未命名歌单)",
|
|
314
|
+
trackCount: it.count != null ? it.count : it.song_count != null ? it.song_count : null,
|
|
315
|
+
creator: it.nickname || it.list_create_username || it.creator || null,
|
|
316
|
+
occurredAt: parseTime(it.create_time || it.addtime),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── normalizers (mirror netease-music) ──────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
function buildSource(raw, occurredAt) {
|
|
323
|
+
return {
|
|
324
|
+
adapter: NAME,
|
|
325
|
+
adapterVersion: VERSION,
|
|
326
|
+
originalId: raw.originalId,
|
|
327
|
+
capturedAt: raw.capturedAt || occurredAt,
|
|
328
|
+
capturedBy: CAPTURED_BY.API,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeSong(p, raw, ingestedAt, subtype, verb) {
|
|
333
|
+
const occurredAt = parseTime(p.occurredAt || p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
334
|
+
const source = buildSource(raw, occurredAt);
|
|
335
|
+
const song = p.song || "(未知歌曲)";
|
|
336
|
+
const artist = p.artist || "";
|
|
337
|
+
const songId = p.songId != null ? String(p.songId) : null;
|
|
338
|
+
const itemId = songId ? `item-kugou-song-${songId}` : `item-kugou-song-${newId()}`;
|
|
339
|
+
return {
|
|
340
|
+
events: [
|
|
341
|
+
{
|
|
342
|
+
id: newId(),
|
|
343
|
+
type: ENTITY_TYPES.EVENT,
|
|
344
|
+
subtype,
|
|
345
|
+
occurredAt,
|
|
346
|
+
actor: "person-self",
|
|
347
|
+
content: { title: `${verb}: ${song}${artist ? " - " + artist : ""}`, text: `${song} ${artist}`.trim() },
|
|
348
|
+
ingestedAt,
|
|
349
|
+
source,
|
|
350
|
+
extra: {
|
|
351
|
+
platform: "kugou",
|
|
352
|
+
song,
|
|
353
|
+
artist,
|
|
354
|
+
album: p.album || null,
|
|
355
|
+
songId,
|
|
356
|
+
playCount: p.playCount != null ? p.playCount : null,
|
|
357
|
+
itemRef: itemId,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
items: [
|
|
362
|
+
{
|
|
363
|
+
id: itemId,
|
|
364
|
+
type: ENTITY_TYPES.ITEM,
|
|
365
|
+
subtype: ITEM_SUBTYPES.MEDIA,
|
|
366
|
+
name: artist ? `${song} - ${artist}` : song,
|
|
367
|
+
ingestedAt,
|
|
368
|
+
source,
|
|
369
|
+
extra: { platform: "kugou", kind: "song", song, artist, album: p.album || null, songId },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
persons: [],
|
|
373
|
+
places: [],
|
|
374
|
+
topics: [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function normalizePlaylist(p, raw, ingestedAt) {
|
|
379
|
+
const occurredAt = parseTime(p.occurredAt || p.capturedAt) || raw.capturedAt || ingestedAt;
|
|
380
|
+
const source = buildSource(raw, occurredAt);
|
|
381
|
+
const pid = p.playlistId != null ? String(p.playlistId) : null;
|
|
382
|
+
return {
|
|
383
|
+
events: [],
|
|
384
|
+
persons: [],
|
|
385
|
+
places: [],
|
|
386
|
+
items: [],
|
|
387
|
+
topics: [
|
|
388
|
+
{
|
|
389
|
+
id: pid ? `topic-kugou-playlist-${pid}` : `topic-kugou-playlist-${newId()}`,
|
|
390
|
+
type: ENTITY_TYPES.TOPIC,
|
|
391
|
+
name: p.name || "(未命名歌单)",
|
|
392
|
+
ingestedAt,
|
|
393
|
+
source,
|
|
394
|
+
extra: {
|
|
395
|
+
platform: "kugou",
|
|
396
|
+
playlistId: pid,
|
|
397
|
+
trackCount: p.trackCount != null ? p.trackCount : null,
|
|
398
|
+
creator: p.creator || null,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function defaultFetch(_opts) {
|
|
406
|
+
throw new Error("music-kugou: no fetchFn configured for cookie-api mode");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = {
|
|
410
|
+
KugouMusicAdapter,
|
|
411
|
+
extractList,
|
|
412
|
+
songItemToRecord,
|
|
413
|
+
playlistItemToRecord,
|
|
414
|
+
NAME,
|
|
415
|
+
VERSION,
|
|
416
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
417
|
+
VALID_KINDS,
|
|
418
|
+
};
|