Compare commits

..
Author SHA1 Message Date
dependabot[bot] 9c12d01719 build(packages): bump mocha from 11.8.0 to 12.0.3
Bumps [mocha](https://github.com/mochajs/mocha) from 11.8.0 to 12.0.3.
- [Release notes](https://github.com/mochajs/mocha/releases)
- [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mochajs/mocha/compare/v11.8.0...v12.0.3)

---
updated-dependencies:
- dependency-name: mocha
  dependency-version: 12.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-06 12:44:33 +00:00
Takanashi Hoshino 2db6684453 feat: 添加日推风格相关接口 (90de64140f) 2026-10-05 22:41:37 +08:00
Takanashi Hoshino 90de64140f feat: 添加日推风格相关接口 2026-10-05 22:38:41 +08:00
Takanashi Hoshino 2aab9957df bump 4.41.0 2026-10-03 20:50:35 +08:00
7N21 373d5eedb3 Merge pull request #278 from MT-SUPER-POWER/main
乐迷团相关接口补充
2026-10-03 20:45:47 +08:00
MT-SUPER-POWER c1cbdcb962 Merge remote-tracking branch 'upstream/HEAD' 2026-10-03 18:46:54 +08:00
MT-SUPER-POWER 37b64ab01e feat: 添加了'乐迷团'的相关接口 2026-10-03 18:42:36 +08:00
7N21 95367213f7 Merge pull request #276 from 1254qwer/fix/xeapi/csr
fix: xeapi 请求从 BSR 改为 CSR
2026-10-03 17:35:18 +08:00
1254qwer bd407cee81 fix: xeapi from BSR to CSR 2026-10-03 17:21:32 +08:00
7N21 a91ea8f832 Merge pull request #265 from cmachsocket/pr
compatibility fix of rumtime and legacy node
2026-10-02 23:44:50 +08:00
Takanashi Hoshino 4a45a14aa7 build: update denpendences 8ad30b591d 2026-10-01 18:19:20 +08:00
7N21 acbea014be Merge branch 'main' into pr 2026-10-01 16:02:25 +08:00
Takanashi Hoshino 8ad30b591d build: update dependences 2026-10-01 13:49:53 +08:00
Takanashi Hoshino cb449463fb Merge remote-tracking branch 'origin/dependabot/github_actions/actions/stale-11' 2026-09-30 22:33:18 +08:00
Takanashi Hoshino 91db12d0f5 Merge remote-tracking branch 'origin/dependabot/github_actions/actions/create-github-app-token-3' 2026-09-30 22:33:06 +08:00
7N21 135df9edda Merge pull request #274 from 1254qwer/feat/neapi
feat: 新增 neapi 算法支持
2026-09-30 20:19:55 +08:00
dependabot[bot] 548f6c170a build(deps): bump actions/create-github-app-token from 1 to 3
Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1 to 3.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-30 11:57:54 +00:00
Takanashi Hoshino 17c07960f9 remove ci 2026-09-30 19:56:15 +08:00
1254qwer c4d448641a feat: Add neapi support 2026-09-27 14:53:29 +08:00
cmachsocket 77a9269828 fix lint 2026-09-19 21:31:39 +08:00
cmachsocket 9a16c89714 compatibility fix of rumtime and legacy node 2026-09-14 21:13:16 +08:00
dependabot[bot] d9253bf930 build(deps): bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 12:47:33 +00:00
34 changed files with 1338 additions and 810 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Mark stale issues
uses: actions/stale@v10
uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: |
-67
View File
@@ -1,67 +0,0 @@
name: OpenCode Automation
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: auto-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
automate:
# 排除其他机器人触发,防止自我循环;
# 但允许 takanashi-hoshino-agent 创建的 PR,这样 /oc 生成的测试 PR 也能被自动评审
if: |
github.actor != 'github-actions[bot]' &&
github.actor != 'opencode-agent[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch config from CF Worker
id: cfg
uses: MoeFurina/pull-oc-config@v1
with:
url: https://bot-storage.interface.focalors.ltd/config
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ steps.cfg.outputs.app_id }}
private-key: ${{ steps.cfg.outputs.app_private_key }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
persist-credentials: true
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install opencode
run: npm i -g opencode-ai@latest
- name: Run automation
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
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: |
${{ steps.cfg.outputs.opencode_prompt }}
${{ steps.cfg.outputs.pr_review_prompt }}
run: |
mkdir -p scripts
curl -fsSL "https://raw.githubusercontent.com/MoeFurina/pull-oc-config/v1/scripts/opencode-automation.mjs" \
-o scripts/opencode-automation.mjs
node scripts/opencode-automation.mjs
+9 -4
View File
@@ -19,9 +19,10 @@ jobs:
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')) &&
github.actor != 'opencode-agent[bot]' &&
github.actor != 'takanashi-hoshino-agent[bot]' &&
github.actor != 'yukka-agent[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
@@ -33,9 +34,9 @@ jobs:
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@v3
with:
app-id: ${{ steps.cfg.outputs.app_id }}
client-id: ${{ steps.cfg.outputs.app_id }}
private-key: ${{ steps.cfg.outputs.app_private_key }}
owner: ${{ github.repository_owner }}
@@ -56,6 +57,7 @@ jobs:
env:
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
OPENCODE_PERMISSION: '{"permission":{"*":"allow","question":"deny"}}'
with:
model: opencode/muse-spark-1.3-contributor-free
use_github_token: true
@@ -63,4 +65,7 @@ jobs:
${{ steps.cfg.outputs.opencode_prompt }}
## 用户指令(来自触发评论,请严格按此执行)
${{ github.event.comment.body }}
${{ github.event.comment.body }}
## 用户要求Review时, 严格执行下面指令
${{ steps.cfg.outputs.pr_review_prompt }}
+1
View File
@@ -6,6 +6,7 @@ node_modules
.history
examples/moddef.json
bin
anonymous_token
.vercel
.env
IFLOW.md
+13 -6
View File
@@ -13,13 +13,18 @@
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
## 目录内非路由文件
- `plugins/`——内部上传辅助(`upload.js`、`songUpload.js`),被 `module/*` require,本身不是路由。
- `module_example/`——接口模板与 `main.js` 库调用示例(含 `test.js` 演示 `login_cellphone`→`song_url` 链路),不是运行时代码。
- `examples/get_static_moddef.js`——用 `getModulesDefinitions(..., false)`(`doRequire=false`)把全部路由导出为 `moddef.json`。改路由注册逻辑时可跑它核对。
- `module_types/` 为空目录;类型声明在根目录 `interface.d.ts`(`types` 字段)。
## 架构
- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(匿名令牌与 xeapi 公钥,存于内存),再调用 `server.serveNcmApi()`。不依赖 `/tmp` 等可写文件系统。
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- `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`、`generateConfig`(库用户可 `await main.generateConfig()` 预热/续期凭证;不调用也行,首个请求会在凭证缺失时自动引导)。
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。
- `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 头;匿名令牌与 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/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
- `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`。
@@ -40,5 +45,7 @@
- **改 `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 隧道)。
- **运行时凭证在内存(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`)。
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
- **`docs:check` 校验的是 `public/docs/home.md`(docsify 站点)**,不是 README;脚本默认只处理该文件,换行会被统一为 `\n`、连续空行压缩到 2 行,PR 改动它后记得跑 `pnpm docs:format`。
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`),并 `globalIgnores(['**/public/'])`——`public/` 下的前端页面不参与 lint。
- **`pnpm-workspace.yaml` 是单包工作区**(`packages: ['.']`),并带 `allowBuilds`(core-js/es5-ext)与 `minimumReleaseAgeExclude` 白名单;改依赖安装行为时先看它。
+8 -1
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
async function start() {
// 启动时预热运行时凭证(匿名令牌 / xeapi 公钥,均存于内存中的 runtimeState)
// 检测是否存在 anonymous_token 文件,没有则生成
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
}
// 启动时更新anonymous_token
const generateConfig = require('./generateConfig')
await generateConfig()
require('./server').serveNcmApi({
+42 -17
View File
@@ -1,30 +1,55 @@
const request = require('./util/request')
const { generateRandomChineseIP, generateDeviceId } = require('./util/index')
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 { loadNeapiKey } = require('./util/neapiKey')
const registerNeapikey = require('./module/register_neapikey')
const tmpPath = require('os').tmpdir()
/**
* 启动 / 手动刷新运行时动态配置。
*
* 凭证均存于内存(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 {
await request.refreshXeapiPublicKey()
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',
)
}
} catch (error) {
console.log(error)
}
try {
await request.refreshAnonymousToken()
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',
)
} catch (error) {
console.log(error)
}
try {
const current = loadNeapiKey()
await registerNeapikey(
{
version: current ? current.version : 0,
behavior: current ? 'update' : 'open',
},
null,
)
} catch (error) {
console.log(error)
}
}
module.exports = generateConfig
+29
View File
@@ -1087,6 +1087,10 @@ export function register_checktoken_v3(
params: { refresh?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function register_neapikey(
params: { version?: string | number; behavior?: string } & RequestBaseConfig,
): Promise<Response>
export function register_xeapikey(
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
): Promise<Response>
@@ -2886,3 +2890,28 @@ export function voicelist_trans(
} & MultiPageConfig &
RequestBaseConfig,
): Promise<Response>
export function fans_group_detail(
params: {
groupId: string
scene?: string
} & RequestBaseConfig,
): Promise<Response>
export function fans_group_feed_recommend(
params: {
fansGroupId: string
cursor?: string
size?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function fans_group_user_groups(
params?: RequestBaseConfig,
): Promise<Response>
export function fans_group_user_group_detail(
params: {
groupId: string | number
} & RequestBaseConfig,
): Promise<Response>
+6 -12
View File
@@ -1,7 +1,13 @@
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 = {}
@@ -40,7 +46,6 @@ moduleFiles.forEach((file) => {
})
let serverModule = null
let generateConfigModule = null
/**
* @type {Record<string, any> & import("./server")}
@@ -52,17 +57,6 @@ module.exports = {
}
return serverModule
},
/**
* 刷新运行时动态配置(匿名令牌 / xeapi 公钥,存于内存 runtimeState)。
* 作为依赖使用时可以不调用——首个请求会在凭证缺失时自动引导;
* 需要预热或手动续期时再 `await main.generateConfig()`。
*/
get generateConfig() {
if (!generateConfigModule) {
generateConfigModule = require('./generateConfig')
}
return generateConfigModule
},
...obj,
}
-60
View File
@@ -16,63 +16,3 @@ 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
})
})
})
+15
View File
@@ -0,0 +1,15 @@
// 乐迷团详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
groupId: query.groupId || query.id || '',
scene: query.scene || '',
}
return request(
'/api/social/fansgroup/bff/detail/get',
data,
createOption(query, 'eapi'),
)
}
+17
View File
@@ -0,0 +1,17 @@
// 乐迷团推荐笔记
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
artistSelf: query.artistSelf || '0',
fansGroupId: query.fansGroupId || query.groupId || '',
cursor: query.cursor || '0',
size: String(query.size || 10),
}
return request(
'/api/fans/group/feed/recommend/get',
data,
createOption(query, 'eapi'),
)
}
+14
View File
@@ -0,0 +1,14 @@
// 用户所处乐迷团详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
groupId: query.groupId || query.id || '',
}
return request(
'/api/social/fansgroup/bff/user/group/detail/get',
data,
createOption(query, 'eapi'),
)
}
+11
View File
@@ -0,0 +1,11 @@
// 获取当前登录用户加入的全部歌手乐迷团列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
'/api/social/fansgroup/bff/user/groups/get',
{},
createOption(query, 'eapi'),
)
}
+11
View File
@@ -0,0 +1,11 @@
// 每日风格推荐场景配置列表获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/homepage/daily/song/config/get`,
data,
createOption(query, 'xeapi'),
)
}
+17
View File
@@ -0,0 +1,17 @@
// 每日风格推荐场景配置设置
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
tags: JSON.stringify({
tagIds: String(query.tags).split(',').map(Number),
categoryId: Number(query.category),
}),
}
console.log(data)
return request(
`/api/homepage/daily/song/tag/save`,
data,
createOption(query, 'xeapi'),
)
}
+11
View File
@@ -0,0 +1,11 @@
// 每日推荐风格歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/homepage/category/daily/song/list`,
data,
createOption(query, 'xeapi'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 每日推荐歌曲 - v1
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ispush: query.ispush || false,
}
return request(
`/api/v3/discovery/recommend/songs`,
data,
createOption(query, 'xeapi'),
)
}
+2 -10
View File
@@ -7,8 +7,7 @@ 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, cookieToJson } = require('../util/index')
const runtimeState = require('../util/runtimeState')
const { generateDeviceId } = require('../util/index')
// function getRandomFromList(list) {
// return list[Math.floor(Math.random() * list.length)]
@@ -26,9 +25,7 @@ function cloudmusic_dll_encode_id(some_id) {
}
module.exports = async (query, request) => {
// 复用进程内已有的设备身份(真实客户端的设备 ID 是稳定的);
// xeapi 公钥注册同样依赖该 deviceId
const deviceId = global.deviceId || generateDeviceId()
const deviceId = generateDeviceId()
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
global.deviceId = deviceId
const encodedId = CryptoJS.enc.Base64.stringify(
@@ -45,11 +42,6 @@ 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: {
+48
View File
@@ -0,0 +1,48 @@
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const { saveNeapiKey } = require('../util/neapiKey')
const { decodeConfig } = require('../util/neapiConfig')
const USER_AGENT =
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)'
module.exports = async (query, request) => {
const version = Number(query.version) || 0
const behavior = query.behavior === 'update' ? 'update' : 'open'
const res = await axios({
method: 'GET',
url: APP_CONF.apiDomain + '/api/gorilla/algorithm/record',
params: { version, behavior },
headers: { 'User-Agent': USER_AGENT },
proxy: false,
})
if (!res.data || res.data.code !== 200 || !res.data.data) {
throw new Error('neapi config request failed')
}
// file 为空表示本地已是最新,无需更新
const { file, circleTime } = res.data.data
const config = file
? saveNeapiKey(decodeConfig(Buffer.from(file, 'base64')), circleTime)
: null
// 只回版本与密钥偏移,密钥本身留在本地缓存
const keyOffsets = {}
if (config) {
keyOffsets.encrypt = config.keyBlob.indexOf(config.encryptKey)
keyOffsets.decrypt = config.keyBlob.indexOf(config.decryptKey)
}
return {
status: 200,
body: {
version: config ? config.version : res.data.data.version,
updated: Boolean(config),
circleTime,
keyOffsets,
},
cookie: [],
}
}
+1 -5
View File
@@ -1,7 +1,6 @@
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 = ''
@@ -33,7 +32,7 @@ module.exports = async (query, request) => {
const res = await axios({
method: 'POST',
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
url: APP_CONF.apiDomain + '/api/bsr/sk/get',
headers: {
'User-Agent':
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
@@ -64,9 +63,6 @@ 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: {
+12 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@neteasecloudmusicapienhanced/api",
"version": "4.40.1",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"version": "4.41.1",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced)",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js",
@@ -75,22 +75,23 @@
"data"
],
"dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.4",
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.5",
"axios": "^1.20.0",
"crypto-js": "^4.2.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"fzstd": "^0.1.1",
"gzip": "^0.1.0",
"jsdom": "^24.1.3",
"music-metadata": "^11.15.0",
"music-metadata": "^11.16.1",
"node-forge": "^1.4.0",
"pac-proxy-agent": "^7.2.0",
"qrcode": "^1.5.4",
"safe-decode-uri-component": "^1.2.1",
"tunnel": "^0.0.6",
"xml2js": "^0.6.2",
"yargs": "^18.1.0"
"yargs": "^18.2.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.7",
@@ -99,21 +100,21 @@
"@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10",
"@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.69.0",
"@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.71.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-html": "^8.2.0",
"eslint-plugin-html": "^8.2.1",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.12.0",
"globals": "^17.13.0",
"husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0",
"mocha": "^11.8.0",
"mocha": "^12.0.3",
"nodemon": "^3.1.14",
"pkg": "^5.8.1",
"power-assert": "^1.6.1",
"prettier": "^3.9.6",
"prettier": "^3.9.9",
"typescript": "^5.9.3"
},
"publishConfig": {
+358 -484
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -319,6 +319,7 @@
<option value="api">api</option>
<option value="linuxapi">linuxapi</option>
<option value="xeapi">xeapi</option>
<option value="neapi">neapi</option>
<option value="" selected>(默认)</option>
</select>
</div>
+88 -2
View File
@@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数)
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新)
## 接口文档
@@ -1307,7 +1307,7 @@ tags: 歌单标签
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为`c512` => `新版c51类型`, `ste2` => `新版环绕立体声类型`, `aac2` => `新版aac类型`, `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`
@@ -2391,6 +2391,16 @@ privilege:权限相关信息
**调用例子 :** `/recommend/songs`
### 获取每日推荐歌曲 - v1
说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 )
**可选参数 :** `ispush`: 是否刷新日推 , 默认为 false
**接口地址 :** `/recommend/songs/v1`
**调用例子 :** `/recommend/songs/v1`
### 每日推荐歌曲-不感兴趣
说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 )
@@ -5878,6 +5888,82 @@ let data = encodeURIComponent(
**调用例子 :** `/song/simi/get?id=39227633`
### 获取用户乐迷团列表
说明 : 登录后调用此接口 , 可获取当前登录用户已加入的全部歌手乐迷团列表
**接口地址 :** `/fans/group/user/groups`
**调用例子 :** `/fans/group/user/groups`
### 获取乐迷团详情
说明 : 调用此接口 , 可获取乐迷团详情
**必选参数 :** `groupId` : 乐迷团 ID
**可选参数 :** `scene` : 场景标识
**接口地址 :** `/fans/group/detail`
**调用例子 :** `/fans/group/detail?groupId=1872529203038486609`
### 获取用户在乐迷团的详情
说明 : 登录后调用此接口 , 可获取当前用户在指定乐迷团的详细成员信息及铭牌等级
**必选参数 :** `groupId` : 乐迷团 ID
**接口地址 :** `/fans/group/user/group/detail`
**调用例子 :** `/fans/group/user/group/detail?groupId=1755777360858267711`
### 获取乐迷团推荐笔记
说明 : 调用此接口 , 可获取指定乐迷团的推荐笔记
**必选参数 :** `fansGroupId` : 乐迷团 ID
**可选参数 :** `cursor` : 游标 , 默认为 0
`size` : 返回数量 , 默认为 10
**接口地址 :** `/fans/group/feed/recommend`
**调用例子 :** `/fans/group/feed/recommend?fansGroupId=1872529203038486609&size=10&cursor=0`
## 每日指定风格歌曲推荐
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐
**接口地址 :** `/recommend/category/songs`
**调用例子 :** `/recommend/category/songs`
## 获取每日指定风格歌曲推荐的风格列表
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐的风格列表
**接口地址 :** `/recommend/category/configs`
**调用例子 :** `/recommend/category/configs`
## 设置每日指定风格歌曲推荐的风格
说明 : 调用此接口 , 可设置每日指定风格歌曲推荐的风格
**必选参数 :**
`tags`: 风格标签, 可通过 `/recommend/category/configs` 获取
`category`: 风格标签的父ID, 可通过 `/recommend/category/configs` 获取
!> 只能设置当前CategoryId下的tags, 不能设置其他CategoryId下的tags, 否则会报错
**接口地址 :** `/recommend/category/config`
**调用例子 :** `/recommend/category/config?tags=10004,10015&categoryId=1000`
## 离线访问此文档
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
+4
View File
@@ -1,7 +1,11 @@
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')
+1
View File
@@ -13,6 +13,7 @@
"apiDomain": "https://interface.music.163.com",
"eapiDomain": "https://interfacepc.music.163.com",
"xeapiDomain": "https://interface3.music.163.com",
"neapiDomain": "https://interface3.music.163.com",
"domain": "https://music.163.com",
"clDomian": "https://clientlog.music.163.com",
"clDomian3": "https://clientlog3.music.163.com",
+106 -8
View File
@@ -2,6 +2,7 @@ const CryptoJS = require('crypto-js')
const crypto = require('crypto')
const forge = require('node-forge')
const zlib = require('zlib')
const zstd = require('./zstd')
const iv = '0102030405060708'
const presetKey = '0CoJUm6Qyw8W8jud'
const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
@@ -151,7 +152,12 @@ const decrypt = (cipher) => {
}
const aesEcbEncrypt = (key, plaintext) => {
const cipher = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null)
// 某些运行时不允许使用 null 作为 IV,因此使用 Buffer.alloc(0) ,同时与上下文deriveX25519AesKey等函数保持一致
const cipher = crypto.createCipheriv(
`aes-${key.length * 8}-ecb`,
key,
Buffer.alloc(0),
)
return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
}
@@ -159,7 +165,7 @@ const aesEcbDecrypt = (key, ciphertext) => {
const decipher = crypto.createDecipheriv(
`aes-${key.length * 8}-ecb`,
key,
null,
Buffer.alloc(0),
)
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
}
@@ -200,9 +206,19 @@ const xeapiMidTransform = (ciphertext) => {
for (let i = 0; i < ciphertext.length; i++) {
xored[i] = ciphertext[i] ^ random[i & 0x0f]
}
const b64 = Buffer.from(xored.toString('base64'))
const rot = b64.length ? (random[0] & 0x0f) % b64.length : 0
return Buffer.concat([random, b64.subarray(rot), b64.subarray(0, rot)])
const rot = xored.length ? (random[0] & 0x0f) % xored.length : 0
return Buffer.concat([random, xored.subarray(rot), xored.subarray(0, rot)])
}
const decoder = new TextDecoder('utf-8', { fatal: true })
const isUtf8 = (buf) => {
try {
decoder.decode(buf)
return true
} catch {
return false
}
}
const xeapiEncryptS = (dynamicKey, publicKeyState, os) => {
@@ -245,7 +261,13 @@ const buildXeapiPlaintext = (uri, data, options = {}) => {
const bodyData = { ...data }
delete bodyData.e_r
const body = Buffer.from(new URLSearchParams(bodyData).toString())
fields.body = body.toString('base64')
if (body.length) {
if (mediaType === 'application/x-www-form-urlencoded' && isUtf8(body)) {
fields.content = body.toString('utf8')
} else {
fields.body = body.toString('base64')
}
}
}
if (fields.queryString) {
@@ -268,7 +290,7 @@ const xeapi = (uri, data, options = {}) => {
const dynamicKey = activeSessionKey || crypto.randomBytes(16)
const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options))
const b = aesEcbEncrypt(
const c = aesEcbEncrypt(
dynamicKey,
xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)),
)
@@ -281,7 +303,7 @@ const xeapi = (uri, data, options = {}) => {
)
return {
B: b.toString('base64'),
C: c.toString('base64url'),
S: s.toString('base64'),
R: r.toString('base64'),
}
@@ -305,11 +327,86 @@ const xeapiDecryptPublicKey = (encryptedData) => {
)
}
const NEAPI_MAGIC = Buffer.from('CSJM')
const NEAPI_NONCE_LEN = 12
const NEAPI_AD_LEN = 16
const NEAPI_TAG_LEN = 16
const NEAPI_HEADER_LEN = 4 + 1 + 1 + 2 + NEAPI_NONCE_LEN + 1 + NEAPI_AD_LEN + 4
// Header: magic, the config version as two big-endian bytes, then the
// nonce / associated data / sealed length.
const neapi = (data, config) => {
const nonce = crypto.randomBytes(NEAPI_NONCE_LEN)
const ad = crypto.randomBytes(NEAPI_AD_LEN)
const cipher = crypto.createCipheriv(
'chacha20-poly1305',
config.encryptKey,
nonce,
{ authTagLength: NEAPI_TAG_LEN },
)
cipher.setAAD(ad)
const sealed = Buffer.concat([
cipher.update(zstd.compress(data)),
cipher.final(),
cipher.getAuthTag(),
])
const header = Buffer.alloc(NEAPI_HEADER_LEN)
NEAPI_MAGIC.copy(header, 0)
header[4] = config.version >> 8
header[5] = config.version & 0xff
header.writeUInt16LE(nonce.length, 6)
nonce.copy(header, 8)
header[8 + nonce.length] = ad.length
ad.copy(header, 9 + nonce.length)
header.writeUInt32LE(sealed.length, 9 + nonce.length + ad.length)
return Buffer.concat([header, sealed]).toString('base64')
}
// Some error responses repeat the same document back to back.
const parseBody = (text) => {
try {
return JSON.parse(text)
} catch (error) {
const boundary = text.indexOf('}{')
if (boundary < 0) throw error
return JSON.parse(text.slice(0, boundary + 1))
}
}
const neapiResDecrypt = (body, config) => {
const raw = Buffer.from(String(body).trim(), 'base64')
const nonceLen = raw.readUInt16LE(6)
const nonce = raw.subarray(8, 8 + nonceLen)
const adLen = raw[8 + nonceLen]
const ad = raw.subarray(9 + nonceLen, 9 + nonceLen + adLen)
const offset = 9 + nonceLen + adLen
const sealed = raw.subarray(offset + 4, offset + 4 + raw.readUInt32LE(offset))
const decipher = crypto.createDecipheriv(
'chacha20-poly1305',
config.decryptKey,
nonce,
{ authTagLength: NEAPI_TAG_LEN },
)
decipher.setAAD(ad)
decipher.setAuthTag(sealed.subarray(sealed.length - NEAPI_TAG_LEN))
const compressed = Buffer.concat([
decipher.update(sealed.subarray(0, sealed.length - NEAPI_TAG_LEN)),
decipher.final(),
])
return parseBody(zstd.decompress(compressed).toString())
}
module.exports = {
weapi,
linuxapi,
eapi,
xeapi,
neapi,
decrypt,
aesEncrypt,
aesDecrypt,
@@ -318,4 +415,5 @@ module.exports = {
xeapiSign,
xeapiResDecrypt,
xeapiDecryptPublicKey,
neapiResDecrypt,
}
+258
View File
@@ -0,0 +1,258 @@
const crypto = require('crypto')
const zstd = require('./zstd')
// gorilla/algorithm/record 返回的 base64 解码后:
// [0 .. 64) RSA 块 -> RSA_PKCS1 去填充得到 RC4 密钥
// [64 .. 68) u32 LE 载荷长度
// [68 .. ) RC4 载荷 -> zstd 解压得到 ECFG 容器
const CONFIG_PUBLIC_KEY = Buffer.from(
'3048024100e4e79c1cb27a9fab7d2c740e99e923ec51c009169ec3a380c60404' +
'e01b060f0160cdc631d9fb7fe53b228a5ff11bee42b965d28c6c2036193f4ccd' +
'c7ed853d110203010001',
'hex',
)
// 容器里的 Lua 区块对指令和字符串做了逐字节异或混淆
const CODE_XOR = [
0x94b4f825, 0x977c6795, 0x327de32b, 0x16da5ba1, 0xcf55a2eb, 0x080bb445,
0x021af055, 0xfc56b8bd,
]
const STR_XOR = Buffer.from('aefda2d1832b518a7741c325fb2687f0', 'hex')
const CHUNK_SIGNATURE = Buffer.from('7f454c46', 'hex')
// 脚本用这两个标记区分请求方向与响应方向
const DIRECTION_ENCRYPT = 251
const DIRECTION_DECRYPT = 503
const KEY_LEN = 32
const T_NUMFLT = 3
const T_NUMINT = 19
const OP_LOADK = 1
const OP_GETUPVAL = 5
const OP_EQ = 31
const op = (ins) => ins & 0x3f
const argA = (ins) => (ins >>> 6) & 0xff
const argB = (ins) => (ins >>> 23) & 0x1ff
const argC = (ins) => (ins >>> 14) & 0x1ff
const argBx = (ins) => (ins >>> 14) & 0x3ffff
const rc4 = (key, data) => {
const s = Buffer.alloc(256)
for (let i = 0; i < 256; i++) s[i] = i
let j = 0
for (let i = 0; i < 256; i++) {
j = (j + s[i] + key[i % key.length]) & 0xff
;[s[i], s[j]] = [s[j], s[i]]
}
const out = Buffer.allocUnsafe(data.length)
let i = 0
j = 0
for (let n = 0; n < data.length; n++) {
i = (i + 1) & 0xff
j = (j + s[i]) & 0xff
;[s[i], s[j]] = [s[j], s[i]]
out[n] = data[n] ^ s[(s[i] + s[j]) & 0xff]
}
return out
}
const unwrapContainer = (raw) => {
const key = crypto.publicDecrypt(
crypto.createPublicKey({
key: CONFIG_PUBLIC_KEY,
format: 'der',
type: 'pkcs1',
}),
raw.subarray(0, 64),
)
const size = raw.readUInt32LE(64)
return zstd.decompress(rc4(key, raw.subarray(68, 68 + size)))
}
class ChunkReader {
constructor(buf, pos = 0) {
this.buf = buf
this.pos = pos
}
u8() {
return this.buf[this.pos++]
}
u32() {
const value = this.buf.readUInt32LE(this.pos)
this.pos += 4
return value
}
i64() {
const value = this.buf.readBigInt64LE(this.pos)
this.pos += 8
return Number(value)
}
f64() {
const value = this.buf.readDoubleLE(this.pos)
this.pos += 8
return value
}
string() {
let size = this.u8()
if (size === 0) return null
if (size === 0xff) size = this.u32()
// 长度比实际字节数多 1(标准 luac 的写法)
const body = this.buf.subarray(this.pos, this.pos + size - 1)
this.pos += size - 1
const out = Buffer.allocUnsafe(body.length)
for (let i = 0; i < body.length; i++) {
out[i] = body[i] ^ STR_XOR[i & 0x0f]
}
return out.toString('utf8')
}
}
const loadProto = (r) => {
const proto = {
source: r.string(),
linedefined: r.u32(),
lastlinedefined: r.u32(),
numparams: r.u8(),
isvararg: r.u8(),
maxstack: r.u8(),
}
let count = r.u32()
proto.code = new Array(count)
for (let i = 0; i < count; i++) {
proto.code[i] = (r.u32() ^ CODE_XOR[i & 7]) >>> 0
}
// 常量标签:0 nil / 1 boolean / 3 float / 19 integer / 4,20 string
// 只保留用得到的整数与字符串
count = r.u32()
proto.consts = new Array(count)
for (let i = 0; i < count; i++) {
const tag = r.u8()
if (tag === 0) proto.consts[i] = null
else if (tag === 1) {
r.u8()
proto.consts[i] = null
} else if (tag === T_NUMFLT) {
r.f64()
proto.consts[i] = null
} else if (tag === T_NUMINT) proto.consts[i] = r.i64()
else proto.consts[i] = r.string()
}
count = r.u32()
proto.upvals = new Array(count)
for (let i = 0; i < count; i++) {
proto.upvals[i] = [r.u8(), r.u8()]
}
count = r.u32()
proto.protos = new Array(count)
for (let i = 0; i < count; i++) {
proto.protos[i] = loadProto(r)
}
// 调试段跳过即可
count = r.u32()
r.pos += 4 * count
count = r.u32()
for (let i = 0; i < count; i++) {
r.string()
r.u32()
r.u32()
}
count = r.u32()
for (let i = 0; i < count; i++) {
r.string()
}
return proto
}
const loadChunk = (buf) => {
if (!buf.subarray(0, 4).equals(CHUNK_SIGNATURE)) {
throw new Error('neapi config is not a chunk')
}
const r = new ChunkReader(buf, 33)
r.u8() // 主闭包 upvalue 数量
return loadProto(r)
}
// 主函数用一连串 LOADK 装载所有标量,据此可以把 upvalue 追回常量值
const mainRegisters = (main) => {
const regs = new Map()
for (const ins of main.code) {
if (op(ins) === OP_LOADK) regs.set(argA(ins), main.consts[argBx(ins)])
}
return regs
}
// create_ctx 里方向 -> 密钥偏移的分支:
// if direction == <a> then offset = <upvalue>
// elseif direction == <b> then offset = <upvalue>
const extractKeyOffsets = (script) => {
const createCtx = script.protos[0]
const regs = mainRegisters(script)
const offsets = {}
let pending = null
for (const ins of createCtx.code) {
const code = op(ins)
if (code === OP_EQ) {
const c = argC(ins)
const constant = c & 0x100 && createCtx.consts[c & 0xff]
if (Number.isInteger(constant)) pending = constant
} else if (code === OP_GETUPVAL && pending !== null) {
const up = createCtx.upvals[argB(ins)]
const constant = up && up[0] === 1 && regs.get(up[1])
if (Number.isInteger(constant)) {
offsets[pending] = constant
pending = null
}
}
}
if (!(DIRECTION_ENCRYPT in offsets) || !(DIRECTION_DECRYPT in offsets)) {
throw new Error('neapi config script has an unexpected direction table')
}
return offsets
}
const extractKeyBlob = (script) => {
const blob = script.consts
.filter(
(c) => typeof c === 'string' && /^[A-Za-z0-9+/]{64,}={0,2}$/.test(c),
)
.sort((a, b) => b.length - a.length)[0]
return Buffer.from(blob, 'base64')
}
const decodeConfig = (raw) => {
const ecfg = unwrapContainer(raw)
// ECFG 头 4 字节 + 大端 u16 版本,之后是 Lua 区块
const version = ecfg.readUInt16BE(4)
const script = loadChunk(ecfg.subarray(6))
const offsets = extractKeyOffsets(script)
const keyBlob = extractKeyBlob(script)
const slice = (direction) =>
keyBlob.subarray(offsets[direction], offsets[direction] + KEY_LEN)
return {
version,
keyBlob,
encryptKey: slice(DIRECTION_ENCRYPT),
decryptKey: slice(DIRECTION_DECRYPT),
}
}
module.exports = {
decodeConfig,
}
+49
View File
@@ -0,0 +1,49 @@
const fs = require('fs')
const path = require('path')
// 配置会轮换,启动时由 /register/neapikey 拉取后缓存到这里
const neapiKeyPath = path.resolve(require('os').tmpdir(), 'neapi_key')
let neapi_key = null
const loadNeapiKey = () => {
if (!neapi_key && fs.existsSync(neapiKeyPath)) {
try {
const stored = JSON.parse(fs.readFileSync(neapiKeyPath, 'utf-8'))
neapi_key = {
version: stored.version,
circleTime: stored.circleTime,
fetchedAt: stored.fetchedAt,
keyBlob: Buffer.from(stored.keyBlob, 'base64'),
encryptKey: Buffer.from(stored.encryptKey, 'base64'),
decryptKey: Buffer.from(stored.decryptKey, 'base64'),
}
} catch (error) {
console.log('[ERR]', error)
}
}
return neapi_key
}
const saveNeapiKey = (config, circleTime) => {
const fetchedAt = Date.now()
fs.writeFileSync(
neapiKeyPath,
JSON.stringify({
version: config.version,
circleTime,
fetchedAt,
keyBlob: config.keyBlob.toString('base64'),
encryptKey: config.encryptKey.toString('base64'),
decryptKey: config.decryptKey.toString('base64'),
}),
'utf-8',
)
neapi_key = { ...config, circleTime, fetchedAt }
return neapi_key
}
module.exports = {
loadNeapiKey,
saveNeapiKey,
}
+127 -66
View File
@@ -7,7 +7,9 @@ const { PacProxyAgent } = require('pac-proxy-agent')
const http = require('http')
const https = require('https')
const tunnel = require('tunnel')
const runtimeState = require('./runtimeState')
const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
const {
cookieToJson,
cookieObjToString,
@@ -16,12 +18,34 @@ const {
} = require('./index')
const { URLSearchParams, URL } = require('url')
const { APP_CONF } = require('./config.json')
const { loadNeapiKey } = require('./neapiKey')
const {
getToken: antiCheatTokenV2,
} = require('../module/register_checktoken_v2')
const {
getToken: antiCheatTokenV3,
} = require('../module/register_checktoken_v3')
const registerNeapikey = require('../module/register_neapikey')
// 预先读取匿名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
@@ -49,6 +73,20 @@ const WNMCID = (function () {
let NMTID = ''
let NMTID_RETRIES_LEFT = 3
// neapi 配置过期时只让一个请求去刷新,并发的其余请求共用同一次结果
let neapiUserStatus = ''
let neapiRefreshing = null
const refreshNeapiKey = (version) => {
if (!neapiRefreshing) {
neapiRefreshing = registerNeapikey({ version, behavior: 'update' }, null)
.catch((error) => console.log('[ERR]', error))
.finally(() => {
neapiRefreshing = null
})
}
return neapiRefreshing
}
// 预先定义osMap
const osMap = {
pc: {
@@ -105,6 +143,7 @@ const DOMAIN = APP_CONF.domain
const API_DOMAIN = APP_CONF.apiDomain
const EAPI_DOMAIN = APP_CONF.eapiDomain
const XEAPI_DOMAIN = APP_CONF.xeapiDomain
const NEAPI_DOMAIN = APP_CONF.neapiDomain
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
@@ -147,8 +186,7 @@ const processCookieObject = (cookie, crypto) => {
}
if (!processedCookie.MUSIC_U) {
processedCookie.MUSIC_A =
processedCookie.MUSIC_A || runtimeState.anonymousToken
processedCookie.MUSIC_A = processedCookie.MUSIC_A || anonymous_token
}
return processedCookie
@@ -175,33 +213,6 @@ 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) {
@@ -215,32 +226,6 @@ 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 } : {}
@@ -305,7 +290,7 @@ const createRequest = async (uri, data, options) => {
break
case 'xeapi':
const xeapiPublicKey = runtimeState.xeapiPublicKey
const xeapiPublicKey = loadXeapiPublicKey()
if (!xeapiPublicKey) {
throw new Error('xeapi public key is missing')
}
@@ -353,6 +338,47 @@ const createRequest = async (uri, data, options) => {
})
break
case 'neapi':
const neapiKey = loadNeapiKey()
if (!neapiKey) {
throw new Error('neapi config is missing')
}
// 超过 circleTime 就后台刷新,本次仍用手上的配置
if (now() - neapiKey.fetchedAt >= neapiKey.circleTime) {
refreshNeapiKey(neapiKey.version)
}
const neapiCookie = {
osver: cookie.osver,
deviceId: cookie.deviceId,
os: cookie.os,
appver: cookie.appver,
versioncode: cookie.versioncode || '140',
mobilename: cookie.mobilename || '',
buildver: cookie.buildver || now().toString().substr(0, 10),
resolution: cookie.resolution || '1920x1080',
__csrf: csrfToken,
channel: cookie.channel,
requestId: generateRequestId(),
}
if (cookie.MUSIC_U) neapiCookie['MUSIC_U'] = cookie.MUSIC_U
if (cookie.MUSIC_A) neapiCookie['MUSIC_A'] = cookie.MUSIC_A
headers['Cookie'] = createHeaderCookie(neapiCookie)
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'android')
headers['content-type'] =
'application/x-www-form-urlencoded;charset=utf-8'
// 服务端按 X-ER 选密钥,配置过期时由下面的 777/999 分支重拉并重试
headers['X-ER'] = String(neapiKey.version)
if (neapiUserStatus) headers['X-USER-STATUS'] = neapiUserStatus
const neapiBody = { ...data }
delete neapiBody.e_r
encryptData = encrypt.neapi(
new URLSearchParams(neapiBody).toString(),
neapiKey,
)
url = (options.domain || NEAPI_DOMAIN) + '/neapi/' + uri.substr(5)
break
case 'eapi':
case 'api':
// header创建
@@ -399,12 +425,19 @@ const createRequest = async (uri, data, options) => {
console.log('[ERR]', 'Unknown Crypto:', crypto)
break
}
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
const use_xeapi = crypto === 'xeapi'
const use_neapi = crypto === 'neapi'
// settings创建
let settings = {
method: 'POST',
url: url,
headers: headers,
data: new URLSearchParams(encryptData).toString(),
// neapi 的请求体就是容器本身,不是表单字段
data: use_neapi
? encryptData
: new URLSearchParams(encryptData).toString(),
httpAgent: createHttpAgent(),
httpsAgent: createHttpsAgent(),
}
@@ -415,9 +448,7 @@ const createRequest = async (uri, data, options) => {
}
// 使用返回值加密
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
const use_xeapi = crypto === 'xeapi'
if (use_e_r || use_xeapi) {
if (use_e_r || use_xeapi || use_neapi) {
settings.encoding = null
settings.responseType = 'arraybuffer'
}
@@ -458,7 +489,7 @@ const createRequest = async (uri, data, options) => {
}
// console.log(settings.headers);
axios(settings)
.then((res) => {
.then(async (res) => {
const body = res.data
const setCookies = res.headers['set-cookie'] || []
@@ -472,7 +503,11 @@ const createRequest = async (uri, data, options) => {
!cookie.NMTID
) {
NMTID_RETRIES_LEFT--
answer.cookie = setCookies.map((x) => {
answer.cookie = (
typeof res.headers['set-cookie'] === 'string'
? [res.headers['set-cookie']]
: res.headers['set-cookie'] || []
).map((x) => {
const cleaned = cleanCookie(x)
const match = x.match(/(?:^|;\s*)NMTID=([^;]+)/)
if (match) {
@@ -485,6 +520,30 @@ const createRequest = async (uri, data, options) => {
answer.cookie = setCookies.map(cleanCookie)
}
// 空值同样要记下来,否则状态恢复后会一直带着上一次的值
const userStatus = res.headers['x-user-status'] || ''
if (use_neapi) {
neapiUserStatus = userStatus
if (
(userStatus === '777' || userStatus === '999') &&
!options.neapiRetried
) {
// 配置已轮换:刷新后重试一次
await refreshNeapiKey(loadNeapiKey().version)
try {
resolve(
await createRequest(uri, data, {
...options,
neapiRetried: true,
}),
)
} catch (error) {
reject(error)
}
return
}
}
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
// logger.debug(`[${crypto}]`, uri)
@@ -503,6 +562,11 @@ const createRequest = async (uri, data, options) => {
xeapiSessionKey = res.headers['x-encr-sskey']
}
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body))
} else if (use_neapi) {
answer.body = encrypt.neapiResDecrypt(
Buffer.from(body).toString(),
loadNeapiKey(),
)
} else if (use_e_r) {
answer.body = encrypt.eapiResDecrypt(
body.toString('hex').toUpperCase(),
@@ -548,6 +612,3 @@ const createRequest = async (uri, data, options) => {
}
module.exports = createRequest
// 供 generateConfig 等复用的强制刷新入口(成功后写入 runtimeState)
module.exports.refreshAnonymousToken = refreshAnonymousToken
module.exports.refreshXeapiPublicKey = refreshXeapiPublicKey
-56
View File
@@ -1,56 +0,0 @@
// 统一的运行时动态状态存储(仅内存,不落盘)
//
// 存放需要在进程内共享、且会在运行期间变化的动态配置,
// 目前包括匿名令牌(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
@@ -0,0 +1,24 @@
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,
}
+41
View File
@@ -0,0 +1,41 @@
const zlib = require('zlib')
const { decompress: fzstdDecompress } = require('fzstd')
// Node 22.15+ 自带 zstd。更早的版本(CI 含 18)用 fzstd 解压,压缩则退回到
// raw block 帧:内容原样存储,任何 zstd 解码器都能读,只是没有压缩率。
const hasNative = typeof zlib.zstdCompressSync === 'function'
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
const WINDOW_DESCRIPTOR = 0x50 // windowLog 20
const BLOCK_MAX = 0x20000 // zstd 单块上限 128 KiB
const compressRawFrame = (input) => {
const parts = [MAGIC, Buffer.from([0x00, WINDOW_DESCRIPTOR])]
let offset = 0
do {
const block = input.subarray(offset, offset + BLOCK_MAX)
const last = offset + BLOCK_MAX >= input.length
const header = Buffer.alloc(3)
header.writeUIntLE((block.length << 3) | (last ? 1 : 0), 0, 3)
parts.push(header, block)
offset += BLOCK_MAX
} while (offset < input.length)
return Buffer.concat(parts)
}
const compress = (input) => {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
return hasNative ? zlib.zstdCompressSync(buf) : compressRawFrame(buf)
}
const decompress = (input) => {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
return hasNative
? zlib.zstdDecompressSync(buf)
: Buffer.from(fzstdDecompress(buf))
}
module.exports = {
compress,
decompress,
}