Compare commits

...
81 Commits
Author SHA1 Message Date
Takanashi Hoshino 1bdc85998b bump 5.0.0 2026-10-17 20:21:42 +08:00
Takanashi Hoshino 49c7800afe build(package): 更换pkg依赖并提升node要求至22 2026-10-17 19:48:29 +08:00
Takanashi Hoshino 19c7dad6db docs: 修正文档 2026-10-17 19:29:09 +08: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
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
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
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
MoeFurina 4a91f77e62 ci: fix pnpm has no pnpm-workspace.yaml 2026-08-18 20:18:41 +08:00
MoeFurina a7437f7ca4 feat: 更新歌曲相关接口 2026-08-18 20:11:30 +08:00
Takanashi Hoshino 498945c401 Merge pull request #241 from 1254qwer/fix/song-url-v1
fix: 支持获取臻音全景声
2026-08-18 19:30:02 +08:00
MoeFurina 7b17a08628 docs: 补全私人漫游模式文档内容 2026-08-18 19:23:30 +08:00
1254qwer 0b5c3927c9 fix: /song/url/v1/302 & /song/download/url/v1 支持臻音全景声 2026-08-17 09:15:51 +08:00
1254qwer 881267c56d fix: /song/url/v1 支持臻音全景声 2026-08-16 19:08:38 +08:00
MoeFurina d7c0db9455 fix: 错误的docker配置 2026-08-09 11:36:53 +08:00
Takanashi Hoshino c1d14a86d9 Merge pull request #237 from NeteaseCloudMusicApiEnhanced/fix/yidun
refactor: 重写checkToken V2模块
2026-08-09 11:21:39 +08:00
MoeFurina a2a2a40d71 fixes 2026-08-07 13:45:01 +08:00
MoeFurina 543660fcdf fix: pkg打包node18兼容 2026-08-07 12:13:43 +08:00
MoeFurina 98a95002b2 fix: node18兼容 2026-08-07 12:01:11 +08:00
MoeFurina c4a3d83abe refactor: 重写checkToken V2模块 2026-08-07 11:52:54 +08:00
Takanashi Hoshino 7753b5de41 Merge pull request #235 from NeteaseCloudMusicApiEnhanced/feat/yunbei_task
feat: 新增云贝任务相关接口
2026-08-06 16:58:07 +08:00
MoeFurina c1fe1eb925 Merge branch 'pr/235' 2026-08-06 16:55:27 +08:00
MoeFurina 3357317a1d upd 2026-08-06 16:51:51 +08:00
MoeFurina a08f425ae7 Merge branch 'main' into feat/yunbei_task 2026-08-06 16:45:11 +08:00
MoeFurina 5b780addba ci: test ci with the workflow file 2026-08-05 20:44:21 +08:00
MoeFurina fc9f41839c ci: test ci with workflow file 2026-08-05 19:44:08 +08:00
MoeFurina 5859944563 format docs 2026-08-05 18:31:49 +08:00
MoeFurina aaa6459e55 remove disclaimer 2026-08-05 18:13:14 +08:00
MoeFurina a898e1d86c feat: 新增云贝任务相关接口 2026-08-05 18:11:31 +08:00
MoeFurina 4045f1ad3f Merge branch 'pr/227' 2026-08-02 16:40:25 +08:00
MoeFurina e6214a3f73 Merge branch 'pr/229' 2026-08-02 16:39:14 +08:00
MoeFurina 686e8bf969 revert: revert some changes 2026-08-02 16:37:46 +08:00
anupamme 9214cb945f fix: V-001 security vulnerability
Automated security fix generated by OrbisAI Security
2026-08-02 08:35:02 +00:00
MoeFurina a79a2d9ef6 fix: 修正workflow文件 2026-08-02 16:19:40 +08:00
7788dev 9608da9b96 fix(voice): load created podcasts in upload example 2026-08-02 12:52:25 +08:00
7788dev 93b3e624d8 feat(voice): support cover image uploads 2026-08-02 11:53:51 +08:00
MoeFurina 15f515c1ba ci(test): test ci with the workflow file 2026-08-02 11:27:45 +08:00
anupamme e255e8f334 fix: V-001 security vulnerability
Automated security fix generated by OrbisAI Security
2026-08-02 03:16:22 +00:00
MoeFurina 16aa61f98e ci(test): test ci workflow file 2026-08-02 11:00:13 +08:00
MoeFurina a939003b92 ci(test): test ci workflow file 2026-08-02 10:46:19 +08:00
MoeFurina 8f4873f2e2 perf: 更新接口定义 2026-08-01 19:11:37 +08:00
MoeFurina a8b45a8b8b Merge branch 'pr/225' 2026-08-01 17:34:54 +08:00
MoeFurina 1a70281c76 ci: 更新问题管理工作流 2026-08-01 17:20:19 +08:00
tanjuntao f5f4ff6eda feat: add complete current-user event listing 2026-08-01 10:25:00 +08:00
tanjuntao eb1b5ba0ea feat: add event privacy endpoint 2026-08-01 10:25:00 +08:00
MoeFurina 6732fc7c32 ci(test): test ci workflow file 2026-07-31 22:21:34 +08:00
MoeFurina 6ce6b84015 fix: 修复分享笔记接口 2026-07-26 22:39:58 +08:00
70 changed files with 4831 additions and 905 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+9 -8
View File
@@ -14,15 +14,16 @@ body:
id: terms id: terms
attributes: attributes:
label: 确认事项 label: 确认事项
description: 在提交功能请求前,请确认以下事项 description: |
在提交功能请求前,请确认以下事项
- 我已经搜索了现有的issues,确认这不是重复请求
- 我已经查看了项目文档
- 该项目符合该项目的目标和范围
- 我确认我提供的接口请求是合理且有意义的
options: options:
- label: 我已经搜索了现有的issues,确认这不是重复请求 - label: 我已确认以上事项
required: true
- label: 我已经查看了项目文档
required: true
- label: 该项目符合该项目的目标和范围
required: true
- label: 我确认我提供的接口请求是合理且有意义的
required: true required: true
+8 -7
View File
@@ -14,15 +14,15 @@ jobs:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux platform: linux
target: node18-linux-x64 target: node22-linux-x64
output: precompiled/app output: precompiled/app
- os: windows-latest - os: windows-latest
platform: win platform: win
target: node18-win-x64 target: node22-win-x64
output: precompiled/app.exe output: precompiled/app.exe
- os: macos-latest - os: macos-latest
platform: macos platform: macos
target: node18-macos-x64 target: node22-macos-x64
output: precompiled/app output: precompiled/app
steps: steps:
@@ -32,12 +32,13 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '18' node-version: '24'
- name: Setup pnpm
run: npm install -g pnpm@9
- name: Install dependencies - name: Install dependencies
run: | run: pnpm install --frozen-lockfile
npm install -g pnpm
pnpm install
- name: Build for ${{ matrix.platform }} - name: Build for ${{ matrix.platform }}
run: | run: |
+67
View File
@@ -0,0 +1,67 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Lint / Unit / Docs
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
# frozen-lockfile: 顺带校验 package.json 与 pnpm-lock.yaml 是否一致
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
# 纯单测,不需要网络,稳定必过
- name: Unit tests
run: pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit
- name: Docs format check
run: pnpm docs:check
integration:
# server.test.js 会启动真实服务器并请求真实网易云 API,
# 可能因上游风控/限流偶发失败,因此不阻塞合并
name: Integration tests (real NetEase API)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run full test suite
run: pnpm test
+4 -29
View File
@@ -6,8 +6,6 @@ permissions:
on: on:
issues: issues:
types: [opened, labeled] types: [opened, labeled]
issue_comment:
types: [created]
jobs: jobs:
welcome-new-issues: welcome-new-issues:
@@ -19,44 +17,21 @@ jobs:
with: with:
issue-number: ${{ github.event.issue.number }} issue-number: ${{ github.event.issue.number }}
body: | body: |
## 快速链接 感谢您在该仓库提出问题!我们非常重视您的反馈,并希望尽快为您提供帮助。
在我们处理您的问题之前,您可以先查看以下资源: 如果您在一周左右的时间里没有收到我们的回复,请在下方留言,以便我们注意到。我们的大部分审核都是志愿者,有时难免会有疏漏。
你可能会想要尝试以下方法来获得更快的帮助:
- 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org) - 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org)
- 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk) - 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk)
- 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题 - 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题
handle-help-wanted:
if: contains(github.event.issue.labels.*.name, 'question')
runs-on: ubuntu-latest
steps:
- name: Add help resources
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
🆘 需要帮助?
这里有一些可能对您有用的资源:
📚 **文档资源**
- [项目文档](https://neteasecloudmusicapienhanced.js.org)
🔍 **常见问题**
- 搜索[已关闭的issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues?q=is%3Aissue+is%3Aclosed) 看看是否有类似的问题已经被解答。
💬 **即时帮助**
- 加入QQ群:https://qm.qq.com/q/TpeP9Uv2yk
如果以上资源无法解决您的问题,请提供更多详细信息,我们会尽快为您解答!
stale-issues: stale-issues:
if: github.event.action == 'opened' if: github.event.action == 'opened'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Mark stale issues - name: Mark stale issues
uses: actions/stale@v10 uses: actions/stale@v11
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: | stale-issue-message: |
+71
View File
@@ -0,0 +1,71 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
bot:
if: |
(contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')) &&
github.actor != 'opencode-agent[bot]' &&
github.actor != 'yukka-agent[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
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@v3
with:
client-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:
# 需要持久化凭证: opencode action 内部会 git fetch origin <分支> 拉取 PR 代码
persist-credentials: true
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
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
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
prompt: |
${{ steps.cfg.outputs.opencode_prompt }}
## 用户指令(来自触发评论,请严格按此执行)
${{ github.event.comment.body }}
## 用户要求Review时, 严格执行下面指令
${{ steps.cfg.outputs.pr_review_prompt }}
@@ -78,17 +78,17 @@ jobs:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux platform: linux
target: node18-linux-x64 target: node22-linux-x64
output: ncm-api-linux-x64 output: ncm-api-linux-x64
- os: windows-latest - os: windows-latest
platform: windows platform: windows
target: node18-win-x64 target: node22-win-x64
output: ncm-api-win-x64.exe output: ncm-api-win-x64.exe
- os: macos-latest - os: macos-latest
platform: macos platform: macos
target: node18-macos-x64 target: node22-macos-x64
output: ncm-api-macos-x64 output: ncm-api-macos-x64
steps: steps:
+1
View File
@@ -10,3 +10,4 @@ anonymous_token
.vercel .vercel
.env .env
IFLOW.md IFLOW.md
precompiled
+47 -19
View File
@@ -1,23 +1,51 @@
# Agent Instructions for NeteaseCloudMusicApiEnhanced # NeteaseCloudMusicApiEnhanced Agent 说明
## Quick Start ## 快速开始
- **Package Manager**: Use `pnpm` (not npm or yarn). - **包管理器**:开发与 CI 使用 `pnpm`,`Dockerfile` 也用 pnpm(`pnpm@9 --frozen-lockfile`,匹配 `pnpm-lock.yaml`)。仓库没有 `yarn.lock`,不要引入 yarn。
- **Node Version**: Requires Node.js 18 or later. - **Node 版本**:`package.json` 的 `engines` 声明 `>=22`;CI/打包在 Node 22–24 上运行。现代 Node 均可。
- **环境变量**:`server.js` 调用了 `dotenv.config()`,本地 `.env` 会被自动加载;所有支持的变量见 `.env.prod.example`。
## Developer Commands ## 常用命令
- **Install dependencies**: `pnpm i` - 安装依赖:`pnpm i`
- **Start server**: `pnpm start` or `node app.js` - 启动服务:`pnpm start`(等价 `node app.js`);热重载开发:`pnpm dev`(nodemon)
- **Start dev server**: `pnpm dev` (uses nodemon) - 跑测试:`pnpm test`(Mocha,超时 60s)
- **Run tests**: `pnpm test` (uses Mocha) - Lint:`pnpm lint`;自动修复:`pnpm lint-fix`
- **Linting**: `pnpm lint` (check) or `pnpm lint-fix` (auto-fix) - 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`(@yao-pkg/pkg,node22 目标,`--no-bytecode` + `--public` 规避 node22 bytecode 回归)
## Architecture & Entrypoints ## 目录内非路由文件
- **Executable Server**: `app.js` is the main entrypoint for running the API server. - `plugins/`——内部上传辅助(`upload.js`、`songUpload.js`),被 `module/*` require,本身不是路由。
- **Module Exports**: `main.js` is the entrypoint when the project is imported as a Node.js dependency. - `module_example/`——接口模板与 `main.js` 库调用示例(含 `test.js` 演示 `login_cellphone`→`song_url` 链路),不是运行时代码。
- **API Endpoints**: Located in the `module/` directory. Each file typically corresponds to an API route. - `examples/get_static_moddef.js`——用 `getModulesDefinitions(..., false)`(`doRequire=false`)把全部路由导出为 `moddef.json`。改路由注册逻辑时可跑它核对。
- **Core Utilities**: Request handling, encryption, and core utilities are found in the `util/` directory. - `module_types/` 为空目录;类型声明在根目录 `interface.d.ts`(`types` 字段)。
## Important Gotchas & Quirks ## 架构
- **Environment Variables**: The server defaults to port 3000 but can be overridden with the `PORT` environment variable. - `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- **Proxy Variables**: Be very careful with proxy environment variables (`http_proxy`, `https_proxy`, `no_proxy`). The request library (like axios) will automatically pick these up. If they point to an unavailable proxy (especially common in Docker environments), requests will fail silently or throw connection errors. - `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
- **Code Style**: The project uses ESLint and Prettier. Always run `pnpm lint-fix` before committing changes to ensure formatting consistency. - `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 头;在 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`。
## 新增/修改接口
- 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
- 照抄同目录模块的写法(选对 `crypto`),用 `createOption(query)` 生成请求选项。
- 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
## 测试
- `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` 会触发自动发布**:`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()`);首次启动先写空文件再刷新。
- **`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` 白名单;改依赖安装行为时先看它。
+7 -2
View File
@@ -2,14 +2,19 @@ FROM node:lts-alpine
RUN apk add --no-cache tini RUN apk add --no-cache tini
ENV NODE_ENV production ENV NODE_ENV=production
RUN npm install -g pnpm@9
USER node USER node
WORKDIR /app WORKDIR /app
COPY --chown=node:node . ./ COPY --chown=node:node . ./
RUN yarn --network-timeout=100000 # --prod 模式下 husky 不会被安装,prepare 脚本会因找不到 husky 而失败,
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
EXPOSE 3000 EXPOSE 3000
+1 -1
View File
@@ -163,7 +163,7 @@ docker run -d -p 3000:3000 moefurina/ncm-api:latest
```bash ```bash
#!/bin/bash #!/bin/bash
export PORT=9000 export PORT=9000
/var/lang/node16/bin/node app.js /var/lang/node22/bin/node app.js
``` ```
4. 完成部署后,访问 API 网关的 URL 即可 4. 完成部署后,访问 API 网关的 URL 即可
+11
View File
@@ -3,6 +3,17 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const tmpPath = require('os').tmpdir() const tmpPath = require('os').tmpdir()
// engines 字段只在 npm/pnpm 安装时生效,直接 node app.js 不会被拦截,
// 这里给出友好的最低版本检查(>=22,与 package.json engines 保持一致)
const [major] = process.versions.node.split('.').map(Number)
if (major < 22) {
console.error(
`[${new Date().toLocaleString()}] Node.js >= 22 is required (current: ${process.versions.node}). ` +
'请升级 Node.js 后重试 / Please upgrade Node.js to v22 or newer.',
)
process.exit(1)
}
async function start() { async function start() {
// 检测是否存在 anonymous_token 文件,没有则生成 // 检测是否存在 anonymous_token 文件,没有则生成
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) { if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
+14
View File
@@ -3,6 +3,8 @@ const path = require('path')
const { register_anonimous } = require('./main') const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index') const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey') const { getXeapiPublicKey } = require('./util/xeapiKey')
const { loadNeapiKey } = require('./util/neapiKey')
const registerNeapikey = require('./module/register_neapikey')
const tmpPath = require('os').tmpdir() const tmpPath = require('os').tmpdir()
async function generateConfig() { async function generateConfig() {
@@ -37,5 +39,17 @@ async function generateConfig() {
} catch (error) { } catch (error) {
console.log(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 module.exports = generateConfig
+328
View File
@@ -37,6 +37,41 @@ export const enum SubAction {
unsub = 0, unsub = 0,
} }
export function ad_get(
params: { type_ids?: string } & RequestBaseConfig,
): Promise<Response>
export function ad_listening_rights(
params: RequestBaseConfig,
): Promise<Response>
export function ad_listening_rights_gain(
params: {
reqUid?: string
uid?: string | number
exposureTime?: string | number
clickTime?: string | number
extraRightsType?: string | number
playContinuously?: boolean | string
source?: string | number
creativeType?: string | number
rightsGainMethod?: string | number
extraRightsGainMethod?: string | number
extraRightsGainDuration?: string | number
nextRightsGainDuration?: string | number
rightsGainType?: string | number
rightsGainDuration?: string | number
gainMethodStep?: string | number
generalRightsInfo?: string
rightsExtJson?: string
appInfo?: string
contextInfo?: string
installed?: string | number
sniffTime?: string | number
type_ids?: string
} & RequestBaseConfig,
): Promise<Response>
export function activate_init_profile( export function activate_init_profile(
params: { nickname: string } & RequestBaseConfig, params: { nickname: string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -119,6 +154,14 @@ export function album_sublist(
params: MultiPageConfig & RequestBaseConfig, params: MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function api(
params: {
uri: string
data?: string | Record<string, unknown>
crypto?: string
} & RequestBaseConfig,
): Promise<Response>
export function artist_album( export function artist_album(
params: { id: string | number } & MultiPageConfig & RequestBaseConfig, params: { id: string | number } & MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -271,10 +314,21 @@ export function batch(
params: { [index: string]: unknown } & RequestBaseConfig, params: { [index: string]: unknown } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function captcha_safe_sent(
params: { ctcode?: number | string } & RequestBaseConfig,
): Promise<Response>
export function captcha_sent( export function captcha_sent(
params: { phone: string; ctcode?: number | string } & RequestBaseConfig, params: { phone: string; ctcode?: number | string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function captcha_sent_v1(
params: {
phone: number | string
ctcode?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function captcha_verify( export function captcha_verify(
params: { params: {
ctcode?: number | string ctcode?: number | string
@@ -387,6 +441,14 @@ export function comment(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_add(
params: {
id: string | number
type: CommentType
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_album( export function comment_album(
params: { params: {
id: string | number id: string | number
@@ -395,6 +457,14 @@ export function comment_album(
RequestBaseConfig, RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_delete(
params: {
id: string | number
type: CommentType
cid: string | number
} & RequestBaseConfig,
): Promise<Response>
export function comment_dj( export function comment_dj(
params: { params: {
id: string | number id: string | number
@@ -469,6 +539,15 @@ export function comment_playlist(
RequestBaseConfig, RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_reply(
params: {
id: string | number
type: CommentType
cid: string | number
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_video( export function comment_video(
params: { params: {
id: string | number id: string | number
@@ -490,6 +569,25 @@ export function daily_signin(
params: { type?: DailySigninType } & RequestBaseConfig, params: { type?: DailySigninType } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function decrypt(
params: {
crypto?: string
data?: string
hexString?: string
isReq?: boolean | string
} & RequestBaseConfig,
): Promise<Response>
export function device_kickoff(
params: { deviceKey: string | number; captcha?: string } & RequestBaseConfig,
): Promise<Response>
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( export function digitalAlbum_ordering(
params: { params: {
payment: string payment: string
@@ -618,6 +716,10 @@ export function dj_toplist_popular(
params: { limit?: string | number } & RequestBaseConfig, params: { limit?: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function eapi_decrypt(
params: { hexString: string; isReq?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function event( export function event(
params: { pagesize?: number; lasttime?: number } & RequestBaseConfig, params: { pagesize?: number; lasttime?: number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -626,6 +728,20 @@ export function event_del(
params: { evId: string | number } & RequestBaseConfig, params: { evId: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export const enum EventPrivacy {
everyone = 0,
following = 1,
onlyMe = 2,
mutualFollowing = 6,
}
export function event_privacy(
params: {
evId: string | number
privacy: EventPrivacy
} & RequestBaseConfig,
): Promise<Response>
export function event_forward( export function event_forward(
params: { params: {
forwords: string forwords: string
@@ -665,6 +781,8 @@ export function hot_topic(
params: MultiPageConfig & RequestBaseConfig, params: MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function inner_version(params: RequestBaseConfig): Promise<Response>
export function like( export function like(
params: { params: {
like?: 'true' | 'false' | boolean like?: 'true' | 'false' | boolean
@@ -678,6 +796,10 @@ export function likelist(
params: { uid: string | number } & RequestBaseConfig, params: { uid: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function listentogether_status(
params: RequestBaseConfig,
): Promise<Response>
export function login( export function login(
params: { email: string; password: string } & RequestBaseConfig, params: { email: string; password: string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -724,6 +846,17 @@ export function lyric_new(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function middle_play_do_lottery(
params: {
activityId?: string | number
drawCount?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function middle_play_lottery_remain_chance(
params: { activityId?: string | number } & RequestBaseConfig,
): Promise<Response>
export function msg_comments( export function msg_comments(
params: { params: {
uid: string | number uid: string | number
@@ -946,6 +1079,22 @@ export function register_cellphone(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function register_checktoken_v2(
params: { refresh?: boolean | string } & RequestBaseConfig,
): Promise<Response>
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>
export function related_allvideo( export function related_allvideo(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -954,6 +1103,67 @@ export function related_playlist(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function relay_play_state_submit(
params: {
id: string | number
sessionId?: string
progress?: string | number
playMode?: string
type?: string
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_activity_collect(
params: { activityId?: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_activity_get(
params: RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_info_get(
params: { examType: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_question_single_get(
params: {
examType: string | number
taskId: string | number
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_result_get(
params: {
examType: string | number
taskId: string | number
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_start(
params: { examType: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_submit(
params: {
examType: string | number
taskId: string | number
questionId: string | number
answer: string
} & RequestBaseConfig,
): Promise<Response>
declare const rep_ugc_user_collect_vip: (
params: { activityId?: string | number } & RequestBaseConfig,
) => Promise<Response>
export { rep_ugc_user_collect_vip as 'rep_ugc_user_collect-vip' }
export function rep_ugc_user_get(params: RequestBaseConfig): Promise<Response>
export function rep_ugc_user_sign(params: RequestBaseConfig): Promise<Response>
export function rep_ugc_user_vip(params: RequestBaseConfig): Promise<Response>
export const enum ResourceType { export const enum ResourceType {
mv = 1, mv = 1,
dj = 4, dj = 4,
@@ -979,6 +1189,22 @@ export function scrobble(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function scrobble_v1(
params: {
id: string | number
time: string | number
total?: string | number
sourceid?: string | number
sourceId?: string | number
source?: string
name?: string
artist?: string
bitrate?: string | number
level?: string
vip?: boolean | string
} & RequestBaseConfig,
): Promise<Response>
export function search( export function search(
params: { params: {
keywords: string keywords: string
@@ -1160,6 +1386,7 @@ export const enum SoundQualityType {
jyeffect = 'jyeffect', jyeffect = 'jyeffect',
jymaster = 'jymaster', jymaster = 'jymaster',
sky = 'sky', sky = 'sky',
vivid = 'vivid',
} }
export function song_url_v1( export function song_url_v1(
@@ -1267,6 +1494,25 @@ export function user_event(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export interface UserEventAllResponse {
code: number
events: Record<string, unknown>[]
/** Upstream account statistic; it can include events no longer returned. */
size: number | null
/** Number of unique events in `events`. */
retrievedCount: number
/** Positive difference between `size` and `retrievedCount`. */
unavailableCount: number | null
sizeMismatch: boolean | null
pageCount: number
more: false
lasttime: string | number | null
}
export function user_event_all(
params: RequestBaseConfig,
): Promise<Response<UserEventAllResponse>>
export function user_followeds( export function user_followeds(
params: { params: {
uid: string | number uid: string | number
@@ -1433,6 +1679,23 @@ export function yunbei_task_finish(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function yunbei_ad_task_list(
params: RequestBaseConfig,
): Promise<Response>
export function yunbei_ad_task_recommend_song(
params: {
offset?: number | string
limit?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function yunbei_ad_task_finish(
params: {
yunbeiAmount?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function msg_recentcontact(params: RequestBaseConfig): Promise<Response> export function msg_recentcontact(params: RequestBaseConfig): Promise<Response>
export function hug_comment( export function hug_comment(
@@ -2292,6 +2555,10 @@ export function song_chorus(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function song_cloud_download(
params: { id: string | number } & RequestBaseConfig,
): Promise<Response>
export function song_creators( export function song_creators(
params: { params: {
id: string | number id: string | number
@@ -2403,6 +2670,18 @@ export function threshold_detail_get(
params: RequestBaseConfig, params: RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function thinktank_audit_resource_detail(
params: { type?: string | number } & RequestBaseConfig,
): Promise<Response>
export function thinktank_audit_resource_update(
params: {
type?: string | number
taskId: string | number
judgement: string | number
} & RequestBaseConfig,
): Promise<Response>
export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response> export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response>
export function ugc_album_get( export function ugc_album_get(
@@ -2529,6 +2808,14 @@ export function verify_qrcodestatus(
export function vip_sign(params: RequestBaseConfig): Promise<Response> export function vip_sign(params: RequestBaseConfig): Promise<Response>
export function vip_sign_detail(
params: { timestamp: string | number } & RequestBaseConfig,
): Promise<Response>
export function vip_sign_history(
params: { type?: string | number } & RequestBaseConfig,
): Promise<Response>
export function vip_sign_info(params: RequestBaseConfig): Promise<Response> export function vip_sign_info(params: RequestBaseConfig): Promise<Response>
export function vip_tasks_v1( export function vip_tasks_v1(
@@ -2549,6 +2836,22 @@ export function voice_upload(
name: string name: string
data: string | Buffer data: string | Buffer
} }
imgFile?: {
name: string
data: string | Buffer
}
voiceListId: string | number
coverImgId?: string | number
categoryId: string | number
secondCategoryId: string | number
description: string
songName?: string
privacy?: string | number
publishTime?: string | number
autoPublish?: string | number
autoPublishText?: string
orderNo?: string | number
composedSongs?: string
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@@ -2587,3 +2890,28 @@ export function voicelist_trans(
} & MultiPageConfig & } & MultiPageConfig &
RequestBaseConfig, RequestBaseConfig,
): Promise<Response> ): 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>
+18 -3
View File
@@ -17,9 +17,24 @@ module.exports = async (query, request) => {
try { try {
if (raw?.ads) { if (raw?.ads) {
const ad = Object.values(raw.ads)[0] const ad = Object.values(raw.ads)[0]
if (ad?.extJson) { // 逆向 v9.5.61:客户端从 ad.adExtMap["req_id"] 取 reqUid
const ext = JSON.parse(ad.extJson) if (ad?.adExtMap) {
reqId = ext?.contextInfo?.req_id || '' if (typeof ad.adExtMap === 'string') {
try {
reqId = JSON.parse(ad.adExtMap).req_id || ''
} catch (_) {}
} else {
reqId = ad.adExtMap.req_id || ''
}
}
// 兜底:adLogId.requestId / ad.reqId / extJson.contextInfo.req_id
if (!reqId && ad?.adLogId?.requestId) reqId = ad.adLogId.requestId
if (!reqId && ad?.reqId) reqId = ad.reqId
if (!reqId && ad?.extJson) {
try {
const ext = JSON.parse(ad.extJson)
reqId = ext?.contextInfo?.req_id || ''
} catch (_) {}
} }
} }
} catch (_) {} } catch (_) {}
+186 -22
View File
@@ -1,65 +1,229 @@
// 看广告免费听歌 - 领取免费听权益 // 看广告免费听歌 - 领取免费听权益
// 请求流程(基于逆向网易云音乐 v9.5.45 RN Hermes 源码): // 请求流程(基于逆向网易云音乐 v9.5.61 原生 Kotlin 源码,classes5/18/19.dex):
// 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid // 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid
// 2. 调用本接口传入 reqUid 及相关权益参数,领取免费听权益 // 2. 调用本接口传入 reqUid 及相关权益参数,领取权益
// 3. 服务器返回 gainFlag 等标识,用于展示领取结果 // 3. 服务器返回 gainFlag 等标识,用于展示领取结果
//
// 调用链(逆向还原):
// AdDSLIncentiveVideoRightsHelper.requestRightsGainInner
// → AdDSLUtils.requestRightGain(ad, exposeTime, clickTime, creativeType, cb)
// → 构造 ListeningRightRequestParams(21 字段)→ JSON.stringify
// → body = { "reqParam": "..." } → POST /api/ad/listening/rights/gain
// (注意:客户端 AdDSLIncentiveVideoRightsHelper 写死 creativeType=36)
//
// 接口不只领取听歌时长,还包含"看视频得云贝"等权益:
// rightsGainMethod=6 (LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益) 时,
// 弹窗 AdLaxinSegmentedGuideDialog 展示 "获得X云贝"(2000 云贝等),
// 权益数值由广告下发的 AdLaxinSegmentedRightsGuidePopup.rightsValue 决定,
// extraRightsType 作为权益类型随本接口上传。
//
// rightsGainMethod 枚举(h80/a):
// 1=EXPOSE 曝光, 2=EXPOSE_CLICK 曝光+点击, 3=EXPOSE_DOWNLOAD 曝光+下载,
// 4=CLICK_STAY 点击+停留, 5=EXPOSURE_OR_CLICK_STAY 曝光或点击停留,
// 6=LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const adGet = require('./ad_get.js') const adGet = require('./ad_get.js')
// 安全 JSON 解析(字符串字段可能是 JSON 文本)
function safeParse(str, fallback) {
if (typeof str !== 'string') return fallback
try {
return JSON.parse(str)
} catch (_) {
return fallback
}
}
module.exports = async (query, request) => { module.exports = async (query, request) => {
const time = Date.now() const time = Date.now()
// 如果未传入 reqUid,自动从 ad_get 获取最新广告的 req_id // 从广告对象自动补齐请求字段。
// 实测 v9.5.61 真实 API 返回(/ad/get)确认的字段路径:
// reqUid = ad.extJson.contextInfo.req_id(真实响应中唯一存在)
// contextInfo = ad.extJson.contextInfo(完整对象序列化)
// generalRightsInfo = ad.generalRightsInfo(字符串 JSON)
// creativeType = ad.creativeType
// rightsGainMethod / extraRightsType / rightsGainDuration / rightsGainType /
// extraRightsGainMethod / extraRightsGainDuration / nextRightsGainDuration /
// rightsExtJson / source / rightsUpperLimit / qualified
// = ad.generalRightsInfo(字符串)解析后取值
// (逆向类字段 adExtMap/adLogId/listeningRightHintInfo 真实响应中不存在,仅兜底)
// sniffTime = method==3 或 6 时 currentTimeMillis
let reqUid = query.reqUid || '' let reqUid = query.reqUid || ''
if (!reqUid) { let contextInfo = query.contextInfo
let creativeType = query.creativeType
let generalRightsInfo = query.generalRightsInfo
// 广告 hint 配置:仅当调用方未显式传参时用广告下发值兜底
const hint = {}
if (!reqUid || contextInfo === undefined || creativeType === undefined) {
try { try {
const adRes = await adGet( const adRes = await adGet(
{ ...query, type_ids: query.type_ids || '["400002_0"]' }, { ...query, type_ids: query.type_ids || '["400002_0"]' },
request, request,
) )
reqUid = adRes?.body?.extra?.reqId || '' const ad = Object.values(adRes?.body?.ads || {})[0]
if (!ad) throw new Error('ads 为空(未登录或广告位无广告)')
// reqUid:真实 API 返回中 req_id 在 ad.extJson.contextInfo.req_id(实测 v9.5.61)
// (逆向类字段 adExtMap/adLogId 在真实响应中不存在,仅作兜底)
if (!reqUid) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const extMap =
typeof ad?.adExtMap === 'string'
? safeParse(ad.adExtMap, {})
: ad?.adExtMap
reqUid =
ext?.contextInfo?.req_id ||
extMap?.req_id ||
ad?.adLogId?.requestId ||
ad?.reqId ||
adRes?.body?.extra?.reqId ||
''
}
// contextInfo:真实 API 返回中在 ad.extJson.contextInfo(实测 v9.5.61)
if (contextInfo === undefined) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const ci =
ext?.contextInfo || ad?.adLogId?.contextInfo || ad?.showContext
if (ci) {
contextInfo = typeof ci === 'string' ? ci : JSON.stringify(ci)
}
}
if (creativeType === undefined && ad?.creativeType !== undefined) {
creativeType = ad.creativeType
}
if (generalRightsInfo === undefined && ad?.generalRightsInfo) {
generalRightsInfo =
typeof ad.generalRightsInfo === 'string'
? ad.generalRightsInfo
: JSON.stringify(ad.generalRightsInfo)
}
// 缓存广告下发的领取配置(后续用于兜底)
// 实测 v9.5.61:rightsGainMethod/rightsUpperLimit/qualified 等在
// ad.generalRightsInfo(字符串)里,ad.listeningRightHintInfo 真实响应中不存在
const hintCfg =
typeof ad?.listeningRightHintInfo === 'string'
? safeParse(ad.listeningRightHintInfo, {})
: ad?.listeningRightHintInfo || {}
const gri =
typeof ad?.generalRightsInfo === 'string'
? safeParse(ad.generalRightsInfo, {})
: ad?.generalRightsInfo || {}
Object.assign(hint, hintCfg, gri)
console.log(`自动获取 reqUid: ${reqUid}`) console.log(`自动获取 reqUid: ${reqUid}`)
} catch (e) { } catch (e) {
// 获取广告失败,后续请求会因缺少 reqUid 被拒绝 // 获取广告失败,后续请求会因缺少 reqUid 被拒绝
} }
} }
const rightsGainMethod = query.rightsGainMethod
? parseInt(query.rightsGainMethod)
: hint.rightsGainMethod || 2
const rightsParam = { const rightsParam = {
// 必填: 广告请求 ID,自动从 ad_get 获取 // 必填: 广告请求 ID,自动从 ad_get 获取
reqUid, reqUid,
// 广告创意类型 (默认 1) // 曝光时间戳
creativeType: parseInt(query.creativeType || 2), exposureTime: query.exposureTime ? parseInt(query.exposureTime) : time,
// 时间戳 // 当前登录用户 ID(原版客户端从 Profile.getUserId() 获取)
exposureTime: query.exposureTime || time, userId: query.uid ? parseInt(query.uid) : undefined,
clickTime: query.clickTime || time,
// 权益领取方式 // 点击时间戳
rightsGainMethod: parseInt(query.rightsGainMethod || 2), clickTime: query.clickTime ? parseInt(query.clickTime) : time,
// 权益时长相关 // 额外权益类型(拉新分段权益等,决定发放云贝/时长/下载,来自广告配置)
rightsGainDuration: query.rightsGainDuration extraRightsType: query.extraRightsType
? parseInt(query.rightsGainDuration) ? parseInt(query.extraRightsType)
: undefined, : hint.extraRightsType !== undefined
? parseInt(hint.extraRightsType)
: undefined,
// 是否连续播放(默认 false)
playContinuously: query.playContinuously ? true : false,
// 来源标识(原版从 ad.listeningRightHintInfo.source 读取)
source: query.source
? parseInt(query.source)
: hint.source !== undefined
? parseInt(hint.source)
: undefined,
// 广告创意类型(激励视频场景=36,优先取 query / 广告对象)
creativeType: creativeType !== undefined ? parseInt(creativeType) : 36,
// 权益领取方式(1~6 枚举,见文件头注释)
rightsGainMethod,
// 权益扩展方式与时长
extraRightsGainMethod: query.extraRightsGainMethod extraRightsGainMethod: query.extraRightsGainMethod
? parseInt(query.extraRightsGainMethod) ? parseInt(query.extraRightsGainMethod)
: undefined, : hint.extraRightsGainMethod !== undefined
? parseInt(hint.extraRightsGainMethod)
: undefined,
extraRightsGainDuration: query.extraRightsGainDuration extraRightsGainDuration: query.extraRightsGainDuration
? parseInt(query.extraRightsGainDuration) ? parseInt(query.extraRightsGainDuration)
: undefined, : hint.extraRightsGainDuration !== undefined
? parseInt(hint.extraRightsGainDuration)
: undefined,
nextRightsGainDuration: query.nextRightsGainDuration nextRightsGainDuration: query.nextRightsGainDuration
? parseInt(query.nextRightsGainDuration) ? parseInt(query.nextRightsGainDuration)
: hint.nextRightsGainDuration !== undefined
? parseInt(hint.nextRightsGainDuration)
: undefined,
// 权益类型(含 RIGHTS_GAIN_TYPE_CURRENT_DAY 等取值)
rightsGainType: query.rightsGainType
? parseInt(query.rightsGainType)
: hint.rightsGainType !== undefined
? parseInt(hint.rightsGainType)
: undefined,
// 权益时长
rightsGainDuration: query.rightsGainDuration
? parseInt(query.rightsGainDuration)
: hint.rightsGainDuration !== undefined
? parseInt(hint.rightsGainDuration)
: undefined,
// 领取步骤(UNGAIN/GAINING/GAIN_FINISHED 对应的值)
gainMethodStep: query.gainMethodStep
? parseInt(query.gainMethodStep)
: undefined, : undefined,
// 来源标识 | 权益扩展信息 // 通用权益信息(ad.generalRightsInfo 序列化 JSON)
source: query.source || undefined, generalRightsInfo,
rightsExtJson: query.rightsExtJson || undefined,
// 权益扩展信息
rightsExtJson: query.rightsExtJson || hint.rightsExtJson || undefined,
// 应用信息(下载类广告) // 应用信息(下载类广告)
appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined, appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined,
// 广告上下文(ad.adLogId.contextInfo,客户端真实来源)
contextInfo,
// 应用是否已安装(下载类广告)
installed: query.installed ? parseInt(query.installed) : undefined, installed: query.installed ? parseInt(query.installed) : undefined,
// 嗅探时间:逆向确认(classes5.dex AdDSLUtils.requestRightGain):
// rightsGainMethod==3(曝光+下载) 或 6(LAXIN) 时传 currentTimeMillis
sniffTime:
rightsGainMethod === 3 || rightsGainMethod === 6
? query.sniffTime
? parseInt(query.sniffTime)
: time
: undefined,
} }
// 清理 undefined 字段 // 清理 undefined 字段
@@ -67,7 +231,7 @@ module.exports = async (query, request) => {
if (rightsParam[key] === undefined) delete rightsParam[key] if (rightsParam[key] === undefined) delete rightsParam[key]
}) })
// 将参数序列化为 reqParam 字符串 (与 RN 源码完全一致) // 将参数序列化为 reqParam 字符串 (与原生源码一致)
const data = { const data = {
reqParam: JSON.stringify(rightsParam), reqParam: JSON.stringify(rightsParam),
} }
+1 -1
View File
@@ -48,7 +48,7 @@ module.exports = async (query, request) => {
case 'linuxapi': { case 'linuxapi': {
if (isReq) { if (isReq) {
const pureHex = data.replace(/\s/g, '') const pureHex = data.replace(/\s/g, '')
const decrypted = aesDecrypt(pureHex, linuxapiKey, '', 'hex') const decrypted = aesDecrypt(pureHex, 'ecb', linuxapiKey, '', 'hex')
result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8)) result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8))
} else { } else {
result = typeof data === 'string' ? JSON.parse(data) : data result = typeof data === 'string' ? JSON.parse(data) : data
+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'),
)
}
+34
View File
@@ -0,0 +1,34 @@
// 修改本人动态的可见权限
const createOption = require('../util/option.js')
const PRIVACY_VALUES = new Set([0, 1, 2, 6])
module.exports = (query, request) => {
const eventId = String(query.evId ?? '').trim()
const rawPrivacy = String(query.privacy ?? '').trim()
const privacy = Number(rawPrivacy)
if (
!eventId ||
!rawPrivacy ||
!Number.isInteger(privacy) ||
!PRIVACY_VALUES.has(privacy)
) {
return Promise.resolve({
status: 400,
body: {
code: 400,
message: 'evId is required and privacy must be one of 0, 1, 2, 6',
},
cookie: [],
})
}
const data = {
eventId,
privacy,
}
return request(`/api/event/privacy/op`, data, createOption(query))
}
+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'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 红心与取消红心歌曲- v1
const createOption = require('../util/option.js')
module.exports = (query, request) => {
query.like = query.like == 'false' ? false : true
const data = {
alg: 'itembased',
trackId: query.id,
like: query.like,
time: '3',
}
return request(`/api/v1/radio/like`, data, createOption(query, 'xeapi', 'v3'))
}
+1 -1
View File
@@ -19,6 +19,6 @@ module.exports = (query, request) => {
return request( return request(
`/api/middle/play/do/lottery`, `/api/middle/play/do/lottery`,
data, data,
createOption(query, 'eapi', 'v3'), createOption(query, 'eapi', 'v2'),
) )
} }
-3
View File
@@ -1,8 +1,5 @@
// 私人FM - 模式选择 // 私人FM - 模式选择
// aidj, DEFAULT, FAMILIAR, EXPLORE, SCENE_RCMD ( EXERCISE, FOCUS, NIGHT_EMO )
// 来不及解释这几个了
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = { const data = {
+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'),
)
}
+140 -39
View File
@@ -1,32 +1,145 @@
// 易盾反作弊 Token 注册端点 // 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用 // 通过易盾官方 Watchman SDK(Web 版,跑在 jsdom 模拟的浏览器环境里)
// 实时调用 getToken(businessId) 获取反作弊 token,供后续带 checkToken 的请求使用
// //
// GET /register/checktoken → 返回当前 token(尚无则自动获取) // GET /register/checktoken/v2 → 实时获取新 token(不缓存)
// POST /register/checktoken → 强制刷新 token // POST /register/checktoken/v2 → 实时获取新 token
// GET /register/checktoken?refresh=1 → 强制刷新
// //
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
// 避免反作弊 token 复用触发风控。
//
// 注:jsdom 固定用 v24(engines >=18),其依赖链为纯 CJS,可被 pkg 静态
// 分析自动打包;v30+ 引入纯 ESM 依赖且要求 Node >=22,无法在 CI(18-24) 与
// pkg(node18) 目标下运行。
//
const { JSDOM, VirtualConsole } = require('jsdom')
const { default: axios } = require('axios') const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('../util/config.json')
const logger = require('../util/logger')
const URL = APP_CONF.dunDomainV2 + '/v2/config/js?pn=YD00000558929251' // 网易云音乐在易盾的 productNumber 与 businessId
let _token = '' const PRODUCT_NUMBER = 'YD00000558929251'
const BUSINESS_ID = 'bd5d2f973ef74cd2a61325a412ae54d9'
const TOOL_JS_URL = `${APP_CONF.dunStaticDomain}/tool.min.js`
async function fetch() { // 最小 HTML 外壳,模拟网页环境
const res = await axios.get(URL, { timeout: 10000 }) const HTML =
const data = res.data '<!doctype html><html><head><meta charset="UTF-8"></head><body></body></html>'
if (data && data.code === 200 && data.result && data.result.conf) {
return data.result.conf let toolJs = ''
} let wm = null // Watchman 实例(进程内复用,可反复 getToken)
throw new Error('易盾返回异常: ' + JSON.stringify(data).substring(0, 200)) let dom = null // jsdom 实例(失败时 close 释放活动句柄,避免泄漏)
let initPromise = null
// 获取 tool.min.js(内存缓存,避免每次初始化重复下载)
async function getToolJs() {
if (toolJs) return toolJs
const res = await axios.get(TOOL_JS_URL, { timeout: 10000 })
toolJs = String(res.data)
return toolJs
} }
// 端点处理 // 初始化 Watchman(进程内只初始化一次,实例可反复 getToken)
module.exports = async (query) => { async function ensureWatchman() {
const refresh = query.refresh === '1' || query.refresh === 'true' if (wm) return wm
let token = refresh ? null : _token if (initPromise) return initPromise
if (!token) {
token = await fetch() initPromise = (async () => {
_token = token const js = await getToolJs()
const virtualConsole = new VirtualConsole()
virtualConsole.on('jsdomError', () => {})
dom = new JSDOM(HTML, {
url: 'https://music.163.com/',
referrer: 'https://music.163.com/',
contentType: 'text/html',
runScripts: 'dangerously',
resources: 'usable', // 允许动态加载 watchman.min.js / JSONP
pretendToBeVisual: true,
virtualConsole,
beforeParse(window) {
// 抹掉 headless 特征,避免易盾风控误判
Object.defineProperty(window.navigator, 'webdriver', {
get: () => undefined,
})
window.chrome = { runtime: {} }
window.navigator.languages = ['zh-CN', 'zh']
window.navigator.plugins = [1, 2, 3, 4, 5]
},
})
const script = dom.window.document.createElement('script')
script.textContent = js
dom.window.document.body.appendChild(script)
return new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
reject(new Error('watchman 初始化超时'))
}, 15000)
dom.window.initWatchman({
auto: true,
productNumber: PRODUCT_NUMBER,
onload(instance) {
if (settled) return
settled = true
clearTimeout(timer)
wm = instance
resolve(instance)
},
onerror(...args) {
if (settled) return
settled = true
clearTimeout(timer)
reject(new Error('watchman 初始化失败'))
},
})
})
})()
try {
return await initPromise
} catch (e) {
// 失败路径:关闭 jsdom 释放 rAF/子资源/定时器等活动句柄,防止每次失败泄漏约 30MB
if (dom) {
dom.window.close()
dom = null
}
initPromise = null
wm = null
throw e
}
}
// 获取新 token
async function fetchToken() {
const instance = await ensureWatchman()
const raw = instance.getInstance()
await new Promise((resolve) => {
const timer = setTimeout(() => resolve(), 15000)
raw.I(() => {
clearTimeout(timer)
resolve()
})
})
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(''), 15000)
instance.getToken(BUSINESS_ID, (tk) => {
clearTimeout(timer)
resolve(typeof tk === 'string' ? tk : '')
})
})
}
// 端点处理:每次实时获取新 token
module.exports = async () => {
let token = ''
try {
token = await fetchToken()
} catch (e) {
logger.warn('[checkToken v2]', e.message)
} }
return { return {
status: 200, status: 200,
@@ -34,24 +147,12 @@ module.exports = async (query) => {
} }
} }
// 模块加载时自动获取一次 Token,确保 request.js 使用时不为空 // 给 request.js 读取用:每次调用实时获取新 token,不缓存
fetch() module.exports.getToken = async () => {
.then((token) => { try {
_token = token return await fetchToken()
}) } catch (e) {
.catch(() => { logger.warn('[checkToken v2]', e.message)
// 静默失败,后续请求时会重试 return ''
})
// 给 request.js 读取用
module.exports.getToken = () => {
if (!_token) {
// 如果 Token 为空,异步触发获取
fetch()
.then((token) => {
_token = token
})
.catch(() => {})
} }
return _token
} }
+19 -12
View File
@@ -1,15 +1,16 @@
// 易盾反作弊 Token 注册端点 // 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用 // 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
// //
// GET /register/checktoken → 返回当前 token(尚无则自动获取) // GET /register/checktoken/v3 → 实时获取新 token(不缓存)
// POST /register/checktoken → 强制刷新 token // POST /register/checktoken/v3 → 实时获取新 token
// GET /register/checktoken?refresh=1 → 强制刷新 //
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
// 避免反作弊 token 复用触发风控。
// //
const { default: axios } = require('axios') const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('../util/config.json')
const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251' const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251'
let _token = ''
async function fetch() { async function fetch() {
const res = await axios.get(URL, { timeout: 10000 }) const res = await axios.get(URL, { timeout: 10000 })
@@ -19,13 +20,13 @@ async function fetch() {
throw new Error('易盾返回异常: ' + body.substring(0, 100)) throw new Error('易盾返回异常: ' + body.substring(0, 100))
} }
// 端点处理 // 端点处理:每次实时获取新 token
module.exports = async (query) => { module.exports = async () => {
const refresh = query.refresh === '1' || query.refresh === 'true' let token = ''
let token = refresh ? null : _token try {
if (!token) {
token = await fetch() token = await fetch()
_token = token } catch (e) {
// token 获取失败时返回空,由调用方决定是否重试
} }
return { return {
status: 200, status: 200,
@@ -33,5 +34,11 @@ module.exports = async (query) => {
} }
} }
// 给 request.js 读取用 // 给 request.js 读取用:每次调用实时获取新 token,不缓存
module.exports.getToken = () => _token module.exports.getToken = async () => {
try {
return await fetch()
} catch (e) {
return ''
}
}
+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: [],
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ module.exports = async (query, request) => {
const currentKeyVersion = query.currentKeyVersion || '' const currentKeyVersion = query.currentKeyVersion || ''
const data = { const data = {
appVersion: '9.1.65', appVersion: '9.5.61',
currentKeyVersion, currentKeyVersion,
deviceId, deviceId,
nonce, nonce,
@@ -32,10 +32,10 @@ module.exports = async (query, request) => {
const res = await axios({ const res = await axios({
method: 'POST', method: 'POST',
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get', url: APP_CONF.apiDomain + '/api/bsr/sk/get',
headers: { headers: {
'User-Agent': 'User-Agent':
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)', 'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '', Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
}, },
data: new URLSearchParams(data).toString(), data: new URLSearchParams(data).toString(),
+5 -1
View File
@@ -7,5 +7,9 @@ module.exports = (query, request) => {
msg: query.msg || '', msg: query.msg || '',
id: query.id || '', id: query.id || '',
} }
return request(`/api/share/friends/resource`, data, createOption(query)) return request(
`/api/share/friends/resource`,
data,
createOption(query, 'xeapi', 'v3'),
)
} }
+12 -2
View File
@@ -1,13 +1,23 @@
// 获取客户端歌曲下载链接 - v1 // 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准 // 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断 // 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = { const data = {
id: query.id, id: query.id,
immerseType: 'c51', immerseType: 'c51',
level: query.level, level: query.level,
} }
return request(`/api/song/enhance/download/url/v1`, data, createOption(query)) const options = createOption(query)
if (query.level === 'vivid') {
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/download/url/v1`, data, options)
} }
+15
View File
@@ -0,0 +1,15 @@
// 插播相似歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
positionCode: 'toolBarRcmdSong',
resourceId: query.id,
resourceType: 'song',
}
return request(
`/api/link/position/show/resource`,
data,
createOption(query, 'eapi'),
)
}
+14 -7
View File
@@ -1,11 +1,12 @@
// 歌曲链接 - v1 // 歌曲链接 - v1
// 此版本不再采用 br 作为音质区分的标准 // 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断 // 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数 // 当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 logger = require('../util/logger.js')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = async (query, request) => { module.exports = async (query, request) => {
const { const {
matchID, matchID,
@@ -16,6 +17,7 @@ module.exports = async (query, request) => {
level: query.level, level: query.level,
encodeType: 'flac', encodeType: 'flac',
} }
const options = createOption(query, 'xeapi')
if (query.unblock === 'true') { if (query.unblock === 'true') {
try { try {
const result = await matchID(query.id, query.source) const result = await matchID(query.id, query.source)
@@ -54,9 +56,14 @@ module.exports = async (query, request) => {
if (data.level == 'sky') { if (data.level == 'sky') {
data.immerseType = query.immerseType || 'c51' data.immerseType = query.immerseType || 'c51'
} }
return request( if (data.level == 'vivid') {
`/api/song/enhance/player/url/v1`, data.encodeType = 'mp3'
data, const cookie = options.cookie
createOption(query, 'xeapi'), options.cookie = {
) ...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/player/url/v1`, data, options)
} }
+17 -4
View File
@@ -1,20 +1,30 @@
// 获取客户端歌曲下载链接 - v1 // 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准 // 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断 // 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = async (query, request) => { module.exports = async (query, request) => {
const data = { const data = {
id: query.id, id: query.id,
immerseType: 'c51', immerseType: 'c51',
level: query.level, level: query.level,
} }
const options = createOption(query)
if (query.level === 'vivid') {
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
const response = await request( const response = await request(
`/api/song/enhance/download/url/v1`, `/api/song/enhance/download/url/v1`,
data, data,
createOption(query), options,
) )
let url = response?.body?.data?.[0]?.url let url = response?.body?.data?.url || response?.body?.data?.[0]?.url
if (!url) { if (!url) {
const fallbackData = { const fallbackData = {
@@ -25,10 +35,13 @@ module.exports = async (query, request) => {
if (query.level === 'sky') { if (query.level === 'sky') {
fallbackData.immerseType = 'c51' fallbackData.immerseType = 'c51'
} }
if (query.level === 'vivid') {
fallbackData.encodeType = 'mp3'
}
const fallback = await request( const fallback = await request(
`/api/song/enhance/player/url/v1`, `/api/song/enhance/player/url/v1`,
fallbackData, fallbackData,
createOption(query), options,
) )
url = fallback?.body?.data?.[0]?.url url = fallback?.body?.data?.[0]?.url
+22
View File
@@ -0,0 +1,22 @@
// 歌曲百科
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const extJson = {
states: {
playingResource: {
current: query.id,
scene: 'songWiki',
},
},
}
const data = {
extJson: JSON.stringify(extJson),
positionCode: 'songWikiMainPosition',
}
return request(
`/api/link/page/parent/relation/construct/info`,
data,
createOption(query, 'eapi'),
)
}
+3 -2
View File
@@ -4,9 +4,10 @@ const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = { const data = {
getcounts: true, getcounts: true,
time: query.lasttime || -1, time: query.lasttime ?? -1,
limit: query.limit || 30, limit: query.limit ?? 30,
total: false, total: false,
fromRN: 'true',
} }
return request(`/api/event/get/${query.uid}`, data, createOption(query)) return request(`/api/event/get/${query.uid}`, data, createOption(query))
} }
+135
View File
@@ -0,0 +1,135 @@
// 获取当前登录用户可被上游枚举的全部动态
const userAccount = require('./user_account.js')
const userEvent = require('./user_event.js')
const PAGE_SIZE = 100
const MAX_PAGES = 1000
const errorResponse = (message, cookie = []) => ({
status: 502,
body: {
code: 502,
message,
},
cookie,
})
module.exports = async (query, request) => {
const accountResult = await userAccount(query, request)
const uid =
accountResult.body?.account?.id || accountResult.body?.profile?.userId
if (!uid) {
if (
accountResult.status !== 200 ||
(accountResult.body?.code && accountResult.body.code !== 200)
) {
return accountResult
}
return {
status: 401,
body: {
code: 401,
message: 'A valid login cookie is required',
},
cookie: accountResult.cookie || [],
}
}
const cookies = [...(accountResult.cookie || [])]
const events = []
const eventIds = new Set()
const cursors = new Set()
let lasttime = -1
let more = true
let pageCount = 0
let size = null
while (more) {
pageCount += 1
const pageResult = await userEvent(
{
...query,
uid,
lasttime,
limit: PAGE_SIZE,
},
request,
)
cookies.push(...(pageResult.cookie || []))
if (pageResult.status !== 200 || pageResult.body?.code !== 200) {
return {
...pageResult,
cookie: cookies,
}
}
if (pageCount === 1) {
const reportedSize = pageResult.body.size
const numericSize = Number(reportedSize)
size =
reportedSize != null && Number.isFinite(numericSize)
? numericSize
: null
}
for (const event of pageResult.body.events || []) {
if (event?.id == null) {
events.push(event)
continue
}
const eventId = String(event.id)
if (!eventIds.has(eventId)) {
eventIds.add(eventId)
events.push(event)
}
}
more = Boolean(pageResult.body.more)
lasttime = pageResult.body.lasttime
if (more) {
const cursor = String(lasttime ?? '')
if (!cursor || cursors.has(cursor)) {
return errorResponse(
'Upstream event pagination cursor stalled',
cookies,
)
}
cursors.add(cursor)
}
if (more && pageCount >= MAX_PAGES) {
return errorResponse(
`Upstream event pagination exceeded ${MAX_PAGES} pages`,
cookies,
)
}
}
const retrievedCount = events.length
const unavailableCount =
size == null ? null : Math.max(size - retrievedCount, 0)
return {
status: 200,
body: {
code: 200,
events,
size,
retrievedCount,
unavailableCount,
sizeMismatch: size == null ? null : size !== retrievedCount,
pageCount,
more: false,
lasttime: lasttime ?? null,
},
cookie: cookies,
}
}
+38 -50
View File
@@ -2,6 +2,7 @@ const { default: axios } = require('axios')
const fs = require('fs') const fs = require('fs')
var xml2js = require('xml2js') var xml2js = require('xml2js')
const uploadPlugin = require('../plugins/upload')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper') const { getFileExtension, readFileChunk } = require('../util/fileHelper')
@@ -19,15 +20,7 @@ function createDupkey() {
return s.join('') return s.join('')
} }
module.exports = async (query, request) => { module.exports = async (query, request, dependencies = {}) => {
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
if (!query.songFile) { if (!query.songFile) {
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
@@ -38,6 +31,19 @@ module.exports = async (query, request) => {
}) })
} }
const axiosRequest = dependencies.axios || axios
const uploadImage = dependencies.uploadPlugin || uploadPlugin
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const coverImgId = query.imgFile
? (await uploadImage(query, request)).imgId
: query.coverImgId
const tokenRes = await request( const tokenRes = await request(
`/api/nos/token/alloc`, `/api/nos/token/alloc`,
{ {
@@ -53,7 +59,7 @@ module.exports = async (query, request) => {
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F') const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
const docId = tokenRes.body.result.docId const docId = tokenRes.body.result.docId
const res = await axios({ const res = await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: { headers: {
@@ -94,7 +100,7 @@ module.exports = async (query, request) => {
) )
} }
const res3 = await axios({ const res3 = await axiosRequest({
method: 'put', method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@@ -117,7 +123,7 @@ module.exports = async (query, request) => {
} }
completeStr += '</CompleteMultipartUpload>' completeStr += '</CompleteMultipartUpload>'
await axios({ await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@@ -128,29 +134,29 @@ module.exports = async (query, request) => {
data: completeStr, data: completeStr,
}) })
const voiceData = JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
])
await request( await request(
`/api/voice/workbench/voice/batch/upload/preCheck`, `/api/voice/workbench/voice/batch/upload/preCheck`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),
@@ -163,25 +169,7 @@ module.exports = async (query, request) => {
`/api/voice/workbench/voice/batch/upload/v2`, `/api/voice/workbench/voice/batch/upload/v2`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),
+5 -1
View File
@@ -1,5 +1,9 @@
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = {} const data = {}
return request(`/api/pointmall/user/sign`, data, createOption(query, 'weapi')) return request(
`/api/pointmall/user/sign`,
data,
createOption(query, 'xeapi', 'v3'),
)
} }
+26
View File
@@ -0,0 +1,26 @@
// 云贝广告任务 - 完成任务领取云贝
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// POST /api/ad/power/yunbei/distribution/create
// 参数: yunbeiAmount (单次可得云贝, 客户端从 list 接口的 singleAmount 取值, 当前为 150)
// 返回: true (领取成功)
//
// 实测验证 (2026-08-05):
// - 仅传 yunbeiAmount 即可成功领取, 无需真实听歌/看视频
// - 单日上限 10 次 × 150 云贝 = 1500 云贝/天
// - 超限返回 code:400 "单日完成任务数已达上限"
// - 无频率限制, 800ms 间隔连续调用均成功
//
// 建议: 领取前先调 yunbei_ad_task_list 查询 times, 达到 10 次即停止
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
yunbeiAmount: query.yunbeiAmount || 150,
}
return request(
`/api/ad/power/yunbei/distribution/create`,
data,
createOption(query, 'weapi'),
)
}
+16
View File
@@ -0,0 +1,16 @@
// 云贝广告任务 - 查询今日任务状态
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// GET /api/ad/power/yunbei/distribution/list
// 返回: { times: 今日已完成次数, amount: 今日累计云贝, singleAmount: 单次可得云贝 }
// 注意: 单日上限 10 次, 单次 150 云贝 (每天最多 1500)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/ad/power/yunbei/distribution/list`,
data,
createOption(query, 'weapi'),
)
}
+20
View File
@@ -0,0 +1,20 @@
// 云贝广告任务 - 获取推荐歌曲
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// POST /api/ad/power/yunbei/distribution/recommend/song
// 参数: offset (默认 0), limit (默认 10, 客户端一次 10 首)
// 返回: 推荐歌曲数组 [{ songId, songName, artistName, albumUrl, songChorusStartTime, likeFlag, alg }]
// 注意: alg 均为 alg_payrec_yunBei_*, 为"听歌得云贝"任务专属推荐
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
offset: query.offset || 0,
limit: query.limit || 10,
}
return request(
`/api/ad/power/yunbei/distribution/recommend/song`,
data,
createOption(query, 'weapi'),
)
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { banner, lyric } from '@neteasecloudmusicapienhanced/api' import { banner, lyric } from '@neteasecloudmusicapienhanced/api'
import { logger } from '../util/logger.js' import type { Response } from '@neteasecloudmusicapienhanced/api'
banner({ type: 0 }).then((res) => { import logger from '../util/logger.js'
banner({ type: 0 }).then((res: Response) => {
logger.info(res) logger.info(res)
}) })
lyric({ lyric({
id: '33894312', id: '33894312',
}).then((res) => { }).then((res: Response) => {
logger.info(res) logger.info(res)
}) })
+48
View File
@@ -0,0 +1,48 @@
const {
login_cellphone,
yunbei_ad_task_list,
yunbei_ad_task_recommend_song,
yunbei_ad_task_finish,
} = require('../main')
// 云贝广告任务(听歌/看视频得云贝)完整流程示例
// 1. list - 查询今日任务状态(次数/云贝)
// 2. finish - 领取云贝, 仅需传 yunbeiAmount(单次 150), 无需真实听歌/看视频
// 3. recommend/song - 可选, 获取推荐歌曲列表
// 注意: 单日上限 10 次 x 150 = 1500 云贝/天
async function main() {
const login = await login_cellphone({
phone: '手机号',
password: '密码',
})
const cookie = login.body.cookie
// 1. 查询今日任务状态
const list = await yunbei_ad_task_list({ cookie })
const { times, amount, singleAmount } = list.body
console.log(
`今日已完成 ${times} 次, 累计 ${amount} 云贝, 单次可得 ${singleAmount} 云贝`,
)
if (times >= 10) {
console.log('已达单日上限, 明天再来吧')
return
}
// 2. 领取云贝(单次 150)
const finish = await yunbei_ad_task_finish({
yunbeiAmount: singleAmount,
cookie,
})
console.log('领取结果:', finish.body)
// 3. 获取推荐歌曲(听歌任务专属推荐, 可选)
const rcmd = await yunbei_ad_task_recommend_song({
offset: 0,
limit: 10,
cookie,
})
console.log('推荐歌曲:', rcmd.body.map((s) => s.songName).join(', '))
}
main()
+20 -18
View File
@@ -1,7 +1,7 @@
{ {
"name": "@neteasecloudmusicapienhanced/api", "name": "@neteasecloudmusicapienhanced/api",
"version": "4.39.0", "version": "5.0.0",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护", "description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced)",
"scripts": { "scripts": {
"dev": "nodemon app.js", "dev": "nodemon app.js",
"start": "node app.js", "start": "node app.js",
@@ -11,9 +11,9 @@
"prepare": "husky install", "prepare": "husky install",
"docs:format": "node scripts/format-docs.js", "docs:format": "node scripts/format-docs.js",
"docs:check": "node scripts/format-docs.js --check", "docs:check": "node scripts/format-docs.js --check",
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app", "pkgwin": "node scripts/build-pkg.js . -t node22-win-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public",
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app", "pkglinux": "node scripts/build-pkg.js . -t node22-linux-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public",
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app" "pkgmacos": "node scripts/build-pkg.js . -t node22-macos-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public"
}, },
"bin": "./app.js", "bin": "./app.js",
"pkg": { "pkg": {
@@ -52,7 +52,7 @@
}, },
"homepage": "https://neteasecloudmusicapienhanced.js.org/", "homepage": "https://neteasecloudmusicapienhanced.js.org/",
"engines": { "engines": {
"node": ">=12" "node": ">=22"
}, },
"lint-staged": { "lint-staged": {
"*.js": [ "*.js": [
@@ -75,44 +75,46 @@
"data" "data"
], ],
"dependencies": { "dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.4", "@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.5",
"axios": "^1.18.1", "axios": "^1.20.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"fzstd": "^0.1.1",
"gzip": "^0.1.0", "gzip": "^0.1.0",
"music-metadata": "^11.14.0", "jsdom": "^24.1.3",
"music-metadata": "^11.16.1",
"node-forge": "^1.4.0", "node-forge": "^1.4.0",
"pac-proxy-agent": "^7.2.0", "pac-proxy-agent": "^7.2.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"safe-decode-uri-component": "^1.2.1", "safe-decode-uri-component": "^1.2.1",
"tunnel": "^0.0.6", "tunnel": "^0.0.6",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"yargs": "^18.0.0" "yargs": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.6", "@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-fileupload": "^1.5.1", "@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",
"@types/node": "25.9.2", "@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.65.0", "@typescript-eslint/parser": "^8.71.0",
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-html": "^8.1.4", "eslint-plugin-html": "^8.2.1",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"globals": "^17.7.0", "globals": "^17.13.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0", "intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"mocha": "^11.7.6", "mocha": "^11.8.0",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"pkg": "^5.8.1", "@yao-pkg/pkg": "^6.23.0",
"power-assert": "^1.6.1", "power-assert": "^1.6.1",
"prettier": "^3.9.6", "prettier": "^3.9.9",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"publishConfig": { "publishConfig": {
+1195 -507
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
packages:
- '.'
allowBuilds:
core-js: true
es5-ext: true
minimumReleaseAgeExclude:
- '@neteasecloudmusicapienhanced/unblockmusic-utils@0.4.2'
+1
View File
@@ -319,6 +319,7 @@
<option value="api">api</option> <option value="api">api</option>
<option value="linuxapi">linuxapi</option> <option value="linuxapi">linuxapi</option>
<option value="xeapi">xeapi</option> <option value="xeapi">xeapi</option>
<option value="neapi">neapi</option>
<option value="" selected>(默认)</option> <option value="" selected>(默认)</option>
</select> </select>
</div> </div>
+7 -3
View File
@@ -255,9 +255,13 @@
} }
logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`) logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`)
for (var song of resp.data.result) { for (var song of resp.data.result) {
logs.write( var a = document.createElement('a');
`[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>` a.href = 'https://music.163.com/song?id=' + encodeURIComponent(song.song.id);
) a.target = '_blank';
a.textContent = song.song.name + ' - ' + song.song.album.name + ' (' + (song.startTime / 1000) + 's)';
logs.appendChild(document.createTextNode('[result] '));
logs.appendChild(a);
logs.appendChild(document.createElement('br'));
} }
}) })
}) })
+229 -22
View File
@@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析 - 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试 - 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持 - 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数) - 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新)
## 接口文档 ## 接口文档
@@ -826,6 +826,8 @@ tags: 歌单标签
`lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据 `lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据
接口会传入官方客户端使用的 `fromRN=true`,登录用户读取自己的动态时可获得网易云允许本人查看的非公开动态。返回的 `size` 是网易云账户侧统计值,不保证等于分页后实际可获取的动态数量。
**接口地址 :** `/user/event` **接口地址 :** `/user/event`
**调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743` **调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743`
@@ -843,6 +845,38 @@ tags: 歌单标签
41、21 分享视频 41、21 分享视频
``` ```
### 获取当前登录用户的全部可枚举动态
说明 : 登录后调用此接口,会组合 `/user/account` 与 `/user/event` 的原子能力:先从 Cookie 取得当前用户 id,再自动跟随 `lasttime` 游标读取至 `more=false`。接口返回网易云允许当前用户本人查看的公开及非公开动态,不能读取其他用户的私密动态。
本接口不接受 `limit` 或 `lasttime`,一次请求会完成全部上游分页。账号动态较多时,请预留足够的请求时间。
数量字段说明:
- `size`:网易云返回的账户统计数量,可能包含已删除、被屏蔽、资源失效或旧类型且不再下发的记录。
- `retrievedCount`:本次实际取得的唯一动态数量,始终等于 `events.length`。
- `unavailableCount`:`size - retrievedCount` 的正差值;缺失记录没有返回 id,无法继续读取或修改。
- `sizeMismatch`:`size` 与 `retrievedCount` 是否不一致。若上游未返回 `size`,相关字段为 `null`。
- `pageCount`:本次实际请求的上游分页数量。
每条动态的 `privacySetting` 表示当前可见权限:`0` 为所有人,`1` 为我关注的人,`2` 为仅自己,`6` 为互相关注的人。
**接口地址 :** `/user/event/all`
**调用例子 :** `/user/event/all`
### 修改动态可见权限
说明 : 登录后调用此接口,可以修改当前账号本人发布的单条动态的可见权限。此接口只负责一次原子修改;上游没有批量修改接口。如需批量操作,可先调用 `/user/event/all` 并按 `privacySetting` 筛选,再由调用方逐条调用本接口,同时自行处理限速、失败重试和部分成功。
**必选参数 :** `evId` : 动态 id
`privacy` : 目标可见权限。`0` 为所有人,`1` 为我关注的人,`2` 为仅自己,`6` 为互相关注的人
**接口地址 :** `/event/privacy`
**调用例子 :** `/event/privacy?evId=6712917601&privacy=0`
### 转发用户动态 ### 转发用户动态
说明 : 登录后调用此接口 ,可以转发用户动态 说明 : 登录后调用此接口 ,可以转发用户动态
@@ -1230,7 +1264,6 @@ tags: 歌单标签
> 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲 > 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲
### 歌单详情动态 ### 歌单详情动态
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数 说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
@@ -1271,14 +1304,14 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id **必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`, `level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带` `lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false` `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` **接口地址 :** `/song/url/v1`
**调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless` `/song/url/v1?id=1969519579&level=sky&immerseType=ste` **调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless` `/song/url/v1?id=1969519579&level=sky&immerseType=ste` `/song/url/v1?id=1969519579&level=vivid`
说明:`杜比全景声`音质需要设备支持,不同的设备可能会返回不同码率的 url。cookie 需要传入`os=pc`保证返回正常码率的 url。 说明:`杜比全景声`音质需要设备支持,不同的设备可能会返回不同码率的 url。cookie 需要传入`os=pc`保证返回正常码率的 url。
@@ -1288,7 +1321,7 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id **必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`, `level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带` `lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false` `unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**接口地址 :** `/song/url/v1/302` **接口地址 :** `/song/url/v1/302`
@@ -2358,6 +2391,16 @@ privilege:权限相关信息
**调用例子 :** `/recommend/songs` **调用例子 :** `/recommend/songs`
### 获取每日推荐歌曲 - v1
说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 )
**可选参数 :** `ispush`: 是否刷新日推 , 默认为 false
**接口地址 :** `/recommend/songs/v1`
**调用例子 :** `/recommend/songs/v1`
### 每日推荐歌曲-不感兴趣 ### 每日推荐歌曲-不感兴趣
说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 ) 说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 )
@@ -2440,6 +2483,18 @@ privilege:权限相关信息
喜欢成功则返回数据的 code 为 200, 其余为失败 喜欢成功则返回数据的 code 为 200, 其余为失败
### 喜欢音乐 - 新版
说明: 调用此接口 , 传入音乐 id, 可喜欢该音乐
**必选参数 :** `id`: 歌曲 id
**可选参数 :** `like`: 布尔值 , 默认为 true 即喜欢 , 若传 false, 则取消喜欢
**接口地址 :** `/like/v1`
**调用例子 :** `/like/v1?id=347230`
### 喜欢音乐列表 ### 喜欢音乐列表
说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐 id 列表(id 数组) 说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐 id 列表(id 数组)
@@ -3592,6 +3647,36 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子 :** `/yunbei/task/finish?userTaskId=5146243240&depositCode=0` **调用例子 :** `/yunbei/task/finish?userTaskId=5146243240&depositCode=0`
### 云贝广告任务 - 今日任务状态
说明 :登录后调用此接口可查询云贝广告任务("听歌/看视频得云贝")今日状态。逆向自云贝任务中心 H5 页面(st.music.163.com/yunbei-listen)。返回 `times`(今日已完成次数)、`amount`(今日累计云贝)、`singleAmount`(单次可得云贝)。单日上限 10 次。
**接口地址 :** `/yunbei/task/list/v1`
**调用例子 :** `/yunbei/task/list/v1`
### 云贝广告任务 - 获取推荐歌曲
说明 :登录后调用此接口可获取云贝广告任务的推荐歌曲列表。返回数组项含 `songId`、`songName`、`artistName`、`albumUrl`、`songChorusStartTime`、`likeFlag`、`alg`(均为 `alg_payrec_yunBei_*`)。
**可选参数 :** `offset`: 偏移数量,默认为 0
`limit`: 取出数量,默认为 10(客户端每次固定取 10 首)
**接口地址 :** `/yunbei/task/recommend/song`
**调用例子 :** `/yunbei/task/recommend/song` `/yunbei/task/recommend/song?offset=0&limit=10`
### 云贝广告任务 - 完成任务领取云贝
说明 :登录后调用此接口可完成任务并领取云贝。实测仅需传 `yunbeiAmount`(单次云贝数,客户端从 `list` 接口的 `singleAmount` 取值,当前为 150)即可成功领取,无需真实听歌/看视频。单日上限 10 次 × 150 = 1500 云贝/天,超限返回 `code:400 "单日完成任务数已达上限"`。建议领取前先调用 `/yunbei/task/list/v1` 查询今日剩余次数。
**可选参数 :** `yunbeiAmount`: 单次云贝数,默认为 150
**接口地址 :** `/yunbei/task/finish/v1`
**调用例子 :** `/yunbei/task/finish/v1?yunbeiAmount=150`
### 云贝收入 ### 云贝收入
说明 :登录后调用此接口可获取云贝收入 说明 :登录后调用此接口可获取云贝收入
@@ -4075,6 +4160,16 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子:** `/song/wiki/summary?id=1958384591` **调用例子:** `/song/wiki/summary?id=1958384591`
### 音乐百科
说明: 调用此接口可以获取歌曲的音乐百科信息
**接口地址:** `/song/wiki/info`
**必选参数:** `id`: 歌曲 ID
**调用例子:** `/song/wiki/info?id=1958384591`
### 乐谱列表 ### 乐谱列表
说明: 调用此接口可以获取歌曲的乐谱列表 说明: 调用此接口可以获取歌曲的乐谱列表
@@ -4337,27 +4432,33 @@ ONLINE 已发布
### 播客上传声音 ### 播客上传声音
说明: 可以上传声音到播客,例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a> 说明: 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传声音文件 formData(name 为`songFile`),可通过 formData(name 为`imgFile`)同时上传声音封面。例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a>
**接口地址:** `/voice/upload` **接口地址:** `/voice/upload`
**必选参数:** **必选参数:**
`voiceListId`: 播客 id
`coverImgId`: 播客封面 `songFile`: 声音文件
`voiceListId`: 播客 id
`categoryId`: 分类 id `categoryId`: 分类 id
`secondCategoryId`:次级分类 id `secondCategoryId`: 次级分类 id
`description`: 声音介绍 `description`: 声音介绍
**可选参数:** **可选参数:**
`imgFile`: 声音封面图片文件,上传后会自动生成图片 id。与`coverImgId`同时传入时,优先使用`imgFile`
`coverImgId`: 已上传的声音封面图片 id,未传入`imgFile`时使用该值
`songName`: 声音名称 `songName`: 声音名称
`privacy`: 设为隐私声音,播客如果是隐私博客,则必须设为 1 `privacy`: 设为隐私声音,播客如果是隐私播客,则必须设为 1
`publishTime`:默认立即发布,定时发布的话需传入时间戳 `publishTime`: 默认立即发布,定时发布的话需传入时间戳
`autoPublish`: 是否发布动态,是则传入 1 `autoPublish`: 是否发布动态,是则传入 1
@@ -4584,7 +4685,7 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
### 歌曲音质详情 ### 歌曲音质详情
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出 `高清环绕声`、`沉浸环绕声`、`超清母带`等音质的信息 说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出`高清臻音`、`杜比全景声`、`臻音全景声`、`超清母带`、`沉浸环绕声`等音质的信息
**必选参数:** **必选参数:**
@@ -4621,11 +4722,11 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
**必选参数:** **必选参数:**
`mode`: 模式 (aidj, DEFAULT, FAMILIAR, EXPLORE, SCENE_RCMD) `mode`: 模式 (`DEFAULT`: 默认模式,`FAMILIAR`: 熟悉模式,`EXPLORE`: 探索模式,`SCENE_RCMD`: 场景模式, 需要传入`submode`参数,`PUZZLE_MODE_RCMD`: 拼图模式)
**可选参数:** **可选参数:**
`submode`: 当 mode 为 SCENE_RCMD 是可为 ( EXERCISE, FOCUS, NIGHT_EMO ) `submode`: 当 mode 为 SCENE_RCMD 时传入 ( `EXERCISE`: 运动, `FOCUS`: 专注, `NIGHT_EMO`: 伤感, `SLEEP_HELP`: 助眠, `RELAX`: 放松, `CHEERFUL`: 欢快, `LYRICAL`: 抒情, `CURE`: 治愈, `SWEET`: 情歌, `RHYTHM_BLUES`: R&B, `RAINY`: 雨天, `GAMES`: 游戏, `RAP`: 说唱, `K_POP`: K-Pop, `ORIGINAL_MUSICIAL`: 宝藏原创, `ELECTRONIC`: 电音, `COMMUTE`: 出行, `TAKE_SHOWER`: 洗澡, `COFFEE_SHOP`: 咖啡馆, `ROCK`: 摇滚, `INSPIRATIONAL`: 励志, `CHINESE`: 华语, `ENGLISH`: 欧美, `YUEYU`: 粤语, `MANYAO`: 慢摇DJ, `JINGDIAN`: 经典, `LIGHT`: 轻音乐, `GUOFENG`: 国风, `FOLK`: 民谣, `ACG`: 二次元, `GUDIAN`: 古典, `JAZZ`: 爵士, `JAPANESE`: 日语, `GLOBAL`: 全球, `FRANCH`: 法语, `BLUE`: 蓝调, `DANCE`: 舞蹈, `LATIN`: 拉丁, `PUNK`: 放克, `COUNTRY`: 乡村乐, `MUSICAL`: 音乐剧, `YINGSHI`: 影视)
**接口地址:** `/personal/fm/mode` **接口地址:** `/personal/fm/mode`
@@ -4705,7 +4806,7 @@ bitrate = Math.floor(br / 1000)
**必选参数 :** `id` : 音乐 id **必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`, `level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带` `lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
**接口地址 :** `/song/download/url/v1` **接口地址 :** `/song/download/url/v1`
@@ -5154,7 +5255,6 @@ let data = encodeURIComponent(
**调用例子:** `/broadcast/sub?id=5&t=1` **调用例子:** `/broadcast/sub?id=5&t=1`
### 用户的创建歌单列表 ### 用户的创建歌单列表
说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表 说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表
@@ -5229,7 +5329,6 @@ let data = encodeURIComponent(
**调用例子 :** `/voicelist/my/created` **调用例子 :** `/voicelist/my/created`
### DIFM电台 - 分类 ### DIFM电台 - 分类
说明: 调用此接口, 获取DIFM电台分类 说明: 调用此接口, 获取DIFM电台分类
@@ -5408,7 +5507,7 @@ let data = encodeURIComponent(
**接口地址 :** `/comment/report` **接口地址 :** `/comment/report`
**调用例子 :* `/comment/report?id=2058263032&cid=123456789&reason=人身攻击` *_调用例子 :_ `/comment/report?id=2058263032&cid=123456789&reason=人身攻击`
### 多级行政区划数据 ### 多级行政区划数据
@@ -5498,13 +5597,23 @@ let data = encodeURIComponent(
**调用例子 :** `/ad/get` **调用例子 :** `/ad/get`
### 获取30分钟免费听歌时长 ### 看广告领取权益(免费听歌时长 / 云贝等)
说明 : 登录后调用此接口, 获取30分钟免费听歌时长 说明 : 登录后调用此接口, 领取广告权益。权益类型由广告平台下发的配置决定, 不仅限于 30 分钟免费听歌时长, 还包括"看视频获得最高 2000 云贝"等拉新分段权益(`rightsGainMethod=6`)。除下方常用参数外, 权益类型/时长/扩展权益等其余字段会自动从广告下发配置补齐, 无需手动传入。
!> 警告: 通过调取接口出现的任何问题由调用者自行承担 !> 警告: 通过调取接口出现的任何问题由调用者自行承担
**可选参数 :** `reqUid` 通过`/ad/get` 获取的广告ID **可选参数 :**
`reqUid` : 广告请求 ID, 通过 `/ad/get` 获取, 未传时自动获取
`uid` : 当前登录用户 ID, 不传时服务端从 Cookie 识别
`rightsGainMethod` : 权益领取方式, `1`: 曝光, `2`: 曝光+点击(默认), `3`: 曝光+下载, `4`: 点击+停留, `5`: 曝光或点击停留, `6`: 拉新曝光/下载分段权益(看视频得云贝)
`type_ids` : 广告位类型, 默认 `["400002_0"]`
`creativeType` : 广告创意类型, 激励视频场景为 `36`, 默认 `36`
**接口地址 :** `/ad/listening/rights/gain` **接口地址 :** `/ad/listening/rights/gain`
@@ -5757,6 +5866,104 @@ let data = encodeURIComponent(
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234` **调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
### 上报设备中心设备名称
说明: 登录后调用此接口, 传入 `deviceName`, 可修改当前设备在网易云登录设备管理列表里显示的名称
**必选参数 :** `deviceName`: 期望展示的设备名称
`name`: `deviceName` 的兼容别名, 二选一即可
**接口地址 :** `/deviceinfo/center/upload`
**调用例子 :** `/deviceinfo/center/upload?deviceName=XXXXX`
### 插播相似歌曲
说明: 调用此接口, 传入歌曲 id, 可获取插播相似歌曲
**必选参数 :** `id`: 歌曲 id
**接口地址 :** `/song/simi/get`
**调用例子 :** `/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, 可离线访问 此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
+1
View File
@@ -99,6 +99,7 @@ curl -s {origin}/search?keywords=网易云</code></pre>
<a href="/scrobble.html">听歌打卡示例</a> · <a href="/scrobble.html">听歌打卡示例</a> ·
<a href="/yidun.html">获取 CheckToken</a> · <a href="/yidun.html">获取 CheckToken</a> ·
<a href="/free_listen.html">免费听权益</a> · <a href="/free_listen.html">免费听权益</a> ·
<a href="/yunbei_task.html">云贝广告任务</a> ·
<a href="/ugc.html">云小编任务中心</a> · <a href="/ugc.html">云小编任务中心</a> ·
<a href="/ugc_lottery.html">云小编每日抽奖</a> <a href="/ugc_lottery.html">云小编每日抽奖</a>
</div> </div>
+28 -13
View File
@@ -217,8 +217,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>选择文件</label> <label for="songFile">选择声音文件</label>
<input type="file" name="songFile" accept="audio/*" /> <input id="songFile" type="file" name="songFile" accept="audio/*" />
</div>
<div class="form-group">
<label for="imgFile">选择声音封面(可选)</label>
<input id="imgFile" type="file" name="imgFile" accept="image/*" />
</div> </div>
<button class="btn" @click="submit">上传</button> <button class="btn" @click="submit">上传</button>
@@ -251,29 +256,36 @@
methods: { methods: {
submit() { submit() {
console.info('submit') console.info('submit')
const file = document.querySelector('input[type=file]').files[0] const songFile = document.querySelector('input[name=songFile]').files[0]
if (!file) { const imgFile = document.querySelector('input[name=imgFile]').files[0]
alert('请选择文件') if (!songFile) {
alert('请选择声音文件')
return return
} }
this.upload(file) this.upload(songFile, imgFile)
}, },
async getData() { async getData() {
this.loading = true this.loading = true
try { try {
const res = await axios({ const res = await axios({
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`, url: `/voicelist/my/created?limit=100&cookie=${localStorage.getItem(
'cookie',
)}`,
}) })
console.info(res.data.data) console.info(res.data.data)
this.voicelist = res.data.data.list || [] this.voicelist = res.data.data.data || []
this.voicelist.forEach(async (i) => { this.voicelist.forEach(async (i) => {
try { try {
const res2 = await axios({ const res2 = await axios({
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`, url: `/voicelist/list?voiceListId=${
i.voiceListId
}&limit=5&cookie=${localStorage.getItem('cookie')}`,
}) })
i.voiceListData = res2.data.data.list || [] i.voiceListData = res2.data.data
? res2.data.data.list || []
: []
console.info(res2) console.info(res2)
} catch (err) { } catch (err) {
console.error('获取播客详情失败:', err) console.error('获取播客详情失败:', err)
@@ -286,14 +298,17 @@
} }
}, },
upload(file) { upload(songFile, imgFile) {
if (!this.currentVoice) { if (!this.currentVoice) {
alert('请先选择播客列表') alert('请先选择播客列表')
return return
} }
var formData = new FormData() var formData = new FormData()
formData.append('songFile', file) formData.append('songFile', songFile)
if (imgFile) {
formData.append('imgFile', imgFile)
}
axios({ axios({
method: 'post', method: 'post',
@@ -312,7 +327,7 @@
data: formData, data: formData,
}) })
.then((res) => { .then((res) => {
alert(`${file.name} 上传成功`) alert(`${songFile.name} 上传成功`)
}) })
.catch((err) => { .catch((err) => {
console.error('上传失败:', err) console.error('上传失败:', err)
+161 -49
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Token 注册器 — 网易云音乐 API Enhanced</title> <title>易盾 Watchman 调试器 — 网易云音乐 API Enhanced</title>
<style> <style>
:root { :root {
--fg: #333; --fg: #333;
@@ -17,7 +17,7 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
.container { max-width: 720px; margin: 40px auto; padding: 0 20px; } .container { max-width: 760px; margin: 40px auto; padding: 0 20px; }
@media (max-width: 480px) { @media (max-width: 480px) {
.container { margin: 20px auto; padding: 0 16px; } .container { margin: 20px auto; padding: 0 16px; }
header.site-header h1 { font-size: 22px; } header.site-header h1 { font-size: 22px; }
@@ -28,8 +28,12 @@
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; } .sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } .block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
.block h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; } .block h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 160px; overflow: auto; } .field { margin-bottom: 10px; }
.row { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
input[type="text"] { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Courier New', monospace; color: var(--fg); background: #fafafa; }
input[type="text"]:focus { outline: none; border-color: #888; background: #fff; }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 220px; overflow: auto; }
.row { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; } button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
button:hover { background: #eee; } button:hover { background: #eee; }
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); } button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
@@ -41,32 +45,65 @@
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; } .tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
.flex { display: flex; align-items: center; gap: 10px; } .flex { display: flex; align-items: center; gap: 10px; }
.flex.sb { justify-content: space-between; } .flex.sb { justify-content: space-between; }
.mt-2 { margin-top: 14px; } .hint { font-size: 12px; color: var(--muted); margin-top: 8px; }
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; } footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
</style> </style>
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<header class="site-header"> <header class="site-header">
<h1>🔑 Token 注册器</h1> <h1>🛡️ 易盾 Watchman 调试器</h1>
<p class="sub">从易盾获取反作弊 token 并注册到服务器,供广告等需要 checkToken 的接口使用</p> <p class="sub">
直接调用易盾官方 Web SDK(tool.min.js)获取反作弊 token。
与 <code>module/register_checktoken_v2.js</code> 内部(jsdom 模拟同一套 SDK)逻辑一致,可用于对照验证。
</p>
</header> </header>
<!-- Watchman 初始化 -->
<div class="block"> <div class="block">
<div class="flex sb"> <div class="flex sb">
<h3>当前 Token</h3> <h3>① 初始化 Watchman</h3>
<span id="statusTag" class="tag no">未注册</span> <span id="initTag" class="tag no">未初始化</span>
</div>
<div class="field">
<label for="pnInput">productNumber(易盾产品号)</label>
<input type="text" id="pnInput" value="YD00000558929251" spellcheck="false">
</div> </div>
<pre id="tokenDisplay">(空)</pre>
<div class="row"> <div class="row">
<button id="btnGet" class="primary">获取 Token</button> <button id="btnInit" class="primary">初始化</button>
<button id="btnRefresh">强制刷新</button> <button id="btnReset">重置</button>
</div> </div>
</div> </div>
<!-- 获取 Token -->
<div class="block"> <div class="block">
<h3>服务器响应</h3> <div class="flex sb">
<pre id="respDisplay">{}</pre> <h3>② 获取 Token</h3>
<span id="getTag" class="tag no">—</span>
</div>
<div class="field">
<label for="bidInput">businessId(业务 ID,来自网易 clientcfg 下发)</label>
<input type="text" id="bidInput" value="bd5d2f973ef74cd2a61325a412ae54d9" spellcheck="false">
</div>
<div class="row">
<button id="btnGet" class="primary" disabled>获取 Token</button>
<button id="btnCopy" disabled>复制</button>
<span class="hint">覆盖 /api/playlist/subscribe、/api/song/like、/api/user/follow 等接口</span>
</div>
<pre id="tokenDisplay">(尚未获取)</pre>
</div>
<!-- 服务器端对照 -->
<div class="block">
<div class="flex sb">
<h3>③ 服务器端对照(/register/checktoken/v2)</h3>
<span id="srvTag" class="tag no">—</span>
</div>
<div class="row">
<button id="btnSrv">获取服务器端 Token</button>
<span class="hint">node + jsdom 模拟同一套 SDK 的实时结果</span>
</div>
<pre id="srvDisplay">(尚未获取)</pre>
</div> </div>
<footer class="site-footer"> <footer class="site-footer">
@@ -74,46 +111,121 @@
</footer> </footer>
</main> </main>
<script src="https://acstatic-dun.126.net/tool.min.js"></script>
<script> <script>
const $ = (id) => document.getElementById(id) const $ = (id) => document.getElementById(id)
const base = location.origin
async function call(refresh) { const DEFAULTS = {
const btn = $('btnGet') pn: 'YD00000558929251',
const btnR = $('btnRefresh') bid: 'bd5d2f973ef74cd2a61325a412ae54d9',
btn.disabled = btnR.disabled = true
const tag = $('statusTag')
tag.className = 'tag busy'
tag.textContent = '获取中…'
try {
const url = base + '/register/checktoken/v2' + (refresh ? '?refresh=1' : '')
const res = await fetch(url)
const data = await res.json()
$('respDisplay').textContent = JSON.stringify(data, null, 2)
if (data.token) {
$('tokenDisplay').textContent = data.token
tag.className = 'tag ok'
tag.textContent = '已注册'
} else {
$('tokenDisplay').textContent = '(获取失败)'
tag.className = 'tag no'
tag.textContent = '失败'
}
} catch (e) {
$('respDisplay').textContent = JSON.stringify({ error: e.message }, null, 2)
tag.className = 'tag no'
tag.textContent = e.message
}
btn.disabled = btnR.disabled = false
} }
$('btnGet').onclick = () => call(false) let wm = null // Watchman 实例
$('btnRefresh').onclick = () => call(true)
call(false) const setTag = (id, text, state) => {
const tag = $(id)
tag.textContent = text
tag.className = 'tag ' + state // ok / no / busy
}
// ① 初始化
function initShield() {
if (typeof window.initWatchman !== 'function') {
alert('未找到 initWatchman:请确认 tool.min.js 已加载(CDN 是否可访问)')
return
}
const pn = $('pnInput').value.trim() || DEFAULTS.pn
setTag('initTag', '初始化中…', 'busy')
$('btnInit').disabled = true
window.initWatchman({
auto: true,
productNumber: pn,
onload(instance) {
wm = instance
setTag('initTag', '已就绪', 'ok')
$('btnInit').disabled = false
$('btnGet').disabled = false
},
onerror() {
wm = null
setTag('initTag', '初始化失败', 'no')
$('btnInit').disabled = false
$('btnGet').disabled = true
},
})
}
function resetShield() {
wm = null
setTag('initTag', '未初始化', 'no')
setTag('getTag', '—', 'no')
$('btnGet').disabled = true
$('btnCopy').disabled = true
$('tokenDisplay').textContent = '(尚未获取)'
}
// ② 获取 token
function getToken() {
if (!wm) {
alert('请先完成初始化')
return
}
const bid = $('bidInput').value.trim() || DEFAULTS.bid
setTag('getTag', '获取中…', 'busy')
$('btnGet').disabled = true
wm.getToken(bid, (tk) => {
if (typeof tk === 'string' && tk) {
$('tokenDisplay').textContent = tk
setTag('getTag', 'OK', 'ok')
$('btnCopy').disabled = false
} else {
$('tokenDisplay').textContent = '(获取失败)'
setTag('getTag', '失败', 'no')
$('btnCopy').disabled = true
}
$('btnGet').disabled = false
})
}
// ③ 服务器端对照
async function fetchServerToken() {
setTag('srvTag', '获取中…', 'busy')
$('btnSrv').disabled = true
try {
const res = await fetch(
location.origin + '/register/checktoken/v2?t=' + Date.now(),
)
const data = await res.json()
$('srvDisplay').textContent = data.token
? data.token
: JSON.stringify(data, null, 2)
setTag('srvTag', data.token ? 'OK' : '失败', data.token ? 'ok' : 'no')
} catch (e) {
$('srvDisplay').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('srvTag', e.message, 'no')
}
$('btnSrv').disabled = false
}
// 复制
async function copyToken() {
const txt = $('tokenDisplay').textContent
try {
await navigator.clipboard.writeText(txt)
setTag('getTag', '已复制', 'ok')
} catch (e) {
setTag('getTag', '复制失败', 'no')
}
}
$('btnInit').onclick = initShield
$('btnReset').onclick = resetShield
$('btnGet').onclick = getToken
$('btnCopy').onclick = copyToken
$('btnSrv').onclick = fetchServerToken
// 自动初始化
initShield()
</script> </script>
</body> </body>
</html> </html>
+622
View File
@@ -0,0 +1,622 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>云贝广告任务 — 网易云音乐 API Enhanced</title>
<style>
:root {
--fg: #333;
--muted: #666;
--border: #ddd;
--bg: #f5f5f5;
--panel: #ffffff;
--accent: #333;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
@media (max-width: 480px) {
.container {
margin: 20px auto;
padding: 0 16px;
}
header.site-header h1 {
font-size: 22px;
}
.block {
padding: 16px;
}
}
header.site-header {
margin-bottom: 24px;
}
header.site-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.sub {
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.block {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.block h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.block .desc {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
}
pre {
margin: 0;
background: #f9f9f9;
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Courier New', monospace;
font-size: 13px;
max-height: 200px;
overflow: auto;
}
pre.data {
max-height: 360px;
}
.row {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
button {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: var(--panel);
color: var(--fg);
transition: all 0.15s;
}
button:hover {
background: #eee;
}
button.primary {
background: var(--fg);
color: var(--panel);
border-color: var(--fg);
}
button.primary:hover {
opacity: 0.85;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.danger {
background: #a11;
color: #fff;
border-color: #a11;
}
button.danger:hover {
opacity: 0.85;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
}
.tag.ok {
background: #e6f7e6;
color: #1a7a1a;
border-color: #b7e6b7;
}
.tag.no {
background: #ffe6e6;
color: #a11;
border-color: #f5c6c6;
}
.tag.busy {
background: #fff3cd;
color: #856404;
border-color: #ffeeba;
}
.flex {
display: flex;
align-items: center;
gap: 10px;
}
.flex.sb {
justify-content: space-between;
}
.step {
display: flex;
gap: 12px;
align-items: flex-start;
}
.step .num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--fg);
color: var(--panel);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
margin-top: 1px;
}
.step .num.done {
background: #1a7a1a;
}
.step .body {
flex: 1;
min-width: 0;
}
.info-grid {
display: grid;
grid-template-columns: 80px 1fr;
gap: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.info-grid .lbl {
color: var(--muted);
}
footer.site-footer {
margin-top: 32px;
padding-top: 12px;
border-top: 1px solid var(--border);
color: var(--muted);
text-align: center;
font-size: 13px;
}
footer.site-footer a {
color: var(--fg);
text-decoration: none;
}
footer.site-footer a:hover {
border-bottom: 1px dotted var(--fg);
}
/* ---- 今日进度条 ---- */
.progress-wrap {
margin-top: 10px;
}
.progress-bar {
height: 10px;
border-radius: 5px;
background: #eee;
border: 1px solid var(--border);
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
border-radius: 5px;
background: #1a7a1a;
transition: width 0.4s ease;
}
.progress-text {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
/* ---- 歌曲列表 ---- */
.song-list {
margin-top: 10px;
}
.song-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
background: #fafafa;
}
.song-item img {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
background: #eee;
}
.song-item .song-name {
font-size: 14px;
font-weight: 500;
}
.song-item .song-artist {
font-size: 12px;
color: var(--muted);
}
.song-item .song-like {
margin-left: auto;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<main class="container">
<header class="site-header">
<h1>🪙 云贝广告任务 · 看视频得云贝</h1>
<p class="sub">
云贝任务中心「看广告视频得云贝」— 查询今日状态、领取云贝、获取推荐歌曲
</p>
</header>
<!-- 步骤 1: 查询今日任务状态 -->
<div class="block">
<div class="step">
<div class="num" id="s1num">1</div>
<div class="body">
<div class="flex sb">
<h3>查询今日任务状态</h3>
<span id="s1tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用
/yunbei/task/list/v1,查看今日已完成次数、累计云贝与单次可得云贝
</p>
<div class="row">
<button id="step1" class="primary">① 查询状态</button>
</div>
<div class="info-grid" id="s1info" style="display: none">
<div class="lbl">已完成</div>
<div id="s1times">-</div>
<div class="lbl">累计云贝</div>
<div id="s1amount">-</div>
<div class="lbl">单次可得</div>
<div id="s1single">-</div>
</div>
<div class="progress-wrap" id="s1progress" style="display: none">
<div class="progress-bar">
<div class="progress-fill" id="s1fill"></div>
</div>
<div class="progress-text" id="s1ptext"></div>
</div>
<pre id="s1pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 步骤 2: 领取云贝 -->
<div class="block">
<div class="step">
<div class="num" id="s2num">2</div>
<div class="body">
<div class="flex sb">
<h3>领取云贝</h3>
<span id="s2tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用 /yunbei/task/finish/v1 完成任务并领取云贝。实测仅需传
yunbeiAmount(单次 150),无需真实听歌/看视频。单日上限 10 次 ×
150 = 1500 云贝/天。
</p>
<div class="row">
<button id="step2" class="primary" disabled>
② 领取一次 (+150)
</button>
<button id="step2x" disabled>连续领取至上限</button>
</div>
<div class="info-grid" id="s2info" style="display: none">
<div class="lbl">本次结果</div>
<div id="s2result">-</div>
<div class="lbl">今日累计</div>
<div id="s2amount">-</div>
</div>
<pre id="s2pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 步骤 3: 获取推荐歌曲 -->
<div class="block">
<div class="step">
<div class="num" id="s3num">3</div>
<div class="body">
<div class="flex sb">
<h3>获取推荐歌曲</h3>
<span id="s3tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用
/yunbei/task/recommend/song,获取「听歌得云贝」任务专属推荐(alg
均为 alg_payrec_yunBei_*)
</p>
<div class="row">
<button id="step3" class="primary" disabled>③ 获取推荐</button>
</div>
<div class="song-list" id="s3list" style="display: none"></div>
<pre id="s3pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 一键全流程 -->
<div class="block">
<div class="flex sb">
<h3>一键运行</h3>
<span id="allTag" class="tag no">就绪</span>
</div>
<p class="desc" style="margin-top: 4px">
查询今日状态 → 领取一次云贝 → 获取推荐歌曲
</p>
<div class="row">
<button id="runAll" class="primary">▶ 一键运行</button>
<button id="clearAll" class="danger">✕ 清空</button>
</div>
</div>
<footer class="site-footer">
<a href="/">← 返回首页</a>
</footer>
</main>
<script>
const $ = (id) => document.getElementById(id)
const base = location.origin
function setTag(id, type, text) {
const el = $(id)
el.className = 'tag ' + type
el.textContent = text
}
function setNum(id, done) {
$(id).className = 'num' + (done ? ' done' : '')
}
function nocache(url) {
const sep = url.indexOf('?') > -1 ? '&' : '?'
return url + sep + '_t=' + Date.now()
}
/** 今日进度展示: times/10 */
function renderProgress(times, singleAmount) {
const pct = Math.min(100, Math.round((times / 10) * 100))
$('s1fill').style.width = pct + '%'
$('s1ptext').textContent =
'今日进度: ' +
times +
' / 10 次' +
(times >= 10
? '(已达上限,明天再来吧)'
: '(剩余 ' + (10 - times) + ' 次 × ' + singleAmount + ' 云贝)')
}
// ===== 步骤 1: 查询状态 =====
async function step1() {
const btn = $('step1')
btn.disabled = true
setTag('s1tag', 'busy', '查询中…')
setNum('s1num', false)
try {
const res = await fetch(nocache(base + '/yunbei/task/list/v1'))
const data = await res.json()
$('s1pre').textContent = JSON.stringify(data, null, 2)
const body = data.data || {}
if (data.code === 200 && typeof body.times === 'number') {
setTag('s1tag', 'ok', '已查询')
setNum('s1num', true)
$('s1info').style.display = 'grid'
$('s1times').textContent = body.times
$('s1amount').textContent = body.amount + ' 云贝'
$('s1single').textContent = body.singleAmount + ' 云贝'
$('s1progress').style.display = 'block'
renderProgress(body.times, body.singleAmount)
$('step2').disabled = body.times >= 10
$('step2x').disabled = body.times >= 10
$('step3').disabled = false
window.__singleAmount = body.singleAmount || 150
return body
}
setTag('s1tag', 'no', '查询失败')
return null
} catch (e) {
$('s1pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s1tag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
$('step1').onclick = step1
// ===== 步骤 2: 领取云贝 =====
async function claimOnce() {
const btn = $('step2')
btn.disabled = true
$('step2x').disabled = true
setTag('s2tag', 'busy', '领取中…')
setNum('s2num', false)
try {
const amount = window.__singleAmount || 150
const res = await fetch(
nocache(base + '/yunbei/task/finish/v1?yunbeiAmount=' + amount),
)
const data = await res.json()
$('s2pre').textContent = JSON.stringify(data, null, 2)
if (data.code === 200) {
setTag('s2tag', 'ok', '已领取 +' + amount)
setNum('s2num', true)
$('s2info').style.display = 'grid'
$('s2result').textContent = String(data.data)
await step1()
return true
}
setTag('s2tag', 'no', '领取失败: ' + (data.message || data.code))
$('s2info').style.display = 'none'
return false
} catch (e) {
$('s2pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s2tag', 'no', e.message)
return false
} finally {
btn.disabled = false
$('step2x').disabled = false
}
}
$('step2').onclick = claimOnce
// 连续领取至上限
$('step2x').onclick = async function () {
const btn = this
btn.disabled = true
$('step2').disabled = true
setTag('s2tag', 'busy', '连续领取中…')
let okCount = 0
let failed = false
for (let i = 0; i < 10; i++) {
const state = await step1()
if (!state) {
failed = true
break
}
if (state.times >= 10) break
const ok = await claimOnce()
if (!ok) {
failed = true
break
}
okCount++
}
setTag(
's2tag',
failed ? 'no' : 'ok',
failed ? '中断' : '已领满 ' + okCount + ' 次',
)
btn.disabled = false
$('step2').disabled = false
}
// ===== 步骤 3: 获取推荐歌曲 =====
$('step3').onclick = async function () {
const btn = this
btn.disabled = true
setTag('s3tag', 'busy', '获取中…')
setNum('s3num', false)
$('s3list').style.display = 'none'
try {
const res = await fetch(
nocache(base + '/yunbei/task/recommend/song?offset=0&limit=10'),
)
const data = await res.json()
$('s3pre').textContent = JSON.stringify(data, null, 2)
const list = data.data || []
if (data.code === 200 && Array.isArray(list) && list.length) {
setTag('s3tag', 'ok', '共 ' + list.length + ' 首')
setNum('s3num', true)
const box = $('s3list')
box.style.display = 'block'
box.innerHTML = ''
list.forEach((s) => {
const item = document.createElement('div')
item.className = 'song-item'
item.innerHTML =
'<img src="' +
(s.albumUrl || '') +
'" alt="" onerror="this.style.visibility=\'hidden\'">' +
'<div><div class="song-name">' +
(s.songName || '-') +
'</div>' +
'<div class="song-artist">' +
(s.artistName || '-') +
'</div></div>' +
'<div class="song-like">' +
(s.likeFlag ? '♥ 已喜欢' : '♡ 未喜欢') +
'</div>'
box.appendChild(item)
})
return list
}
setTag('s3tag', 'no', '获取失败')
return null
} catch (e) {
$('s3pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s3tag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
// ===== 一键运行 =====
$('runAll').onclick = async function () {
const btn = this
btn.disabled = true
setTag('allTag', 'busy', '运行中…')
await step1()
const state = window.__singleAmount ? true : false
if (state) await claimOnce()
await $('step3').onclick()
setTag('allTag', 'ok', '完成')
btn.disabled = false
}
// ===== 清空 =====
$('clearAll').onclick = function () {
const steps = ['s1', 's2', 's3']
const tags = { s1: '未开始', s2: '未开始', s3: '未开始' }
steps.forEach((s) => {
setTag(s + 'tag', 'no', tags[s])
setNum(s + 'num', false)
$(s + 'pre').textContent = '(尚未执行)'
const info = $(s + 'info')
if (info) info.style.display = 'none'
})
$('s1progress').style.display = 'none'
$('s3list').style.display = 'none'
$('step1').disabled = false
$('step2').disabled = true
$('step2x').disabled = true
$('step3').disabled = true
setTag('allTag', 'no', '就绪')
$('runAll').disabled = false
}
</script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
/**
* Invoke the pkg packager (cross-platform, independent of node_modules layout).
*
* Background: on Windows, pnpm links node_modules/@yao-pkg/pkg to
* node_modules/.pnpm/@yao-pkg+pkg@<ver>/node_modules/@yao-pkg/pkg via a junction.
* Some CI environments (pnpm 11 + Windows runner) may fail to create that link,
* so executing node_modules/@yao-pkg/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('@yao-pkg/pkg/lib-es5/bin.js')
} catch (err) {
// require.resolve failed (e.g. missing node_modules/@yao-pkg/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('@yao-pkg+pkg@'))
.map((dir) =>
path.join(
pnpmDir,
dir,
'node_modules',
'@yao-pkg',
'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)
+34
View File
@@ -0,0 +1,34 @@
const assert = require('assert')
const eventPrivacy = require('../module/event_privacy')
describe('event privacy modules', () => {
it('updates event privacy with the official client parameters', async () => {
let captured
const expected = { status: 200, body: { code: 200 }, cookie: [] }
const result = await eventPrivacy(
{ evId: '123456789012345678', privacy: '0', cookie: { MUSIC_U: 'x' } },
async (uri, data, options) => {
captured = { uri, data, options }
return expected
},
)
assert.strictEqual(result, expected)
assert.strictEqual(captured.uri, '/api/event/privacy/op')
assert.deepStrictEqual(captured.data, {
eventId: '123456789012345678',
privacy: 0,
})
assert.strictEqual(captured.options.crypto, '')
})
it('rejects unknown event privacy values before making a request', async () => {
let requested = false
const result = await eventPrivacy({ evId: '1', privacy: '3' }, async () => {
requested = true
})
assert.strictEqual(requested, false)
assert.strictEqual(result.status, 400)
})
})
+145
View File
@@ -0,0 +1,145 @@
const assert = require('assert')
const userEventAll = require('../module/user_event_all')
describe('all current user events module', () => {
it('resolves the current user and aggregates every upstream page', async () => {
const calls = []
const pages = [
{
status: 200,
body: {
code: 200,
events: [{ id: 1 }, { id: 2 }],
size: 5,
more: true,
lasttime: 100,
},
cookie: ['page=1'],
},
{
status: 200,
body: {
code: 200,
events: [{ id: 2 }, { id: 3 }],
size: 5,
more: true,
lasttime: 50,
},
cookie: ['page=2'],
},
{
status: 200,
body: {
code: 200,
events: [{ id: 4 }],
size: 1,
more: false,
lasttime: 0,
},
cookie: ['page=3'],
},
]
const result = await userEventAll(
{ cookie: { MUSIC_U: 'x' } },
async (uri, data, options) => {
calls.push({ uri, data, options })
if (uri === '/api/nuser/account/get') {
return {
status: 200,
body: { code: 200, account: { id: 42 } },
cookie: ['account=1'],
}
}
return pages.shift()
},
)
assert.deepStrictEqual(
result.body.events.map((event) => event.id),
[1, 2, 3, 4],
)
assert.strictEqual(result.body.size, 5)
assert.strictEqual(result.body.retrievedCount, 4)
assert.strictEqual(result.body.unavailableCount, 1)
assert.strictEqual(result.body.sizeMismatch, true)
assert.strictEqual(result.body.pageCount, 3)
assert.strictEqual(result.body.more, false)
assert.deepStrictEqual(result.cookie, [
'account=1',
'page=1',
'page=2',
'page=3',
])
assert.strictEqual(calls[0].uri, '/api/nuser/account/get')
assert.strictEqual(calls[0].options.crypto, 'weapi')
assert.deepStrictEqual(
calls.slice(1).map((call) => call.data),
[
{
getcounts: true,
time: -1,
limit: 100,
total: false,
fromRN: 'true',
},
{
getcounts: true,
time: 100,
limit: 100,
total: false,
fromRN: 'true',
},
{
getcounts: true,
time: 50,
limit: 100,
total: false,
fromRN: 'true',
},
],
)
})
it('requires a valid login cookie', async () => {
const result = await userEventAll({}, async () => ({
status: 200,
body: { code: 200, account: null, profile: null },
cookie: [],
}))
assert.strictEqual(result.status, 401)
assert.strictEqual(result.body.code, 401)
})
it('fails instead of returning a partial list when the cursor stalls', async () => {
let page = 0
const result = await userEventAll({}, async (uri) => {
if (uri === '/api/nuser/account/get') {
return {
status: 200,
body: { code: 200, account: { id: 42 } },
cookie: [],
}
}
page += 1
return {
status: 200,
body: {
code: 200,
events: [],
size: 1,
more: true,
lasttime: 100,
},
cookie: [],
}
})
assert.strictEqual(page, 2)
assert.strictEqual(result.status, 502)
assert.match(result.body.message, /cursor stalled/)
})
})
+129
View File
@@ -0,0 +1,129 @@
const assert = require('assert')
const voiceUpload = require('../module/voice_upload')
function createVoiceUploadHarness(uploadPlugin) {
const requestCalls = []
return {
requestCalls,
dependencies: {
uploadPlugin,
axios: async (options) => {
if (options.method === 'post' && options.url.endsWith('?uploads')) {
return {
data: '<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>',
}
}
if (options.method === 'put') {
return { headers: { etag: 'etag-1' } }
}
return { data: {} }
},
},
request: async (uri, data, options) => {
requestCalls.push({ uri, data, options })
if (uri === '/api/nos/token/alloc') {
return {
body: {
result: {
objectKey: 'voice/audio.mp3',
docId: 'audio-doc-id',
token: 'nos-token',
},
},
}
}
if (uri === '/api/voice/workbench/voice/batch/upload/v2') {
return { body: { data: { voiceId: 'voice-id' } } }
}
return { body: { code: 200 } }
},
}
}
function createVoiceUploadQuery(overrides = {}) {
const data = Buffer.from('audio')
return {
songFile: {
name: 'episode.mp3',
mimetype: 'audio/mpeg',
size: data.length,
data,
},
voiceListId: 'voice-list-id',
categoryId: 'category-id',
secondCategoryId: 'second-category-id',
description: 'episode description',
...overrides,
}
}
describe('voice upload cover', () => {
it('uses the uploaded image as the cover for every voice submission', async () => {
let uploadedQuery
const harness = createVoiceUploadHarness(async (query) => {
uploadedQuery = query
return { imgId: 'uploaded-cover-id' }
})
const query = createVoiceUploadQuery({
imgFile: {
name: 'cover.jpg',
mimetype: 'image/jpeg',
data: Buffer.from('image'),
},
coverImgId: 'fallback-cover-id',
})
const result = await voiceUpload(
query,
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploadedQuery, query)
assert.strictEqual(voiceCalls.length, 2)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'uploaded-cover-id')
})
assert.deepStrictEqual(result, {
status: 200,
body: {
code: 200,
data: { voiceId: 'voice-id' },
},
})
})
it('keeps using coverImgId when no image file is uploaded', async () => {
let uploaded = false
const harness = createVoiceUploadHarness(async () => {
uploaded = true
return { imgId: 'unexpected-cover-id' }
})
await voiceUpload(
createVoiceUploadQuery({ coverImgId: 'existing-cover-id' }),
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploaded, false)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'existing-cover-id')
})
})
})
+20 -30
View File
@@ -1,31 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2015", "target": "ES2015",
"module": "commonjs", "module": "node16",
"experimentalDecorators": true, "experimentalDecorators": true,
"ignoreDeprecations": "6.0", "moduleResolution": "node16",
"moduleResolution": "node", "lib": ["esnext", "esnext.asynciterable", "dom"],
"lib": [ "esModuleInterop": true,
"esnext", "allowJs": true,
"esnext.asynciterable", "sourceMap": true,
"dom" "strict": true,
], "noEmit": true,
"esModuleInterop": true, "baseUrl": ".",
"allowJs": true, "paths": {
"sourceMap": true, "~/*": ["./*"],
"strict": true, "@/*": ["./*"],
"noEmit": true, "@neteasecloudmusicapienhanced/api": ["./interface.d.ts"]
"baseUrl": ".", }
"paths": { },
"~/*": [ "exclude": ["node_modules"]
"./*" }
],
"@/*": [
"./*"
]
},
},
"exclude": [
"node_modules"
]
}
+2 -1
View File
@@ -13,11 +13,12 @@
"apiDomain": "https://interface.music.163.com", "apiDomain": "https://interface.music.163.com",
"eapiDomain": "https://interfacepc.music.163.com", "eapiDomain": "https://interfacepc.music.163.com",
"xeapiDomain": "https://interface3.music.163.com", "xeapiDomain": "https://interface3.music.163.com",
"neapiDomain": "https://interface3.music.163.com",
"domain": "https://music.163.com", "domain": "https://music.163.com",
"clDomian": "https://clientlog.music.163.com", "clDomian": "https://clientlog.music.163.com",
"clDomian3": "https://clientlog3.music.163.com", "clDomian3": "https://clientlog3.music.163.com",
"dunDomainV3": "https://ac.dun.163yun.com", "dunDomainV3": "https://ac.dun.163yun.com",
"dunDomainV2": "https://ac.dun.163.com", "dunStaticDomain": "https://acstatic-dun.126.net",
"encrypt": true, "encrypt": true,
"encryptResponse": false, "encryptResponse": false,
"clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9", "clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9",
+111 -12
View File
@@ -2,6 +2,7 @@ const CryptoJS = require('crypto-js')
const crypto = require('crypto') const crypto = require('crypto')
const forge = require('node-forge') const forge = require('node-forge')
const zlib = require('zlib') const zlib = require('zlib')
const zstd = require('./zstd')
const iv = '0102030405060708' const iv = '0102030405060708'
const presetKey = '0CoJUm6Qyw8W8jud' const presetKey = '0CoJUm6Qyw8W8jud'
const linuxapiKey = 'rFgB&h#%2?^eDg:Q' const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
@@ -34,12 +35,12 @@ const aesEncrypt = (text, mode, key, iv, format = 'base64') => {
return encrypted.ciphertext.toString().toUpperCase() return encrypted.ciphertext.toString().toUpperCase()
} }
const aesDecrypt = (ciphertext, key, iv, format = 'base64') => { const aesDecrypt = (ciphertext, mode, key, iv, format = 'base64') => {
let bytes let bytes
if (format === 'base64') { if (format === 'base64') {
bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), { bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), {
iv: CryptoJS.enc.Utf8.parse(iv), iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.ECB, mode: CryptoJS.mode[mode.toUpperCase()],
padding: CryptoJS.pad.Pkcs7, padding: CryptoJS.pad.Pkcs7,
}) })
} else { } else {
@@ -48,7 +49,7 @@ const aesDecrypt = (ciphertext, key, iv, format = 'base64') => {
CryptoJS.enc.Utf8.parse(key), CryptoJS.enc.Utf8.parse(key),
{ {
iv: CryptoJS.enc.Utf8.parse(iv), iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.ECB, mode: CryptoJS.mode[mode.toUpperCase()],
padding: CryptoJS.pad.Pkcs7, padding: CryptoJS.pad.Pkcs7,
}, },
) )
@@ -97,7 +98,7 @@ const eapi = (url, object) => {
const eapiResDecrypt = (encryptedParams, aeapi = false) => { const eapiResDecrypt = (encryptedParams, aeapi = false) => {
// 使用aesDecrypt解密参数 // 使用aesDecrypt解密参数
try { try {
const decrypted = aesDecrypt(encryptedParams, eapiKey, '', 'hex') // WordArray const decrypted = aesDecrypt(encryptedParams, 'ecb', eapiKey, '', 'hex') // WordArray
if (aeapi) { if (aeapi) {
// 带压缩的解密:先转 Base64 再解压 // 带压缩的解密:先转 Base64 再解压
@@ -120,6 +121,7 @@ const eapiReqDecrypt = (encryptedParams) => {
// 使用 aesDecrypt 解密参数 // 使用 aesDecrypt 解密参数
const decryptedData = aesDecrypt( const decryptedData = aesDecrypt(
encryptedParams, encryptedParams,
'ecb',
eapiKey, eapiKey,
'', '',
'hex', 'hex',
@@ -150,7 +152,12 @@ const decrypt = (cipher) => {
} }
const aesEcbEncrypt = (key, plaintext) => { 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()]) return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
} }
@@ -158,7 +165,7 @@ const aesEcbDecrypt = (key, ciphertext) => {
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
`aes-${key.length * 8}-ecb`, `aes-${key.length * 8}-ecb`,
key, key,
null, Buffer.alloc(0),
) )
return Buffer.concat([decipher.update(ciphertext), decipher.final()]) return Buffer.concat([decipher.update(ciphertext), decipher.final()])
} }
@@ -199,9 +206,19 @@ const xeapiMidTransform = (ciphertext) => {
for (let i = 0; i < ciphertext.length; i++) { for (let i = 0; i < ciphertext.length; i++) {
xored[i] = ciphertext[i] ^ random[i & 0x0f] xored[i] = ciphertext[i] ^ random[i & 0x0f]
} }
const b64 = Buffer.from(xored.toString('base64')) const rot = xored.length ? (random[0] & 0x0f) % xored.length : 0
const rot = b64.length ? (random[0] & 0x0f) % b64.length : 0 return Buffer.concat([random, xored.subarray(rot), xored.subarray(0, rot)])
return Buffer.concat([random, b64.subarray(rot), b64.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) => { const xeapiEncryptS = (dynamicKey, publicKeyState, os) => {
@@ -244,7 +261,13 @@ const buildXeapiPlaintext = (uri, data, options = {}) => {
const bodyData = { ...data } const bodyData = { ...data }
delete bodyData.e_r delete bodyData.e_r
const body = Buffer.from(new URLSearchParams(bodyData).toString()) 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) { if (fields.queryString) {
@@ -267,7 +290,7 @@ const xeapi = (uri, data, options = {}) => {
const dynamicKey = activeSessionKey || crypto.randomBytes(16) const dynamicKey = activeSessionKey || crypto.randomBytes(16)
const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options)) const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options))
const b = aesEcbEncrypt( const c = aesEcbEncrypt(
dynamicKey, dynamicKey,
xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)), xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)),
) )
@@ -280,7 +303,7 @@ const xeapi = (uri, data, options = {}) => {
) )
return { return {
B: b.toString('base64'), C: c.toString('base64url'),
S: s.toString('base64'), S: s.toString('base64'),
R: r.toString('base64'), R: r.toString('base64'),
} }
@@ -304,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 = { module.exports = {
weapi, weapi,
linuxapi, linuxapi,
eapi, eapi,
xeapi, xeapi,
neapi,
decrypt, decrypt,
aesEncrypt, aesEncrypt,
aesDecrypt, aesDecrypt,
@@ -317,4 +415,5 @@ module.exports = {
xeapiSign, xeapiSign,
xeapiResDecrypt, xeapiResDecrypt,
xeapiDecryptPublicKey, 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,
}
+153 -26
View File
@@ -17,13 +17,15 @@ const {
generateRandomChineseIP, generateRandomChineseIP,
} = require('./index') } = require('./index')
const { URLSearchParams, URL } = require('url') const { URLSearchParams, URL } = require('url')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('./config.json')
const { loadNeapiKey } = require('./neapiKey')
const { const {
getToken: antiCheatTokenV2, getToken: antiCheatTokenV2,
} = require('../module/register_checktoken_v2') } = require('../module/register_checktoken_v2')
const { const {
getToken: antiCheatTokenV3, getToken: antiCheatTokenV3,
} = require('../module/register_checktoken_v3') } = require('../module/register_checktoken_v3')
const registerNeapikey = require('../module/register_neapikey')
// 预先读取匿名token并缓存 // 预先读取匿名token并缓存
const anonymous_token = fs.readFileSync( const anonymous_token = fs.readFileSync(
@@ -68,6 +70,23 @@ const WNMCID = (function () {
return `${randomString}.${now().toString()}.01.0` return `${randomString}.${now().toString()}.01.0`
})() })()
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 // 预先定义osMap
const osMap = { const osMap = {
pc: { pc: {
@@ -114,7 +133,7 @@ const userAgentMap = {
api: { api: {
pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.1.29.205117', pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.1.29.205117',
android: android:
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)', 'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
iphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)', iphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)',
}, },
} }
@@ -124,6 +143,7 @@ const DOMAIN = APP_CONF.domain
const API_DOMAIN = APP_CONF.apiDomain const API_DOMAIN = APP_CONF.apiDomain
const EAPI_DOMAIN = APP_CONF.eapiDomain const EAPI_DOMAIN = APP_CONF.eapiDomain
const XEAPI_DOMAIN = APP_CONF.xeapiDomain const XEAPI_DOMAIN = APP_CONF.xeapiDomain
const NEAPI_DOMAIN = APP_CONF.neapiDomain
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803]) const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
@@ -136,7 +156,7 @@ const chooseUserAgent = (crypto, uaType = 'pc') => {
} }
// cookie处理 // cookie处理
const processCookieObject = (cookie, uri) => { const processCookieObject = (cookie, crypto) => {
const _ntes_nuid = CryptoJS.lib.WordArray.random(32).toString() const _ntes_nuid = CryptoJS.lib.WordArray.random(32).toString()
const os = osMap[cookie.os] || osMap['pc'] const os = osMap[cookie.os] || osMap['pc']
@@ -155,8 +175,14 @@ const processCookieObject = (cookie, uri) => {
appver: cookie.appver || os.appver, appver: cookie.appver || os.appver,
} }
if (uri.indexOf('login') === -1) { // 服务端下发条件为不带NMTID请求任意eapi加密方式接口
processedCookie['NMTID'] = CryptoJS.lib.WordArray.random(16).toString() 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) { if (!processedCookie.MUSIC_U) {
@@ -187,14 +213,16 @@ const generateRequestId = () => {
.padStart(4, '0')}` .padStart(4, '0')}`
} }
const createRequest = (uri, data, options) => { const createRequest = async (uri, data, options) => {
let token = '' let token = ''
switch (options.checkToken) { switch (options.checkToken) {
case 'v2': case 'v2':
token = antiCheatTokenV2() // 每次实时获取反作弊 token,不缓存
token = await antiCheatTokenV2()
break break
case 'v3': case 'v3':
token = antiCheatTokenV3() // 每次实时获取反作弊 token,不缓存
token = await antiCheatTokenV3()
break break
} }
@@ -203,6 +231,12 @@ const createRequest = (uri, data, options) => {
const headers = options.headers ? { ...options.headers } : {} const headers = options.headers ? { ...options.headers } : {}
const ip = options.realIP || options.ip || '' const ip = options.realIP || options.ip || ''
// 加密方式选择
let crypto = options.crypto
if (crypto === '') {
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
}
// IP头设置 // IP头设置
if (ip) { if (ip) {
headers['X-Real-IP'] = ip headers['X-Real-IP'] = ip
@@ -215,19 +249,13 @@ const createRequest = (uri, data, options) => {
} }
if (typeof cookie === 'object') { if (typeof cookie === 'object') {
cookie = processCookieObject(cookie, uri) cookie = processCookieObject(cookie, crypto)
headers['Cookie'] = cookieObjToString(cookie) headers['Cookie'] = cookieObjToString(cookie)
} }
let url = '' let url = ''
let encryptData = '' let encryptData = ''
let crypto = options.crypto
const csrfToken = cookie['__csrf'] || '' const csrfToken = cookie['__csrf'] || ''
// 加密方式选择
if (crypto === '') {
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
}
const answer = { status: 500, body: {}, cookie: [] } const answer = { status: 500, body: {}, cookie: [] }
data.e_r = toBoolean( data.e_r = toBoolean(
@@ -310,6 +338,47 @@ const createRequest = (uri, data, options) => {
}) })
break 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 'eapi':
case 'api': case 'api':
// header创建 // header创建
@@ -330,9 +399,8 @@ const createRequest = (uri, data, options) => {
if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
if (options.checkToken) { if (options.checkToken) header['X-antiCheatToken'] = token
header['X-antiCheatToken'] = token if (crypto === 'eapi' && cookie.NMTID) header['NMTID'] = cookie.NMTID
}
headers['Cookie'] = createHeaderCookie(header) headers['Cookie'] = createHeaderCookie(header)
headers['User-Agent'] = headers['User-Agent'] =
@@ -357,12 +425,19 @@ const createRequest = (uri, data, options) => {
console.log('[ERR]', 'Unknown Crypto:', crypto) console.log('[ERR]', 'Unknown Crypto:', crypto)
break break
} }
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
const use_xeapi = crypto === 'xeapi'
const use_neapi = crypto === 'neapi'
// settings创建 // settings创建
let settings = { let settings = {
method: 'POST', method: 'POST',
url: url, url: url,
headers: headers, headers: headers,
data: new URLSearchParams(encryptData).toString(), // neapi 的请求体就是容器本身,不是表单字段
data: use_neapi
? encryptData
: new URLSearchParams(encryptData).toString(),
httpAgent: createHttpAgent(), httpAgent: createHttpAgent(),
httpsAgent: createHttpsAgent(), httpsAgent: createHttpsAgent(),
} }
@@ -373,9 +448,7 @@ const createRequest = (uri, data, options) => {
} }
// 使用返回值加密 // 使用返回值加密
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r if (use_e_r || use_xeapi || use_neapi) {
const use_xeapi = crypto === 'xeapi'
if (use_e_r || use_xeapi) {
settings.encoding = null settings.encoding = null
settings.responseType = 'arraybuffer' settings.responseType = 'arraybuffer'
} }
@@ -416,11 +489,60 @@ const createRequest = (uri, data, options) => {
} }
// console.log(settings.headers); // console.log(settings.headers);
axios(settings) axios(settings)
.then((res) => { .then(async (res) => {
const body = res.data const body = res.data
answer.cookie = (res.headers['set-cookie'] || []).map((x) => const setCookies = res.headers['set-cookie'] || []
x.replace(/\s*Domain=[^(;|$)]+;*/, ''),
) const cleanCookie = (x) => x.replace(/\s*Domain=[^(;|$)]+;*/, '')
// 仅对真正未携带 NMTID 的探测请求采集并消耗重试次数
if (
crypto === 'eapi' &&
!NMTID &&
NMTID_RETRIES_LEFT > 0 &&
!cookie.NMTID
) {
NMTID_RETRIES_LEFT--
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) {
//不需要处理竞争, 官方客户端真实操作
NMTID = match[1]
}
return cleaned
})
} else {
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: 统一注释块,需要时取消注释查看请求/返回的原始密文 // debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
@@ -440,6 +562,11 @@ const createRequest = (uri, data, options) => {
xeapiSessionKey = res.headers['x-encr-sskey'] xeapiSessionKey = res.headers['x-encr-sskey']
} }
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body)) 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) { } else if (use_e_r) {
answer.body = encrypt.eapiResDecrypt( answer.body = encrypt.eapiResDecrypt(
body.toString('hex').toUpperCase(), body.toString('hex').toUpperCase(),
+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,
}