ym-mcp-wangyi 0.1.0
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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/index.js +217 -0
- package/dist/lib/api.js +83 -0
- package/dist/lib/bplist.js +224 -0
- package/dist/lib/cdp.js +244 -0
- package/dist/lib/cookies.js +313 -0
- package/dist/lib/errors.js +19 -0
- package/dist/lib/last-play.js +33 -0
- package/dist/lib/mmkv.js +63 -0
- package/dist/lib/paths.js +101 -0
- package/dist/lib/play.js +45 -0
- package/dist/lib/process.js +51 -0
- package/dist/lib/queue.js +131 -0
- package/dist/lib/status.js +58 -0
- package/package.json +40 -0
package/dist/lib/cdp.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { installCandidates } from "./paths.js";
|
|
5
|
+
import { isInstalled, isRunning } from "./process.js";
|
|
6
|
+
import { ToolError } from "./errors.js";
|
|
7
|
+
export const DEFAULT_CDP_PORT = Number(process.env.WANGYI_CDP_PORT || 9223);
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
export async function cdpListTargets(port = DEFAULT_CDP_PORT) {
|
|
12
|
+
const urls = [`http://127.0.0.1:${port}/json/list`, `http://127.0.0.1:${port}/json`];
|
|
13
|
+
for (const url of urls) {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
continue;
|
|
18
|
+
const data = (await res.json());
|
|
19
|
+
if (Array.isArray(data))
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// try next
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
export async function findOrpheusPage(port = DEFAULT_CDP_PORT) {
|
|
29
|
+
const targets = await cdpListTargets(port);
|
|
30
|
+
const pages = targets.filter((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
|
31
|
+
return (pages.find((t) => String(t.url || "").startsWith("orpheus://orpheus/app.html")) ||
|
|
32
|
+
pages.find((t) => String(t.url || "").startsWith("orpheus://")) ||
|
|
33
|
+
pages[0] ||
|
|
34
|
+
null);
|
|
35
|
+
}
|
|
36
|
+
export async function isCdpReady(port = DEFAULT_CDP_PORT) {
|
|
37
|
+
return Boolean(await findOrpheusPage(port));
|
|
38
|
+
}
|
|
39
|
+
export async function cdpEvaluate(expression, awaitPromise = false, port = DEFAULT_CDP_PORT) {
|
|
40
|
+
const target = await findOrpheusPage(port);
|
|
41
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
42
|
+
throw new ToolError("CDP_UNAVAILABLE", "网易云未开启本地控制通道(CDP)", `请先执行: node dist/index.js launch\n会以 --remote-debugging-port=${port} 重启客户端,之后 play/next/prev 才能真正控制播放`);
|
|
43
|
+
}
|
|
44
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
45
|
+
try {
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => reject(new Error("CDP websocket timeout")), 8000);
|
|
48
|
+
ws.addEventListener("open", () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
ws.addEventListener("error", () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(new Error("CDP websocket error"));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
const id = 1;
|
|
58
|
+
const payload = JSON.stringify({
|
|
59
|
+
id,
|
|
60
|
+
method: "Runtime.evaluate",
|
|
61
|
+
params: { expression, returnByValue: true, awaitPromise },
|
|
62
|
+
});
|
|
63
|
+
const response = await new Promise((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => reject(new Error("CDP evaluate timeout")), 15000);
|
|
65
|
+
ws.addEventListener("message", (event) => {
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(String(event.data));
|
|
68
|
+
if (msg.id !== id)
|
|
69
|
+
return;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve(msg);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
reject(err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
ws.send(payload);
|
|
79
|
+
});
|
|
80
|
+
const result = response.result || {};
|
|
81
|
+
if (result.exceptionDetails) {
|
|
82
|
+
const detail = result.exceptionDetails.exception?.description ||
|
|
83
|
+
result.exceptionDetails.text ||
|
|
84
|
+
"Unknown JavaScript error";
|
|
85
|
+
throw new ToolError("CDP_ERROR", `客户端控制失败: ${detail}`);
|
|
86
|
+
}
|
|
87
|
+
return result.result?.value;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
try {
|
|
91
|
+
ws.close();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// ignore
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const BOOTSTRAP = `
|
|
99
|
+
(() => {
|
|
100
|
+
if (!window.__wymReq) {
|
|
101
|
+
const id = Date.now();
|
|
102
|
+
if (typeof webpackJsonp === "undefined") return false;
|
|
103
|
+
webpackJsonp.push([[id], { [id]: function(m, e, r) { window.__wymReq = r; } }, [[id]]]);
|
|
104
|
+
}
|
|
105
|
+
return Boolean(window.__wymReq);
|
|
106
|
+
})()
|
|
107
|
+
`;
|
|
108
|
+
const FIND_STORE = `
|
|
109
|
+
(() => {
|
|
110
|
+
if (!window.__wymReq) return null;
|
|
111
|
+
if (window.__wymStoreId != null) return window.__wymStoreId;
|
|
112
|
+
const req = window.__wymReq;
|
|
113
|
+
for (let i = 0; i < 400; i++) {
|
|
114
|
+
try {
|
|
115
|
+
const mod = req(i);
|
|
116
|
+
if (mod && mod.a && typeof mod.a.getStore === "function" && typeof mod.a.getDispatch === "function") {
|
|
117
|
+
window.__wymStoreId = i;
|
|
118
|
+
return i;
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
})()
|
|
124
|
+
`;
|
|
125
|
+
export async function bootstrapClientRuntime() {
|
|
126
|
+
const ok = await cdpEvaluate(BOOTSTRAP);
|
|
127
|
+
if (!ok) {
|
|
128
|
+
throw new ToolError("CDP_ERROR", "无法注入网易云客户端运行时(webpackJsonp 不可用)");
|
|
129
|
+
}
|
|
130
|
+
const storeId = await cdpEvaluate(FIND_STORE);
|
|
131
|
+
if (typeof storeId !== "number") {
|
|
132
|
+
throw new ToolError("CDP_ERROR", "找不到客户端 Store 模块,可能版本不兼容");
|
|
133
|
+
}
|
|
134
|
+
return { storeId };
|
|
135
|
+
}
|
|
136
|
+
export async function cdpPlayerState() {
|
|
137
|
+
await bootstrapClientRuntime();
|
|
138
|
+
const value = await cdpEvaluate(`
|
|
139
|
+
(() => {
|
|
140
|
+
const s = window.__wymReq(window.__wymStoreId).a.getStore();
|
|
141
|
+
const playing = s.playing || {};
|
|
142
|
+
const list = (s.playingList && s.playingList.curPlayingList) || [];
|
|
143
|
+
const cur = playing.curPlaying || {};
|
|
144
|
+
return {
|
|
145
|
+
songId: String(cur.resourceId || ""),
|
|
146
|
+
title: String((cur.track || {}).name || ""),
|
|
147
|
+
playing: playing.playingState === 2,
|
|
148
|
+
queue: list.map((x) => ({
|
|
149
|
+
id: String(x.resourceId || ""),
|
|
150
|
+
title: String((x.track || {}).name || ""),
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
})()
|
|
154
|
+
`);
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
export async function cdpSkip(delta) {
|
|
158
|
+
await bootstrapClientRuntime();
|
|
159
|
+
const ok = await cdpEvaluate(`
|
|
160
|
+
(() => {
|
|
161
|
+
const dispatch = window.__wymReq(window.__wymStoreId).a.getDispatch();
|
|
162
|
+
dispatch({
|
|
163
|
+
type: "playingList/jump2Track",
|
|
164
|
+
payload: { flag: ${delta}, type: "call", triggerScene: "minibarController" }
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
})()
|
|
168
|
+
`);
|
|
169
|
+
if (ok !== true)
|
|
170
|
+
throw new ToolError("CDP_ERROR", "切歌指令失败");
|
|
171
|
+
}
|
|
172
|
+
function resolveExecutable() {
|
|
173
|
+
const found = installCandidates().find((p) => existsSync(p)) || null;
|
|
174
|
+
if (!found)
|
|
175
|
+
return null;
|
|
176
|
+
if (process.platform === "darwin" && found.endsWith(".app")) {
|
|
177
|
+
const binary = join(found, "Contents", "MacOS", "NeteaseMusic");
|
|
178
|
+
return existsSync(binary) ? binary : found;
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
}
|
|
182
|
+
function quitClient() {
|
|
183
|
+
if (process.platform === "darwin") {
|
|
184
|
+
spawnSync("osascript", ["-e", 'tell application "NeteaseMusic" to quit'], {
|
|
185
|
+
timeout: 10000,
|
|
186
|
+
stdio: "ignore",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (process.platform === "win32") {
|
|
190
|
+
spawnSync("taskkill", ["/IM", "cloudmusic.exe", "/F"], {
|
|
191
|
+
timeout: 10000,
|
|
192
|
+
stdio: "ignore",
|
|
193
|
+
windowsHide: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
while (Date.now() - start < 10000) {
|
|
198
|
+
if (!isRunning())
|
|
199
|
+
return;
|
|
200
|
+
spawnSync(process.platform === "win32" ? "timeout" : "sleep", process.platform === "win32" ? ["/t", "1"] : ["0.2"], {
|
|
201
|
+
stdio: "ignore",
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export async function launchWithCdp(port = DEFAULT_CDP_PORT) {
|
|
207
|
+
if (!isInstalled()) {
|
|
208
|
+
throw new ToolError("NOT_INSTALLED", "未找到网易云音乐客户端");
|
|
209
|
+
}
|
|
210
|
+
if (await isCdpReady(port)) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
port,
|
|
214
|
+
relaunched: false,
|
|
215
|
+
message: `控制通道已就绪(127.0.0.1:${port})`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const exe = resolveExecutable();
|
|
219
|
+
if (!exe) {
|
|
220
|
+
throw new ToolError("NOT_INSTALLED", "找不到可执行文件,无法带 CDP 启动");
|
|
221
|
+
}
|
|
222
|
+
const wasRunning = isRunning();
|
|
223
|
+
if (wasRunning)
|
|
224
|
+
quitClient();
|
|
225
|
+
// `open -a App --args` often drops CEF flags on macOS; spawn the binary directly.
|
|
226
|
+
spawn(exe, [`--remote-debugging-address=127.0.0.1`, `--remote-debugging-port=${port}`, "--remote-allow-origins=*"], {
|
|
227
|
+
detached: true,
|
|
228
|
+
stdio: "ignore",
|
|
229
|
+
}).unref();
|
|
230
|
+
for (let i = 0; i < 60; i++) {
|
|
231
|
+
if (await isCdpReady(port)) {
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
port,
|
|
235
|
+
relaunched: true,
|
|
236
|
+
message: wasRunning
|
|
237
|
+
? `已重启网易云并开启控制通道(127.0.0.1:${port})`
|
|
238
|
+
: `已启动网易云控制通道(127.0.0.1:${port})`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
await sleep(250);
|
|
242
|
+
}
|
|
243
|
+
throw new ToolError("CDP_UNAVAILABLE", "启动后仍未检测到 CDP 控制通道", `请手动退出网易云后执行: node dist/index.js launch`);
|
|
244
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { cookieDbPath, localStatePath, mmkvPath, storageDir } from "./paths.js";
|
|
8
|
+
import { readMmkvEntries, stripToBplist } from "./mmkv.js";
|
|
9
|
+
import { nsDictionary, parseKeyedArchive, resolveUid } from "./bplist.js";
|
|
10
|
+
import { ToolError } from "./errors.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const COOKIE_NAMES = ["MUSIC_U", "MUSIC_A_T", "MUSIC_R_T", "MUSIC_R_U", "NMTID", "__csrf"];
|
|
13
|
+
const AUTH_NAMES = new Set(["MUSIC_U", "MUSIC_A_T", "MUSIC_R_T", "MUSIC_R_U"]);
|
|
14
|
+
function isNeteaseHost(value) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
return false;
|
|
17
|
+
const host = value.trim().toLowerCase().replace(/^\./, "");
|
|
18
|
+
return host === "music.163.com" || host.endsWith(".music.163.com") || host === "163.com" || host.endsWith(".163.com");
|
|
19
|
+
}
|
|
20
|
+
function readMmkvCookies(filePath) {
|
|
21
|
+
const entries = readMmkvEntries(filePath);
|
|
22
|
+
const cookies = {};
|
|
23
|
+
let userId = "";
|
|
24
|
+
const userRaw = entries.get("userInfo");
|
|
25
|
+
if (userRaw) {
|
|
26
|
+
try {
|
|
27
|
+
const { objects, root } = parseKeyedArchive(stripToBplist(userRaw));
|
|
28
|
+
const uid = root.userId;
|
|
29
|
+
if (typeof uid === "string" || typeof uid === "number")
|
|
30
|
+
userId = String(uid);
|
|
31
|
+
const musicU = root.musicU;
|
|
32
|
+
if (typeof musicU === "string" && musicU)
|
|
33
|
+
cookies.MUSIC_U = musicU;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// ignore malformed userInfo
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const cookieRaw = entries.get("cookie");
|
|
40
|
+
if (cookieRaw) {
|
|
41
|
+
const { objects, root } = parseKeyedArchive(stripToBplist(cookieRaw));
|
|
42
|
+
const selected = {};
|
|
43
|
+
for (const [domain, cookieMapRef] of Object.entries(root)) {
|
|
44
|
+
if (!isNeteaseHost(domain))
|
|
45
|
+
continue;
|
|
46
|
+
const cookieMap = nsDictionary(objects, cookieMapRef);
|
|
47
|
+
for (const cookieRef of Object.values(cookieMap)) {
|
|
48
|
+
const cookieObj = resolveUid(objects, cookieRef);
|
|
49
|
+
if (!cookieObj || typeof cookieObj !== "object" || Array.isArray(cookieObj))
|
|
50
|
+
continue;
|
|
51
|
+
const propsRef = cookieObj.properties;
|
|
52
|
+
if (propsRef == null)
|
|
53
|
+
continue;
|
|
54
|
+
const props = nsDictionary(objects, propsRef);
|
|
55
|
+
const name = props.Name;
|
|
56
|
+
const value = props.Value;
|
|
57
|
+
const cookieDomain = props.Domain ?? domain;
|
|
58
|
+
if (typeof name !== "string" || !COOKIE_NAMES.includes(name))
|
|
59
|
+
continue;
|
|
60
|
+
if (typeof value !== "string" || !value || !isNeteaseHost(cookieDomain))
|
|
61
|
+
continue;
|
|
62
|
+
const normalized = String(cookieDomain).trim().toLowerCase().replace(/^\./, "");
|
|
63
|
+
const rank = normalized === "music.163.com" ? 0 : 1;
|
|
64
|
+
const prev = selected[name];
|
|
65
|
+
if (!prev || rank < prev.rank)
|
|
66
|
+
selected[name] = { rank, value };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [name, { value }] of Object.entries(selected))
|
|
70
|
+
cookies[name] = value;
|
|
71
|
+
}
|
|
72
|
+
return { cookies, userId };
|
|
73
|
+
}
|
|
74
|
+
function queryCookies(dbPath) {
|
|
75
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
76
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
77
|
+
try {
|
|
78
|
+
return db
|
|
79
|
+
.prepare(`select name, value, encrypted_value, host_key from cookies
|
|
80
|
+
where host_key like '%163.com%' or host_key like '%netease%'`)
|
|
81
|
+
.all();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function copyCookieDb() {
|
|
88
|
+
const dbPath = cookieDbPath();
|
|
89
|
+
if (!existsSync(dbPath))
|
|
90
|
+
return null;
|
|
91
|
+
const dir = mkdtempSync(join(tmpdir(), "ym-mcp-wangyi-cookies-"));
|
|
92
|
+
const db = join(dir, "Cookies");
|
|
93
|
+
copyFileSync(dbPath, db);
|
|
94
|
+
for (const extra of ["Cookies-wal", "Cookies-shm", "Cookies-journal"]) {
|
|
95
|
+
const src = join(dirname(dbPath), extra);
|
|
96
|
+
if (existsSync(src))
|
|
97
|
+
copyFileSync(src, join(dir, extra));
|
|
98
|
+
}
|
|
99
|
+
return { dir, db };
|
|
100
|
+
}
|
|
101
|
+
function blobOf(value) {
|
|
102
|
+
if (!value)
|
|
103
|
+
return null;
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return value ? Buffer.from(value) : null;
|
|
106
|
+
if (value.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
return Buffer.from(value);
|
|
109
|
+
}
|
|
110
|
+
function dpapiUnprotect(buf) {
|
|
111
|
+
const b64 = buf.toString("base64");
|
|
112
|
+
const script = `
|
|
113
|
+
Add-Type -AssemblyName System.Security
|
|
114
|
+
$enc = [Convert]::FromBase64String('${b64}')
|
|
115
|
+
$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
116
|
+
[Convert]::ToBase64String($dec)
|
|
117
|
+
`.trim();
|
|
118
|
+
try {
|
|
119
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
120
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { encoding: "utf8", timeout: 15000, windowsHide: true });
|
|
121
|
+
return Buffer.from(out.trim(), "base64");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function windowsOsCryptKey() {
|
|
128
|
+
const file = localStatePath();
|
|
129
|
+
if (!existsSync(file)) {
|
|
130
|
+
// also try parent CEFCache Local State / storage Local State
|
|
131
|
+
const alt = join(storageDir(), "Local State");
|
|
132
|
+
if (!existsSync(alt))
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const path = existsSync(localStatePath()) ? localStatePath() : join(storageDir(), "Local State");
|
|
136
|
+
try {
|
|
137
|
+
const json = JSON.parse(readFileSync(path, "utf8"));
|
|
138
|
+
const raw = json.os_crypt?.encrypted_key;
|
|
139
|
+
if (!raw)
|
|
140
|
+
return null;
|
|
141
|
+
const buf = Buffer.from(raw, "base64");
|
|
142
|
+
if (buf.subarray(0, 5).toString("ascii") !== "DPAPI")
|
|
143
|
+
return null;
|
|
144
|
+
const key = dpapiUnprotect(buf.subarray(5));
|
|
145
|
+
return key && key.length === 32 ? key : null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function decryptChromiumGcm(enc, key) {
|
|
152
|
+
const prefix = enc.subarray(0, 3).toString("ascii");
|
|
153
|
+
if (prefix !== "v10" && prefix !== "v11")
|
|
154
|
+
return "";
|
|
155
|
+
if (enc.length < 3 + 12 + 16)
|
|
156
|
+
return "";
|
|
157
|
+
const nonce = enc.subarray(3, 15);
|
|
158
|
+
const tag = enc.subarray(enc.length - 16);
|
|
159
|
+
const data = enc.subarray(15, enc.length - 16);
|
|
160
|
+
try {
|
|
161
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
162
|
+
decipher.setAuthTag(tag);
|
|
163
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function macKeychainKey() {
|
|
170
|
+
for (const [account, service] of [
|
|
171
|
+
["Chromium", "Chromium Safe Storage"],
|
|
172
|
+
["Chrome", "Chrome Safe Storage"],
|
|
173
|
+
["Chromium", "Chrome Safe Storage"],
|
|
174
|
+
]) {
|
|
175
|
+
try {
|
|
176
|
+
const secret = execFileSync("/usr/bin/security", ["find-generic-password", "-a", account, "-s", service, "-w"], { encoding: "utf8", timeout: 10000 }).trim();
|
|
177
|
+
if (secret)
|
|
178
|
+
return pbkdf2Sync(secret, "saltysalt", 1003, 16, "sha1");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// try next
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
function decryptMacCbc(enc, key) {
|
|
187
|
+
if (!enc.subarray(0, 3).equals(Buffer.from("v10")) || enc.length <= 3)
|
|
188
|
+
return "";
|
|
189
|
+
try {
|
|
190
|
+
const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
|
|
191
|
+
const plain = Buffer.concat([decipher.update(enc.subarray(3)), decipher.final()]);
|
|
192
|
+
const pad = plain[plain.length - 1];
|
|
193
|
+
if (pad > 0 && pad <= 16)
|
|
194
|
+
return plain.subarray(0, plain.length - pad).toString("utf8");
|
|
195
|
+
return plain.toString("utf8");
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function readCefCookies() {
|
|
202
|
+
const copied = copyCookieDb();
|
|
203
|
+
if (!copied)
|
|
204
|
+
return { cookies: {}, encrypted: false };
|
|
205
|
+
let rows = [];
|
|
206
|
+
try {
|
|
207
|
+
try {
|
|
208
|
+
rows = queryCookies(copied.db);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
try {
|
|
212
|
+
rows = queryCookies(cookieDbPath());
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
rows = [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
rmSync(copied.dir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
const osCryptKey = process.platform === "win32" ? windowsOsCryptKey() : null;
|
|
223
|
+
const macKey = process.platform === "darwin" ? macKeychainKey() : null;
|
|
224
|
+
const cookies = {};
|
|
225
|
+
let encrypted = false;
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
if (!COOKIE_NAMES.includes(row.name))
|
|
228
|
+
continue;
|
|
229
|
+
if (cookies[row.name])
|
|
230
|
+
continue;
|
|
231
|
+
if (row.value) {
|
|
232
|
+
cookies[row.name] = row.value;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const enc = blobOf(row.encrypted_value);
|
|
236
|
+
if (!enc)
|
|
237
|
+
continue;
|
|
238
|
+
let plain = "";
|
|
239
|
+
if (osCryptKey)
|
|
240
|
+
plain = decryptChromiumGcm(enc, osCryptKey);
|
|
241
|
+
else if (macKey)
|
|
242
|
+
plain = decryptMacCbc(enc, macKey);
|
|
243
|
+
if (plain)
|
|
244
|
+
cookies[row.name] = plain;
|
|
245
|
+
else
|
|
246
|
+
encrypted = true;
|
|
247
|
+
}
|
|
248
|
+
return { cookies, encrypted };
|
|
249
|
+
}
|
|
250
|
+
function buildHeader(cookies) {
|
|
251
|
+
return COOKIE_NAMES.filter((n) => cookies[n])
|
|
252
|
+
.map((n) => `${n}=${cookies[n]}`)
|
|
253
|
+
.join("; ");
|
|
254
|
+
}
|
|
255
|
+
function isLoggedIn(cookies, userId) {
|
|
256
|
+
if ([...AUTH_NAMES].some((n) => Boolean(cookies[n])))
|
|
257
|
+
return true;
|
|
258
|
+
return Boolean(userId && userId !== "0" && userId !== "-1");
|
|
259
|
+
}
|
|
260
|
+
export function loadCookies() {
|
|
261
|
+
const empty = {
|
|
262
|
+
header: "",
|
|
263
|
+
loggedIn: false,
|
|
264
|
+
cookieEncrypted: false,
|
|
265
|
+
source: "",
|
|
266
|
+
userId: "",
|
|
267
|
+
};
|
|
268
|
+
if (existsSync(mmkvPath())) {
|
|
269
|
+
try {
|
|
270
|
+
const { cookies, userId } = readMmkvCookies(mmkvPath());
|
|
271
|
+
const header = buildHeader(cookies);
|
|
272
|
+
if (header || userId) {
|
|
273
|
+
return {
|
|
274
|
+
header,
|
|
275
|
+
loggedIn: isLoggedIn(cookies, userId),
|
|
276
|
+
cookieEncrypted: false,
|
|
277
|
+
source: "mmkv",
|
|
278
|
+
userId,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// fall through to CEF
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const { cookies, encrypted } = readCefCookies();
|
|
288
|
+
const header = buildHeader(cookies);
|
|
289
|
+
return {
|
|
290
|
+
header,
|
|
291
|
+
loggedIn: isLoggedIn(cookies, ""),
|
|
292
|
+
cookieEncrypted: encrypted && !isLoggedIn(cookies, ""),
|
|
293
|
+
source: header ? "cef" : "",
|
|
294
|
+
userId: "",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return empty;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function requireCookieHeader() {
|
|
302
|
+
const cookies = loadCookies();
|
|
303
|
+
if (cookies.loggedIn && cookies.header.includes("MUSIC_U="))
|
|
304
|
+
return cookies.header;
|
|
305
|
+
if (cookies.loggedIn && cookies.header)
|
|
306
|
+
return cookies.header;
|
|
307
|
+
if (cookies.cookieEncrypted) {
|
|
308
|
+
throw new ToolError("COOKIE_ENCRYPTED", "Cookie 只有密文,无法得到 MUSIC_U", process.platform === "win32"
|
|
309
|
+
? "Windows 上已尝试用 Local State + DPAPI 解密。请确认已用当前 Windows 用户登录网易云音乐。"
|
|
310
|
+
: "请先在网易云音乐客户端登录。可先关闭客户端再重试读取 MMKV。");
|
|
311
|
+
}
|
|
312
|
+
throw new ToolError("NOT_LOGGED_IN", "未读到网易云登录态(MUSIC_U)", `先打开客户端登录。存储目录:${storageDir()}`);
|
|
313
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class ToolError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
hint;
|
|
4
|
+
constructor(code, message, hint = "") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ToolError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.hint = hint;
|
|
9
|
+
}
|
|
10
|
+
toJSON() {
|
|
11
|
+
return { ok: false, code: this.code, error: this.message, hint: this.hint };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function asToolError(err, fallback = "INTERNAL") {
|
|
15
|
+
if (err instanceof ToolError)
|
|
16
|
+
return err;
|
|
17
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
18
|
+
return new ToolError(fallback, message);
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function stateDir() {
|
|
5
|
+
if (process.platform === "win32") {
|
|
6
|
+
const base = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
|
|
7
|
+
return join(base, "ym-mcp-wangyi");
|
|
8
|
+
}
|
|
9
|
+
return join(homedir(), ".cache", "ym-mcp-wangyi");
|
|
10
|
+
}
|
|
11
|
+
function statePath() {
|
|
12
|
+
return join(stateDir(), "last-play.json");
|
|
13
|
+
}
|
|
14
|
+
export function readLastPlay() {
|
|
15
|
+
const file = statePath();
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
20
|
+
if (!data?.id)
|
|
21
|
+
return null;
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function writeLastPlay(id) {
|
|
29
|
+
const dir = stateDir();
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
const payload = { id: String(id), at: Date.now() };
|
|
32
|
+
writeFileSync(statePath(), JSON.stringify(payload), "utf8");
|
|
33
|
+
}
|
package/dist/lib/mmkv.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
function readVarint(payload, position) {
|
|
3
|
+
let value = 0;
|
|
4
|
+
for (let shift = 0; shift < 35; shift += 7) {
|
|
5
|
+
if (position >= payload.length)
|
|
6
|
+
throw new Error("mmkv truncated");
|
|
7
|
+
const byte = payload[position++];
|
|
8
|
+
value |= (byte & 0x7f) << shift;
|
|
9
|
+
if (!(byte & 0x80))
|
|
10
|
+
return { value, position };
|
|
11
|
+
}
|
|
12
|
+
throw new Error("mmkv varint overflow");
|
|
13
|
+
}
|
|
14
|
+
/** Read key/value entries from an MMKV data file (plain, not encrypted). */
|
|
15
|
+
export function readMmkvEntries(filePath) {
|
|
16
|
+
const payload = readFileSync(filePath);
|
|
17
|
+
const entries = new Map();
|
|
18
|
+
// NetEase stores a 4-byte actual-size then 4-byte unused; entries start at offset 8.
|
|
19
|
+
let position = 8;
|
|
20
|
+
while (position < payload.length && payload[position] !== 0) {
|
|
21
|
+
let keyLength;
|
|
22
|
+
try {
|
|
23
|
+
({ value: keyLength, position } = readVarint(payload, position));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (keyLength <= 0 || keyLength > 512 || position + keyLength > payload.length)
|
|
29
|
+
break;
|
|
30
|
+
const keyBytes = payload.subarray(position, position + keyLength);
|
|
31
|
+
position += keyLength;
|
|
32
|
+
let key;
|
|
33
|
+
try {
|
|
34
|
+
key = keyBytes.toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (!key || /[\x00-\x08]/.test(key))
|
|
40
|
+
break;
|
|
41
|
+
let valueLength;
|
|
42
|
+
try {
|
|
43
|
+
({ value: valueLength, position } = readVarint(payload, position));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (valueLength < 0 || valueLength > 5_000_000 || position + valueLength > payload.length)
|
|
49
|
+
break;
|
|
50
|
+
entries.set(key, Buffer.from(payload.subarray(position, position + valueLength)));
|
|
51
|
+
position += valueLength;
|
|
52
|
+
}
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
/** Strip MMKV value prefix so the remaining buffer starts at bplist00. */
|
|
56
|
+
export function stripToBplist(raw) {
|
|
57
|
+
if (raw.subarray(0, 8).toString("ascii") === "bplist00")
|
|
58
|
+
return raw;
|
|
59
|
+
const idx = raw.indexOf("bplist00", 0, "ascii");
|
|
60
|
+
if (idx < 0)
|
|
61
|
+
throw new Error("no bplist in mmkv value");
|
|
62
|
+
return raw.subarray(idx);
|
|
63
|
+
}
|