Compare commits

..
Author SHA1 Message Date
Takanashi Hoshino ddfd680301 refactors 2026-09-13 12:04:37 +08:00
Takanashi Hoshino c478367c6c refactor: 重构获取anonymous token以及xeapikey的获取 2026-09-13 11:13:56 +08:00
7N21 a8c781fd64 Merge pull request #260 from Cosmosuperbaka/main
feat: 新增登录设备管理中设备名称上报接口
2026-09-12 00:46:53 +08:00
Cosmosuperbaka 690b462577 docs: 补充设备名称上报接口的文档与类型声明 2026-09-12 00:38:43 +08:00
Cosmosuperbaka 25ac0c041f fix: 设备名称上报接口补充必填参数校验 2026-09-11 20:36:43 +08:00
Cosmosuperbaka edef19c3fb feat: 新增设备中心设备名称上报接口 2026-09-10 16:07:54 +08:00
Takanashi Hoshino d55d92cd00 ci: switch model 2026-09-04 20:01:15 +08:00
MoeFurina f5ce55bcb4 build: update dependence 2026-08-27 23:51:49 +08:00
MoeFurina 4480bd4359 ci: switch pnpm 9 2026-08-27 23:43:19 +08:00
MoeFurina e43b123c8a ci: fix pnpm 11 not work 2026-08-27 23:31:52 +08:00
MoeFurina ed9bdd6ffa ci: fix pnpm 11 not work 2026-08-27 23:14:52 +08:00
MoeFurina 7b733e54ab ci: fix pnpm 11 not work 2026-08-27 22:59:30 +08:00
MoeFurina edfaf6c0d1 ci: switch another model 2026-08-27 22:47:35 +08:00
Takanashi Hoshino f83ad951f6 Merge pull request #243 from cXp1r/main
fix: 修正NMTID来源
2026-08-25 12:22:34 +08:00
cXp1r 22003c2c32 fix: 修复NMTID保底有随机生成导致服务器永远不会下放, 致使重复进入分支浪费性能 2026-08-25 00:51:47 +08:00
cXp1r 8376699f5e fix: 修改NMTID采取逻辑和匹配逻辑 2026-08-24 17:46:19 +08:00
cXp1r 5c98a9a1ea fix: 修正NMTID来源 2026-08-24 16:26:12 +08:00
MoeFurina 7c17de7f09 ci: switch another model 2026-08-22 12:47:42 +08:00
Takanashi Hoshino a7e8d485f3 Merge pull request #242 from SteveCodeFox/main
fix: 音质相关说明
2026-08-20 18:10:41 +08:00
SteveCodeFox 0957be6e76 fix: "高清全景声" => "高清臻音"
add: "ste2", "c512", "aac2"三种沉浸声类型说明
2026-08-20 13:19:50 +08:00
25 changed files with 663 additions and 405 deletions
+4 -3
View File
@@ -34,10 +34,11 @@ jobs:
with:
node-version: '18'
- name: Setup pnpm
run: npm install -g pnpm@9
- name: Install dependencies
run: |
npm install -g pnpm
pnpm install
run: pnpm install --frozen-lockfile
- name: Build for ${{ matrix.platform }}
run: |
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- name: Run automation
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
MODEL: opencode/deepseek-v4-flash-free
MODEL: opencode/muse-spark-1.3-contributor-free
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
ISSUE_PROMPT: ${{ steps.cfg.outputs.opencode_prompt }}
PR_PROMPT: |
+3 -3
View File
@@ -48,8 +48,8 @@ jobs:
- name: Configure git identity
run: |
git config --global user.name "takanashi-hoshino-agent[bot]"
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
git config --global user.name "yukka-agent[bot]"
git config --global user.email "yukka-agent[bot]@users.noreply.github.com"
- name: Run opencode
uses: anomalyco/opencode/github@latest
@@ -57,7 +57,7 @@ jobs:
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
model: opencode/deepseek-v4-flash-free
model: opencode/muse-spark-1.3-contributor-free
use_github_token: true
prompt: |
${{ steps.cfg.outputs.opencode_prompt }}
-1
View File
@@ -6,7 +6,6 @@ node_modules
.history
examples/moddef.json
bin
anonymous_token
.vercel
.env
IFLOW.md
+11 -7
View File
@@ -14,13 +14,15 @@
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
## 架构
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(匿名令牌与 xeapi 公钥,存于内存),再调用 `server.serveNcmApi()`。不依赖 `/tmp` 等可写文件系统。
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`、`generateConfig`(库用户可 `await main.generateConfig()` 预热/续期凭证;不调用也行,首个请求会在凭证缺失时自动引导)。
- `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))`。`createOption` 在 `util/option.js`,负责 crypto、cookie(回退到 `NETEASE_COOKIE`)、proxy、realIP/randomCNIP、headers、timeout。
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;匿名令牌与 xeapi 公钥每次请求实时读取 `util/runtimeState.js`,缺失时按需引导(并发共享同一刷新任务,刷新自身的请求带 `skipTokenEnsure` 防自等待死锁)。另导出 `refreshAnonymousToken` / `refreshXeapiPublicKey` 供 `generateConfig` 复用。
- `util/runtimeState.js`——统一的进程内运行时动态状态(`anonymousToken` / `xeapiPublicKey`)。纯内存、零文件 I/O;`register_anonimous` / `register_xeapikey` 成功后写入,`util/request.js` 读取,`ensure()` 提供缺失时按需刷新。以后新增动态配置放这里,不要再往 `os.tmpdir()` 写文件。
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
- `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。
## 新增/修改接口
- 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
@@ -28,13 +30,15 @@
- 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
## 测试
- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测。
- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测,不需要网络。
- **CI 门禁只看稳定项**:`ci-check.yml` 在 Node 18/22/24 上跑 `pnpm lint` + `main.test.js` + `pnpm docs:check`;完整集成测试(`server.test.js`)是 `continue-on-error`,不阻塞合并。本地跑全量测试失败先怀疑上游风控。
- 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。
- 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "<describe/it 名字>" server.test.js main.test.js --exit`
- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit`
## 坑与注意
- **改 `package.json` 的 `version` 会触发自动发布**:push 到 `main` 后会自动打 GitHub Release(`pkg` 二进制)、构建并推送 Docker 镜像(Docker Hub + GHCR)、`pnpm publish` 到 npm。别顺手改版本号。
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged`,但 `.husky/` 下没有真正的 hook,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
- **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
- **运行时凭证在内存(runtimeState)**:`anonymous_token` 与 `xeapi_public_key` 存于 `util/runtimeState.js`,不做任何文件 I/O(历史上曾以文件形式放 `os.tmpdir()`,为可移植性已移除——只读 FS / Android / iOS / serverless 均可运行)。启动时 `generateConfig()` 先取 xeapi 公钥、再注册匿名令牌(顺序不能反,后者依赖前者);运行中过期可再调 `generateConfig()` 或对应 `register_*` 接口,立即生效、无需重启。
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。
+1 -8
View File
@@ -1,14 +1,7 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
async function start() {
// 检测是否存在 anonymous_token 文件,没有则生成
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
}
// 启动时更新anonymous_token
// 启动时预热运行时凭证(匿名令牌 / xeapi 公钥,均存于内存中的 runtimeState)
const generateConfig = require('./generateConfig')
await generateConfig()
require('./server').serveNcmApi({
+17 -28
View File
@@ -1,41 +1,30 @@
const fs = require('fs')
const path = require('path')
const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey')
const tmpPath = require('os').tmpdir()
const request = require('./util/request')
const { generateRandomChineseIP, generateDeviceId } = require('./util/index')
/**
* 启动 / 手动刷新运行时动态配置。
*
* 凭证均存于内存(util/runtimeState.js),不做任何文件 I/O:
* - 设备 ID 先行 —— xeapi 公钥注册需携带 deviceId(服务端对无
* deviceId 的请求不下发 sk),匿名注册也复用同一设备身份;
* - xeapi 公钥其次 —— 注册匿名令牌走 xeapi 加密,依赖该公钥;
* - 匿名令牌最后注册,三者就绪后冷启动一次即可完成引导。
*/
async function generateConfig() {
global.cnIp = generateRandomChineseIP()
if (!global.deviceId) {
global.deviceId = generateDeviceId()
}
try {
const res = await register_anonimous()
const cookie = res.body.cookie
if (cookie) {
const cookieObj = cookieToJson(cookie)
fs.writeFileSync(
path.resolve(tmpPath, 'anonymous_token'),
cookieObj.MUSIC_A,
'utf-8',
)
}
await request.refreshXeapiPublicKey()
} catch (error) {
console.log(error)
}
try {
let currentPublicKey = {}
try {
currentPublicKey = JSON.parse(
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
)
} catch (_) {}
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
fs.writeFileSync(
path.resolve(tmpPath, 'xeapi_public_key'),
JSON.stringify(publicKey),
'utf-8',
)
await request.refreshAnonymousToken()
} catch (error) {
console.log(error)
}
}
module.exports = generateConfig
+4
View File
@@ -584,6 +584,10 @@ export function device_kickoff(
export function device_list(params: RequestBaseConfig): Promise<Response>
export function deviceinfo_center_upload(
params: { deviceName: string; name?: string } & RequestBaseConfig,
): Promise<Response>
export function digitalAlbum_ordering(
params: {
payment: string
+12 -6
View File
@@ -1,13 +1,7 @@
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
const { cookieToJson } = require('./util')
const anonymousTokenPath = path.resolve(tmpPath, 'anonymous_token')
if (!fs.existsSync(anonymousTokenPath)) {
fs.writeFileSync(anonymousTokenPath, '', 'utf-8')
}
/** @type {Record<string, any>} */
let obj = {}
@@ -46,6 +40,7 @@ moduleFiles.forEach((file) => {
})
let serverModule = null
let generateConfigModule = null
/**
* @type {Record<string, any> & import("./server")}
@@ -57,6 +52,17 @@ module.exports = {
}
return serverModule
},
/**
* 刷新运行时动态配置(匿名令牌 / xeapi 公钥,存于内存 runtimeState)。
* 作为依赖使用时可以不调用——首个请求会在凭证缺失时自动引导;
* 需要预热或手动续期时再 `await main.generateConfig()`。
*/
get generateConfig() {
if (!generateConfigModule) {
generateConfigModule = require('./generateConfig')
}
return generateConfigModule
},
...obj,
}
+60
View File
@@ -16,3 +16,63 @@ describe('methods in module', () => {
assert.strictEqual(typeof main.activate_init_profile, 'function')
})
})
describe('runtimeState', () => {
const runtimeState = require('./util/runtimeState')
it('has generateConfig for bootstrapping in-memory credentials', () => {
assert.strictEqual(typeof main.generateConfig, 'function')
})
it('exposes anonymousToken / xeapiPublicKey in memory', () => {
assert.ok('anonymousToken' in runtimeState)
assert.ok('xeapiPublicKey' in runtimeState)
})
describe('ensure()', () => {
it('returns existing value without refreshing', async () => {
runtimeState.ensureProbe = 'ready'
let called = 0
const value = await runtimeState.ensure('ensureProbe', async () => {
called++
})
assert.strictEqual(value, 'ready')
assert.strictEqual(called, 0)
delete runtimeState.ensureProbe
})
it('refreshes when missing and shares one task among concurrent calls', async () => {
let refreshes = 0
const refresh = () =>
new Promise((resolve) =>
setTimeout(() => {
refreshes++
runtimeState.ensureProbe = 'fresh'
resolve()
}, 20),
)
const results = await Promise.all([
runtimeState.ensure('ensureProbe', refresh),
runtimeState.ensure('ensureProbe', refresh),
runtimeState.ensure('ensureProbe', refresh),
])
assert.deepStrictEqual(results, ['fresh', 'fresh', 'fresh'])
assert.strictEqual(refreshes, 1)
delete runtimeState.ensureProbe
})
it('propagates refresh failure and allows retry', async () => {
let attempts = 0
const fail = async () => {
attempts++
throw new Error('boom')
}
await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/)
assert.strictEqual(attempts, 1)
// 失败后任务被清理,下一次调用会重试
await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/)
assert.strictEqual(attempts, 2)
delete runtimeState.ensureProbe
})
})
})
+28
View File
@@ -0,0 +1,28 @@
// 上报设备中心设备名称
// 官方 PC 端在登录成功后调用此接口,将当前登录设备在“登录设备管理”中的名称设置为自定义名称
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const deviceName = String(query.deviceName || query.name || '').trim()
if (!deviceName) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: deviceName',
},
})
}
const data = {
deviceName,
}
return request(
`/api/deviceinfo/center/upload`,
data,
createOption(query, 'eapi'),
)
}
+10 -2
View File
@@ -7,7 +7,8 @@ const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2'
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
const { generateDeviceId } = require('../util/index')
const { generateDeviceId, cookieToJson } = require('../util/index')
const runtimeState = require('../util/runtimeState')
// function getRandomFromList(list) {
// return list[Math.floor(Math.random() * list.length)]
@@ -25,7 +26,9 @@ function cloudmusic_dll_encode_id(some_id) {
}
module.exports = async (query, request) => {
const deviceId = generateDeviceId()
// 复用进程内已有的设备身份(真实客户端的设备 ID 是稳定的);
// xeapi 公钥注册同样依赖该 deviceId
const deviceId = global.deviceId || generateDeviceId()
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
global.deviceId = deviceId
const encodedId = CryptoJS.enc.Base64.stringify(
@@ -42,6 +45,11 @@ module.exports = async (query, request) => {
createOption(query, 'xeapi'),
)
if (result.body.code === 200) {
// 注册成功后立即写入运行时状态,后续请求直接使用新令牌(无需重启)
const cookieObj = cookieToJson(result.cookie.join(';'))
if (cookieObj.MUSIC_A) {
runtimeState.anonymousToken = cookieObj.MUSIC_A
}
result = {
status: 200,
body: {
+4
View File
@@ -1,6 +1,7 @@
const { default: axios } = require('axios')
const encrypt = require('../util/crypto')
const { APP_CONF } = require('../util/config.json')
const runtimeState = require('../util/runtimeState')
const generateNonce = () => {
let nonce = ''
@@ -63,6 +64,9 @@ module.exports = async (query, request) => {
throw new Error('xeapi public key response missing sk')
}
// 获取成功后立即写入运行时状态,供 util/request.js 的 xeapi 请求使用
runtimeState.xeapiPublicKey = { ...publicKey, deviceId }
return {
status: 200,
body: {
+1 -1
View File
@@ -1,6 +1,6 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
+2 -2
View File
@@ -1,8 +1,8 @@
// 歌曲链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c512(新版c51类型)、ste2(新版环绕立体声类型)、aac2(新版aac类型)、c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
+1 -1
View File
@@ -1,6 +1,6 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
+11 -11
View File
@@ -11,9 +11,9 @@
"prepare": "husky install",
"docs:format": "node scripts/format-docs.js",
"docs:check": "node scripts/format-docs.js --check",
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
"pkgwin": "node scripts/build-pkg.js . -t node18-win-x64 -C GZip -o precompiled/app",
"pkglinux": "node scripts/build-pkg.js . -t node18-linux-x64 -C GZip -o precompiled/app",
"pkgmacos": "node scripts/build-pkg.js . -t node18-macos-x64 -C GZip -o precompiled/app"
},
"bin": "./app.js",
"pkg": {
@@ -75,15 +75,15 @@
"data"
],
"dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.2",
"axios": "^1.19.0",
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.4",
"axios": "^1.20.0",
"crypto-js": "^4.2.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"gzip": "^0.1.0",
"jsdom": "^24.1.3",
"music-metadata": "^11.14.0",
"music-metadata": "^11.15.0",
"node-forge": "^1.4.0",
"pac-proxy-agent": "^7.2.0",
"qrcode": "^1.5.4",
@@ -93,19 +93,19 @@
"yargs": "^18.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^9.39.5",
"@types/express": "^5.0.6",
"@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10",
"@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.69.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-html": "^8.1.4",
"eslint-plugin-html": "^8.2.0",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.11.0",
"globals": "^17.12.0",
"husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0",
+260 -257
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,5 +1,9 @@
packages:
- '.'
allowBuilds:
core-js: true
es5-ext: true
minimumReleaseAgeExclude:
- '@neteasecloudmusicapienhanced/[email protected]'
+17 -5
View File
@@ -1304,10 +1304,10 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为 `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为`c512` => `新版c51类型`, `ste2` => `新版环绕立体声类型`, `aac2` => `新版aac类型`, `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
**接口地址 :** `/song/url/v1`
@@ -1321,7 +1321,7 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**接口地址 :** `/song/url/v1/302`
@@ -4675,7 +4675,7 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
### 歌曲音质详情
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出 `高清环绕声`、`沉浸环绕声`、`超清母带`等音质的信息
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出`高清臻音`、`杜比全景声`、`臻音全景声`、`超清母带`、`沉浸环绕声`等音质的信息
**必选参数:**
@@ -4796,7 +4796,7 @@ bitrate = Math.floor(br / 1000)
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
**接口地址 :** `/song/download/url/v1`
@@ -5856,6 +5856,18 @@ let data = encodeURIComponent(
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
### 上报设备中心设备名称
说明: 登录后调用此接口, 传入 `deviceName`, 可修改当前设备在网易云登录设备管理列表里显示的名称
**必选参数 :** `deviceName`: 期望展示的设备名称
`name`: `deviceName` 的兼容别名, 二选一即可
**接口地址 :** `/deviceinfo/center/upload`
**调用例子 :** `/deviceinfo/center/upload?deviceName=XXXXX`
### 插播相似歌曲
说明: 调用此接口, 传入歌曲 id, 可获取插播相似歌曲
+51
View File
@@ -0,0 +1,51 @@
/**
* Invoke the pkg packager (cross-platform, independent of node_modules layout).
*
* Background: on Windows, pnpm links node_modules/pkg to
* node_modules/.pnpm/pkg@<ver>/node_modules/pkg via a junction. Some CI
* environments (pnpm 11 + Windows runner) may fail to create that link,
* so executing node_modules/pkg/lib-es5/bin.js directly throws
* MODULE_NOT_FOUND.
*
* This script first resolves pkg via require.resolve and falls back to
* scanning the .pnpm physical directory, so pkg is found whether the
* junction exists or not.
*/
const { spawnSync } = require('child_process')
const fs = require('fs')
const path = require('path')
function resolvePkgBin() {
try {
return require.resolve('pkg/lib-es5/bin.js')
} catch (err) {
// require.resolve failed (e.g. missing node_modules/pkg link): scan .pnpm
const pnpmDir = path.join(process.cwd(), 'node_modules', '.pnpm')
if (fs.existsSync(pnpmDir)) {
const candidates = fs
.readdirSync(pnpmDir)
.filter((dir) => dir.startsWith('pkg@'))
.map((dir) =>
path.join(pnpmDir, dir, 'node_modules', 'pkg', 'lib-es5', 'bin.js'),
)
.filter((file) => fs.existsSync(file))
if (candidates.length) {
return candidates[0]
}
}
return null
}
}
const bin = resolvePkgBin()
if (!bin) {
console.error(
'[build-pkg] pkg executable not found; run `pnpm install --frozen-lockfile` first',
)
process.exit(1)
}
const result = spawnSync(process.execPath, [bin, ...process.argv.slice(2)], {
stdio: 'inherit',
})
process.exit(result.status === null ? 1 : result.status)
-4
View File
@@ -1,11 +1,7 @@
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
/** @type {import("express").Express & serverMod.ExpressExtension} */
let app
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
}
const serverMod = require('./server')
before(async () => {
const generateConfig = require('./generateConfig')
+105 -41
View File
@@ -7,9 +7,7 @@ const { PacProxyAgent } = require('pac-proxy-agent')
const http = require('http')
const https = require('https')
const tunnel = require('tunnel')
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
const runtimeState = require('./runtimeState')
const {
cookieToJson,
cookieObjToString,
@@ -25,26 +23,6 @@ const {
getToken: antiCheatTokenV3,
} = require('../module/register_checktoken_v3')
// 预先读取匿名token并缓存
const anonymous_token = fs.readFileSync(
path.resolve(tmpPath, './anonymous_token'),
'utf-8',
)
const xeapiPublicKeyPath = path.resolve(tmpPath, './xeapi_public_key')
let xeapi_public_key = null
const loadXeapiPublicKey = () => {
if (!xeapi_public_key && fs.existsSync(xeapiPublicKeyPath)) {
try {
xeapi_public_key = JSON.parse(
fs.readFileSync(xeapiPublicKeyPath, 'utf-8'),
)
} catch (error) {
console.log('[ERR]', error)
}
}
return xeapi_public_key
}
// 预先绑定常用函数和常量
const floor = Math.floor
const random = Math.random
@@ -68,6 +46,9 @@ const WNMCID = (function () {
return `${randomString}.${now().toString()}.01.0`
})()
let NMTID = ''
let NMTID_RETRIES_LEFT = 3
// 预先定义osMap
const osMap = {
pc: {
@@ -136,7 +117,7 @@ const chooseUserAgent = (crypto, uaType = 'pc') => {
}
// cookie处理
const processCookieObject = (cookie, uri) => {
const processCookieObject = (cookie, crypto) => {
const _ntes_nuid = CryptoJS.lib.WordArray.random(32).toString()
const os = osMap[cookie.os] || osMap['pc']
@@ -155,12 +136,19 @@ const processCookieObject = (cookie, uri) => {
appver: cookie.appver || os.appver,
}
if (uri.indexOf('login') === -1) {
processedCookie['NMTID'] = CryptoJS.lib.WordArray.random(16).toString()
// 服务端下发条件为不带NMTID请求任意eapi加密方式接口
if (cookie.NMTID) {
processedCookie['NMTID'] = cookie.NMTID
} else if (NMTID) {
processedCookie['NMTID'] = NMTID
} else if (NMTID_RETRIES_LEFT <= 0 || crypto !== 'eapi') {
processedCookie['NMTID'] =
'00O' + CryptoJS.lib.WordArray.random(19).toString()
}
if (!processedCookie.MUSIC_U) {
processedCookie.MUSIC_A = processedCookie.MUSIC_A || anonymous_token
processedCookie.MUSIC_A =
processedCookie.MUSIC_A || runtimeState.anonymousToken
}
return processedCookie
@@ -187,6 +175,33 @@ const generateRequestId = () => {
.padStart(4, '0')}`
}
// ---- 运行时凭证的强制刷新 ----
// 匿名令牌与 xeapi 公钥存于 util/runtimeState(纯内存、零文件 I/O),
// 由对应的 register_* 模块在成功获取后写入,此处只负责编排调用。
// 刷新自身的请求带 skipTokenEnsure 标记:跳过下方匿名令牌的按需引导,
// 避免“刷新任务等待自己”的死锁(注册匿名账号本就不依赖旧令牌)
const refreshTokenRequest = (uri, data, options = {}) =>
createRequest(uri, data, { ...options, skipTokenEnsure: true })
// 强制刷新匿名令牌(成功后由 register_anonimous 写入 runtimeState)
const refreshAnonymousToken = async () => {
const registerAnonimous = require('../module/register_anonimous')
await registerAnonimous({}, refreshTokenRequest)
}
// 强制刷新 xeapi 公钥(register_xeapikey 直接走 axios,无重入问题;
// 成功后由其写入 runtimeState)
const refreshXeapiPublicKey = async () => {
const registerXeapikey = require('../module/register_xeapikey')
await registerXeapikey(
{
currentKeyVersion: (runtimeState.xeapiPublicKey || {}).version || '',
},
null,
)
}
const createRequest = async (uri, data, options) => {
let token = ''
switch (options.checkToken) {
@@ -200,11 +215,43 @@ const createRequest = async (uri, data, options) => {
break
}
// 匿名令牌缺失时按需引导:并发请求共享同一次刷新任务;
// 刷新自身的请求(skipTokenEnsure)跳过,避免任务等待自己造成死锁
if (!runtimeState.anonymousToken && !options.skipTokenEnsure) {
try {
await runtimeState.ensure('anonymousToken', refreshAnonymousToken)
} catch (error) {
console.log('[ERR]', error)
}
}
// 本请求走 xeapi 加密且公钥缺失时按需获取
// (须在进入下方同步 executor 前完成,加密过程需要同步拿到公钥)
const pendingCrypto =
options.crypto === undefined || options.crypto === ''
? APP_CONF.encrypt
? 'eapi'
: 'api'
: options.crypto
if (pendingCrypto === 'xeapi' && !runtimeState.xeapiPublicKey) {
try {
await runtimeState.ensure('xeapiPublicKey', refreshXeapiPublicKey)
} catch (error) {
console.log('[ERR]', error)
}
}
return new Promise((resolve, reject) => {
// 变量声明和初始化
const headers = options.headers ? { ...options.headers } : {}
const ip = options.realIP || options.ip || ''
// 加密方式选择
let crypto = options.crypto
if (crypto === '') {
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
}
// IP头设置
if (ip) {
headers['X-Real-IP'] = ip
@@ -217,19 +264,13 @@ const createRequest = async (uri, data, options) => {
}
if (typeof cookie === 'object') {
cookie = processCookieObject(cookie, uri)
cookie = processCookieObject(cookie, crypto)
headers['Cookie'] = cookieObjToString(cookie)
}
let url = ''
let encryptData = ''
let crypto = options.crypto
const csrfToken = cookie['__csrf'] || ''
// 加密方式选择
if (crypto === '') {
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
}
const answer = { status: 500, body: {}, cookie: [] }
data.e_r = toBoolean(
@@ -264,7 +305,7 @@ const createRequest = async (uri, data, options) => {
break
case 'xeapi':
const xeapiPublicKey = loadXeapiPublicKey()
const xeapiPublicKey = runtimeState.xeapiPublicKey
if (!xeapiPublicKey) {
throw new Error('xeapi public key is missing')
}
@@ -332,9 +373,8 @@ const createRequest = async (uri, data, options) => {
if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
if (options.checkToken) {
header['X-antiCheatToken'] = token
}
if (options.checkToken) header['X-antiCheatToken'] = token
if (crypto === 'eapi' && cookie.NMTID) header['NMTID'] = cookie.NMTID
headers['Cookie'] = createHeaderCookie(header)
headers['User-Agent'] =
@@ -420,9 +460,30 @@ const createRequest = async (uri, data, options) => {
axios(settings)
.then((res) => {
const body = res.data
answer.cookie = (res.headers['set-cookie'] || []).map((x) =>
x.replace(/\s*Domain=[^(;|$)]+;*/, ''),
)
const setCookies = res.headers['set-cookie'] || []
const cleanCookie = (x) => x.replace(/\s*Domain=[^(;|$)]+;*/, '')
// 仅对真正未携带 NMTID 的探测请求采集并消耗重试次数
if (
crypto === 'eapi' &&
!NMTID &&
NMTID_RETRIES_LEFT > 0 &&
!cookie.NMTID
) {
NMTID_RETRIES_LEFT--
answer.cookie = setCookies.map((x) => {
const cleaned = cleanCookie(x)
const match = x.match(/(?:^|;\s*)NMTID=([^;]+)/)
if (match) {
//不需要处理竞争, 官方客户端真实操作
NMTID = match[1]
}
return cleaned
})
} else {
answer.cookie = setCookies.map(cleanCookie)
}
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
@@ -487,3 +548,6 @@ const createRequest = async (uri, data, options) => {
}
module.exports = createRequest
// 供 generateConfig 等复用的强制刷新入口(成功后写入 runtimeState)
module.exports.refreshAnonymousToken = refreshAnonymousToken
module.exports.refreshXeapiPublicKey = refreshXeapiPublicKey
+56
View File
@@ -0,0 +1,56 @@
// 统一的运行时动态状态存储(仅内存,不落盘)
//
// 存放需要在进程内共享、且会在运行期间变化的动态配置,
// 目前包括匿名令牌(MUSIC_A)与 xeapi 公钥。
//
// 设计约定:
// - 只存在于内存中,不做任何文件 I/O —— 在没有 /tmp 或临时目录
// 不可写的平台(Android / iOS / 嵌入式 / serverless)上同样可用
// - 写入方(module/register_anonimous.js、module/register_xeapikey.js)
// 在成功获取新值后立即写入,读取方(util/request.js)每次请求实时读取,
// 进程内即时生效,无需重启
// - 凭证缺失时可通过 ensure() 按需刷新,并发调用共享同一次刷新任务
// - 以后新增的动态配置请放在这里,不要再往 os.tmpdir() 写文件
const runtimeState = {
/** 匿名令牌(MUSIC_A cookie),由 module/register_anonimous.js 成功后写入 */
anonymousToken: '',
/**
* xeapi 公钥(含 sk / version / deviceId 等字段),
* 由 module/register_xeapikey.js 成功后写入
*/
xeapiPublicKey: null,
}
/** 进行中的按需刷新任务:并发调用共享同一次刷新,避免重复请求 */
const refreshTasks = new Map()
/**
* 确保某项动态状态可用:已有值时直接返回;缺失时执行 refresh 刷新
* (refresh 成功后需自行把新值写入 runtimeState[key])。
* 并发调用会共享同一个进行中的刷新任务。
*
* @param {string} key runtimeState 上的字段名
* @param {() => Promise} refresh 刷新函数
* @returns {Promise<*>} 该状态当前的值(刷新失败时抛出错误)
*/
runtimeState.ensure = async (key, refresh) => {
if (runtimeState[key]) return runtimeState[key]
if (!refreshTasks.has(key)) {
refreshTasks.set(
key,
Promise.resolve()
.then(refresh)
.finally(() => {
refreshTasks.delete(key)
}),
)
}
await refreshTasks.get(key)
return runtimeState[key]
}
module.exports = runtimeState
-24
View File
@@ -1,24 +0,0 @@
const registerXeapiKey = require('../module/register_xeapikey')
const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => {
const result = await registerXeapiKey(
{
deviceId,
currentKeyVersion: currentPublicKey.version || '',
},
null,
)
const publicKey = result.body
if (!publicKey.sk && currentPublicKey.sk) {
publicKey.sk = currentPublicKey.sk
}
if (!publicKey.sk) {
throw new Error('xeapi public key response missing sk')
}
return publicKey
}
module.exports = {
getXeapiPublicKey,
}