netease-music/scripts/netease-api.mjs
2026-10-02 07:43:33 +08:00

748 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}