748 lines
34 KiB
JavaScript
748 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
||
// netease-api — 网易云接口客户端(自包含,零 npm 依赖)
|
||
//
|
||
// 原理:网页端所有请求都走 /weapi/<路径>,参数用「AES-128-CBC 两次 + RSA 加密密钥」包装,
|
||
// 这套加密是固定常量(见下方 NONCE/IV/MODULUS),所以不需要跑任何 API 服务,直接调。
|
||
// 登录态就是 MUSIC_U cookie,由调用方从环境变量 NETEASE_COOKIE 传进来。
|
||
//
|
||
// 输出:统一 JSON 到 stdout(内部原语,不给人读);出错时 status 非 0 + {"error":..,"hint":..}
|
||
// 用法:node netease-api.mjs <命令> [参数]
|
||
// 列表命令支持 --offset N(默认 0):跳过 N 条;count 是本页条数,total 是分页前总数
|
||
// search <关键词> [--limit 20] 搜歌
|
||
// song <关键词|id> 解析成一首歌(关键词取最匹配一条)
|
||
// play-ids <id,id,...> [--start N] 批量详情(只取数);start 从 1 开始,默认 1
|
||
// cover <关键词|id> [--download PATH] [--force] 封面 JSON;可下载图片(最多 10 MB)
|
||
// url <id...> [--level lossless] 取播放地址(有 VIP 就取无损)
|
||
// daily [--limit 40] 每日推荐
|
||
// playlist <id|链接> [--limit 500] 歌单全部歌曲
|
||
// artist <关键词|id> [--limit 50] 歌手热门 50 首
|
||
// album <关键词|id> [--limit 200] 专辑整张
|
||
// albums <歌手|id> [--limit 60] 歌手的专辑列表(含搜索索引里没有的老专辑)
|
||
// playlists <关键词> [--limit 20] 搜歌单(别人做的)
|
||
// charts [--limit 100] 排行榜列表
|
||
// chart <名字|id> [--limit 500] 放某个榜单
|
||
// myalbums / myartists [--limit 60] 我收藏的专辑 / 歌手
|
||
// fm [--limit 12] 私人 FM
|
||
// recent [--limit 30] 最近播放(一周)
|
||
// podcasts [--recommend] [--limit 50] 我的播客订阅(或推荐)
|
||
// podcast <名字|id> [--limit 3] 某播客最新 N 期
|
||
// episode <节目id> 某一期节目
|
||
// liked [--limit 1000] 我喜欢的音乐(按收藏时间由近到远)
|
||
// liked-ids 我喜欢的歌曲 ID 全表(只读)
|
||
// dislike <id> 私人 FM 垃圾桶(fm/trash)
|
||
// like <id> [--unlike] 喜欢/取消喜欢
|
||
// lyric <id> [--trans] 歌词(--trans 带翻译)
|
||
// myplaylists [--limit 100] 我的歌单(含「我喜欢的音乐」)
|
||
// session 登录态与账号信息
|
||
// uid 当前账号 uid(未登录报错)
|
||
// qr-start 生成扫码登录的二维码内容与 key
|
||
// qr-poll <key> [--timeout 120] 轮询扫码结果,成功时输出 cookie
|
||
import crypto from 'node:crypto';
|
||
import { writeFile } from 'node:fs/promises';
|
||
import { resolve } from 'node:path';
|
||
|
||
const NONCE = '0CoJUm6Qyw8W8jud';
|
||
const IV = '0102030405060708';
|
||
const PUBKEY = '010001';
|
||
const MODULUS =
|
||
'00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7';
|
||
const UA =
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||
const COOKIE = process.env.NETEASE_COOKIE || '';
|
||
|
||
function aesEncrypt(text, key) {
|
||
const c = crypto.createCipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(IV));
|
||
return Buffer.concat([c.update(text, 'utf8'), c.final()]).toString('base64');
|
||
}
|
||
function modPow(b, e, m) {
|
||
let r = 1n;
|
||
b %= m;
|
||
while (e > 0n) {
|
||
if (e & 1n) r = (r * b) % m;
|
||
b = (b * b) % m;
|
||
e >>= 1n;
|
||
}
|
||
return r;
|
||
}
|
||
function rsaEncrypt(text) {
|
||
const hex = Buffer.from(text).reverse().toString('hex');
|
||
return modPow(BigInt('0x' + hex), BigInt('0x' + PUBKEY), BigInt('0x' + MODULUS))
|
||
.toString(16)
|
||
.padStart(256, '0');
|
||
}
|
||
function cookieValue(name) {
|
||
const m = (COOKIE || '').match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
|
||
return m ? m[1] : '';
|
||
}
|
||
|
||
function weapi(data) {
|
||
const secret = crypto.randomBytes(8).toString('hex').slice(0, 16);
|
||
const plain = JSON.stringify({ ...data, csrf_token: cookieValue('__csrf') });
|
||
return {
|
||
params: aesEncrypt(aesEncrypt(plain, NONCE), secret),
|
||
encSecKey: rsaEncrypt(secret),
|
||
};
|
||
}
|
||
|
||
class ApiError extends Error {
|
||
constructor(code, hint) {
|
||
super(hint);
|
||
this.code = code;
|
||
this.hint = hint;
|
||
}
|
||
}
|
||
|
||
async function plainPost(path, data) {
|
||
let res;
|
||
try {
|
||
res = await fetch('https://music.163.com/' + path, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Referer: 'https://music.163.com/',
|
||
'User-Agent': UA,
|
||
...(COOKIE ? { Cookie: COOKIE } : {}),
|
||
},
|
||
body: new URLSearchParams(data).toString(),
|
||
});
|
||
} catch (e) {
|
||
throw new ApiError('external', '连不上 music.163.com:' + e.message);
|
||
}
|
||
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
|
||
const text = await res.text();
|
||
if (!text) throw new ApiError('external', `接口返回空(HTTP ${res.status})`);
|
||
return JSON.parse(text);
|
||
}
|
||
|
||
let lastSetCookie = [];
|
||
|
||
// 登录会分多步下发 Set-Cookie,同名会有多条;按名字去重(取最后一条),避免服务端看到重复 cookie
|
||
function jarToCookie(jar) {
|
||
const map = new Map();
|
||
for (const c of jar) {
|
||
const kv = c.split(';')[0];
|
||
const i = kv.indexOf('=');
|
||
if (i > 0) map.set(kv.slice(0, i).trim(), kv.slice(i + 1));
|
||
}
|
||
return [...map].map(([k, v]) => `${k}=${v}`).join('; ');
|
||
}
|
||
async function weapiPost(path, data = {}) {
|
||
const body = new URLSearchParams(weapi(data)).toString();
|
||
let res;
|
||
try {
|
||
res = await fetch('https://music.163.com/weapi/' + path + '?csrf_token=' + encodeURIComponent(cookieValue('__csrf')), {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Referer: 'https://music.163.com/',
|
||
'User-Agent': UA,
|
||
...(COOKIE ? { Cookie: COOKIE } : {}),
|
||
},
|
||
body,
|
||
});
|
||
} catch (e) {
|
||
throw new ApiError('external', '连不上 music.163.com:' + e.message);
|
||
}
|
||
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
|
||
const text = await res.text();
|
||
let json;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
throw new ApiError('external', `接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 120)}`);
|
||
}
|
||
return json;
|
||
}
|
||
|
||
const song = (s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
artist: (s.ar || s.artists || []).map((a) => a.name).join(' / '),
|
||
album: (s.al || s.album || {}).name || '',
|
||
ms: s.dt || s.duration || 0,
|
||
vip: s.fee === 1 || s.fee === 4,
|
||
cover: String(s.al?.picUrl || s.album?.picUrl || s.picUrl || ''),
|
||
// 没有明确布尔值就不猜;调用方可用 liked-ids 一次取全表后按 id 补齐。
|
||
...(typeof s.liked === 'boolean' ? { liked: s.liked } : {}),
|
||
});
|
||
const num = (v) => (Number.isFinite(+v) ? +v : 0);
|
||
|
||
function songId(value) {
|
||
if (!/^\d+$/.test(value || '') || !Number.isSafeInteger(+value) || +value < 1) {
|
||
throw new ApiError('usage', '歌曲 id 需要正整数');
|
||
}
|
||
return +value;
|
||
}
|
||
|
||
async function cmdPlayIds(args, o) {
|
||
if (args.length !== 1) throw new ApiError('usage', '用法:play-ids <id,id,...> [--start N]');
|
||
const ids = args[0].split(',').map(songId);
|
||
if (!Number.isSafeInteger(o.start) || o.start < 1 || o.start > ids.length) {
|
||
throw new ApiError('usage', '--start 需要输入列表内的序号(从 1 开始)');
|
||
}
|
||
const songs = [];
|
||
for (let i = 0; i < ids.length; i += 200) songs.push(...await songsByIds(ids.slice(i, i + 200)));
|
||
if (songs.length !== ids.length) throw new ApiError('notfound', '输入列表中有歌曲详情缺失,队列未替换');
|
||
return { count: songs.length, total: ids.length, start: o.start, songs };
|
||
}
|
||
|
||
// 搜索必须走明文接口:weapi 版的 cloudsearch 现在对匿名请求回空体(不报错,就空)
|
||
async function cmdSearch(kw, o) {
|
||
const j = await plainPost('api/search/get/web', { s: kw, type: 1, limit: o.limit, offset: o.offset, total: true });
|
||
const list = (j.result && j.result.songs) || [];
|
||
return { kw, count: list.length, total: j.result?.songCount ?? list.length, songs: list.map(song) };
|
||
}
|
||
async function cmdSong(kw) {
|
||
if (/^\d+$/.test(kw)) {
|
||
const j = await weapiPost('v3/song/detail', { c: JSON.stringify([{ id: +kw }]) });
|
||
const s = (j.songs || []).find((s) => +s.id === +kw);
|
||
if (!s) throw new ApiError('notfound', `没有这首歌:id=${kw}`);
|
||
return song(s);
|
||
}
|
||
const r = await cmdSearch(kw, { limit: 5, offset: 0 });
|
||
if (!r.songs.length) throw new ApiError('notfound', `搜不到「${kw}」`);
|
||
return r.songs[0];
|
||
}
|
||
|
||
const MAX_COVER_BYTES = 10_000_000;
|
||
async function downloadCover(cover, file, force) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 30_000);
|
||
let body;
|
||
try {
|
||
const url = new URL(cover);
|
||
if (!['http:', 'https:'].includes(url.protocol)) throw new ApiError('external', '封面地址必须是 HTTP(S) URL');
|
||
// 图片请求不携带登录 cookie;先完整读取有上限的响应,再打开目标文件。
|
||
const res = await fetch(url, {
|
||
headers: { Referer: 'https://music.163.com/', 'User-Agent': UA },
|
||
signal: controller.signal,
|
||
});
|
||
if (!res.ok) throw new ApiError([404, 410].includes(res.status) ? 'notfound' : 'external', `封面下载失败(HTTP ${res.status})`);
|
||
const type = res.headers.get('content-type');
|
||
if (type && !/^image\//i.test(type)) throw new ApiError('external', `封面响应不是图片:${type}`);
|
||
if (Number(res.headers.get('content-length')) > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
|
||
if (!res.body) throw new ApiError('notfound', '封面图片为空');
|
||
const chunks = [];
|
||
let bytes = 0;
|
||
for await (const chunk of res.body) {
|
||
bytes += chunk.byteLength;
|
||
if (bytes > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
|
||
chunks.push(chunk);
|
||
}
|
||
if (!bytes) throw new ApiError('notfound', '封面图片为空');
|
||
body = Buffer.concat(chunks, bytes);
|
||
} catch (e) {
|
||
if (e instanceof ApiError) throw e;
|
||
throw new ApiError(controller.signal.aborted ? 'timeout' : 'external', '封面下载失败:' + e.message);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
}
|
||
try {
|
||
await writeFile(file, body, { flag: force ? 'w' : 'wx' });
|
||
} catch (e) {
|
||
if (e.code === 'EEXIST') throw new ApiError('usage', `文件已存在:${file}(覆盖需 --force)`);
|
||
throw new ApiError('external', `无法写入封面:${e.message}`);
|
||
}
|
||
return body.length;
|
||
}
|
||
|
||
async function cmdCover(kw, o) {
|
||
if (!kw.trim() || pos.some((a) => a.startsWith('--'))) throw new ApiError('usage', 'cover 需要歌曲关键词或 id');
|
||
if (o.download !== undefined && (!o.download.trim() || o.download.startsWith('--'))) throw new ApiError('usage', '--download 需要文件路径');
|
||
if (o.force && o.download === undefined) throw new ApiError('usage', '--force 只能与 --download 一起使用');
|
||
let s = await cmdSong(kw);
|
||
// 明文搜索可能省略图片 URL;只在缺封面时补查选中歌曲的详情。
|
||
if (!s.cover && !/^\d+$/.test(kw)) s = await cmdSong(String(s.id));
|
||
const { id, name, artist, cover } = s;
|
||
if (!cover) throw new ApiError('notfound', `这首歌没有封面:id=${id}`);
|
||
if (o.download === undefined) return { id, name, artist, cover };
|
||
const file = resolve(o.download);
|
||
const bytes = await downloadCover(cover, file, o.force);
|
||
return { id, cover, file, bytes };
|
||
}
|
||
async function cmdUrl(ids, o) {
|
||
const level = o.level;
|
||
const j = await weapiPost('song/enhance/player/url/v1', {
|
||
ids: JSON.stringify(ids.map(Number)),
|
||
level,
|
||
encodeType: ['standard', 'higher'].includes(level) ? 'aac' : 'flac',
|
||
});
|
||
return {
|
||
level: j.data && j.data[0] && j.data[0].level,
|
||
items: (j.data || []).map((d) => ({ id: d.id, url: d.url, br: d.br, level: d.level, size: d.size, fee: d.fee })),
|
||
};
|
||
}
|
||
async function cmdDaily(o) {
|
||
const j = await weapiPost('v2/discovery/recommend/songs', {});
|
||
const list = (j.data && j.data.dailySongs) || j.recommend || [];
|
||
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '每日推荐返回空:' + JSON.stringify(j).slice(0, 160));
|
||
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
|
||
return { count: songs.length, total: list.length, songs };
|
||
}
|
||
async function cmdPlaylist(idOrKw, o) {
|
||
let id = String(idOrKw);
|
||
if (!/^\d+$/.test(id)) {
|
||
const hit = await searchFirst(String(idOrKw), 1000, 'playlists', 'playlist');
|
||
id = hit.id;
|
||
}
|
||
const j = await weapiPost('v6/playlist/detail', { id: +id, n: o.limit, s: 8 });
|
||
if (!j.playlist) throw new ApiError('notfound', `没有这个歌单:id=${id}`);
|
||
const tracks = j.playlist.tracks || [];
|
||
let songs = tracks.slice(o.offset, o.offset + o.limit).map(song);
|
||
// detail 不认 offset,内嵌 tracks 也可能截断;按完整 trackIds 取本页,缺的详情才补查。
|
||
if (Array.isArray(j.playlist.trackIds)) {
|
||
const ids = j.playlist.trackIds.slice(o.offset, o.offset + o.limit).map((t) => t.id);
|
||
const byId = new Map(tracks.map((s) => [s.id, song(s)]));
|
||
const missing = ids.filter((id) => !byId.has(id));
|
||
for (const s of await songsByIds(missing)) byId.set(s.id, s);
|
||
songs = ids.map((id) => byId.get(id)).filter(Boolean);
|
||
}
|
||
return {
|
||
id: j.playlist.id,
|
||
name: j.playlist.name,
|
||
count: songs.length,
|
||
total: j.playlist.trackCount ?? tracks.length,
|
||
songs,
|
||
};
|
||
}
|
||
const norm = (v) => String(v || '').toLowerCase().replace(/\s+/g, '').replace(/[((].*$/, '');
|
||
|
||
// 同名/别名优先;专辑再按曲目数排(正版专辑 > 同名单曲/翻唱)
|
||
function pickBest(list, kw, kind) {
|
||
const want = norm(kw);
|
||
const score = (it) => {
|
||
const names = [it.name, ...(it.alias || [])].map(norm);
|
||
if (want && names.includes(want)) return 2;
|
||
if (want && names.some((n) => n && (n.includes(want) || want.includes(n)))) return 1;
|
||
return 0;
|
||
};
|
||
const ranked = list.map((it) => ({ s: score(it), it })).sort((a, b) => b.s - a.s);
|
||
let cands = ranked.filter((r) => r.s === ranked[0].s).map((r) => r.it);
|
||
if (ranked[0].s === 2 && kind === 'album') cands = cands.slice().sort((a, b) => (b.size || 0) - (a.size || 0));
|
||
return cands[0];
|
||
}
|
||
|
||
// 搜歌手/专辑/歌单/播客:先走 weapi cloudsearch(已登录时索引全,明文库里连周杰伦《范特西》都没有),
|
||
// 拿不到再回退明文 api/search/get/web
|
||
async function searchList(kw, type, key, limit = 10, offset = 0) {
|
||
let result;
|
||
try {
|
||
const j = await weapiPost('cloudsearch/get/web', { s: kw, type, limit, offset, total: true });
|
||
result = j.result;
|
||
} catch { /* 回退明文 */ }
|
||
// 有总数的空页就是到末尾了,不换搜索索引重新找一页。
|
||
if (!(result?.[key]?.length) && !(offset > 0 && result?.playlistCount != null)) {
|
||
const j = await plainPost('api/search/get/web', { s: kw, type, limit, offset, total: true });
|
||
result = j.result;
|
||
}
|
||
const list = result?.[key] || [];
|
||
return { list, total: result?.playlistCount ?? list.length };
|
||
}
|
||
async function searchFirst(kw, type, key, kind) {
|
||
const { list } = await searchList(kw, type, key);
|
||
if (!list.length) throw new ApiError('notfound', `搜不到「${kw}」`);
|
||
return pickBest(list, kw, kind);
|
||
}
|
||
|
||
// 歌手热门:v1/artist/<id> 的 hotSongs;老接口 artist/top/song 作回退
|
||
async function cmdArtist(kw, o) {
|
||
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
|
||
let j = await weapiPost(`v1/artist/${id}`, {});
|
||
let list = j.hotSongs || [];
|
||
let name = (j.artist && j.artist.name) || '';
|
||
if (!list.length) {
|
||
j = await weapiPost('artist/top/song', { id });
|
||
list = j.songs || [];
|
||
}
|
||
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有可播的歌:${kw}`);
|
||
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
|
||
return { id, name: name || kw, count: songs.length, total: list.length, songs };
|
||
}
|
||
|
||
// 歌手的专辑列表(含经典老专辑,搜索索引里没有的也能从这里拿到 id)
|
||
async function cmdAlbums(kw, o) {
|
||
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
|
||
const j = await weapiPost(`artist/albums/${id}`, { limit: o.limit, offset: o.offset, total: true });
|
||
const list = j.hotAlbums || [];
|
||
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有专辑:${kw}`);
|
||
return {
|
||
id,
|
||
name: (list[0] && list[0].artist && list[0].artist.name) || kw,
|
||
count: list.length,
|
||
total: j.artist?.albumSize ?? j.total ?? list.length,
|
||
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, year: (a.publishTime && new Date(a.publishTime).getFullYear()) || 0 })),
|
||
};
|
||
}
|
||
|
||
// 专辑:v1/album/<id> 的 songs(整张,按专辑顺序)
|
||
async function cmdAlbum(kw, o) {
|
||
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 10, 'albums', 'album')).id;
|
||
const j = await weapiPost(`v1/album/${id}`, {});
|
||
const list = j.songs || [];
|
||
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这张专辑没有可播的歌:${kw}`);
|
||
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
|
||
return { id, name: (j.album && j.album.name) || kw, count: songs.length, total: j.album?.size ?? j.album?.songCount ?? list.length, songs };
|
||
}
|
||
|
||
// ---------- 歌单搜索 / 榜单 / 收藏 / FM / 最近 / 播客 ----------
|
||
|
||
async function cmdPlaylists(kw, o) {
|
||
const { list, total } = await searchList(kw, 1000, 'playlists', o.limit, o.offset);
|
||
if (!list.length && !o.offset) throw new ApiError('notfound', `搜不到歌单「${kw}」`);
|
||
return {
|
||
kw,
|
||
count: list.length,
|
||
total,
|
||
playlists: list.map((p) => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
count: p.trackCount || 0,
|
||
creator: (p.creator || {}).nickname || '',
|
||
sub: p.subscribed === true,
|
||
})),
|
||
};
|
||
}
|
||
|
||
async function cmdCharts(o) {
|
||
const j = await weapiPost('toplist', {});
|
||
const list = j.list || [];
|
||
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '榜单接口没返回列表');
|
||
const page = list.slice(o.offset, o.offset + o.limit);
|
||
return {
|
||
count: page.length,
|
||
total: j.total ?? j.count ?? list.length,
|
||
charts: page.map((c) => ({ id: c.id, name: c.name, count: c.trackCount, freq: c.updateFrequency })),
|
||
};
|
||
}
|
||
|
||
async function cmdChart(kw, o) {
|
||
let id = /^\d+$/.test(kw) ? +kw : 0;
|
||
let name = kw;
|
||
if (!id) {
|
||
const j = await weapiPost('toplist', {});
|
||
const hit = pickBest(j.list || [], kw, 'chart');
|
||
if (!hit) throw new ApiError('notfound', `没有这个榜单:${kw}(用 charts 看全部)`);
|
||
id = hit.id;
|
||
name = hit.name;
|
||
}
|
||
const r = await cmdPlaylist(String(id), o);
|
||
return { ...r, chart_id: id, chart: name };
|
||
}
|
||
|
||
async function cmdMyAlbums(o) {
|
||
const j = await weapiPost('album/sublist', { limit: o.limit, offset: o.offset, total: true });
|
||
const list = j.data || [];
|
||
return {
|
||
count: list.length,
|
||
total: j.count ?? j.total ?? list.length,
|
||
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, artist: (a.artists || []).map((x) => x.name).join(' / ') })),
|
||
};
|
||
}
|
||
|
||
async function cmdMyArtists(o) {
|
||
const j = await weapiPost('artist/sublist', { limit: o.limit, offset: o.offset, total: true });
|
||
const list = j.data || [];
|
||
return { count: list.length, total: j.count ?? j.total ?? list.length, artists: list.map((a) => ({ id: a.id, name: a.name, albums: a.albumSize, songs: a.musicSize })) };
|
||
}
|
||
|
||
// 私人 FM:接口一次固定给 3 首且不认 limit,多拉几次凑够(去重)
|
||
async function cmdFm(o) {
|
||
const want = Math.min(o.limit, 30);
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (let i = 0; i < 12 && out.length < want; i++) {
|
||
const j = await weapiPost('radio/get', {});
|
||
const batch = j.data || [];
|
||
if (!batch.length) break;
|
||
for (const s of batch) if (!seen.has(s.id)) { seen.add(s.id); out.push(song(s)); }
|
||
}
|
||
if (!out.length) throw new ApiError('external', '私人 FM 没返回歌曲');
|
||
const picked = out.slice(0, want);
|
||
return { count: picked.length, songs: picked };
|
||
}
|
||
|
||
async function cmdRecent(o) {
|
||
const uid = await currentUid();
|
||
const j = await weapiPost('v1/play/record', { uid, type: 1 }); // type=1:最近一周
|
||
const list = j.weekData || j.allData || [];
|
||
const items = list.slice(o.offset, o.offset + o.limit).filter((x) => x.song).map((x) => ({ ...song(x.song), plays: x.playCount }));
|
||
if (!items.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', '最近播放是空的');
|
||
return { count: items.length, total: j.total ?? j.count ?? list.length, songs: items };
|
||
}
|
||
|
||
async function cmdPodcasts(o) {
|
||
// 两个端点都认 limit、不认 offset;拉到页尾再本地截,避免每页重复第一页。
|
||
const j = await weapiPost(o.recommend ? 'djradio/recommend/v1' : 'djradio/get/subed', {
|
||
limit: o.offset + o.limit, offset: o.offset,
|
||
});
|
||
const list = j.djRadios || [];
|
||
const page = list.slice(o.offset, o.offset + o.limit);
|
||
if (!list.length && (!o.offset || j.code !== 200)) {
|
||
throw new ApiError('notfound', o.recommend ? '没有推荐播客' : '没订阅播客(加 --recommend 看推荐的)');
|
||
}
|
||
return {
|
||
kind: o.recommend ? 'recommend' : 'subscribed',
|
||
count: page.length,
|
||
total: j.count ?? j.total ?? list.length,
|
||
podcasts: page.map((r) => ({ id: r.id, name: r.name, episodes: r.programCount, dj: (r.dj || {}).nickname || '' })),
|
||
};
|
||
}
|
||
|
||
// 放某播客的最新 N 期:名字先在订阅+推荐里找,找不到再搜播客
|
||
async function cmdPodcast(kw, o) {
|
||
let id = /^\d+$/.test(kw) ? +kw : 0;
|
||
let name = kw;
|
||
if (!id) {
|
||
const subs = (await weapiPost('djradio/get/subed', { limit: 100, offset: 0 })).djRadios || [];
|
||
const rec = (await weapiPost('djradio/recommend/v1', { limit: 100, offset: 0 })).djRadios || [];
|
||
const hit = pickBest([...subs, ...rec], kw, 'djradio');
|
||
if (hit) {
|
||
id = hit.id;
|
||
name = hit.name;
|
||
} else {
|
||
const s = await searchFirst(kw, 1009, 'djRadios', 'djradio');
|
||
id = s.id;
|
||
name = s.name;
|
||
}
|
||
}
|
||
const j = await weapiPost('dj/program/byradio', { radioId: id, limit: o.limit, offset: o.offset, asc: false });
|
||
const progs = j.programs || [];
|
||
const songs = progs
|
||
.filter((p) => p.mainSong && p.mainSong.id)
|
||
.map((p) => ({ ...song(p.mainSong), programId: p.id, program: p.name || '' }));
|
||
if (!songs.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这个播客没有可播的节目:${kw}`);
|
||
return { id, name, count: songs.length, total: j.count ?? j.total ?? progs[0]?.radio?.programCount ?? progs.length, songs };
|
||
}
|
||
|
||
async function cmdEpisode(pid, o) {
|
||
const j = await weapiPost('dj/program/detail', { id: +pid });
|
||
const p = j.program || {};
|
||
const ms = p.mainSong || {};
|
||
if (!ms.id) throw new ApiError('notfound', `没有这期节目:${pid}`);
|
||
return {
|
||
id: +pid,
|
||
name: p.name || ms.name || '',
|
||
dj: (p.dj || {}).nickname || '',
|
||
count: 1,
|
||
songs: [{ ...song(ms), programId: +pid, program: p.name || '' }],
|
||
};
|
||
}
|
||
|
||
// 「我喜欢的音乐」顺序 = 收藏时间由近到远(与客户端一致):
|
||
// song/like/get 返回的 ids 顺序与收藏时间无关(实测最老收藏的排第 1),
|
||
// 真值是 specialType=5 歌单的 trackIds[].at(收藏时间毫秒戳),按它倒序再取详情。
|
||
async function cmdLiked(o) {
|
||
const pid = await likedPlaylistId();
|
||
if (!pid) throw new ApiError('notfound', '找不到「我喜欢的音乐」歌单');
|
||
// n=0 省掉内嵌 tracks,trackIds 仍全量返回
|
||
const j = await weapiPost('v6/playlist/detail', { id: pid, n: 0, s: 8 });
|
||
const tids = ((j.playlist || {}).trackIds || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0));
|
||
const ids = tids.slice(o.offset, o.offset + o.limit).map((x) => x.id);
|
||
const songs = (await songsByIds(ids)).map((s) => ({ ...s, liked: true }));
|
||
return { count: songs.length, total: tids.length, songs };
|
||
}
|
||
async function songsByIds(ids) {
|
||
if (!ids.length) return [];
|
||
const det = await weapiPost('v3/song/detail', { c: JSON.stringify(ids.map((id) => ({ id }))) });
|
||
const byId = new Map((det.songs || []).map((s) => [s.id, song(s)]));
|
||
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||
}
|
||
async function likedPlaylistId() {
|
||
const uid = await currentUid();
|
||
const j = await weapiPost('user/playlist', { uid, limit: 1000, offset: 0, includeVideo: true });
|
||
const p = (j.playlist || []).find((x) => x.specialType === 5) || (j.playlist || [])[0];
|
||
return p && p.id;
|
||
}
|
||
|
||
async function cmdLike(id, o) {
|
||
const like = !o.unlike;
|
||
const j = await weapiPost('radio/like', { alg: 'itembased', trackId: +id, like, time: 3 });
|
||
if (j.code === 200) return { id: +id, liked: like, via: 'radio/like', code: j.code };
|
||
const first = `radio/like code=${j.code}:${j.message || j.msg || ''}`;
|
||
// 风控(-460)时退一步:直接对「我喜欢的音乐」歌单做增删,走另一套风控
|
||
const pid = await likedPlaylistId();
|
||
if (pid) {
|
||
const j2 = await weapiPost('playlist/manipulate/tracks', {
|
||
op: like ? 'add' : 'del',
|
||
pid,
|
||
trackIds: JSON.stringify([+id]),
|
||
imme: 'true',
|
||
});
|
||
if (j2.code === 200 || (like && j2.code === 502)) {
|
||
// 502 = 歌单内歌曲重复 → 目标状态已经达成,按幂等成功处理
|
||
return { id: +id, liked: like, via: 'playlist/manipulate/tracks', already: j2.code === 502, fallback_from: first };
|
||
}
|
||
throw new ApiError('external', `网易云拒绝了这次操作(${first};playlist/manipulate code=${j2.code}:${j2.message || j2.msg || ''})`);
|
||
}
|
||
throw new ApiError('external', `网易云拒绝了这次操作(${first})`);
|
||
}
|
||
async function cmdLyric(id, o) {
|
||
const j = await weapiPost('song/lyric', { id: +id, lv: -1, kv: -1, tv: -1 });
|
||
return { id: +id, lyric: (j.lrc && j.lrc.lyric) || '', trans: o.trans ? (j.tlyric && j.tlyric.lyric) || '' : undefined };
|
||
}
|
||
async function cmdSession() {
|
||
const j = await weapiPost('w/nuser/account/get', {});
|
||
return {
|
||
loggedIn: !!(j.profile && j.profile.userId),
|
||
uid: num(j.profile && j.profile.userId),
|
||
nick: (j.profile && j.profile.nickname) || '',
|
||
vip: (j.profile && j.profile.vipType) || 0,
|
||
};
|
||
}
|
||
async function currentUid() {
|
||
const s = await cmdSession();
|
||
if (!s.loggedIn) throw new ApiError('auth', '没登录:先跑 pi-skill netease-music login(手机扫码)');
|
||
return s.uid;
|
||
}
|
||
async function cmdMyPlaylists(o) {
|
||
const uid = await currentUid();
|
||
// user/playlist 的 limit 不稳定,非零 offset 还会多跳过置顶歌单;从头取再本地分页。
|
||
const j = await weapiPost('user/playlist', { uid, limit: o.offset + o.limit, includeVideo: true });
|
||
const list = j.playlist || [];
|
||
const page = list.slice(o.offset, o.offset + o.limit);
|
||
return {
|
||
count: page.length,
|
||
total: j.total ?? j.count ?? list.length,
|
||
playlists: page.map((p) => ({ id: p.id, name: p.name, count: p.trackCount, special: p.specialType || 0, sub: p.subscribed === true })),
|
||
};
|
||
}
|
||
async function cmdLikeState(id) {
|
||
// 这个端点回的是「我喜欢的歌曲 id 全表」(key 是 ids),不是单曲对象
|
||
const j = await weapiPost('song/like/get', { trackIds: JSON.stringify([Number(id)]) });
|
||
if (j.code !== 200) throw new ApiError('external', `查喜欢状态失败(code=${j.code}):${j.message || j.msg || ''}`);
|
||
const ids = (Array.isArray(j.ids) ? j.ids : []).map(Number);
|
||
return { id: Number(id), liked: ids.includes(Number(id)), liked_total: ids.length, code: j.code };
|
||
}
|
||
async function cmdLikedIds(args) {
|
||
if (args.length) throw new ApiError('usage', '用法:liked-ids');
|
||
const j = await weapiPost('song/like/get', { trackIds: '[]' });
|
||
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `查喜欢列表失败(code=${j.code}):${j.message || j.msg || ''}`);
|
||
if (!Array.isArray(j.ids)) throw new ApiError('external', '喜欢列表缺少 ids,不能当成空列表');
|
||
const ids = j.ids.map(Number);
|
||
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) throw new ApiError('external', '喜欢列表含无效歌曲 id');
|
||
return { count: ids.length, total: ids.length, ids };
|
||
}
|
||
async function cmdDislike(args) {
|
||
if (args.length !== 1) throw new ApiError('usage', '用法:dislike <歌曲id>');
|
||
const id = songId(args[0]);
|
||
// NeteaseCloudMusicApi 的 fm/trash 对应此 weapi 写端点。
|
||
const j = await weapiPost('radio/trash/add', { songId: id, time: 25, alg: 'RT' });
|
||
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `私人 FM 不喜欢失败(code=${j.code}):${j.message || j.msg || ''}`);
|
||
return { id, disliked: true, code: j.code };
|
||
}
|
||
async function cmdSmsSend(phone) {
|
||
const j = await weapiPost('sms/captcha/sent', { cellphone: String(phone), ctcode: '86' });
|
||
if (j.code !== 200) throw new ApiError('external', `发验证码没成功(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
|
||
return { sent: true };
|
||
}
|
||
async function cmdSmsLogin(phone, code) {
|
||
const j = await weapiPost('login/cellphone', { phone: String(phone), countrycode: '86', captcha: String(code), rememberLogin: true });
|
||
const cookie = jarToCookie(lastSetCookie);
|
||
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('auth', `登录没成(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
|
||
return { state: 'ok', cookie };
|
||
}
|
||
async function cmdQrStart() {
|
||
const j = await weapiPost('login/qrcode/unikey', { type: 1, header: {} });
|
||
if (!j.unikey) throw new ApiError('external', '拿不到二维码 key:' + JSON.stringify(j).slice(0, 160));
|
||
return { key: j.unikey, url: 'https://music.163.com/login?codekey=' + j.unikey };
|
||
}
|
||
async function cmdQrPoll(key, o) {
|
||
const deadline = Date.now() + o.timeout * 1000;
|
||
let state = 'waiting';
|
||
while (Date.now() < deadline) {
|
||
const j = await weapiPost('login/qrcode/client/login', { key, type: 1 });
|
||
if (j.code === 800) return { state: 'expired', code: j.code };
|
||
if (j.code === 802 && state !== 'scanned') {
|
||
state = 'scanned';
|
||
console.error('已扫到,等你在手机上点「确认登录」…');
|
||
}
|
||
if (j.code === 803) {
|
||
const cookie = jarToCookie(lastSetCookie);
|
||
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('external', '登录成功了但没拿到 MUSIC_U:' + cookie.slice(0, 120));
|
||
return { state: 'ok', cookie };
|
||
}
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
return { state, code: 801 };
|
||
}
|
||
|
||
// ---------- 参数解析 ----------
|
||
const argv = process.argv.slice(2);
|
||
const CMD = argv[0];
|
||
const opts = { limit: 0, offset: 0, start: 1, level: 'lossless', trans: false, unlike: false, timeout: 120 };
|
||
const pos = [];
|
||
for (let i = 1; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--limit') opts.limit = +argv[++i];
|
||
else if (a === '--offset') opts.offset = +argv[++i];
|
||
else if (a === '--start') {
|
||
const value = argv[++i];
|
||
opts.start = /^\d+$/.test(value || '') ? +value : NaN;
|
||
}
|
||
else if (a === '--level') opts.level = argv[++i];
|
||
else if (a === '--trans') opts.trans = true;
|
||
else if (a === '--unlike') opts.unlike = true;
|
||
else if (a === '--timeout') opts.timeout = +argv[++i];
|
||
else if (a === '--recommend') opts.recommend = true;
|
||
else if (a === '--download') opts.download = argv[++i] ?? '';
|
||
else if (a === '--force') opts.force = true;
|
||
else pos.push(a);
|
||
}
|
||
const DEFAULTS = { search: 20, daily: 40, playlist: 500, liked: 1000, myplaylists: 100, artist: 50, album: 200, albums: 60,
|
||
playlists: 20, charts: 100, myalbums: 60, myartists: 60, fm: 12, recent: 30, podcasts: 50, podcast: 3 };
|
||
if (!opts.limit) opts.limit = DEFAULTS[CMD] || 20;
|
||
|
||
const rawPost = (path, data) => (String(path).startsWith('api/') ? plainPost(path, data) : weapiPost(path, data));
|
||
|
||
const HANDLERS = {
|
||
search: () => cmdSearch(pos.join(' '), opts),
|
||
song: () => cmdSong(pos.join(' ')),
|
||
'play-ids': () => cmdPlayIds(pos, opts),
|
||
cover: () => cmdCover(pos.join(' '), opts),
|
||
url: () => cmdUrl(pos, opts),
|
||
daily: () => cmdDaily(opts),
|
||
playlist: () => cmdPlaylist(String(pos[0]).replace(/.*[?&#/]id=/, ''), opts),
|
||
playlists: () => cmdPlaylists(pos.join(' '), opts),
|
||
charts: () => cmdCharts(opts),
|
||
chart: () => cmdChart(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
|
||
myalbums: () => cmdMyAlbums(opts),
|
||
myartists: () => cmdMyArtists(opts),
|
||
fm: () => cmdFm(opts),
|
||
recent: () => cmdRecent(opts),
|
||
podcasts: () => cmdPodcasts(opts),
|
||
podcast: () => cmdPodcast(String(pos.join(' ')), opts),
|
||
episode: () => cmdEpisode(pos[0], opts),
|
||
artist: () => cmdArtist(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
|
||
album: () => cmdAlbum(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
|
||
albums: () => cmdAlbums(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
|
||
liked: () => cmdLiked(opts),
|
||
'liked-ids': () => cmdLikedIds(pos),
|
||
dislike: () => cmdDislike(pos),
|
||
like: () => cmdLike(pos[0], opts),
|
||
lyric: () => cmdLyric(pos[0], opts),
|
||
myplaylists: () => cmdMyPlaylists(opts),
|
||
session: () => cmdSession(),
|
||
uid: () => currentUid().then((uid) => ({ uid })),
|
||
raw: () => rawPost(pos[0], JSON.parse(pos[1] || '{}')),
|
||
'like-state': () => cmdLikeState(pos[0]),
|
||
'sms-send': () => cmdSmsSend(pos[0]),
|
||
'sms-login': () => cmdSmsLogin(pos[0], pos[1]),
|
||
'qr-start': () => cmdQrStart(),
|
||
'qr-poll': () => cmdQrPoll(pos[0], opts),
|
||
};
|
||
|
||
if (!HANDLERS[CMD]) {
|
||
console.error(JSON.stringify({ error: 'usage', hint: `未知命令 ${CMD || '(空)'};可用:${Object.keys(HANDLERS).join(' ')}` }));
|
||
process.exit(2);
|
||
}
|
||
try {
|
||
if (!Number.isSafeInteger(opts.offset) || opts.offset < 0) throw new ApiError('usage', '--offset 需要非负整数');
|
||
const out = await HANDLERS[CMD]();
|
||
console.log(JSON.stringify(out));
|
||
} catch (e) {
|
||
const code = e instanceof ApiError ? e.code : 'internal';
|
||
console.error(JSON.stringify({ error: code, hint: e.hint || String(e) }));
|
||
process.exit(code === 'usage' ? 2 : code === 'auth' ? 3 : code === 'notfound' ? 4 : 5);
|
||
}
|