Compare commits

..
30 Commits
Author SHA1 Message Date
MoeFurina c3e0258b85 feat: 新增维度音乐相关接口 2026-05-22 19:44:56 +08:00
MoeFurina 843c19f089 update 4.31.0 2026-04-17 20:14:15 +08:00
MoeFurina c50aeddf5f update deps 2026-03-20 19:55:24 +08:00
MoeFurina b8af386082 update to 4.30.0 2026-03-20 19:54:16 +08:00
MoeFurinaandbinaryify d10296ccd8 feat(module): add user playlist collect
Co-Authored-By: binaryify <binaryify@gmail.com>
2026-02-11 16:25:30 +08:00
MoeFurinaandbinaryify 805d0bccc5 ci: update Node.js requirement and improve code consistency
Updated the README to require Node.js 18+ and added Docker usage instructions. Refactored util/client-sign.js for consistent code style and improved variable naming. Fixed the login_cellphone module to use 'weapi' in createOption. Removed deprecated code from server.js and improved CORS header formatting. Minor fix in util/request.js for user agent selection.

Co-Authored-By: binaryify <binaryify@gmail.com>
2026-01-16 22:35:16 +08:00
MoeFurina 5fc9346e0e chore(deps): update dependences 2025-11-15 00:29:32 +08:00
ImFurina e79490327d chore(deps): update dependence 2025-10-06 14:13:01 +08:00
ImFurina a30df5ae7f chore(dep): update dependence 2025-09-30 19:50:03 +08:00
ImFurina 06e4722225 chore(deps): 更新必要的依赖 2025-09-23 20:29:09 +08:00
ImFurina e0bcb214cb chore(deps): 更新部分依赖 2025-09-12 20:40:13 +08:00
ImFurinaandbinaryify 5478041ddd chore(version): 更新api到4.28.0
Co-Authored-By: binaryify <binaryify@gmail.com>
2025-09-07 13:20:37 +08:00
IamFurina 5105a9a09d feat. 更新API到4.27.0 2025-04-18 21:52:00 +08:00
IamFurina e3a43a807a perf. 加快访问速度 2025-03-24 12:29:41 +08:00
IamFurina 18715ca75d Update server.js 2025-03-23 15:07:05 +08:00
IamFurina e72f0dfbff Update server.js 2025-03-23 15:03:12 +08:00
IamFurina 54bdcf9482 Fix: 修复API不正常工作 2025-03-15 15:04:02 +08:00
IamFurina c4ad7a25c5 reapply. 重新加入解灰功能 2025-03-15 14:26:56 +08:00
IamFurina 03eb677b27 temp. 暂时移除解灰功能 2025-03-15 14:23:39 +08:00
IamFurina a22c798a8d revert. 回退操作 2025-03-15 14:21:04 +08:00
IamFurina 712c26d3da fix. 添加选择条件以防止多次调用接口 2025-03-15 14:16:01 +08:00
IamFurina b725db1380 feat. 接口支持无损音质 2025-03-13 18:49:09 +08:00
IamFurina 0de5526d17 chore. 加入.env配置文件 2025-03-13 18:47:17 +08:00
IamFurina 78fef459c8 feat. 接口支持无损音质 2025-03-13 18:44:53 +08:00
IamFurina 090eb4e085 Patch: 更新API 2025-02-26 21:33:07 +08:00
IamFurina 619fd10f4b Fix: 修复API不正常工作 2025-02-26 21:11:15 +08:00
IamFurina e219072f3c Patch: 更新API 2025-02-22 19:49:48 +08:00
IamFurina a345848533 Patch: 更新API 2025-02-22 19:38:19 +08:00
IamFurina 4e1307166a Patch: 更新todo 2025-02-22 19:28:30 +08:00
IamFurina 8f9d38529c Initial commit
Created from https://vercel.com/new
2025-02-22 11:16:58 +00:00
185 changed files with 20255 additions and 22730 deletions
-5
View File
@@ -1,5 +0,0 @@
# 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
+1 -2
View File
@@ -7,9 +7,8 @@
!/app.js !/app.js
!/server.js !/server.js
!/package.json !/package.json
!/package-lock.json
!/index.js !/index.js
!/generateConfig.js !/generateConfig.js
!/main.js !/main.js
!/data !/data
!/.env
!/pnpm-lock.yaml
+2
View File
@@ -0,0 +1,2 @@
# 解灰接口支持无损音质
ENABLE_FLAC = true
-27
View File
@@ -1,27 +0,0 @@
### CORS资源共享设置
## 如果你需要配置, 请取消下面的注释并设置具体的域名
# CORS_ALLOW_ORIGIN = "*"
### 代理设置
## 启用反向代理
ENABLE_PROXY = false
## 代理配置
PROXY_URL = "https://your-proxy-url.com/?proxy="
### 随机IP设置
## 启用随机中国IP(默认关闭);启用后,所有请求默认使用随机中国IP,除非请求参数randomCNIP显式关闭
ENABLE_RANDOM_CN_IP = false
### UnblockNeteaseMusic 设置项
## 启用全局解灰, 无论是否调用参数都会使用解灰(不推荐开启)
ENABLE_GENERAL_UNBLOCK = false
## 歌曲启用无损音质
ENABLE_FLAC = true
## 启用无损音质时,是否选择音质最高的
SELECT_MAX_BR = false
## 严格按照配置音源设置顺序进行匹配
FOLLOW_SOURCE_ORDER = true
## Cookie设置项; 推荐在Vercel等平台进行环境变量的设置或者设置github secret
# 网易云 cookie
# 格式:MUSIC_U=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# NETEASE_COOKIE = ""
+57
View File
@@ -0,0 +1,57 @@
module.exports = {
root: true,
ignorePatterns: ['public/'],
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: ['html'],
extends: ['plugin:prettier/recommended'],
env: {
browser: true,
node: true,
},
rules: {
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
indent: ['error', 2, { SwitchCase: 1 }],
'space-infix-ops': ['error', { int32Hint: false }],
'key-spacing': [
2,
{
beforeColon: false,
afterColon: true,
},
],
'no-octal': 2,
'no-redeclare': 2,
'comma-spacing': 2,
'no-new-object': 2,
'arrow-spacing': 2,
quotes: [
2,
'single',
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
},
overrides: [
{
files: ['**/*.ts'],
parser: '@typescript-eslint/parser',
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
// 'prettier/@typescript-eslint',
],
},
],
}
-1
View File
@@ -1 +0,0 @@
custom: ["https://rewards.focalors.ltd"]
+42
View File
@@ -0,0 +1,42 @@
---
name: 创建问题反馈
about: 问题反馈
title: "<不按照模版格式或者删除模版信息将得不到处理,确认看完了调用前须知再发 issues>"
labels: ''
assignees: ''
---
## 环境
- 系统/平台: <你的系统和平台>
- nodejs 版本: <你的 NodeJS 版本号>
- API版本: <运行的云音乐 API 的版本号, 对应 package.json 里面的 version>
## 出现问题
<请清晰描述出现的问题,不要使用xxx不行之类的模糊不清的语句,我没有读心术>
## 重现步骤
<请清晰描述重现步骤,调用的接口链接和参数以及命令行错误信息,最好贴上截图(命令行错误信息和接口返回内容)>
## 期待效果
<请清晰描述现在的效果和期待的效果>
---
>维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
>先看文档有没有相关说明,调用前须知必看,确认看完了调用前须知再发 issues
>先在 issues 搜一下是否有相似问题,没有再发,否则直接关闭
>不处理别人搭建的线上服务的问题,此项目不提供任何线上服务,请自行搭建
>重现步骤尽量详细,不能含糊不清,包含请求地址和对应参数以及操作过程描述,不是每个人都喜欢猜别人遇到了什么问题和找参数一个个试,也比较浪费时间
>如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
>460 cheating 的问题把 `utils/request.js` 里面的 `headers['X-Real-IP']` 的注释取消掉就好
+11
View File
@@ -0,0 +1,11 @@
---
name: 功能需求
about: 希望添加新接口
title: ''
labels: 功能需求
assignees: ''
---
**功能描述**
- 请清晰描述你所希望添加的功能,最好贴上对应客户端界面的截图
-117
View File
@@ -1,117 +0,0 @@
name: 🐛 Bug报告
description: 报告API其中的错误或问题
title: "[Bug] "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
感谢您报告Bug!请填写以下信息帮助我们快速定位和修复问题。
- type: checkboxes
id: terms
attributes:
label: 确认事项
description: |
在提交Bug报告前,请确认以下事项:
- 我已经搜索了现有的issues,确认这不是重复问题
- 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
- 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
- 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
- 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
options:
- label: 我已确认以上事项
required: true
- type: input
id: version
attributes:
label: API版本
description: 请提供您使用的API版本以及nodejs版本
placeholder: "v4.2x.x, Node.js v22.x.x"
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
description: 此项目支持本地部署和云平台部署,您使用的是哪种方式?
options:
- 本机直接运行 (node / pm2)
- 本机 Docker 部署
- Vercel
- Heroku
- Railway
- 其他云平台 (请在描述中说明)
validations:
required: true
- type: textarea
id: bug-description
attributes:
label: Bug描述
description: 请详细描述您遇到的问题
placeholder: |
清晰简洁地描述bug的具体表现...
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: 重现步骤
description: 请提供详细的重现步骤
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 期望行为
description: 请描述您期望发生的行为
placeholder: 清晰简洁地描述您期望应该发生什么...
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: 实际行为
description: 请描述实际发生的行为
placeholder: 清晰简洁地描述实际发生了什么...
validations:
required: true
- type: textarea
id: error-output
attributes:
label: 错误输出日志
description: 请提供完整的错误信息日志
render: shell
placeholder: |
错误信息输出...
- type: textarea
id: additional-context
attributes:
label: 额外信息
description: 添加任何其他有助于理解问题的信息
placeholder: |
- 截图
- 相关配置文件
- 其他相关信息
- type: checkboxes
id: help
attributes:
label: 愿意协助
description: 您是否愿意帮助修复这个bug?
options:
- label: 我愿意提交PR来修复这个bug
- label: 我可以提供更多测试用例
- label: 我可以帮助测试修复方案
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: ↑请尽量使用议题模板创建议题↑
url: https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/issues/new/choose
about: 选择适合的议题模板可以帮助我们更快地定位和解决问题
- name: 提问的艺术
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
about: 默认所有 Issues 发起者均已了解此处的内容
@@ -1,73 +0,0 @@
name: ✨ 接口添加请求
description: 希望添加新接口
title: "[Feature] "
labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
感谢您提出新功能建议!请填写以下信息帮助我们理解您的需求。
- type: checkboxes
id: terms
attributes:
label: 确认事项
description: |
在提交功能请求前,请确认以下事项
- 我已经搜索了现有的issues,确认这不是重复请求
- 我已经查看了项目文档
- 该项目符合该项目的目标和范围
- 我确认我提供的接口请求是合理且有意义的
options:
- label: 我已确认以上事项
required: true
- type: textarea
id: proposed-solution
attributes:
label: 功能描述
description: 请详细描述您希望添加的功能,最好贴上对应客户端界面的截图
placeholder: |
请详细描述您期望的功能:
- 新的网易云音乐api 接口?
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
description: 您认为这个功能的优先级如何?
options:
- 低 (可有可无的改进)
- 中 (会显著改善使用体验)
- 高 (严重影响了我使用)
- 紧急 (阻塞了重要工作)
validations:
required: true
- type: textarea
id: compatibility
attributes:
label: 兼容性考虑
description: 这个功能是否会影响现有代码的兼容性?
placeholder: |
- 是否是破坏性变更?
- 是否需要语法变更?
- 对性能的影响?
- type: checkboxes
id: implementation
attributes:
label: 实现意愿
description: 您是否愿意参与这个功能的实现?
options:
- label: 我愿意提交PR来实现这个功能
- label: 我可以协助设计API接口
- label: 我可以提供测试用例
- label: 我可以帮助编写文档
-41
View File
@@ -1,41 +0,0 @@
# .github/dependabot.yml
version: 2
updates:
# 更新 npm (JavaScript) 依赖
- package-ecosystem: "npm"
directory: "/" # package.json 所在的位置
schedule:
interval: "weekly" # 每周一检查
# 默认情况下,Dependabot 会自动获取最新版本。
# 你可以使用 `ignore` 来排除某些特定更新
ignore:
- dependency-name: "eslint"
# 对于 ESLint,忽略主版本 8 以上的所有更新
versions: ["> 8"]
# 自动合并小版本更新的 PR (可选)
# 可以设置针对 patch 或 minor 版本更新自动合并
commit-message:
prefix: "build(packages):" # PR 合并提交信息的前缀
# 更新 GitHub Actions 依赖
- package-ecosystem: "github-actions"
directory: "/" # .github/workflows/ 目录所在的位置
schedule:
interval: "monthly"
# 更新 Python (pip) 依赖,如果你的项目有 requirements.txt
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
# 如果你有多个目录(例如一个 monorepo),可以为每个目录单独配置
# - package-ecosystem: "npm"
# directory: "/packages/frontend"
# schedule:
# interval: "weekly"
# - package-ecosystem: "npm"
# directory: "/packages/backend"
# schedule:
# interval: "weekly"
-53
View File
@@ -1,53 +0,0 @@
name: Build Artifacts
on:
workflow_dispatch: # 手动触发
push:
branches: [main]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
platform: linux
target: node18-linux-x64
output: precompiled/app
- os: windows-latest
platform: win
target: node18-win-x64
output: precompiled/app.exe
- os: macos-latest
platform: macos
target: node18-macos-x64
output: precompiled/app
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
- name: Install dependencies
run: |
npm install -g pnpm
pnpm install
- name: Build for ${{ matrix.platform }}
run: |
npm run pkg${{ matrix.platform }}
env:
PKG_TARGET: ${{ matrix.target }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: app-${{ matrix.platform }}
path: ${{ matrix.output }}
if-no-files-found: error
-67
View File
@@ -1,67 +0,0 @@
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: [18, 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
-54
View File
@@ -1,54 +0,0 @@
name: Issue Management
permissions:
issues: write
contents: read
on:
issues:
types: [opened, labeled]
jobs:
welcome-new-issues:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Welcome new issue
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
感谢您在该仓库提出问题!我们非常重视您的反馈,并希望尽快为您提供帮助。
如果您在一周左右的时间里没有收到我们的回复,请在下方留言,以便我们注意到。我们的大部分审核都是志愿者,有时难免会有疏漏。
你可能会想要尝试以下方法来获得更快的帮助:
- 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org)
- 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk)
- 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题
stale-issues:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Mark stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: |
这个issue已经30天没有活动了。
如果这个问题仍然存在,请回复此评论说明当前状态。
否则,这个issue将在7天后自动关闭。
您可以随时重新打开已关闭的issue。
stale-pr-message: |
这个PR已经30天没有活动了。
如果您仍在处理这个PR,请回复此评论。
否则,这个PR将在7天后自动关闭。
days-before-stale: 30
days-before-close: 7
stale-issue-label: "stale"
stale-pr-label: "stale"
exempt-issue-labels: "pinned,enhancement,priority:high,ongoing,fatal"
exempt-pr-labels: "pinned,priority:high,ongoing,fatal"
-67
View File
@@ -1,67 +0,0 @@
name: OpenCode Automation
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: auto-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
automate:
# 排除其他机器人触发,防止自我循环;
# 但允许 takanashi-hoshino-agent 创建的 PR,这样 /oc 生成的测试 PR 也能被自动评审
if: |
github.actor != 'github-actions[bot]' &&
github.actor != 'opencode-agent[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch config from CF Worker
id: cfg
uses: MoeFurina/pull-oc-config@v1
with:
url: https://bot-storage.interface.focalors.ltd/config
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ steps.cfg.outputs.app_id }}
private-key: ${{ steps.cfg.outputs.app_private_key }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
persist-credentials: true
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install opencode
run: npm i -g opencode-ai@latest
- name: Run automation
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
MODEL: opencode/deepseek-v4-flash-free
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
ISSUE_PROMPT: ${{ steps.cfg.outputs.opencode_prompt }}
PR_PROMPT: |
${{ steps.cfg.outputs.opencode_prompt }}
${{ steps.cfg.outputs.pr_review_prompt }}
run: |
mkdir -p scripts
curl -fsSL "https://raw.githubusercontent.com/MoeFurina/pull-oc-config/v1/scripts/opencode-automation.mjs" \
-o scripts/opencode-automation.mjs
node scripts/opencode-automation.mjs
-66
View File
@@ -1,66 +0,0 @@
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 != 'takanashi-hoshino-agent[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
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@v1
with:
app-id: ${{ steps.cfg.outputs.app_id }}
private-key: ${{ steps.cfg.outputs.app_private_key }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
# 需要持久化凭证: opencode action 内部会 git fetch origin <分支> 拉取 PR 代码
persist-credentials: true
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
git config --global user.name "takanashi-hoshino-agent[bot]"
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
model: opencode/deepseek-v4-flash-free
use_github_token: true
prompt: |
${{ steps.cfg.outputs.opencode_prompt }}
## 用户指令(来自触发评论,请严格按此执行)
${{ github.event.comment.body }}
@@ -1,315 +0,0 @@
name: Create Release on Version Change
on:
push:
branches:
- main
paths:
- package.json
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
detect:
name: Detect Version Change
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
should_release: ${{ steps.check.outputs.should_release }}
tag_exists: ${{ steps.tagcheck.outputs.exists }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Extract version from package.json
id: version
run: |
echo "value=$(jq -r .version package.json)" >> $GITHUB_OUTPUT
- name: Check if version changed
id: check
shell: bash
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
exit 0
fi
if git diff "${{ github.event.before }}" "${{ github.sha }}" -- package.json | grep -q '"version"'; then
echo "should_release=true" >> $GITHUB_OUTPUT
else
echo "should_release=false" >> $GITHUB_OUTPUT
fi
- name: Check if tag already exists
id: tagcheck
shell: bash
run: |
VERSION="${{ steps.version.outputs.value }}"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
build:
name: Build ${{ matrix.platform }}
needs: detect
if: |
needs.detect.outputs.should_release == 'true' &&
needs.detect.outputs.tag_exists != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
target: node18-linux-x64
output: ncm-api-linux-x64
- os: windows-latest
platform: windows
target: node18-win-x64
output: ncm-api-win-x64.exe
- os: macos-latest
platform: macos
target: node18-macos-x64
output: ncm-api-macos-x64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build binary
shell: bash
env:
PKG_TARGET: ${{ matrix.target }}
run: |
mkdir -p release-artifacts
case "${{ matrix.platform }}" in
linux)
npm run pkglinux
mv precompiled/app "release-artifacts/${{ matrix.output }}"
;;
windows)
npm run pkgwin
mv precompiled/app.exe "release-artifacts/${{ matrix.output }}"
;;
macos)
npm run pkgmacos
mv precompiled/app "release-artifacts/${{ matrix.output }}"
;;
esac
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.platform }}-binary
path: release-artifacts/*
release:
name: Create GitHub Release
needs:
- detect
- build
if: |
needs.detect.outputs.should_release == 'true' &&
needs.detect.outputs.tag_exists != 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: release-artifacts
merge-multiple: true
- name: List flattened artifacts
shell: bash
run: |
mkdir -p final-artifacts
cp release-artifacts/* final-artifacts/
ls -lah final-artifacts
- name: Generate release notes
shell: bash
run: |
VERSION="${{ needs.detect.outputs.version }}"
PREV_TAG=$(git tag --sort=-v:refname | head -1)
{
echo "# Release v${VERSION}"
echo ""
echo "## 更新内容 / Changelog"
echo ""
if [ -n "$PREV_TAG" ]; then
echo "从 \`$PREV_TAG\` 到 \`v$VERSION\` 的提交记录:"
echo ""
git log "$PREV_TAG..HEAD" \
--no-merges \
--pretty=format:"- %s (%h)"
else
echo "首次发布,包含以下提交:"
echo ""
git log \
--no-merges \
--pretty=format:"- %s (%h)"
fi
echo ""
echo ""
echo "---"
echo "自动发布 via GitHub Actions"
} > release-notes.md
cat release-notes.md
- name: Create Git tag
run: |
git tag "v${{ needs.detect.outputs.version }}"
git push origin "v${{ needs.detect.outputs.version }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.detect.outputs.version }}
name: Release v${{ needs.detect.outputs.version }}
body_path: release-notes.md
files: final-artifacts/*
draft: false
prerelease: false
publish-docker:
name: Publish Docker Image
needs: detect
if: |
needs.detect.outputs.should_release == 'true' &&
needs.detect.outputs.tag_exists != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Read package version
id: pkg
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
moefurina/ncm-api:latest
moefurina/ncm-api:${{ env.VERSION }}
ghcr.io/neteasecloudmusicapienhanced/ncm-api:latest
ghcr.io/neteasecloudmusicapienhanced/ncm-api:${{ env.VERSION }}
platforms: linux/amd64,linux/arm64/v8
publish-npm:
name: Publish to npm
needs: detect
if: |
needs.detect.outputs.should_release == 'true' &&
needs.detect.outputs.tag_exists != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm publish --access public --provenance
-36
View File
@@ -1,36 +0,0 @@
name: Upstream Sync
permissions:
contents: write
on:
schedule:
- cron: "0 4 * * *" # At 12PM UTC+8
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
# Step 1: run a standard checkout action
- name: Checkout target repo
uses: actions/checkout@v6
# Step 2: run the sync action
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.3
with:
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
- name: Sync check
if: failure()
run: |
echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork."
exit 1
-3
View File
@@ -8,6 +8,3 @@ examples/moddef.json
bin bin
anonymous_token anonymous_token
.vercel .vercel
.env
IFLOW.md
precompiled
+1 -1
View File
@@ -1,4 +1,4 @@
language: node_js language: node_js
node_js: node_js:
- 22 - 12
-40
View File
@@ -1,40 +0,0 @@
# NeteaseCloudMusicApiEnhanced Agent 说明
## 快速开始
- **包管理器**:开发与 CI 使用 `pnpm`,`Dockerfile` 也用 pnpm(`pnpm@9 --frozen-lockfile`,匹配 `pnpm-lock.yaml`)。仓库没有 `yarn.lock`,不要引入 yarn。
- **Node 版本**:README 推荐 Node 22+;`package.json` 的 `engines` 声明 `>=12`;CI/打包在 Node 18–24 上运行。现代 Node 均可。
- **环境变量**:`server.js` 调用了 `dotenv.config()`,本地 `.env` 会被自动加载;所有支持的变量见 `.env.prod.example`。
## 常用命令
- 安装依赖:`pnpm i`
- 启动服务:`pnpm start`(等价 `node app.js`);热重载开发:`pnpm dev`(nodemon)
- 跑测试:`pnpm test`(Mocha,超时 60s)
- Lint:`pnpm lint`;自动修复:`pnpm lint-fix`
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
## 架构
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。
- `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 导入使用。
## 新增/修改接口
- 新建 `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` 是纯单测。
- 测试使用 `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`
## 坑与注意
- **改 `package.json` 的 `version` 会触发自动发布**:push 到 `main` 后会自动打 GitHub Release(`pkg` 二进制)、构建并推送 Docker 镜像(Docker Hub + GHCR)、`pnpm publish` 到 npm。别顺手改版本号。
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged`,但 `.husky/` 下没有真正的 hook,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
- **代理环境变量已失效**: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()`);首次启动先写空文件再刷新。
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。
+1390
View File
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -2,20 +2,15 @@ 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 . ./
# --prod 模式下 husky 不会被安装,prepare 脚本会因找不到 husky 而失败, RUN yarn --network-timeout=100000
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
EXPOSE 3000 EXPOSE 3000
CMD [ "/sbin/tini", "--", "node", "app.js" ] CMD [ "/sbin/tini", "--", "node", "app.js" ]
+495 -178
View File
@@ -1,90 +1,76 @@
<p align="center"> # 网易云音乐 API
<img src="./public/static/neteaselogo.png" alt="网易云音乐 API Enhanced Logo" />
网易云音乐 Node.js API service
<p>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/npm/v/NeteaseCloudMusicApi.svg" alt="Version"></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/npm/l/NeteaseCloudMusicApi.svg" alt="License"></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/david/dev/binaryify/NeteaseCloudMusicApi.svg" alt="devDependencies" ></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/david/binaryify/NeteaseCloudMusicApi.svg" alt="devDependencies" ></a>
<a href="https://codeclimate.com/github/Binaryify/NeteaseCloudMusicApi"><img src="https://codeclimate.com/github/Binaryify/NeteaseCloudMusicApi/badges/gpa.svg" /></a>
</p> </p>
<p align="center"> ## 灵感来自
<a href="https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api">
<img src="https://img.shields.io/npm/v/@neteasecloudmusicapienhanced/api?style=flat-square" alt="NPM Version" />
</a>
<a href="https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api">
<img src="https://img.shields.io/npm/dm/@neteasecloudmusicapienhanced/api?style=flat-square" alt="NPM Downloads" />
</a>
<a href="https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced">
<img src="https://img.shields.io/github/license/NeteaseCloudMusicApiEnhanced/api-enhanced?style=flat-square" alt="License" />
</a>
<a href="https://nodejs.org/">
<img src="https://img.shields.io/node/v/@neteasecloudmusicapienhanced/api?style=flat-square" alt="Node Version" />
</a>
<a href="https://hub.docker.com/r/moefurina/ncm-api">
<img src="https://img.shields.io/docker/v/moefurina/ncm-api?style=flat-square" alt="Docker Version" />
</a>
<a href="https://hub.docker.com/r/moefurina/ncm-api">
<img src="https://img.shields.io/docker/pulls/moefurina/ncm-api?style=flat-square" alt="Docker Pulls" />
</a>
<a href="https://www.typescriptlang.org/">
<img src="https://img.shields.io/badge/TypeScript-Ready-blue?style=flat-square&logo=typescript" alt="TypeScript" />
</a>
</p>
[disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music)
[darknessomi/musicbox](https://github.com/darknessomi/musicbox)
## 项目简介 [sqaiyan/netmusic-node](https://github.com/sqaiyan/netmusic-node)
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署 [greats3an/pyncm](https://github.com/greats3an/pyncm)
> [!IMPORTANT] ## 环境要求
>
> ## 注意
>
> - 本项目是自由项目, 不带任何担保, 在基于`MIT License`的许可下, 任何人都可以自由使用、修改和分发本项目的代码, 包括用于商业目的.
> - 该项目是个合作计划, 有很多人参与了此次贡献, 前往 [Contributors](https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/graphs/contributors) 页面查看所有贡献者
> - 原作者项目 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 并非完全停止维护, 你可以在 [NeteaseCloudMusicApi 的 NPMJS 页面](https://www.npmjs.com/package/NeteaseCloudMusicApi) 查看最新版本信息。
> - 感谢尊重和理解!
## 快速开始 需要 NodeJS 18+ 环境
### 环境要求 ## 安装
- Node.js 22 及以上 ```shell
- 推荐使用 pnpm 进行依赖管理 $ git clone git@gitlab.com:Binaryify/NeteaseCloudMusicApi.git
$ cd NeteaseCloudMusicApi
### 安装 $ npm install
```bash
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git
cd api-enhanced
pnpm i
``` ```
### 启动服务 或者
```bash ```shell
# 默认端口 3000 $ git clone https://gitlab.com/Binaryify/NeteaseCloudMusicApi.git
node app.js $ cd NeteaseCloudMusicApi
$ npm install
# 指定端口(如 4000)
PORT=4000 node app.js # Mac/Linux
set PORT=4000 && node app.js # Windows
``` ```
### 重要提示 ## 运行
- 调用前请务必阅读文档的 [调用前须知](https://neteasecloudmusicapienhanced.js.org/#/?id=%e8%b0%83%e7%94%a8%e5%89%8d%e9%a1%bb%e7%9f%a5) 部分。 调用前务必阅读文档的`调用前须知`
- 推荐将敏感信息(如 cookie)通过部署平台的环境变量进行配置。
## 在线体验与文档 ```shell
$ node app.js
```
- [在线文档](https://neteasecloudmusicapienhanced.js.org/) 服务器启动默认端口为 3000,若不想使用 3000 端口,可使用以下命令: Mac/Linux
- [NPM 包地址](https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api)
- [Dockerhub 主页](https://hub.docker.com/r/moefurina/ncm-api)
## 常见部署方式 ```shell
$ PORT=4000 node app.js
```
- 推荐参考[在线文档](https://neteasecloudmusicapienhanced.js.org/) windows 下使用 git-bash 或者 cmder 等终端执行以下命令:
## Docker 部署说明 ```shell
$ set PORT=4000 && node app.js
```
本项目支持通过 Docker 一键部署,无需手动安装 Node.js 或依赖。 ## npx 方式运行
可在终端直接执行
```bash
npx NeteaseCloudMusicApi@latest
```
使用此命令,可直接启动服务,无需下载或者 clone 项目
## Docker 容器运行
> 注意: 在 docker 中运行的时候, 由于使用了 request 来发请求, 所以会检查几个 proxy 相关的环境变量(如下所列), 这些环境变量 会影响到 request 的代理, 详情请参考[request 的文档](https://github.com/request/request#proxies), 如果这些环境变量 指向的代理不可用, 那么就会造成错误, 所以在使用 docker 的时候一定要注意这些环境变量. 不过, 要是你在 query 中加上了 proxy 参数, 那么环境变量会被覆盖, 就会用你通过 proxy 参数提供的代理了. > 注意: 在 docker 中运行的时候, 由于使用了 request 来发请求, 所以会检查几个 proxy 相关的环境变量(如下所列), 这些环境变量 会影响到 request 的代理, 详情请参考[request 的文档](https://github.com/request/request#proxies), 如果这些环境变量 指向的代理不可用, 那么就会造成错误, 所以在使用 docker 的时候一定要注意这些环境变量. 不过, 要是你在 query 中加上了 proxy 参数, 那么环境变量会被覆盖, 就会用你通过 proxy 参数提供的代理了.
@@ -98,156 +84,487 @@ request 相关的环境变量
6. NO_PROXY 6. NO_PROXY
```shell ```shell
docker pull moefurina/ncm-api:latest docker pull binaryify/netease_cloud_music_api
docker run -d -p 3000:3000 --name ncm-api moefurina/ncm-api:latest docker run -d -p 3000:3000 --name netease_cloud_music_api binaryify/netease_cloud_music_api
// 或者 // 或者
docker run -d -p 3000:3000 moefurina/ncm-api:latest docker run -d -p 3000:3000 binaryify/netease_cloud_music_api
// 去掉或者设置相关的环境变量 // 去掉或者设置相关的环境变量
docker run -d -p 3000:3000 --name ncm-api -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= moefurina/ncm-api:latest docker run -d -p 3000:3000 --name netease_cloud_music_api -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= binaryify/netease_cloud_music_api
// 或者 // 或者
docker run -d -p 3000:3000 -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= moefurina/ncm-api:latest docker run -d -p 3000:3000 -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= binaryify/netease_cloud_music_api
``` ```
> 以下是自行 build docker 镜像方式 > 以下是自行 build docker 镜像方式
```shell ```
$ git clone https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced && cd api-enhanced $ git clone https://gitlab.com/Binaryify/neteasecloudmusicapi.git && cd NeteaseCloudMusicApi
$ sudo docker build . -t ncm-api $ sudo docker build . -t netease-music-api
$ sudo docker run -d -p 3000:3000 ncm-api $ sudo docker run -d -p 3000:3000 netease-music-api
## Vercel 部署
v4.0.8 加入了 Vercel 配置文件,可以直接在 Vercel 下部署了,不需要自己的服务器
### 操作方法
1. fork 此项目
2. 在 Vercel 官网点击 `New Project`
3. 点击 `Import Git Repository` 并选择你 fork 的此项目并点击`import`
4. 点击 `PERSONAL ACCOUNT` 的 `select`
5. 直接点`Continue`
6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可
## 腾讯云 serverless 部署
因 `Vercel` 在国内访问太慢,在此提供腾讯云 serverless 部署方法(注意:腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费)
### 操作方法
1. fork 此项目
2. 在腾讯云 serverless 应用管理页面( https://console.cloud.tencent.com/sls ),点击`新建应用`
3. 顶部`创建方式`选择 `Web 应用`
4. 选择 `Express框架`,点击底部`下一步按钮`
5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目
6. 启动文件填入:
```
#!/bin/bash
export PORT=9000
/var/lang/node16/bin/node app.js
``` ```
## 3. 环境变量 7. 点击`完成`,等待部署完成,点击`资源列表`的 `API网关` 里的 `URL`,正常情况会打开文档地址,点击文档`例子`可查看接口调用效果
| 变量名 | 默认值 | 说明 | ## 可以在 Node.js 调用
|----------------------------|--------------------------------------|----------------------------------------------------|
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。可填写单个源,或使用逗号分隔多个源(例如 `https://a.com,https://b.com`)。 |
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
| **ENABLE_RANDOM_CN_IP** | `false` | 是否默认启用随机中国IP。启用后,所有请求默认使用随机中国IP,除非请求参数 `randomCNIP` 显式关闭。 |
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
| **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 |
--- v3.31.0 后支持 Node.js 调用,导入的方法为`module`内的文件名,返回内容包含`status`和`body`,`status`为状态码,`body`为请求返回内容,参考`module_example` 文件夹下的 `test.js`
### 4. 更新镜像
更新到最新版本:
```bash
docker pull moefurina/ncm-api:latest
docker stop ncm-api && docker rm ncm-api
docker run -d -p 3000:3000 moefurina/ncm-api:latest
```
## Vercel 一键部署
1. fork 本项目
2. 在 Vercel 官网新建项目,导入 fork 的仓库
3. 直接 Deploy
## 腾讯云 Serverless 部署
1. fork 本项目
2. 在腾讯云 serverless 控制台新建 Web 应用,选择 Express 框架
3. 代码仓库选择 fork 的项目,启动文件填写:
```bash
#!/bin/bash
export PORT=9000
/var/lang/node16/bin/node app.js
```
4. 完成部署后,访问 API 网关的 URL 即可
## Node.js 方式调用
支持直接在 Node.js 项目中引入和调用,返回 Promise:
```js ```js
const { const { login_cellphone, user_cloud } = require('NeteaseCloudMusicApi')
login_cellphone,
user_cloud,
} = require('@neteasecloudmusicapienhanced/api')
async function main() { async function main() {
const result = await login_cellphone({ phone: '手机号', password: '密码' }) try {
console.log(result) const result = await login_cellphone({
const result2 = await user_cloud({ cookie: result.body.cookie }) phone: '手机号',
console.log(result2.body) password: '密码',
})
console.log(result)
const result2 = await user_cloud({
cookie: result.body.cookie, // 凭证
})
console.log(result2.body)
} catch (error) {
console.log(error)
}
} }
main() main()
``` ```
## TypeScript 支持 ## 支持 TypeScript
```ts ```ts
import { banner } from '@neteasecloudmusicapienhanced/api' // test.ts
banner({ type: 0 }).then((res) => console.log(res)) import { banner } from 'NeteaseCloudMusicApi'
banner({ type: 0 }).then((res) => {
console.log(res)
})
``` ```
## 使用文档
[文档地址](https://docs-neteasecloudmusicapi.vercel.app)
## 调试工具
- `eapi` 请求参数或返回内容可在 `/eapi_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
## 功能特性
1. 登录
2. 刷新登录
3. 发送验证码
4. 校验验证码
5. 注册(修改密码)
6. 获取用户信息 , 歌单,收藏,mv, dj 数量
7. 获取用户歌单
8. 获取用户电台
9. 获取用户关注列表
10. 获取用户粉丝列表
11. 获取用户动态
12. 获取用户播放记录
13. 获取精品歌单
14. 获取歌单详情
15. 搜索
16. 搜索建议
17. 获取歌词
18. 歌曲评论
19. 收藏单曲到歌单
20. 专辑评论
21. 歌单评论
22. mv 评论
23. 电台节目评论
24. banner
25. 获取歌曲详情
26. 获取专辑内容
27. 获取歌手单曲
28. 获取歌手 mv
29. 获取歌手专辑
30. 获取歌手描述
31. 获取相似歌手
32. 获取相似歌单
33. 相似 mv
34. 获取相似音乐
35. 获取最近 5 个听了这首歌的用户
36. 获取每日推荐歌单
37. 获取每日推荐歌曲
38. 私人 FM
39. 签到
40. 喜欢音乐
41. 垃圾桶
42. 歌单 ( 网友精选碟 )
43. 新碟上架
44. 热门歌手
45. 最新 mv
46. 推荐 mv
47. 推荐歌单
48. 推荐新音乐
49. 推荐电台
50. 推荐节目
51. 独家放送
52. mv 排行
53. 获取 mv 数据
54. 播放 mv/视频
55. 排行榜
56. 歌手榜
57. 云盘
58. 电台 - 推荐
59. 电台 - 分类
60. 电台 - 分类推荐
61. 电台 - 订阅
62. 电台 - 详情
63. 电台 - 节目
64. 给评论点赞
65. 获取动态列表
66. 热搜列表(简略)
67. 发送私信
68. 发送私信歌单
69. 新建歌单
70. 收藏/取消收藏歌单
71. 歌单分类
72. 收藏的歌手列表
73. 订阅的电台列表
74. 相关歌单
75. 付费精选接口
76. 音乐是否可用检查接口
77. 登录状态
78. 获取视频播放地址
79. 发送/删除评论
80. 热门评论
81. 视频评论
82. 退出登录
83. 所有榜单
84. 所有榜单内容摘要
85. 收藏视频
86. 收藏 MV
87. 视频详情
88. 相关视频
89. 关注用户
90. 新歌速递
91. 喜欢音乐列表(无序)
92. 收藏的 MV 列表
93. 获取最新专辑
94. 听歌打卡
95. 获取视频标签/分类下的视频
96. 已收藏专辑列表
97. 获取动态评论
98. 歌单收藏者列表
99. 云盘歌曲删除
100. 热门话题
101. 电台 - 推荐类型
102. 电台 - 非热门类型
103. 电台 - 今日优选
104. 心动模式/智能播放
105. 转发动态
106. 删除动态
107. 分享歌曲、歌单、mv、电台、电台节目到动态
108. 通知-私信
109. 通知-评论
110. 通知-@我
111. 通知-通知
112. 设置
113. 云盘数据详情
114. 私信内容
115. 我的数字专辑
116. batch 批量请求接口
117. 获取视频标签列表
118. 全部 mv
119. 网易出品 mv
120. 收藏/取消收藏专辑
121. 专辑动态信息
122. 热搜列表(详细)
123. 更换绑定手机
124. 检测手机号码是否已注册
125. 初始化昵称
126. 更新歌单描述
127. 更新歌单名
128. 更新歌单标签
129. 默认搜索关键词
130. 删除歌单
131. 电台 banner
132. 用户电台
133. 热门电台
134. 电台 - 节目详情
135. 电台 - 节目榜
136. 电台 - 新晋电台榜/热门电台榜
137. 类别热门电台
138. 云村热评(官方下架,暂不能用)
139. 电台 24 小时节目榜
140. 电台 24 小时主播榜
141. 电台最热主播榜
142. 电台主播新人榜
143. 电台付费精品榜
144. 歌手热门 50 首歌曲
145. 购买数字专辑
146. 获取 mv 点赞转发评论数数据
147. 获取视频点赞转发评论数数据
148. 调整歌单顺序
149. 调整歌曲顺序
150. 独家放送列表
151. 获取推荐视频
152. 获取视频分类列表
153. 获取全部视频列表接口
154. 获取历史日推可用日期列表
155. 获取历史日推详细数据
156. 国家编码列表
157. 首页-发现
158. 首页-发现-圆形图标入口列表
159. 全部新碟
160. 数字专辑-新碟上架
161. 数字专辑&数字单曲-榜单
162. 数字专辑-语种风格馆
163. 数字专辑详情
164. 更新头像
165. 歌单封面上传
166. 楼层评论
167. 歌手全部歌曲
168. 精品歌单标签列表
169. 用户等级信息
170. 电台个性推荐
171. 用户绑定信息
172. 用户绑定手机
173. 新版评论
174. 点赞过的视频
175. 收藏视频到视频歌单
176. 删除视频歌单里的视频
177. 最近播放的视频
178. 音乐日历
179. 电台订阅者列表
180. 云贝签到信息
181. 云贝签到
182. 云贝所有任务
183. 云贝 todo 任务
184. 云贝今日签到信息
185. 云贝完成任务
186. 云贝收入
187. 云贝支出
188. 云贝账户信息
189. 账号信息
190. 最近联系人
191. 私信音乐
192. 抱一抱评论
193. 评论抱一抱列表
194. 收藏的专栏
195. 关注歌手新歌
196. 关注歌手新 MV
197. 歌手详情
198. 云盘上传
199. 二维码登录
200. 话题详情
201. 话题详情热门动态
202. 歌单详情动态
203. 绑定手机
204. 一起听状态
205. 用户历史评论
206. 云盘歌曲信息匹配纠正
207. 云贝推歌
208. 云贝推歌历史记录
209. 已购单曲
210. 获取 mlog 播放地址
211. 将 mlog id 转为视频 id
212. vip 成长值
213. vip 成长值获取记录
214. vip 任务
215. 领取 vip 成长值
216. 歌手粉丝
217. 数字专辑详情
218. 数字专辑销量
219. 音乐人数据概况
220. 音乐人播放趋势
221. 音乐人任务
222. 账号云豆数
223. 领取云豆
224. 获取 VIP 信息
225. 音乐人签到
226. 获取客户端歌曲下载 url
227. 获取歌单所有歌曲
228. 乐签信息
229. 获取歌手视频
230. 最近播放-歌曲
231. 最近播放-视频
232. 最近播放-声音
233. 最近播放-歌单
234. 最近播放-专辑
235. 最近播放-播客
236. 签到进度
237. 重复昵称检测
238. 歌手粉丝数量
239. 音乐人任务(新)
240. 内部版本接口
241. 歌单更新播放量
242. 黑胶时光机
243. 音乐百科 - 简要信息
244. 乐谱列表
245. 乐谱内容
246. 曲风列表
247. 曲风偏好
248. 曲风详情
249. 曲风-歌曲
250. 曲风-专辑
251. 曲风-歌单
252. 曲风-歌手
253. 私信和通知接口
254. 回忆坐标
255. 播客搜索
256. 播客声音上传
257. 验证接口-二维码生成
258. 验证接口-二维码检测
259. 听歌识曲
260. 根据nickname获取userid接口
261. 播客声音列表
262. 专辑简要百科信息
263. 歌曲简要百科信息
264. 歌手简要百科信息
265. mv简要百科信息
266. 搜索歌手
267. 用户贡献内容
268. 用户贡献条目、积分、云贝数量
269. 年度听歌报告
270. 播客声音搜索
271. 播客声音排序
272. 播客列表详情
273. 本地歌曲文件匹配网易云歌曲信息
274. 歌曲音质详情
275. 歌曲红心数量
276. 私人 FM 模式选择
277. 播客删除
278. 电台排行榜获取
279. 获取声音歌词
280. 获取专辑歌曲的音质
281. 歌手动态信息
282. 最近听歌列表
283. 云盘导入歌曲
284. 获取客户端歌曲下载链接 - 新版
285. 当前账号关注的用户/歌手
286. 会员下载歌曲记录
287. 会员本月下载歌曲记录
288. 已购买单曲
289. 歌曲是否喜爱
290. 用户是否互相关注
291. 歌曲动态封面
292. 用户徽章
293. 用户状态
294. 用户状态 - 支持设置的状态
295. 用户状态 - 相同状态的用户
296. 用户状态 - 编辑
297. 听歌足迹 - 年度听歌足迹
298. 听歌足迹 - 今日收听
299. 听歌足迹 - 总收听时长
300. 听歌足迹 - 本周/本月收听时长
301. 听歌足迹 - 周/月/年收听报告
302. 歌单导入 - 元数据/文字/链接导入
303. 歌单导入 - 任务状态
304. 副歌时间
305. 相关歌单推荐
306. 歌词摘录 - 歌词摘录信息
307. 歌词摘录 - 我的歌词本
308. 歌词摘录 - 添加/修改摘录歌词
309. 歌词摘录 - 删除摘录歌词
310. 广播电台 - 分类/地区信息
311. 广播电台 - 我的收藏
312. 广播电台 - 电台信息
313. 广播电台 - 全部电台
314. 广播电台 - 收藏/取消收藏电台
315. 用户的创建歌单列表
316. 用户的收藏歌单列表
317. DIFM电台 - 分类
318. DIFM电台 - 收藏列表
319. DIFM电台 - 收藏频道
320. DIFM电台 - 取消收藏频道
321. DIFM电台 - 播放列表
322. 助眠解压 - 特定时间场景下的推荐资源
323. 助眠解压 - 标签列表
324. 助眠解压 - 获取标签下资源列表
325. 助眠解压 - 查看同类推荐
326. 助眠解压 - 收藏
327. 助眠解压 - 收藏列表
328. 跑步漫游
329. 歌曲创作者信息
330. 获取用户详情
331. 更新用户信息
332. 更新歌单
333. 歌手分类列表
334. 收藏/取消收藏歌手
335. 获取音乐 url
336. 获取音乐 url - 新版
337. 搜索多重匹配
338. 对歌单添加或删除歌曲
339. 获取逐字歌词
340. 资源点赞( MV,电台,视频)
341. 每日推荐歌曲-不感兴趣
342. mv 地址
343. 排行榜详情
344. 电台 - 付费精品
345. 电台 - 付费精选
346. 发送私信(带专辑)
347. 云贝
348. 一起听相关
349. 获取 VIP 信息(app端)
350. 公开隐私歌单
351. 云村星评馆 - 简要评论
352. 私人 DJ
353. 播客列表
354. 播客声音详情
355. 多级行政区划数据
356. 指定维度音乐排行榜详情
357. 指定维度音乐排行榜列表
## 单元测试 ## 单元测试
```bash ```shell
pnpm test $ npm test
``` ```
## 主要功能特性 ## SDK
- 登录/注册/验证码 | 语言 | 作者 | 地址 | 类型 |
- 用户信息、歌单、动态、播放记录 | :----: | :-----------------------------------------: | :--------------------------------------------------------------------------------------------------------: | :----: |
- 歌曲、专辑、歌手、MV、歌词、评论、排行榜 | Java | [JackuXL](https://github.com/JackuXL) | [https://github.com/JackuXL/NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
- 搜索、推荐、私人 FM、签到、云盘 | Java | [1015770492](https://github.com/1015770492) | https://github.com/1015770492/yumbo-music-utils | 第三方 |
- 歌曲解锁(解灰) | Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
## 贡献与社区 ## 贡献者
- 欢迎提交 PR、Issue 参与维护 ![](https://opencollective.com/NeteaseCloudMusicApi/contributors.svg?width=890)
- 要贡献新接口, 请参考[这篇文章](https://www.focalors.ltd/post/how-to-contribute-ncm-api)
### 致谢
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
感谢大佬们为逆向eapi, weapi, xeapi等加密算法所做的贡献
项目参考:
[trazyn/ieaseMusic](https://github.com/trazyn/ieaseMusic)
[listen1/listen1_chrome_extension](https://github.com/listen1/listen1_chrome_extension)
[chaunsin/netease-cloud-music](https://github.com/chaunsin/netease-cloud-music)
[folltoshe/netease-report-listen-song](https://github.com/folltoshe/netease-report-listen-song)
### SDK 生态
| 语言 | 作者 | 地址 | 类型 |
|--------|---------------------------------------------|------------------------------------------------------------------------------------------|-----|
| Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
| Java | [1015770492](https://github.com/1015770492) | [yumbo-music-utils](https://github.com/1015770492/yumbo-music-utils) | 第三方 |
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
| Swift | [Lincb522](https://github.com/Lincb522) | [NeteaseCloudMusicApi-Swift](https://github.com/Lincb522/NeteaseCloudMusicAPI-Swift) | 第三方 |
| Rust | [SPlayer-Dev](https://github.com/splayer-dev) | [ncm-api-rs](https://github.com/SPlayer-Dev/ncm-api-rs) | 第三方 |
### 依赖此项目的优秀开源项目
- [qier2222/YesPlayMusic](https://github.com/qier222/YesPlayMusic)
- [MaigoLabs/amaoke.app](https://github.com/MaigoLabs/amaoke.app)
- [Yueby/music-together](https://github.com/Yueby/music-together)
- [chthollyphlie/folia-major](https://github.com/chthollyphile/folia-major)
## License ## License
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE) [The MIT License (MIT)](https://gitlab.com/Binaryify/NeteaseCloudMusicApi/blob/main/LICENSE)
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
const { defineConfig, globalIgnores } = require('eslint/config')
const html = require('eslint-plugin-html')
const globals = require('globals')
const tsParser = require('@typescript-eslint/parser')
const js = require('@eslint/js')
const { FlatCompat } = require('@eslint/eslintrc')
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
module.exports = defineConfig([
{
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
parser: 'babel-eslint',
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
html,
},
extends: compat.extends('plugin:prettier/recommended'),
rules: {
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
indent: [
'error',
2,
{
SwitchCase: 1,
},
],
'space-infix-ops': [
'error',
{
int32Hint: false,
},
],
'key-spacing': [
2,
{
beforeColon: false,
afterColon: true,
},
],
'no-octal': 2,
'no-redeclare': 2,
'comma-spacing': 2,
'no-new-object': 2,
'arrow-spacing': 2,
quotes: [
2,
'single',
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
},
},
globalIgnores(['**/public/']),
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
},
extends: compat.extends('plugin:@typescript-eslint/recommended'),
},
])
+1 -2
View File
@@ -1,7 +1,6 @@
const fsPromises = require('fs/promises') const fsPromises = require('fs/promises')
const path = require('path') const path = require('path')
const server = require('../server') const server = require('../server')
const logger = require('../util/logger.js')
const exportFile = path.join(__dirname, 'moddef.json') const exportFile = path.join(__dirname, 'moddef.json')
@@ -17,7 +16,7 @@ async function main() {
) )
fsPromises.writeFile(exportFile, JSON.stringify(def, null, 4)) fsPromises.writeFile(exportFile, JSON.stringify(def, null, 4))
logger.info(`👍 Get your own definition at: ${exportFile}`) console.log(`👍 Get your own definition at: ${exportFile}`)
} }
main() main()
-17
View File
@@ -2,7 +2,6 @@ const fs = require('fs')
const path = require('path') 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 tmpPath = require('os').tmpdir() const tmpPath = require('os').tmpdir()
async function generateConfig() { async function generateConfig() {
@@ -21,21 +20,5 @@ async function generateConfig() {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }
try {
let currentPublicKey = {}
try {
currentPublicKey = JSON.parse(
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
)
} catch (_) {}
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
fs.writeFileSync(
path.resolve(tmpPath, 'xeapi_public_key'),
JSON.stringify(publicKey),
'utf-8',
)
} catch (error) {
console.log(error)
}
} }
module.exports = generateConfig module.exports = generateConfig
-1
View File
@@ -1 +0,0 @@
import './app.js'
-1051
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
## 环境
- 系统/平台: <你的系统和平台>
- nodejs 版本: <你的 NodeJS 版本号>
- API版本:<运行的云音乐 API 的版本号, 对应 package.json 里面的 version>
## 出现问题
<出现的问题>
## 重现步骤
<重现步骤>
## 期待效果
<现在的效果,期待的效果>
>先看文档有没有相关说明,调用前须知必看
>先在 issues 搜一下是否有相似问题,没有再发,否则直接关闭
>不处理别人搭建的线上服务的问题,此项目不提供任何线上服务,请自行搭建
>重现步骤尽量详细,不能含糊不清,包含请求地址和对应参数以及操作过程描述,不是每个人都喜欢猜别人遇到了什么问题和找参数一个个试,也比较浪费时间
>如果不是提建议,提 issues 如果不照着模版来将不会优先处理或直接关闭
>460 cheating 的问题把 `util/request.js` 里面的 `headers['X-Real-IP']` 的注释取消掉就好
-51
View File
@@ -1,51 +0,0 @@
// 获取广告
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const data = {
type_ids: query.type_ids || '["400002_0"]',
}
const option = createOption(query, 'xeapi', 'v3')
const res = await request(`/api/ad/get`, data, option)
const raw = res.body
// 提取广告中的 req_id
let reqId = ''
try {
if (raw?.ads) {
const ad = Object.values(raw.ads)[0]
// 逆向 v9.5.61:客户端从 ad.adExtMap["req_id"] 取 reqUid
if (ad?.adExtMap) {
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 (_) {}
return {
status: 200,
body: {
code: 200,
ads: raw?.ads || null,
message: raw?.message || null,
extra: { reqId },
},
}
}
-13
View File
@@ -1,13 +0,0 @@
// 获取免费听时长状态
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
entrance: 'FREE_LISTEN_RN',
}
return request(
`/api/ad/homepage/free/tab/extend/v2`,
data,
createOption(query, 'xeapi'),
)
}
-252
View File
@@ -1,252 +0,0 @@
// 看广告免费听歌 - 领取免费听权益
// 请求流程(基于逆向网易云音乐 v9.5.61 原生 Kotlin 源码,classes5/18/19.dex):
// 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid
// 2. 调用本接口传入 reqUid 及相关权益参数,领取权益
// 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 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) => {
const time = Date.now()
// 从广告对象自动补齐请求字段。
// 实测 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 contextInfo = query.contextInfo
let creativeType = query.creativeType
let generalRightsInfo = query.generalRightsInfo
// 广告 hint 配置:仅当调用方未显式传参时用广告下发值兜底
const hint = {}
if (!reqUid || contextInfo === undefined || creativeType === undefined) {
try {
const adRes = await adGet(
{ ...query, type_ids: query.type_ids || '["400002_0"]' },
request,
)
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}`)
} catch (e) {
// 获取广告失败,后续请求会因缺少 reqUid 被拒绝
}
}
const rightsGainMethod = query.rightsGainMethod
? parseInt(query.rightsGainMethod)
: hint.rightsGainMethod || 2
const rightsParam = {
// 必填: 广告请求 ID,自动从 ad_get 获取
reqUid,
// 曝光时间戳
exposureTime: query.exposureTime ? parseInt(query.exposureTime) : time,
// 当前登录用户 ID(原版客户端从 Profile.getUserId() 获取)
userId: query.uid ? parseInt(query.uid) : undefined,
// 点击时间戳
clickTime: query.clickTime ? parseInt(query.clickTime) : time,
// 额外权益类型(拉新分段权益等,决定发放云贝/时长/下载,来自广告配置)
extraRightsType: query.extraRightsType
? parseInt(query.extraRightsType)
: 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
? parseInt(query.extraRightsGainMethod)
: hint.extraRightsGainMethod !== undefined
? parseInt(hint.extraRightsGainMethod)
: undefined,
extraRightsGainDuration: query.extraRightsGainDuration
? parseInt(query.extraRightsGainDuration)
: hint.extraRightsGainDuration !== undefined
? parseInt(hint.extraRightsGainDuration)
: undefined,
nextRightsGainDuration: 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,
// 通用权益信息(ad.generalRightsInfo 序列化 JSON)
generalRightsInfo,
// 权益扩展信息
rightsExtJson: query.rightsExtJson || hint.rightsExtJson || undefined,
// 应用信息(下载类广告)
appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined,
// 广告上下文(ad.adLogId.contextInfo,客户端真实来源)
contextInfo,
// 应用是否已安装(下载类广告)
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 字段
Object.keys(rightsParam).forEach((key) => {
if (rightsParam[key] === undefined) delete rightsParam[key]
})
// 将参数序列化为 reqParam 字符串 (与原生源码一致)
const data = {
reqParam: JSON.stringify(rightsParam),
}
const res = await request(
`/api/ad/listening/rights/gain`,
data,
createOption(query, 'xeapi', 'v3'),
)
return {
status: 200,
body: {
code: 200,
data: res.body,
},
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
// 可按需修改此 API 的代码 // 可按需修改此 API 的代码
/* {"extInfo":"{\"lastRequestTimestamp\":1692358373509,\"lbsInfoList\":[{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692358543},{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692055283}],\"listenedTs\":false,\"noAidjToAidj\":true}","header":"{}"} */ /* {"extInfo":"{\"lastRequestTimestamp\":1692358373509,\"lbsInfoList\":[{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692358543},{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692055283}],\"listenedTs\":false,\"noAidjToAidj\":true}","header":"{}"} */
const logger = require('../util/logger.js')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
var extInfo = {} var extInfo = {}
@@ -23,6 +22,6 @@ module.exports = (query, request) => {
const data = { const data = {
extInfo: JSON.stringify(extInfo), extInfo: JSON.stringify(extInfo),
} }
// logger.info(data) // console.log(data)
return request(`/api/aidj/content/rcmd/info`, data, createOption(query)) return request(`/api/aidj/content/rcmd/info`, data, createOption(query))
} }
-16
View File
@@ -1,16 +0,0 @@
// 获取关注歌手的新歌曲和 MV
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
startTimestamp: query.startTimestamp || query.before || Date.now(),
sourceType: query.sourceType || 1,
limit: query.limit || 10,
firstRequest: query.firstRequest ?? true,
}
return request(
`/api/sub/artist/new/works/song-mv/list/v2`,
data,
createOption(query, 'eapi'),
)
}
-10
View File
@@ -1,10 +0,0 @@
// 获取所有关注歌手最近的 50 首新歌
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
`/api/sub/artist/new/works/song/playall`,
{},
createOption(query, 'eapi'),
)
}
-13
View File
@@ -1,13 +0,0 @@
// 发送安全验证码
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ctcode: query.ctcode || '86',
}
return request(
`/api/sms/captcha/safe/sent`,
data,
createOption(query, 'eapi'),
)
}
-16
View File
@@ -1,16 +0,0 @@
// 发送验证码
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ctcode: query.ctcode || '86',
secrete: 'music_middleuser_pclogin',
cellphone: query.phone,
scene: '0',
}
return request(
`/api/middle/captcha/sent/v1`,
data,
createOption(query, 'eapi'),
)
}
+1 -5
View File
@@ -6,9 +6,5 @@ module.exports = (query, request) => {
cellphone: query.phone, cellphone: query.phone,
countrycode: query.countrycode, countrycode: query.countrycode,
} }
return request( return request(`/api/cellphone/existence/check`, data, createOption(query))
`/api/cellphone/existence/check`,
data,
createOption(query, 'eapi'),
)
} }
+120 -139
View File
@@ -1,26 +1,23 @@
const mm = require('music-metadata')
const uploadPlugin = require('../plugins/songUpload') const uploadPlugin = require('../plugins/songUpload')
const md5 = require('md5')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
const {
isTempFile,
getFileSize,
getFileMd5,
cleanupTempFile,
getFileExtension,
sanitizeFilename,
} = require('../util/fileHelper')
let mm
module.exports = async (query, request) => { module.exports = async (query, request) => {
mm = require('music-metadata') let ext = 'mp3'
// if (query.songFile.name.indexOf('flac') > -1) {
// ext = 'flac'
// }
if (query.songFile.name.includes('.')) {
ext = query.songFile.name.split('.').pop()
}
query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString( query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString(
'utf-8', 'utf-8',
) )
const ext = getFileExtension(query.songFile.name) const filename = query.songFile.name
const filename = sanitizeFilename(query.songFile.name) .replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const bitrate = 999000 const bitrate = 999000
if (!query.songFile) { if (!query.songFile) {
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
@@ -30,135 +27,119 @@ module.exports = async (query, request) => {
}, },
}) })
} }
if (!query.songFile.md5) {
const useTemp = isTempFile(query.songFile) // 命令行上传没有md5和size信息,需要填充
let fileSize = await getFileSize(query.songFile) query.songFile.md5 = md5(query.songFile.data)
let fileMd5 = await getFileMd5(query.songFile) query.songFile.size = query.songFile.data.byteLength
}
query.songFile.md5 = fileMd5 const res = await request(
query.songFile.size = fileSize `/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: query.songFile.size,
md5: query.songFile.md5,
songId: '0',
version: 1,
},
createOption(query),
)
let artist = ''
let album = ''
let songName = ''
try { try {
const res = await request( const metadata = await mm.parseBuffer(
`/api/cloud/upload/check`, query.songFile.data,
{ query.songFile.mimetype,
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: fileMd5,
songId: '0',
version: 1,
},
createOption(query),
) )
const info = metadata.common
let artist = '' if (info.title) {
let album = '' songName = info.title
let songName = ''
try {
let metadata
if (useTemp) {
metadata = await mm.parseFile(query.songFile.tempFilePath)
} else {
metadata = await mm.parseBuffer(
query.songFile.data,
query.songFile.mimetype,
)
}
const info = metadata.common
if (info.title) songName = info.title
if (info.album) album = info.album
if (info.artist) artist = info.artist
} catch (error) {
logger.info('元数据解析错误:', error.message)
} }
if (info.album) {
const tokenRes = await request( album = info.album
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: fileMd5,
},
createOption(query),
)
if (!tokenRes.body.result || !tokenRes.body.result.resourceId) {
logger.error('Token分配失败:', tokenRes.body)
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
} }
if (info.artist) {
if (res.body.needUpload) { artist = info.artist
logger.info('需要上传,开始上传流程...')
try {
const uploadInfo = await uploadPlugin(query, request)
logger.info('上传完成:', uploadInfo?.body?.result?.resourceId)
} catch (uploadError) {
logger.error('上传失败:', uploadError)
return Promise.reject(uploadError)
}
} else {
logger.info('文件已存在,跳过上传')
} }
// if (metadata.native.ID3v1) {
// metadata.native.ID3v1.forEach((item) => {
// // console.log(item.id, item.value)
// if (item.id === 'title') {
// songName = item.value
// }
// if (item.id === 'artist') {
// artist = item.value
// }
// if (item.id === 'album') {
// album = item.value
// }
// })
// // console.log({
// // songName,
// // album,
// // songName,
// // })
// }
// console.log({
// songName,
// album,
// songName,
// })
} catch (error) {
console.log(error)
}
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: query.songFile.md5,
},
createOption(query),
)
const res2 = await request( if (res.body.needUpload) {
`/api/upload/cloud/info/v2`, const uploadInfo = await uploadPlugin(query, request)
{ // console.log('uploadInfo', uploadInfo.body.result.resourceId)
md5: fileMd5, }
songid: res.body.songId, // console.log(tokenRes.body.result)
filename: query.songFile.name, const res2 = await request(
song: songName || filename, `/api/upload/cloud/info/v2`,
album: album || '未知专辑', {
artist: artist || '未知艺术家', md5: query.songFile.md5,
bitrate: String(bitrate), songid: res.body.songId,
resourceId: tokenRes.body.result.resourceId, filename: query.songFile.name,
}, song: songName || filename,
createOption(query), album: album || '未知专辑',
) artist: artist || '未知艺术家',
bitrate: String(bitrate),
if (res2.body.code !== 200) { resourceId: tokenRes.body.result.resourceId,
logger.error('云盘信息上传失败:', res2.body) },
return Promise.reject({ createOption(query),
status: res2.status || 500, )
body: { // console.log({ res2, privateCloud: res2.body.privateCloud })
code: res2.body.code || 500, // console.log(res.body.songId, 'songid')
msg: res2.body.msg || '上传云盘信息失败', const res3 = await request(
detail: res2.body, `/api/cloud/pub/v2`,
}, {
}) songid: res2.body.songId,
} },
createOption(query),
const res3 = await request( )
`/api/cloud/pub/v2`, // console.log({ res3 })
{ return {
songid: res2.body.songId, status: 200,
}, body: {
createOption(query), ...res.body,
) ...res3.body,
// ...uploadInfo,
return { },
status: 200, cookie: res.cookie,
body: {
...res.body,
...res3.body,
},
cookie: res.cookie,
}
} finally {
if (useTemp) {
await cleanupTempFile(query.songFile.tempFilePath)
}
} }
} }
-11
View File
@@ -1,11 +0,0 @@
// 获取云盘歌词
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
userId: query.uid,
songId: query.sid,
lv: -1,
kv: -1,
}
return request(`/api/cloud/lyric/get`, data, createOption(query, 'eapi'))
}
-72
View File
@@ -1,72 +0,0 @@
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const {
songId,
resourceId,
md5,
filename,
song,
artist,
album,
bitrate = 999000,
} = query
if (!songId || !resourceId || !md5 || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: songId, resourceId, md5, filename',
},
})
}
const songName = song || filename.replace(/\.[^.]+$/, '')
const res2 = await request(
`/api/upload/cloud/info/v2`,
{
md5: md5,
songid: songId,
filename: filename,
song: songName,
album: album || '未知专辑',
artist: artist || '未知艺术家',
bitrate: String(bitrate),
resourceId: resourceId,
},
createOption(query),
)
if (res2.body.code !== 200) {
return Promise.reject({
status: res2.status || 500,
body: {
code: res2.body.code || 500,
msg: res2.body.msg || '上传云盘信息失败',
detail: res2.body,
},
})
}
const res3 = await request(
`/api/cloud/pub/v2`,
{
songid: res2.body.songId,
},
createOption(query),
)
return {
status: 200,
body: {
code: 200,
data: {
songId: res2.body.songId,
...res3.body,
},
},
cookie: res2.cookie,
}
}
-111
View File
@@ -1,111 +0,0 @@
const { default: axios } = require('axios')
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const { md5, fileSize, filename, bitrate = 999000 } = query
if (!md5 || !fileSize || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: md5, fileSize, filename',
},
})
}
const ext = filename.includes('.') ? filename.split('.').pop() : 'mp3'
const checkRes = await request(
`/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: md5,
songId: '0',
version: 1,
},
createOption(query),
)
const bucket = 'jd-musicrep-privatecloud-audio-public'
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: bucket,
ext: ext,
filename: filename
.replace(/\.[^.]+$/, '')
.replace(/\s/g, '')
.replace(/\./g, '_'),
local: false,
nos_product: 3,
type: 'audio',
md5: md5,
},
createOption(query, 'weapi'),
)
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
}
let lbs
try {
lbs = (
await axios({
method: 'get',
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
timeout: 10000,
})
).data
} catch (error) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址失败',
detail: error.message,
},
})
}
if (!lbs || !lbs.upload || !lbs.upload[0]) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址无效',
detail: lbs,
},
})
}
return {
status: 200,
body: {
code: 200,
data: {
needUpload: checkRes.body.needUpload,
songId: checkRes.body.songId,
uploadToken: tokenRes.body.result.token,
objectKey: tokenRes.body.result.objectKey,
resourceId: tokenRes.body.result.resourceId,
uploadUrl: `${lbs.upload[0]}/${bucket}/${tokenRes.body.result.objectKey.replace(/\//g, '%2F')}?offset=0&complete=true&version=1.0`,
bucket: bucket,
md5: md5,
fileSize: fileSize,
filename: filename,
},
},
cookie: checkRes.cookie,
}
}
+1 -1
View File
@@ -25,6 +25,6 @@ module.exports = (query, request) => {
return request( return request(
`/api/resource/comments/${query.t}`, `/api/resource/comments/${query.t}`,
data, data,
createOption(query, 'eapi', 'v2'), createOption(query, 'weapi'),
) )
} }
-18
View File
@@ -1,18 +0,0 @@
const { resourceTypeMap } = require('../util/config.json')
// 发送评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: resourceTypeMap[query.type] + query.id,
content: query.content,
resourceType: '0',
expressionPicId: '-1',
bubbleId: '-1',
}
return request(
`/api/resource/comments/add`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
-15
View File
@@ -1,15 +0,0 @@
const { resourceTypeMap } = require('../util/config.json')
// 删除评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
commentId: query.cid,
threadId: resourceTypeMap[query.type] + query.id,
}
return request(
`/api/resource/comments/delete`,
data,
createOption(query, 'xeapi'),
)
}
-30
View File
@@ -1,30 +0,0 @@
// 评论统计数据
// type: 0=歌曲 1=MV 2=歌单 3=专辑 4=电台节目 5=视频 6=动态 7=电台
// ids: 资源 ID 列表,多个用逗号分隔,如 "123,456"
const { resourceTypeMap } = require('../util/config.json')
const createOption = require('../util/option.js')
// 从 resourceTypeMap 的前缀值中提取网易云内部资源类型编号
// 例如 "R_SO_4_" -> "4", "A_DR_14_" -> "14"
const resourceTypeIdMap = Object.fromEntries(
Object.entries(resourceTypeMap).map(([key, prefix]) => [
key,
prefix.replace(/_$/, '').split('_').pop(),
]),
)
module.exports = (query, request) => {
const ids = String(query.ids || query.id || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
return request(
`/api/resource/commentInfo/list`,
{
resourceType: resourceTypeIdMap[String(query.type || 0)],
resourceIds: JSON.stringify(ids),
},
createOption(query, 'weapi'),
)
}
-17
View File
@@ -1,17 +0,0 @@
const { resourceTypeMap } = require('../util/config.json')
// 发送评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: resourceTypeMap[query.type] + query.id,
commentId: query.cid,
content: query.content,
resourceType: '0',
}
return request(
`/api/v1/resource/comments/reply`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
-11
View File
@@ -1,11 +0,0 @@
// 举报评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: 'R_SO_4_' + query.id,
commentId: query.cid,
reason: query.reason,
}
return request(`/api/report/reportcomment`, data, createOption(query))
}
-97
View File
@@ -1,97 +0,0 @@
const {
eapiResDecrypt,
eapiReqDecrypt,
aesDecrypt,
xeapiResDecrypt,
} = require('../util/crypto')
const CryptoJS = require('crypto-js')
const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
module.exports = async (query, request) => {
const crypto = query.crypto || 'eapi'
const data = query.data || query.hexString || ''
const isReq = query.isReq !== 'false'
if (!data) {
return {
status: 400,
body: { code: 400, message: 'data is required' },
}
}
try {
let result
switch (crypto) {
case 'eapi': {
const pureHex = data.replace(/\s/g, '')
result = isReq ? eapiReqDecrypt(pureHex) : eapiResDecrypt(pureHex)
break
}
case 'weapi': {
if (isReq) {
return {
status: 400,
body: {
code: 400,
message:
'weapi 请求解密需要 RSA 私钥,暂不支持;仅支持 weapi 返回数据解密(e_r=true 时与 eapi 相同)',
},
}
}
const pureHex = data.replace(/\s/g, '')
result = eapiResDecrypt(pureHex)
break
}
case 'linuxapi': {
if (isReq) {
const pureHex = data.replace(/\s/g, '')
const decrypted = aesDecrypt(pureHex, 'ecb', linuxapiKey, '', 'hex')
result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8))
} else {
result = typeof data === 'string' ? JSON.parse(data) : data
}
break
}
case 'xeapi': {
if (isReq) {
return {
status: 400,
body: {
code: 400,
message:
'xeapi 请求解密涉及 X25519 ECDH 密钥交换,流程复杂,暂不支持;仅支持 xeapi 返回数据解密',
},
}
}
const buf = Buffer.from(data, 'base64')
result = xeapiResDecrypt(buf)
break
}
case 'api': {
result = typeof data === 'string' ? JSON.parse(data) : data
break
}
default:
return {
status: 400,
body: { code: 400, message: `未知加密方式: ${crypto}` },
}
}
return {
status: 200,
body: { code: 200, data: result },
}
} catch (error) {
return {
status: 400,
body: { code: 400, message: `解密失败: ${error.message}` },
}
}
}
-14
View File
@@ -1,14 +0,0 @@
// 强制下线设备
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
key: query.deviceKey,
captcha: query.captcha || '',
}
return request(
`/api/middle/user/security/device/kickoff`,
data,
createOption(query, 'eapi'),
)
}
-13
View File
@@ -1,13 +0,0 @@
// 登录设备列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
excStatus: '9',
}
return request(
`/api/middle/user/device/list`,
data,
createOption(query, 'eapi'),
)
}
-34
View File
@@ -1,34 +0,0 @@
// 修改本人动态的可见权限
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))
}
-13
View File
@@ -1,13 +0,0 @@
// 红心与取消红心歌曲- 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'))
}
-12
View File
@@ -1,12 +0,0 @@
// 听歌足迹 - 歌曲播放排行 (Top20)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
`/api/content/activity/listen/data/song/play/rank`,
{
type: query.type || 'month', //周 week 月 month
endTime: query.endTime, // 不填就是本周/月的
},
createOption(query),
)
}
-1
View File
@@ -14,7 +14,6 @@ module.exports = async (query, request) => {
? query.captcha ? query.captcha
: query.md5_password || CryptoJS.MD5(query.password).toString(), : query.md5_password || CryptoJS.MD5(query.password).toString(),
remember: 'true', remember: 'true',
secureCaptcha: query.sca || '',
} }
let result = await request( let result = await request(
`/api/w/login/cellphone`, `/api/w/login/cellphone`,
-24
View File
@@ -1,24 +0,0 @@
// 云小编每日抽奖
//
// activityId:
// 默认 6501202
//
// drawCount:
// 默认 1
//
// checkToken:
// 易盾反作弊 Token
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '6501202',
drawCount: query.drawCount || '1',
}
return request(
`/api/middle/play/do/lottery`,
data,
createOption(query, 'eapi', 'v2'),
)
}
@@ -1,17 +0,0 @@
// 云小编抽奖剩余次数查询
//
// activityId:
// 默认 6501202
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '6501202',
}
return request(
`/api/middle/play/lottery/remain/chance`,
data,
createOption(query, 'eapi'),
)
}
-11
View File
@@ -1,11 +0,0 @@
// 获取音乐人任务
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/nmusician/workbench/special/right/vip/info`,
data,
createOption(query, 'eapi'),
)
}
+3
View File
@@ -1,5 +1,8 @@
// 私人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 = {
-1
View File
@@ -1,5 +1,4 @@
// 私信和通知接口 // 私信和通知接口
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = {} const data = {}
+1 -1
View File
@@ -9,6 +9,6 @@ module.exports = (query, request) => {
? { checkToken: query.checkToken || APP_CONF.checkToken } ? { checkToken: query.checkToken || APP_CONF.checkToken }
: {}), : {}),
} }
query.checkToken = 'v2' // 强制开启checkToken query.checkToken = true // 强制开启checkToken
return request(`/api/playlist/${path}`, data, createOption(query, 'eapi')) return request(`/api/playlist/${path}`, data, createOption(query, 'eapi'))
} }
+1 -2
View File
@@ -1,5 +1,4 @@
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
module.exports = async (query, request) => { module.exports = async (query, request) => {
query.ids = query.ids || '' query.ids = query.ids || ''
const data = { const data = {
@@ -10,7 +9,7 @@ module.exports = async (query, request) => {
}), }),
), ),
} }
logger.info(data) console.log(data)
return request(`/api/playlist/track/add`, data, createOption(query, 'weapi')) return request(`/api/playlist/track/add`, data, createOption(query, 'weapi'))
} }
+1 -3
View File
@@ -2,9 +2,7 @@
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = { const data = {}
afresh: query.afresh,
}
return request( return request(
`/api/v3/discovery/recommend/songs`, `/api/v3/discovery/recommend/songs`,
data, data,
+2 -5
View File
@@ -1,10 +1,7 @@
// 获取游客cookie
const CryptoJS = require('crypto-js') const CryptoJS = require('crypto-js')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2' const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2'
const logger = require('../util/logger.js')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { generateDeviceId } = require('../util/index') const { generateDeviceId } = require('../util/index')
@@ -26,7 +23,7 @@ function cloudmusic_dll_encode_id(some_id) {
module.exports = async (query, request) => { module.exports = async (query, request) => {
const deviceId = generateDeviceId() const deviceId = generateDeviceId()
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`) console.log(`[register_anonimous] deviceId: ${deviceId}`)
global.deviceId = deviceId global.deviceId = deviceId
const encodedId = CryptoJS.enc.Base64.stringify( const encodedId = CryptoJS.enc.Base64.stringify(
CryptoJS.enc.Utf8.parse( CryptoJS.enc.Utf8.parse(
@@ -39,7 +36,7 @@ module.exports = async (query, request) => {
let result = await request( let result = await request(
`/api/register/anonimous`, `/api/register/anonimous`,
data, data,
createOption(query, 'xeapi'), createOption(query, 'weapi'),
) )
if (result.body.code === 200) { if (result.body.code === 200) {
result = { result = {
-158
View File
@@ -1,158 +0,0 @@
// 易盾反作弊 Token 注册端点
// 通过易盾官方 Watchman SDK(Web 版,跑在 jsdom 模拟的浏览器环境里)
// 实时调用 getToken(businessId) 获取反作弊 token,供后续带 checkToken 的请求使用
//
// GET /register/checktoken/v2 → 实时获取新 token(不缓存)
// POST /register/checktoken/v2 → 实时获取新 token
//
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 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 { APP_CONF } = require('../util/config.json')
const logger = require('../util/logger')
// 网易云音乐在易盾的 productNumber 与 businessId
const PRODUCT_NUMBER = 'YD00000558929251'
const BUSINESS_ID = 'bd5d2f973ef74cd2a61325a412ae54d9'
const TOOL_JS_URL = `${APP_CONF.dunStaticDomain}/tool.min.js`
// 最小 HTML 外壳,模拟网页环境
const HTML =
'<!doctype html><html><head><meta charset="UTF-8"></head><body></body></html>'
let toolJs = ''
let wm = null // Watchman 实例(进程内复用,可反复 getToken)
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)
async function ensureWatchman() {
if (wm) return wm
if (initPromise) return initPromise
initPromise = (async () => {
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 {
status: 200,
body: { code: 200, token, registered: !!token },
}
}
// 给 request.js 读取用:每次调用实时获取新 token,不缓存
module.exports.getToken = async () => {
try {
return await fetchToken()
} catch (e) {
logger.warn('[checkToken v2]', e.message)
return ''
}
}
-44
View File
@@ -1,44 +0,0 @@
// 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
//
// GET /register/checktoken/v3 → 实时获取新 token(不缓存)
// POST /register/checktoken/v3 → 实时获取新 token
//
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
// 避免反作弊 token 复用触发风控。
//
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251'
async function fetch() {
const res = await axios.get(URL, { timeout: 10000 })
const body = String(res.data)
const m = body.match(/null\(\[(\d+),\d+,\"([^\"]+)\"\]\)/)
if (m && m[1] === '200') return m[2]
throw new Error('易盾返回异常: ' + body.substring(0, 100))
}
// 端点处理:每次实时获取新 token
module.exports = async () => {
let token = ''
try {
token = await fetch()
} catch (e) {
// token 获取失败时返回空,由调用方决定是否重试
}
return {
status: 200,
body: { code: 200, token, registered: !!token },
}
}
// 给 request.js 读取用:每次调用实时获取新 token,不缓存
module.exports.getToken = async () => {
try {
return await fetch()
} catch (e) {
return ''
}
}
-74
View File
@@ -1,74 +0,0 @@
const { default: axios } = require('axios')
const encrypt = require('../util/crypto')
const { APP_CONF } = require('../util/config.json')
const generateNonce = () => {
let nonce = ''
for (let i = 0; i < 16; i++) {
nonce += Math.floor(Math.random() * 10).toString()
}
return nonce
}
module.exports = async (query, request) => {
const nonce = generateNonce()
const timestamp = String(Date.now())
const deviceId = query.deviceId || global.deviceId || ''
const currentKeyVersion = query.currentKeyVersion || ''
const data = {
appVersion: '9.5.61',
currentKeyVersion,
deviceId,
nonce,
os: 'android',
requestType: 'active',
signature: encrypt.xeapiSign(timestamp, nonce),
t1: '',
t2: '',
timestamp,
uid: '',
}
const res = await axios({
method: 'POST',
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
headers: {
'User-Agent':
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
},
data: new URLSearchParams(data).toString(),
proxy: false,
})
if (
!res.data ||
res.data.code !== 200 ||
!res.data.data ||
!res.data.data.encryptedData
) {
throw new Error('xeapi public key request failed')
}
if (
!res.data.data.signature ||
encrypt.xeapiSign(res.data.data.timestamp, nonce) !==
res.data.data.signature
) {
throw new Error('xeapi public key response signature mismatch')
}
const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData)
if (!publicKey.sk) {
throw new Error('xeapi public key response missing sk')
}
return {
status: 200,
body: {
...publicKey,
deviceId,
},
cookie: [],
}
}
-49
View File
@@ -1,49 +0,0 @@
// 提交歌曲播放状态
const createOption = require('../util/option.js')
const generateSessionId = () =>
Array.from(
{ length: 12 },
() =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[Math.floor(Math.random() * 36)],
).join('')
module.exports = (query, request) => {
const {
id,
sessionId,
progress = 0,
playMode = 'list_loop',
type = 'song',
} = query
if (!id) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数:id',
},
})
}
const playStateSubmitReq = JSON.stringify({
resource: {
id: String(id),
type: type,
},
progress: Number(progress) || 0,
sessionId: sessionId || generateSessionId(),
playMode: playMode,
})
const data = {
playStateSubmitReq: playStateSubmitReq,
}
return request(
'/api/relay/play/state/submit',
data,
createOption(query, 'weapi'),
)
}
-17
View File
@@ -1,17 +0,0 @@
// 云小编领取任务积分
//
// activityId:
// 调用 rep/ugc/activity/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '5001',
}
return request(
`/api/rep/ugc/activity/collect`,
data,
createOption(query, 'eapi'),
)
}
-7
View File
@@ -1,7 +0,0 @@
// 云小编活动信息
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/activity/get`, {}, createOption(query, 'eapi'))
}
-28
View File
@@ -1,28 +0,0 @@
// 云小编考试状态
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
}
return request(
'/api/rep/ugc/exam/info/get',
data,
createOption(query, 'eapi'),
)
}
@@ -1,32 +0,0 @@
// 云小编考试取题
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
}
return request(
'/api/rep/ugc/exam/question/single/get',
data,
createOption(query, 'eapi'),
)
}
-32
View File
@@ -1,32 +0,0 @@
// 云小编考试结果
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
}
return request(
'/api/rep/ugc/exam/result/get',
data,
createOption(query, 'eapi'),
)
}
-24
View File
@@ -1,24 +0,0 @@
// 云小编考试开始
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
}
return request('/api/rep/ugc/exam/start', data, createOption(query, 'eapi'))
}
-36
View File
@@ -1,36 +0,0 @@
// 云小编考试提交
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
//
// questionId:
// 调用 rep/ugc/exam/question/single/get 获取
//
// answer:
// A 对, B 错
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId || !query.questionId || !query.answer)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
questionId: query.questionId,
answer: query.answer,
}
return request('/api/rep/ugc/exam/submit', data, createOption(query, 'eapi'))
}
-16
View File
@@ -1,16 +0,0 @@
// 云小编领取一日会员
//
// 注:前提条件见 rep/ugc/user/vip
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '5001',
}
return request(
`/api/rep/ugc/user/collect-vip`,
data,
createOption(query, 'eapi'),
)
}
-7
View File
@@ -1,7 +0,0 @@
// 云小编获取用户详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/get`, {}, createOption(query, 'eapi'))
}
-7
View File
@@ -1,7 +0,0 @@
// 云小编每日签到
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/sign`, {}, createOption(query, 'eapi'))
}
-12
View File
@@ -1,12 +0,0 @@
// 云小编查询会员任务状态
//
// 状态 (data.status)
// 10: 用户积分达50,可免费领取1日黑胶会员
// 20: 用户积分已达50,可免费领取1日黑胶会员
// 30: 已领取1日黑胶会员,明天再来吧~
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/vip`, {}, createOption(query, 'eapi'))
}
+5 -57
View File
@@ -1,42 +1,8 @@
// 听歌打卡 // 听歌打卡
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { APP_CONF } = require('../util/config.json') module.exports = (query, request) => {
const DOMAIN = APP_CONF.clDomian const data = {
module.exports = async (query, request) => {
// 注入 os=osx 的 cookie
let cookie = query.cookie || ''
if (typeof cookie === 'object') {
cookie = Object.assign({ os: 'osx' }, cookie)
} else if (typeof cookie === 'string') {
if (cookie.indexOf('os=') > -1) {
cookie = cookie.replace(/os=[^;]+/g, 'os=osx')
} else {
cookie = cookie + '; os=osx'
}
} else {
cookie = 'os=osx'
}
query.cookie = cookie
// 1) startplay → 进「最近播放」
const startplayData = {
logs: JSON.stringify([
{
action: 'startplay',
json: {
id: query.id,
type: 'song',
mainsite: '1',
mainsiteWeb: '1',
content: `id=${query.sourceid}`,
},
},
]),
}
// 2) play → 涨「听歌排行」计数
const playData = {
logs: JSON.stringify([ logs: JSON.stringify([
{ {
action: 'play', action: 'play',
@@ -49,30 +15,12 @@ module.exports = async (query, request) => {
type: 'song', type: 'song',
wifi: 0, wifi: 0,
source: 'list', source: 'list',
mainsite: '1', mainsite: 1,
mainsiteWeb: '1', content: '',
content: `id=${query.sourceid}`,
}, },
}, },
]), ]),
} }
const option = createOption(query, 'eapi') return request(`/api/feedback/weblog`, data, createOption(query, 'weapi'))
option.domain = DOMAIN
// 发送两次请求
const res1 = await request(`/api/feedback/weblog`, startplayData, option)
const res2 = await request(`/api/feedback/weblog`, playData, option)
return {
status: 200,
body: {
code: 200,
data: 'success',
details: {
startplay: res1.body,
play: res2.body,
},
},
}
} }
-128
View File
@@ -1,128 +0,0 @@
// 听歌打卡 - NCBL 加密版 (仿桌面客户端 PLV/PLD 上报)
// 复制自 https://github.com/folltoshe/netease-report-listen-song 的 PC 端日志上报方式
//
// PLV 和 PLD 分两次独立上传
const {
buildPlv,
buildPld,
buildRecords,
extractContext,
parseCookie,
buildCookieStr,
buildMetaJson,
doUpload,
} = require('../util/ncbl')
module.exports = async (query, request) => {
// --- 参数校验 ---
const songId = Number(query.id)
if (!songId || isNaN(songId)) {
return { status: 400, body: { code: 400, msg: '缺少有效的 id (歌曲ID)' } }
}
const playTime = Number(query.time)
if (isNaN(playTime) || playTime <= 0) {
return {
status: 400,
body: { code: 400, msg: '缺少有效的 time (播放时长)' },
}
}
const totalTime = Number(query.total) || playTime
const sourceId = String(query.sourceid || query.sourceId || '')
const sourceName = query.source || 'list'
// --- 解析认证上下文 ---
const rawCookie = query.cookie || ''
const cookieObj = parseCookie(rawCookie)
cookieObj.os = 'pc'
const ctx = extractContext(cookieObj)
// 兜底取 token
if (!ctx.auth.token && rawCookie) {
const parsed =
typeof rawCookie === 'string' ? parseCookie(rawCookie) : rawCookie
ctx.auth.token = parsed.MUSIC_U || ''
}
if (!ctx.auth.token) {
return { status: 401, body: { code: 401, msg: '缺少 MUSIC_U 鉴权令牌' } }
}
// --- 构建歌曲和来源 ---
const song = {
id: songId,
name: query.name || '',
artist: query.artist || '',
bitrate: Number(query.bitrate) || 320,
level: query.level || 'exhigh',
vip: query.vip === 'true' || query.vip === true,
time: totalTime,
}
const source = {
id: sourceId || String(songId),
type: 'track',
name: sourceName,
}
const metaJson = buildMetaJson(ctx)
const cookieStr = buildCookieStr(ctx)
const ts = Math.floor(Date.now() / 1000)
const played = Math.min(playTime, totalTime)
const plvBody = buildRecords([
{ time: ts, action: '_plv', data: buildPlv(ctx, song, source) },
])
const pldBody = buildRecords([
{ time: ts, action: '_pld', data: buildPld(ctx, song, source, played) },
])
try {
// 1) 上传 PLV
const plv = await doUpload(ctx, metaJson, plvBody, cookieStr, 'PLV')
if (!plv.success) {
const rateMsg =
plv.respBody?.data?.rate != null
? ` (rate=${plv.respBody.data.rate})`
: ''
return {
status: 200,
body: {
code: plv.respBody?.code || -1,
msg: `PLV 上报失败${rateMsg}`,
details: plv.respBody,
},
}
}
// 2) 上传 PLD
const pld = await doUpload(ctx, metaJson, pldBody, cookieStr, 'PLD')
if (!pld.success) {
return {
status: 200,
body: {
code: pld.respBody?.code || -1,
msg: 'PLV 成功但 PLD 失败',
details: { plv: plv.respBody, pld: pld.respBody },
},
}
}
return {
status: 200,
body: {
code: 200,
data: 'scrobble_v1 上报成功',
details: {
plv: { fileName: plv.fileName, payloadSize: plv.payload.length },
pld: { fileName: pld.fileName, payloadSize: pld.payload.length },
},
},
}
} catch (err) {
return {
status: 502,
body: { code: 502, msg: `请求异常: ${err.message || err}` },
}
}
}
-13
View File
@@ -1,13 +0,0 @@
// 搜索建议pc端
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
keyword: query.keyword || '',
}
return request(
`/api/search/pc/suggest/keyword/get`,
data,
createOption(query),
)
}
+1 -5
View File
@@ -7,9 +7,5 @@ module.exports = (query, request) => {
msg: query.msg || '', msg: query.msg || '',
id: query.id || '', id: query.id || '',
} }
return request( return request(`/api/share/friends/resource`, data, createOption(query))
`/api/share/friends/resource`,
data,
createOption(query, 'xeapi', 'v3'),
)
} }
-9
View File
@@ -1,9 +0,0 @@
// 从云盘获取歌曲下载链接
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
songId: query.id,
}
return request(`/api/cloud/dowonload`, data, createOption(query, 'eapi'))
}
-9
View File
@@ -1,9 +0,0 @@
// 灰色歌曲的其他版本推荐
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
songid: query.songid || query.id,
}
return request(`/api/song/copyright/rcmd`, data, createOption(query, 'eapi'))
}
+2 -12
View File
@@ -1,23 +1,13 @@
// 获取客户端歌曲下载链接 - v1 // 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准 // 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断 // 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
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,
} }
const options = createOption(query) return request(`/api/song/enhance/download/url/v1`, data, 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)
} }
-12
View File
@@ -1,12 +0,0 @@
// 喜欢歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const like = query.like !== 'false'
const data = {
trackId: query.id,
userid: query.uid,
like: like,
}
return request(`/api/song/like`, data, createOption(query))
}
-15
View File
@@ -1,15 +0,0 @@
// 插播相似歌曲
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'),
)
}
-38
View File
@@ -1,38 +0,0 @@
// 网易云歌曲解灰(适配SPlayer的UNM-Server)
// 支持qq音乐、酷狗音乐、酷我音乐、咪咕音乐、第三方网易云API等等(来自GD音乐台)
const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
module.exports = async (query, request) => {
try {
const {
matchID,
} = require('@neteasecloudmusicapienhanced/unblockmusic-utils')
const result = await matchID(query.id, query.source)
const proxy = process.env.PROXY_URL
logger.info('开始解灰', query.id, result)
const useProxy = process.env.ENABLE_PROXY || 'false'
if (result.data.url && result.data.url.includes('kuwo')) {
result.proxyUrl =
useProxy === 'true' ? proxy + result.data.url : result.data.url
}
return {
status: 200,
body: {
code: 200,
data: result.data.url,
proxyUrl: result.proxyUrl || '',
},
}
} catch (e) {
return {
status: 500,
body: {
code: 500,
msg: e.message || 'unblock error',
data: [],
},
}
}
}
-5
View File
@@ -1,5 +0,0 @@
// 夹带私货的东西就不要放在这里了
module.exports = async (query, request) => {
return { status: 200, body: { code: 200, data: [] } }
}
+4 -57
View File
@@ -1,69 +1,16 @@
// 歌曲链接 - v1 // 歌曲链接 - v1
// 此版本不再采用 br 作为音质区分的标准 // 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断 // 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
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 = (query, request) => {
module.exports = async (query, request) => {
const {
matchID,
} = require('@neteasecloudmusicapienhanced/unblockmusic-utils')
require('dotenv').config()
const data = { const data = {
ids: '[' + query.id + ']', ids: '[' + query.id + ']',
level: query.level, level: query.level,
encodeType: 'flac', encodeType: 'flac',
} }
const options = createOption(query, 'xeapi')
if (query.unblock === 'true') {
try {
const result = await matchID(query.id, query.source)
logger.info('Starting unblock(uses modules unblock):', query.id, result)
const useProxy = process.env.ENABLE_PROXY || 'false'
let proxyUrl = ''
if (result.data.url && result.data.url.includes('kuwo')) {
proxyUrl =
useProxy === 'true' && process.env.PROXY_URL
? process.env.PROXY_URL + result.data.url
: result.data.url
}
return {
status: 200,
body: {
code: 200,
msg: 'Warning: Customizing unblock sources is not supported on this endpoint. Please use `/song/url/match` instead.',
data: [
{
id: Number(query.id),
url: result.data.url,
type: 'flac',
level: query.level,
freeTrialInfo: 'null',
fee: 0,
proxyUrl: proxyUrl || '',
},
],
},
cookie: [],
}
} catch (e) {
console.error('Error in unblocking music:', e)
}
}
if (data.level == 'sky') { if (data.level == 'sky') {
data.immerseType = query.immerseType || 'c51' data.immerseType = 'c51'
} }
if (data.level == 'vivid') { return request(`/api/song/enhance/player/url/v1`, data, createOption(query))
data.encodeType = 'mp3'
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/player/url/v1`, data, options)
} }
-66
View File
@@ -1,66 +0,0 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = async (query, request) => {
const data = {
id: query.id,
immerseType: 'c51',
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(
`/api/song/enhance/download/url/v1`,
data,
options,
)
let url = response?.body?.data?.url || response?.body?.data?.[0]?.url
if (!url) {
const fallbackData = {
ids: `[${query.id}]`,
level: query.level,
encodeType: 'flac',
}
if (query.level === 'sky') {
fallbackData.immerseType = 'c51'
}
if (query.level === 'vivid') {
fallbackData.encodeType = 'mp3'
}
const fallback = await request(
`/api/song/enhance/player/url/v1`,
fallbackData,
options,
)
url = fallback?.body?.data?.[0]?.url
if (!url) {
return fallback
}
return {
status: 302,
body: '',
cookie: fallback.cookie || [],
redirectUrl: url,
}
}
return {
status: 302,
body: '',
cookie: response.cookie || [],
redirectUrl: url,
}
}
-22
View File
@@ -1,22 +0,0 @@
// 歌曲百科
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'),
)
}
-20
View File
@@ -1,20 +0,0 @@
// 云小编获取任务
//
// type:
// 1: 歌曲曲风审核 musicalStyleEnter
// 2: 歌曲语种审核 languageEnter
// 3: 歌曲原唱审核 oriSingerEnter
// 4: 情绪标签审核 emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
type: query.type || '4',
}
return request(
`/api/thinktank/audit/resource/detail`,
data,
createOption(query, 'eapi'),
)
}
-36
View File
@@ -1,36 +0,0 @@
// 云小编提交任务
//
// type:
// 1: 歌曲曲风审核 musicalStyleEnter
// 2: 歌曲语种审核 languageEnter
// 3: 歌曲原唱审核 oriSingerEnter
// 4: 情绪标签审核 emotionEnter
//
// taskId:
// 调用 thinktank/audit/resource/detail 获取
//
// judgement:
// 1: 同意, 2: 否决, 3: 跳过 (不算次数)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.taskId || !query.judgement)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
type: query.type || '4',
taskId: query.taskId,
judgement: query.judgement,
}
return request(
`/api/thinktank/audit/resource/update`,
data,
createOption(query, 'eapi'),
)
}
+2 -3
View File
@@ -4,10 +4,9 @@ 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
@@ -1,135 +0,0 @@
// 获取当前登录用户可被上游枚举的全部动态
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,
}
}

Some files were not shown because too many files have changed in this diff Show More