mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-10-20 22:51:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a91f77e62 | ||
|
|
a7437f7ca4 | ||
|
|
498945c401 | ||
|
|
7b17a08628 | ||
|
|
0b5c3927c9 | ||
|
|
881267c56d | ||
|
|
d7c0db9455 | ||
|
|
c1d14a86d9 | ||
|
|
a2a2a40d71 | ||
|
|
543660fcdf | ||
|
|
98a95002b2 | ||
|
|
c4a3d83abe | ||
|
|
7753b5de41 | ||
|
|
c1fe1eb925 | ||
|
|
3357317a1d | ||
|
|
a08f425ae7 | ||
|
|
5b780addba | ||
|
|
fc9f41839c | ||
|
|
5859944563 | ||
|
|
aaa6459e55 | ||
|
|
a898e1d86c | ||
|
|
4045f1ad3f | ||
|
|
e6214a3f73 | ||
|
|
686e8bf969 | ||
|
|
9214cb945f | ||
|
|
a79a2d9ef6 | ||
|
|
9608da9b96 | ||
|
|
93b3e624d8 | ||
|
|
15f515c1ba | ||
|
|
e255e8f334 | ||
|
|
16aa61f98e | ||
|
|
a939003b92 | ||
|
|
8f4873f2e2 | ||
|
|
a8b45a8b8b | ||
|
|
1a70281c76 | ||
|
|
f5f4ff6eda | ||
|
|
eb1b5ba0ea | ||
|
|
6732fc7c32 | ||
|
|
6ce6b84015 | ||
|
|
63d89aa906 | ||
|
|
8c212be86b | ||
|
|
89de56d2a2 | ||
|
|
d298025d7c | ||
|
|
a3287a0180 | ||
|
|
9adfd50c58 | ||
|
|
fda7c9e5b3 | ||
|
|
a8266c6d7b | ||
|
|
64a6b9677a | ||
|
|
20d3a1a7a5 | ||
|
|
4826c65042 | ||
|
|
7822ab08d3 | ||
|
|
41bd6d82ce | ||
|
|
6f8dd1d971 | ||
|
|
ddd0af5474 | ||
|
|
fbd2e06419 | ||
|
|
4442fb4341 | ||
|
|
35d1c61cb4 | ||
|
|
a183e153ef | ||
|
|
2fa78c3826 | ||
|
|
5ae1eab14c | ||
|
|
321c25bd7d | ||
|
|
f527c1ef73 | ||
|
|
0a5a1242f2 | ||
|
|
6946dc8e14 | ||
|
|
22b78779ca | ||
|
|
35607f5050 | ||
|
|
60af95ac06 | ||
|
|
46073e5f50 | ||
|
|
bf12abe2f1 | ||
|
|
1491b91829 | ||
|
|
63f669d7f8 | ||
|
|
d525204dda | ||
|
|
6eb31984fd | ||
|
|
9faeb0aa25 | ||
|
|
f6db3e76e9 | ||
|
|
9163d48f0a | ||
|
|
1454ee216c | ||
|
|
83ee2cda34 | ||
|
|
3c352b5752 | ||
|
|
60ee927e32 | ||
|
|
eb07a525eb | ||
|
|
6fc4231142 | ||
|
|
fa69736300 | ||
|
|
73a5e9d788 | ||
|
|
dc6f56e8a8 | ||
|
|
2c9da8507d | ||
|
|
1db354464f | ||
|
|
395a80e74b | ||
|
|
6550c7c827 | ||
|
|
c959866436 | ||
|
|
e3a1c041b6 | ||
|
|
e2324e26da | ||
|
|
f01e770c35 | ||
|
|
abb56ec014 | ||
|
|
0300c1b51e | ||
|
|
9896242333 | ||
|
|
e3812be97b | ||
|
|
e7ff0da4ba | ||
|
|
65261fa658 | ||
|
|
cb5b2e7d0f | ||
|
|
f58fd4b50d | ||
|
|
1a5f91a192 | ||
|
|
50cc26f297 | ||
|
|
c92613b19e | ||
|
|
7aacc8990b | ||
|
|
8e934789a1 | ||
|
|
d26e6b0615 | ||
|
|
a9c33de182 | ||
|
|
d0935f6e42 | ||
|
|
3e0337ab94 | ||
|
|
5cf43c48ef | ||
|
|
68567f4a14 | ||
|
|
d0d71bb4f9 | ||
|
|
ec7eff1697 | ||
|
|
1882c3c31e | ||
|
|
f21f5677e8 | ||
|
|
39e0e0718d | ||
|
|
6175d55f89 |
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -14,17 +14,17 @@ body:
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 在提交Bug报告前,请确认以下事项
|
||||
description: |
|
||||
在提交Bug报告前,请确认以下事项:
|
||||
|
||||
- 我已经搜索了现有的issues,确认这不是重复问题
|
||||
- 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
|
||||
- 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
|
||||
- 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
|
||||
- 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
|
||||
|
||||
options:
|
||||
- label: 我已经搜索了现有的issues,确认这不是重复问题
|
||||
required: true
|
||||
- label: 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
|
||||
required: true
|
||||
- label: 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
|
||||
required: true
|
||||
- label: 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
|
||||
required: true
|
||||
- label: 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
|
||||
- label: 我已确认以上事项
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
@@ -36,29 +36,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统或平台
|
||||
description: 您在哪个操作系统上遇到了这个问题?
|
||||
options:
|
||||
- Windows 10
|
||||
- Windows 11
|
||||
- Ubuntu 20.04
|
||||
- Ubuntu 22.04
|
||||
- macOS
|
||||
- 其他 Linux 发行版
|
||||
- 使用部署平台或其他 (请在描述中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署平台
|
||||
description:
|
||||
label: 部署方式
|
||||
description: 此项目支持本地部署和云平台部署,您使用的是哪种方式?
|
||||
options:
|
||||
- 我使用的自己的服务器部署
|
||||
- 本机直接运行 (node / pm2)
|
||||
- 本机 Docker 部署
|
||||
- Vercel
|
||||
- Heroku
|
||||
- Railway
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 发起者均已了解此处的内容
|
||||
@@ -14,15 +14,16 @@ body:
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 在提交功能请求前,请确认以下事项
|
||||
description: |
|
||||
在提交功能请求前,请确认以下事项
|
||||
|
||||
- 我已经搜索了现有的issues,确认这不是重复请求
|
||||
- 我已经查看了项目文档
|
||||
- 该项目符合该项目的目标和范围
|
||||
- 我确认我提供的接口请求是合理且有意义的
|
||||
|
||||
options:
|
||||
- label: 我已经搜索了现有的issues,确认这不是重复请求
|
||||
required: true
|
||||
- label: 我已经查看了项目文档
|
||||
required: true
|
||||
- label: 该项目符合该项目的目标和范围
|
||||
required: true
|
||||
- label: 我确认我提供的接口请求是合理且有意义的
|
||||
- label: 我已确认以上事项
|
||||
required: true
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Lint / Unit / Docs
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [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
|
||||
@@ -6,8 +6,6 @@ permissions:
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
welcome-new-issues:
|
||||
@@ -19,38 +17,15 @@ jobs:
|
||||
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) 看是否有类似问题
|
||||
|
||||
handle-help-wanted:
|
||||
if: contains(github.event.issue.labels.*.name, 'question')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add help resources
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
🆘 需要帮助?
|
||||
|
||||
这里有一些可能对您有用的资源:
|
||||
|
||||
📚 **文档资源**
|
||||
- [项目文档](https://neteasecloudmusicapienhanced.js.org)
|
||||
|
||||
🔍 **常见问题**
|
||||
- 搜索[已关闭的issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues?q=is%3Aissue+is%3Aclosed) 看看是否有类似的问题已经被解答。
|
||||
|
||||
💬 **即时帮助**
|
||||
- 加入QQ群:https://qm.qq.com/q/TpeP9Uv2yk
|
||||
|
||||
如果以上资源无法解决您的问题,请提供更多详细信息,我们会尽快为您解答!
|
||||
|
||||
stale-issues:
|
||||
if: github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
@@ -0,0 +1,66 @@
|
||||
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 }}
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.platform }}-binary
|
||||
path: release-artifacts/*
|
||||
@@ -165,17 +165,16 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Flatten artifacts
|
||||
- name: List flattened artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p final-artifacts
|
||||
|
||||
find release-artifacts -type f -exec cp {} final-artifacts/ \;
|
||||
|
||||
cp release-artifacts/* final-artifacts/
|
||||
ls -lah final-artifacts
|
||||
|
||||
- name: Generate release notes
|
||||
@@ -261,13 +260,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
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@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -313,4 +312,4 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm publish --access public
|
||||
- run: pnpm publish --access public --provenance
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
# Step 2: run the sync action
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/[email protected].2
|
||||
uses: aormsby/[email protected].3
|
||||
with:
|
||||
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
|
||||
upstream_sync_branch: main
|
||||
|
||||
+2
-1
@@ -9,4 +9,5 @@ bin
|
||||
anonymous_token
|
||||
.vercel
|
||||
.env
|
||||
IFLOW.md
|
||||
IFLOW.md
|
||||
precompiled
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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`)。
|
||||
-1418
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -2,15 +2,20 @@ FROM node:lts-alpine
|
||||
|
||||
RUN apk add --no-cache tini
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
USER node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node:node . ./
|
||||
|
||||
RUN yarn --network-timeout=100000
|
||||
# --prod 模式下 husky 不会被安装,prepare 脚本会因找不到 husky 而失败,
|
||||
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "/sbin/tini", "--", "node", "app.js" ]
|
||||
CMD [ "/sbin/tini", "--", "node", "app.js" ]
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署(如果原版诈尸, 我会及时同步 or 归档)。
|
||||
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
@@ -45,14 +45,14 @@
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18 及以上
|
||||
- Node.js 22 及以上
|
||||
- 推荐使用 pnpm 进行依赖管理
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git
|
||||
cd api
|
||||
cd api-enhanced
|
||||
pnpm i
|
||||
```
|
||||
|
||||
@@ -215,7 +215,7 @@ pnpm test
|
||||
|
||||
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
|
||||
|
||||
感谢大佬们为逆向eapi, weapi等加密算法所做的贡献
|
||||
感谢大佬们为逆向eapi, weapi, xeapi等加密算法所做的贡献
|
||||
|
||||
项目参考:
|
||||
|
||||
@@ -225,6 +225,8 @@ pnpm test
|
||||
|
||||
[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 生态
|
||||
|
||||
| 语言 | 作者 | 地址 | 类型 |
|
||||
@@ -244,6 +246,8 @@ pnpm test
|
||||
|
||||
- [Yueby/music-together](https://github.com/Yueby/music-together)
|
||||
|
||||
- [chthollyphlie/folia-major](https://github.com/chthollyphile/folia-major)
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE)
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { register_anonimous } = require('./main')
|
||||
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
||||
const { getXeapiPublicKey } = require('./util/xeapiKey')
|
||||
const tmpPath = require('os').tmpdir()
|
||||
|
||||
async function generateConfig() {
|
||||
@@ -20,5 +21,21 @@ async function generateConfig() {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
try {
|
||||
let currentPublicKey = {}
|
||||
try {
|
||||
currentPublicKey = JSON.parse(
|
||||
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
|
||||
)
|
||||
} catch (_) {}
|
||||
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
|
||||
fs.writeFileSync(
|
||||
path.resolve(tmpPath, 'xeapi_public_key'),
|
||||
JSON.stringify(publicKey),
|
||||
'utf-8',
|
||||
)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
module.exports = generateConfig
|
||||
|
||||
Vendored
+1041
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
// 获取广告
|
||||
|
||||
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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 获取免费听时长状态
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// 看广告免费听歌 - 领取免费听权益
|
||||
// 请求流程(基于逆向网易云音乐 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 获取关注歌手的新歌曲和 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 获取所有关注歌手最近的 50 首新歌
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
return request(
|
||||
`/api/sub/artist/new/works/song/playall`,
|
||||
{},
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 发送安全验证码
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 发送验证码
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -6,5 +6,9 @@ module.exports = (query, request) => {
|
||||
cellphone: query.phone,
|
||||
countrycode: query.countrycode,
|
||||
}
|
||||
return request(`/api/cellphone/existence/check`, data, createOption(query))
|
||||
return request(
|
||||
`/api/cellphone/existence/check`,
|
||||
data,
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,6 +25,6 @@ module.exports = (query, request) => {
|
||||
return request(
|
||||
`/api/resource/comments/${query.t}`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
createOption(query, 'eapi', 'v2'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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}` },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 强制下线设备
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 登录设备列表
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 修改本人动态的可见权限
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
const PRIVACY_VALUES = new Set([0, 1, 2, 6])
|
||||
|
||||
module.exports = (query, request) => {
|
||||
const eventId = String(query.evId ?? '').trim()
|
||||
const rawPrivacy = String(query.privacy ?? '').trim()
|
||||
const privacy = Number(rawPrivacy)
|
||||
|
||||
if (
|
||||
!eventId ||
|
||||
!rawPrivacy ||
|
||||
!Number.isInteger(privacy) ||
|
||||
!PRIVACY_VALUES.has(privacy)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
status: 400,
|
||||
body: {
|
||||
code: 400,
|
||||
message: 'evId is required and privacy must be one of 0, 1, 2, 6',
|
||||
},
|
||||
cookie: [],
|
||||
})
|
||||
}
|
||||
|
||||
const data = {
|
||||
eventId,
|
||||
privacy,
|
||||
}
|
||||
|
||||
return request(`/api/event/privacy/op`, data, createOption(query))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 红心与取消红心歌曲- v1
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
query.like = query.like == 'false' ? false : true
|
||||
const data = {
|
||||
alg: 'itembased',
|
||||
trackId: query.id,
|
||||
like: query.like,
|
||||
time: '3',
|
||||
}
|
||||
return request(`/api/v1/radio/like`, data, createOption(query, 'xeapi', 'v3'))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 听歌足迹 - 歌曲播放排行 (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),
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ module.exports = async (query, request) => {
|
||||
? query.captcha
|
||||
: query.md5_password || CryptoJS.MD5(query.password).toString(),
|
||||
remember: 'true',
|
||||
secureCaptcha: query.sca || '',
|
||||
}
|
||||
let result = await request(
|
||||
`/api/w/login/cellphone`,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 云小编每日抽奖
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 云小编抽奖剩余次数查询
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
// 私人FM - 模式选择
|
||||
|
||||
// aidj, DEFAULT, FAMILIAR, EXPLORE, SCENE_RCMD ( EXERCISE, FOCUS, NIGHT_EMO )
|
||||
// 来不及解释这几个了
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 私信和通知接口
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
|
||||
@@ -9,6 +9,6 @@ module.exports = (query, request) => {
|
||||
? { checkToken: query.checkToken || APP_CONF.checkToken }
|
||||
: {}),
|
||||
}
|
||||
query.checkToken = true // 强制开启checkToken
|
||||
query.checkToken = 'v2' // 强制开启checkToken
|
||||
return request(`/api/playlist/${path}`, data, createOption(query, 'eapi'))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// 获取游客cookie
|
||||
|
||||
const CryptoJS = require('crypto-js')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
@@ -37,7 +39,7 @@ module.exports = async (query, request) => {
|
||||
let result = await request(
|
||||
`/api/register/anonimous`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
createOption(query, 'xeapi'),
|
||||
)
|
||||
if (result.body.code === 200) {
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 易盾反作弊 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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 易盾反作弊 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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 提交歌曲播放状态
|
||||
|
||||
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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 云小编领取任务积分
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编活动信息
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/activity/get`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 云小编考试状态
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 云小编考试取题
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 云小编考试结果
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 云小编考试开始
|
||||
//
|
||||
// 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'))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 云小编考试提交
|
||||
//
|
||||
// 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'))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 云小编领取一日会员
|
||||
//
|
||||
// 注:前提条件见 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编获取用户详情
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/user/get`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编每日签到
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/user/sign`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 云小编查询会员任务状态
|
||||
//
|
||||
// 状态 (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'))
|
||||
}
|
||||
+57
-5
@@ -1,8 +1,42 @@
|
||||
// 听歌打卡
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
const { APP_CONF } = require('../util/config.json')
|
||||
const DOMAIN = APP_CONF.clDomian
|
||||
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([
|
||||
{
|
||||
action: 'play',
|
||||
@@ -15,12 +49,30 @@ module.exports = (query, request) => {
|
||||
type: 'song',
|
||||
wifi: 0,
|
||||
source: 'list',
|
||||
mainsite: 1,
|
||||
content: '',
|
||||
mainsite: '1',
|
||||
mainsiteWeb: '1',
|
||||
content: `id=${query.sourceid}`,
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
return request(`/api/feedback/weblog`, data, createOption(query, 'weapi'))
|
||||
const option = createOption(query, 'eapi')
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 听歌打卡 - 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}` },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@ module.exports = (query, request) => {
|
||||
msg: query.msg || '',
|
||||
id: query.id || '',
|
||||
}
|
||||
return request(`/api/share/friends/resource`, data, createOption(query))
|
||||
return request(
|
||||
`/api/share/friends/resource`,
|
||||
data,
|
||||
createOption(query, 'xeapi', 'v3'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 从云盘获取歌曲下载链接
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
songId: query.id,
|
||||
}
|
||||
return request(`/api/cloud/dowonload`, data, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
// 获取客户端歌曲下载链接 - v1
|
||||
// 此版本不再采用 br 作为音质区分的标准
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
const { cookieToJson } = require('../util/index.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
id: query.id,
|
||||
immerseType: 'c51',
|
||||
level: query.level,
|
||||
}
|
||||
return request(`/api/song/enhance/download/url/v1`, data, createOption(query))
|
||||
const options = createOption(query)
|
||||
if (query.level === 'vivid') {
|
||||
const cookie = options.cookie
|
||||
options.cookie = {
|
||||
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
|
||||
os: 'android',
|
||||
appver: '9.5.61',
|
||||
}
|
||||
}
|
||||
return request(`/api/song/enhance/download/url/v1`, data, options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// 插播相似歌曲
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
positionCode: 'toolBarRcmdSong',
|
||||
resourceId: query.id,
|
||||
resourceType: 'song',
|
||||
}
|
||||
return request(
|
||||
`/api/link/position/show/resource`,
|
||||
data,
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
+15
-3
@@ -1,10 +1,12 @@
|
||||
// 歌曲链接 - v1
|
||||
// 此版本不再采用 br 作为音质区分的标准
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), 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 { cookieToJson } = require('../util/index.js')
|
||||
module.exports = async (query, request) => {
|
||||
const {
|
||||
matchID,
|
||||
@@ -15,6 +17,7 @@ module.exports = async (query, request) => {
|
||||
level: query.level,
|
||||
encodeType: 'flac',
|
||||
}
|
||||
const options = createOption(query, 'xeapi')
|
||||
if (query.unblock === 'true') {
|
||||
try {
|
||||
const result = await matchID(query.id, query.source)
|
||||
@@ -51,7 +54,16 @@ module.exports = async (query, request) => {
|
||||
}
|
||||
}
|
||||
if (data.level == 'sky') {
|
||||
data.immerseType = 'c51'
|
||||
data.immerseType = query.immerseType || 'c51'
|
||||
}
|
||||
return request(`/api/song/enhance/player/url/v1`, data, createOption(query))
|
||||
if (data.level == 'vivid') {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
// 获取客户端歌曲下载链接 - v1
|
||||
// 此版本不再采用 br 作为音质区分的标准
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
|
||||
// 而是采用 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,
|
||||
createOption(query),
|
||||
options,
|
||||
)
|
||||
let url = response?.body?.data?.[0]?.url
|
||||
let url = response?.body?.data?.url || response?.body?.data?.[0]?.url
|
||||
|
||||
if (!url) {
|
||||
const fallbackData = {
|
||||
@@ -25,10 +35,13 @@ module.exports = async (query, request) => {
|
||||
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,
|
||||
createOption(query),
|
||||
options,
|
||||
)
|
||||
url = fallback?.body?.data?.[0]?.url
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// 歌曲百科
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const extJson = {
|
||||
states: {
|
||||
playingResource: {
|
||||
current: query.id,
|
||||
scene: 'songWiki',
|
||||
},
|
||||
},
|
||||
}
|
||||
const data = {
|
||||
extJson: JSON.stringify(extJson),
|
||||
positionCode: 'songWikiMainPosition',
|
||||
}
|
||||
return request(
|
||||
`/api/link/page/parent/relation/construct/info`,
|
||||
data,
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 云小编获取任务
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 云小编提交任务
|
||||
//
|
||||
// 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'),
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,10 @@ const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
getcounts: true,
|
||||
time: query.lasttime || -1,
|
||||
limit: query.limit || 30,
|
||||
time: query.lasttime ?? -1,
|
||||
limit: query.limit ?? 30,
|
||||
total: false,
|
||||
fromRN: 'true',
|
||||
}
|
||||
return request(`/api/event/get/${query.uid}`, data, createOption(query))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 获取当前登录用户可被上游枚举的全部动态
|
||||
|
||||
const userAccount = require('./user_account.js')
|
||||
const userEvent = require('./user_event.js')
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const MAX_PAGES = 1000
|
||||
|
||||
const errorResponse = (message, cookie = []) => ({
|
||||
status: 502,
|
||||
body: {
|
||||
code: 502,
|
||||
message,
|
||||
},
|
||||
cookie,
|
||||
})
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const accountResult = await userAccount(query, request)
|
||||
const uid =
|
||||
accountResult.body?.account?.id || accountResult.body?.profile?.userId
|
||||
|
||||
if (!uid) {
|
||||
if (
|
||||
accountResult.status !== 200 ||
|
||||
(accountResult.body?.code && accountResult.body.code !== 200)
|
||||
) {
|
||||
return accountResult
|
||||
}
|
||||
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
code: 401,
|
||||
message: 'A valid login cookie is required',
|
||||
},
|
||||
cookie: accountResult.cookie || [],
|
||||
}
|
||||
}
|
||||
|
||||
const cookies = [...(accountResult.cookie || [])]
|
||||
const events = []
|
||||
const eventIds = new Set()
|
||||
const cursors = new Set()
|
||||
let lasttime = -1
|
||||
let more = true
|
||||
let pageCount = 0
|
||||
let size = null
|
||||
|
||||
while (more) {
|
||||
pageCount += 1
|
||||
|
||||
const pageResult = await userEvent(
|
||||
{
|
||||
...query,
|
||||
uid,
|
||||
lasttime,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
cookies.push(...(pageResult.cookie || []))
|
||||
|
||||
if (pageResult.status !== 200 || pageResult.body?.code !== 200) {
|
||||
return {
|
||||
...pageResult,
|
||||
cookie: cookies,
|
||||
}
|
||||
}
|
||||
|
||||
if (pageCount === 1) {
|
||||
const reportedSize = pageResult.body.size
|
||||
const numericSize = Number(reportedSize)
|
||||
size =
|
||||
reportedSize != null && Number.isFinite(numericSize)
|
||||
? numericSize
|
||||
: null
|
||||
}
|
||||
|
||||
for (const event of pageResult.body.events || []) {
|
||||
if (event?.id == null) {
|
||||
events.push(event)
|
||||
continue
|
||||
}
|
||||
|
||||
const eventId = String(event.id)
|
||||
if (!eventIds.has(eventId)) {
|
||||
eventIds.add(eventId)
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
more = Boolean(pageResult.body.more)
|
||||
lasttime = pageResult.body.lasttime
|
||||
|
||||
if (more) {
|
||||
const cursor = String(lasttime ?? '')
|
||||
if (!cursor || cursors.has(cursor)) {
|
||||
return errorResponse(
|
||||
'Upstream event pagination cursor stalled',
|
||||
cookies,
|
||||
)
|
||||
}
|
||||
cursors.add(cursor)
|
||||
}
|
||||
|
||||
if (more && pageCount >= MAX_PAGES) {
|
||||
return errorResponse(
|
||||
`Upstream event pagination exceeded ${MAX_PAGES} pages`,
|
||||
cookies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const retrievedCount = events.length
|
||||
const unavailableCount =
|
||||
size == null ? null : Math.max(size - retrievedCount, 0)
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
events,
|
||||
size,
|
||||
retrievedCount,
|
||||
unavailableCount,
|
||||
sizeMismatch: size == null ? null : size !== retrievedCount,
|
||||
pageCount,
|
||||
more: false,
|
||||
lasttime: lasttime ?? null,
|
||||
},
|
||||
cookie: cookies,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 一键领取所有会员成长值
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
return request(
|
||||
`/api/vipnewcenter/app/level/task/reward/getall`,
|
||||
data,
|
||||
createOption(query, 'xeapi'),
|
||||
)
|
||||
}
|
||||
+36
-3
@@ -1,7 +1,40 @@
|
||||
// 黑胶乐签打卡
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
return request(`/api/vip-center-bff/task/sign`, data, createOption(query))
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const results = {}
|
||||
|
||||
const taskSign = await request(
|
||||
'/api/vip-center-bff/task/sign',
|
||||
{},
|
||||
createOption(query, 'weapi'),
|
||||
)
|
||||
results.taskSign = taskSign.body
|
||||
|
||||
const checkinDetail = await request(
|
||||
'/api/vipnewcenter/app/level/user/checkin/history/detail',
|
||||
{
|
||||
signDayTime: Date.now(),
|
||||
type: 1,
|
||||
},
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
results.checkinDetail = checkinDetail.body
|
||||
|
||||
// 两个接口都返回 code=200 即视为打卡成功
|
||||
const signed =
|
||||
Number(results.taskSign?.code) === 200 &&
|
||||
Number(results.checkinDetail?.code) === 200
|
||||
|
||||
return {
|
||||
body: {
|
||||
code: 200,
|
||||
...results,
|
||||
signed,
|
||||
message: signed ? '黑胶乐签打卡成功' : '黑胶乐签打卡失败',
|
||||
},
|
||||
cookie: taskSign.cookie,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 黑胶乐签打卡详情
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
signDayTime: query.timestamp,
|
||||
type: '1',
|
||||
}
|
||||
return request(
|
||||
`/api/vipnewcenter/app/level/user/checkin/history/detail`,
|
||||
data,
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 黑胶乐签打卡历史 / 状态查询
|
||||
// 支持传入 type=0(用户信息栏)或 type=1(黑胶乐签)
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
type: query.type || '0',
|
||||
}
|
||||
return request(
|
||||
`/api/vipnewcenter/app/minidesk/music/sign/pc`,
|
||||
data,
|
||||
createOption(query, 'eapi'),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 黑胶乐签签到信息
|
||||
// 黑胶乐签未来签到信息
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 会员任务 - 新版
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
taskType: 'app_vip_task_center',
|
||||
userId: query.id,
|
||||
}
|
||||
return request(
|
||||
`/api/middle/vip/mission/user/progress/list`,
|
||||
data,
|
||||
createOption(query, 'xeapi'),
|
||||
)
|
||||
}
|
||||
+38
-50
@@ -2,6 +2,7 @@ const { default: axios } = require('axios')
|
||||
const fs = require('fs')
|
||||
var xml2js = require('xml2js')
|
||||
|
||||
const uploadPlugin = require('../plugins/upload')
|
||||
const createOption = require('../util/option.js')
|
||||
const { getFileExtension, readFileChunk } = require('../util/fileHelper')
|
||||
|
||||
@@ -19,15 +20,7 @@ function createDupkey() {
|
||||
return s.join('')
|
||||
}
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const ext = getFileExtension(query.songFile.name)
|
||||
const filename =
|
||||
query.songName ||
|
||||
query.songFile.name
|
||||
.replace('.' + ext, '')
|
||||
.replace(/\s/g, '')
|
||||
.replace(/\./g, '_')
|
||||
|
||||
module.exports = async (query, request, dependencies = {}) => {
|
||||
if (!query.songFile) {
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
@@ -38,6 +31,19 @@ module.exports = async (query, request) => {
|
||||
})
|
||||
}
|
||||
|
||||
const axiosRequest = dependencies.axios || axios
|
||||
const uploadImage = dependencies.uploadPlugin || uploadPlugin
|
||||
const ext = getFileExtension(query.songFile.name)
|
||||
const filename =
|
||||
query.songName ||
|
||||
query.songFile.name
|
||||
.replace('.' + ext, '')
|
||||
.replace(/\s/g, '')
|
||||
.replace(/\./g, '_')
|
||||
const coverImgId = query.imgFile
|
||||
? (await uploadImage(query, request)).imgId
|
||||
: query.coverImgId
|
||||
|
||||
const tokenRes = await request(
|
||||
`/api/nos/token/alloc`,
|
||||
{
|
||||
@@ -53,7 +59,7 @@ module.exports = async (query, request) => {
|
||||
|
||||
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
|
||||
const docId = tokenRes.body.result.docId
|
||||
const res = await axios({
|
||||
const res = await axiosRequest({
|
||||
method: 'post',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
|
||||
headers: {
|
||||
@@ -94,7 +100,7 @@ module.exports = async (query, request) => {
|
||||
)
|
||||
}
|
||||
|
||||
const res3 = await axios({
|
||||
const res3 = await axiosRequest({
|
||||
method: 'put',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||
headers: {
|
||||
@@ -117,7 +123,7 @@ module.exports = async (query, request) => {
|
||||
}
|
||||
completeStr += '</CompleteMultipartUpload>'
|
||||
|
||||
await axios({
|
||||
await axiosRequest({
|
||||
method: 'post',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||
headers: {
|
||||
@@ -128,29 +134,29 @@ module.exports = async (query, request) => {
|
||||
data: completeStr,
|
||||
})
|
||||
|
||||
const voiceData = JSON.stringify([
|
||||
{
|
||||
name: filename,
|
||||
autoPublish: query.autoPublish == 1 ? true : false,
|
||||
autoPublishText: query.autoPublishText || '',
|
||||
description: query.description,
|
||||
voiceListId: query.voiceListId,
|
||||
coverImgId,
|
||||
dfsId: docId,
|
||||
categoryId: query.categoryId,
|
||||
secondCategoryId: query.secondCategoryId,
|
||||
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
|
||||
privacy: query.privacy == 1 ? true : false,
|
||||
publishTime: query.publishTime || 0,
|
||||
orderNo: query.orderNo || 1,
|
||||
},
|
||||
])
|
||||
|
||||
await request(
|
||||
`/api/voice/workbench/voice/batch/upload/preCheck`,
|
||||
{
|
||||
dupkey: createDupkey(),
|
||||
voiceData: JSON.stringify([
|
||||
{
|
||||
name: filename,
|
||||
autoPublish: query.autoPublish == 1 ? true : false,
|
||||
autoPublishText: query.autoPublishText || '',
|
||||
description: query.description,
|
||||
voiceListId: query.voiceListId,
|
||||
coverImgId: query.coverImgId,
|
||||
dfsId: docId,
|
||||
categoryId: query.categoryId,
|
||||
secondCategoryId: query.secondCategoryId,
|
||||
composedSongs: query.composedSongs
|
||||
? query.composedSongs.split(',')
|
||||
: [],
|
||||
privacy: query.privacy == 1 ? true : false,
|
||||
publishTime: query.publishTime || 0,
|
||||
orderNo: query.orderNo || 1,
|
||||
},
|
||||
]),
|
||||
voiceData,
|
||||
},
|
||||
{
|
||||
...createOption(query),
|
||||
@@ -163,25 +169,7 @@ module.exports = async (query, request) => {
|
||||
`/api/voice/workbench/voice/batch/upload/v2`,
|
||||
{
|
||||
dupkey: createDupkey(),
|
||||
voiceData: JSON.stringify([
|
||||
{
|
||||
name: filename,
|
||||
autoPublish: query.autoPublish == 1 ? true : false,
|
||||
autoPublishText: query.autoPublishText || '',
|
||||
description: query.description,
|
||||
voiceListId: query.voiceListId,
|
||||
coverImgId: query.coverImgId,
|
||||
dfsId: docId,
|
||||
categoryId: query.categoryId,
|
||||
secondCategoryId: query.secondCategoryId,
|
||||
composedSongs: query.composedSongs
|
||||
? query.composedSongs.split(',')
|
||||
: [],
|
||||
privacy: query.privacy == 1 ? true : false,
|
||||
publishTime: query.publishTime || 0,
|
||||
orderNo: query.orderNo || 1,
|
||||
},
|
||||
]),
|
||||
voiceData,
|
||||
},
|
||||
{
|
||||
...createOption(query),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
return request(`/api/pointmall/user/sign`, data, createOption(query, 'weapi'))
|
||||
return request(
|
||||
`/api/pointmall/user/sign`,
|
||||
data,
|
||||
createOption(query, 'xeapi', 'v3'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 云贝广告任务 - 完成任务领取云贝
|
||||
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
|
||||
// POST /api/ad/power/yunbei/distribution/create
|
||||
// 参数: yunbeiAmount (单次可得云贝, 客户端从 list 接口的 singleAmount 取值, 当前为 150)
|
||||
// 返回: true (领取成功)
|
||||
//
|
||||
// 实测验证 (2026-08-05):
|
||||
// - 仅传 yunbeiAmount 即可成功领取, 无需真实听歌/看视频
|
||||
// - 单日上限 10 次 × 150 云贝 = 1500 云贝/天
|
||||
// - 超限返回 code:400 "单日完成任务数已达上限"
|
||||
// - 无频率限制, 800ms 间隔连续调用均成功
|
||||
//
|
||||
// 建议: 领取前先调 yunbei_ad_task_list 查询 times, 达到 10 次即停止
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
yunbeiAmount: query.yunbeiAmount || 150,
|
||||
}
|
||||
return request(
|
||||
`/api/ad/power/yunbei/distribution/create`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 云贝广告任务 - 查询今日任务状态
|
||||
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
|
||||
// GET /api/ad/power/yunbei/distribution/list
|
||||
// 返回: { times: 今日已完成次数, amount: 今日累计云贝, singleAmount: 单次可得云贝 }
|
||||
// 注意: 单日上限 10 次, 单次 150 云贝 (每天最多 1500)
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
return request(
|
||||
`/api/ad/power/yunbei/distribution/list`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 云贝广告任务 - 获取推荐歌曲
|
||||
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
|
||||
// POST /api/ad/power/yunbei/distribution/recommend/song
|
||||
// 参数: offset (默认 0), limit (默认 10, 客户端一次 10 首)
|
||||
// 返回: 推荐歌曲数组 [{ songId, songName, artistName, albumUrl, songChorusStartTime, likeFlag, alg }]
|
||||
// 注意: alg 均为 alg_payrec_yunBei_*, 为"听歌得云贝"任务专属推荐
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
const data = {
|
||||
offset: query.offset || 0,
|
||||
limit: query.limit || 10,
|
||||
}
|
||||
return request(
|
||||
`/api/ad/power/yunbei/distribution/recommend/song`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { banner, lyric } from '@neteasecloudmusicapienhanced/api'
|
||||
import { logger } from '../util/logger.js'
|
||||
banner({ type: 0 }).then((res) => {
|
||||
import type { Response } from '@neteasecloudmusicapienhanced/api'
|
||||
import logger from '../util/logger.js'
|
||||
banner({ type: 0 }).then((res: Response) => {
|
||||
logger.info(res)
|
||||
})
|
||||
lyric({
|
||||
id: '33894312',
|
||||
}).then((res) => {
|
||||
}).then((res: Response) => {
|
||||
logger.info(res)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const {
|
||||
login_cellphone,
|
||||
yunbei_ad_task_list,
|
||||
yunbei_ad_task_recommend_song,
|
||||
yunbei_ad_task_finish,
|
||||
} = require('../main')
|
||||
|
||||
// 云贝广告任务(听歌/看视频得云贝)完整流程示例
|
||||
// 1. list - 查询今日任务状态(次数/云贝)
|
||||
// 2. finish - 领取云贝, 仅需传 yunbeiAmount(单次 150), 无需真实听歌/看视频
|
||||
// 3. recommend/song - 可选, 获取推荐歌曲列表
|
||||
// 注意: 单日上限 10 次 x 150 = 1500 云贝/天
|
||||
|
||||
async function main() {
|
||||
const login = await login_cellphone({
|
||||
phone: '手机号',
|
||||
password: '密码',
|
||||
})
|
||||
const cookie = login.body.cookie
|
||||
|
||||
// 1. 查询今日任务状态
|
||||
const list = await yunbei_ad_task_list({ cookie })
|
||||
const { times, amount, singleAmount } = list.body
|
||||
console.log(
|
||||
`今日已完成 ${times} 次, 累计 ${amount} 云贝, 单次可得 ${singleAmount} 云贝`,
|
||||
)
|
||||
if (times >= 10) {
|
||||
console.log('已达单日上限, 明天再来吧')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 领取云贝(单次 150)
|
||||
const finish = await yunbei_ad_task_finish({
|
||||
yunbeiAmount: singleAmount,
|
||||
cookie,
|
||||
})
|
||||
console.log('领取结果:', finish.body)
|
||||
|
||||
// 3. 获取推荐歌曲(听歌任务专属推荐, 可选)
|
||||
const rcmd = await yunbei_ad_task_recommend_song({
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
cookie,
|
||||
})
|
||||
console.log('推荐歌曲:', rcmd.body.map((s) => s.songName).join(', '))
|
||||
}
|
||||
|
||||
main()
|
||||
+30
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neteasecloudmusicapienhanced/api",
|
||||
"version": "4.33.1",
|
||||
"version": "4.40.1",
|
||||
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
|
||||
"scripts": {
|
||||
"dev": "nodemon app.js",
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "eslint \"**/*.{js,ts}\"",
|
||||
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
|
||||
"prepare": "husky install",
|
||||
"docs:format": "node scripts/format-docs.js",
|
||||
"docs:check": "node scripts/format-docs.js --check",
|
||||
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
|
||||
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
|
||||
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
|
||||
@@ -41,6 +43,14 @@
|
||||
],
|
||||
"main": "main.js",
|
||||
"types": "./interface.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/issues"
|
||||
},
|
||||
"homepage": "https://neteasecloudmusicapienhanced.js.org/",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -65,44 +75,49 @@
|
||||
"data"
|
||||
],
|
||||
"dependencies": {
|
||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.1",
|
||||
"axios": "^1.16.1",
|
||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.2",
|
||||
"axios": "^1.19.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"gzip": "^0.1.0",
|
||||
"music-metadata": "^11.12.3",
|
||||
"jsdom": "^24.1.3",
|
||||
"music-metadata": "^11.14.0",
|
||||
"node-forge": "^1.4.0",
|
||||
"pac-proxy-agent": "^7.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"safe-decode-uri-component": "^1.2.1",
|
||||
"tunnel": "^0.0.6",
|
||||
"xml2js": "^0.6.2",
|
||||
"yargs": "^18.0.0"
|
||||
"yargs": "^18.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-fileupload": "^1.5.1",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^9.39.4",
|
||||
"@types/node": "25.9.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-html": "^8.1.4",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.6.0",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"globals": "^17.11.0",
|
||||
"husky": "^9.1.7",
|
||||
"intelli-espower-loader": "^1.1.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"mocha": "^11.7.6",
|
||||
"mocha": "^11.8.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"pkg": "^5.8.1",
|
||||
"power-assert": "^1.6.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+791
-444
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@neteasecloudmusicapienhanced/[email protected]'
|
||||
+495
-44
@@ -115,6 +115,182 @@
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
/* ===== 自定义选项区域 ===== */
|
||||
.section-container {
|
||||
margin-top: 20px;
|
||||
padding: 16px 20px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-container + .section-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title .count-badge {
|
||||
font-size: 11px;
|
||||
background: #ddd;
|
||||
color: #666;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dynamic-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dynamic-row .key-input {
|
||||
flex: 0 0 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.dynamic-row .value-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dynamic-row .key-input:focus,
|
||||
.dynamic-row .value-input:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #999;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: #fee;
|
||||
color: #e33;
|
||||
border-color: #fcc;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border: 1px dashed #ccc;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: #999;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* ===== 参数编辑区 Tab ===== */
|
||||
.param-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.param-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.param-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 4px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.param-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.param-panel textarea {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 350px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.param-panel textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -125,6 +301,16 @@
|
||||
<label for="uri">URI</label>
|
||||
<input type="text" id="uri" name="uri" value="/api/song/lyric/v1">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="method">请求方法</label>
|
||||
<select id="method" name="method">
|
||||
<option value="POST" selected>POST</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="crypto">加密方式</label>
|
||||
<select id="crypto" name="crypto">
|
||||
@@ -132,22 +318,45 @@
|
||||
<option value="eapi">eapi</option>
|
||||
<option value="api">api</option>
|
||||
<option value="linuxapi">linuxapi</option>
|
||||
<option value="xeapi">xeapi</option>
|
||||
<option value="" selected>(默认)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="timeout">超时(ms)</label>
|
||||
<input type="number" id="timeout" name="timeout" value="30000" min="0" step="1000" placeholder="0 表示无超时">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label></label>
|
||||
<button type="submit">发送请求</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- ===== 自定义请求头 ===== -->
|
||||
<div class="section-container" id="headersSection">
|
||||
<div class="section-title">
|
||||
请求头 (Headers)
|
||||
<span class="count-badge" id="headerCount">4</span>
|
||||
</div>
|
||||
<div id="headersList"></div>
|
||||
<button class="add-btn" onclick="addHeaderRow('', '')">+ 添加请求头</button>
|
||||
</div>
|
||||
|
||||
<div class="data-result">
|
||||
<div>
|
||||
<label for="result">响应结果</label>
|
||||
<textarea id="result" name="result" readonly></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="data">请求数据</label>
|
||||
<textarea id="data" name="data">
|
||||
<div class="param-container">
|
||||
<div class="param-header">
|
||||
<span class="param-header-title">请求参数</span>
|
||||
<div class="param-tabs">
|
||||
<button class="tab-btn active" data-mode="json" onclick="switchParamTab('json')">JSON</button>
|
||||
<button class="tab-btn" data-mode="kv" onclick="switchParamTab('kv')">键值对</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-panel" id="jsonPanel">
|
||||
<textarea id="data" name="data">
|
||||
{
|
||||
"cp": false,
|
||||
"id": "2058263032",
|
||||
@@ -159,64 +368,306 @@
|
||||
"ytv": 0,
|
||||
"yv": 0
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="param-panel" id="kvPanel" style="display:none">
|
||||
<div id="kvList"></div>
|
||||
<button class="add-btn" onclick="addKvRow('', '')">+ 添加参数</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://fastly.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
async function sendRequest() {
|
||||
document.getElementById('result').value = ""
|
||||
// ===== 动态行管理 =====
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const uri = document.getElementById('uri').value;
|
||||
const crypto = document.getElementById('crypto').value;
|
||||
const data = document.getElementById('data').value;
|
||||
function addHeaderRow(key, value) {
|
||||
const list = document.getElementById('headersList');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dynamic-row';
|
||||
row.innerHTML =
|
||||
'<input type="text" class="key-input" placeholder="Header 名称" value="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<input type="text" class="value-input" placeholder="值" value="' +
|
||||
escapeHtml(value) +
|
||||
'">' +
|
||||
'<button class="remove-btn" onclick="removeRow(this)" title="删除">✕</button>';
|
||||
list.appendChild(row);
|
||||
updateCount();
|
||||
}
|
||||
|
||||
function addKvRow(key, value) {
|
||||
const list = document.getElementById('kvList');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dynamic-row';
|
||||
row.innerHTML =
|
||||
'<input type="text" class="key-input" placeholder="参数名" value="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<input type="text" class="value-input" placeholder="值" value="' +
|
||||
escapeHtml(value) +
|
||||
'">' +
|
||||
'<button class="remove-btn" onclick="removeRow(this)" title="删除">✕</button>';
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function removeRow(btn) {
|
||||
btn.parentElement.remove();
|
||||
}
|
||||
|
||||
function collectHeaders() {
|
||||
const headers = {};
|
||||
document.querySelectorAll('#headersList .dynamic-row').forEach(function (row) {
|
||||
var key = row.querySelector('.key-input').value.trim();
|
||||
var value = row.querySelector('.value-input').value;
|
||||
if (key) headers[key] = value;
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
function collectKvData() {
|
||||
var obj = {};
|
||||
document.querySelectorAll('#kvList .dynamic-row').forEach(function (row) {
|
||||
var key = row.querySelector('.key-input').value.trim();
|
||||
var val = row.querySelector('.value-input').value;
|
||||
if (!key) return;
|
||||
// 自动类型转换
|
||||
if (val === 'true') obj[key] = true;
|
||||
else if (val === 'false') obj[key] = false;
|
||||
else if (val === 'null') obj[key] = null;
|
||||
else if (/^-?\d+(\.\d+)?$/.test(val) && !isNaN(Number(val))) obj[key] = Number(val);
|
||||
else obj[key] = val;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ===== Tab 切换 =====
|
||||
function syncJsonToKv() {
|
||||
try {
|
||||
const res = await axios({
|
||||
url: `/api?timestamp=${Date.now()}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
uri: uri,
|
||||
data: JSON.parse(data),
|
||||
crypto: crypto,
|
||||
},
|
||||
var data = JSON.parse(document.getElementById('data').value);
|
||||
var list = document.getElementById('kvList');
|
||||
list.innerHTML = '';
|
||||
Object.keys(data).forEach(function (k) {
|
||||
addKvRow(k, String(data[k]));
|
||||
});
|
||||
|
||||
document.getElementById('result').value = JSON.stringify(res.data, null, 2);
|
||||
} catch (error) {
|
||||
document.getElementById('result').value = 'Request failed: ' + error.message;
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
(function fillFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.toString()) return;
|
||||
const uri = params.get('uri');
|
||||
const crypto = params.get('crypto');
|
||||
const dataParam = params.get('data');
|
||||
if (uri) {
|
||||
document.getElementById('uri').value = uri;
|
||||
function syncKvToJson() {
|
||||
var obj = collectKvData();
|
||||
document.getElementById('data').value = JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
function switchParamTab(mode) {
|
||||
var jsonPanel = document.getElementById('jsonPanel');
|
||||
var kvPanel = document.getElementById('kvPanel');
|
||||
|
||||
if (mode === 'kv') {
|
||||
if (!syncJsonToKv()) {
|
||||
alert('JSON 格式有误,无法切换到键值对模式,请先修正 JSON');
|
||||
return;
|
||||
}
|
||||
jsonPanel.style.display = 'none';
|
||||
kvPanel.style.display = 'block';
|
||||
} else {
|
||||
syncKvToJson();
|
||||
kvPanel.style.display = 'none';
|
||||
jsonPanel.style.display = 'block';
|
||||
}
|
||||
if (crypto) {
|
||||
const cryptoSelect = document.getElementById('crypto');
|
||||
if ([...cryptoSelect.options].some((opt) => opt.value === crypto)) {
|
||||
cryptoSelect.value = crypto;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 默认请求头(直接套用后端预设值)=====
|
||||
var defaultHeaders = [
|
||||
{ key: 'Cookie', value: '' },
|
||||
{
|
||||
key: 'User-Agent',
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
|
||||
},
|
||||
{ key: 'Referer', value: 'https://music.163.com' },
|
||||
{ key: 'Content-Type', value: 'application/json' },
|
||||
];
|
||||
|
||||
// ===== 发送请求 =====
|
||||
async function sendRequest() {
|
||||
document.getElementById('result').value = '';
|
||||
|
||||
var uri = document.getElementById('uri').value;
|
||||
var method = document.getElementById('method').value;
|
||||
var crypto = document.getElementById('crypto').value;
|
||||
var timeout = parseInt(document.getElementById('timeout').value, 10) || 0;
|
||||
var headers = collectHeaders();
|
||||
|
||||
// 根据当前激活的 tab 读取数据
|
||||
var parsedData;
|
||||
var activeTab = document.querySelector('.tab-btn.active');
|
||||
if (activeTab && activeTab.dataset.mode === 'kv') {
|
||||
parsedData = collectKvData();
|
||||
} else {
|
||||
var rawData = document.getElementById('data').value;
|
||||
try {
|
||||
parsedData = rawData.trim() ? JSON.parse(rawData) : {};
|
||||
} catch (e) {
|
||||
document.getElementById('result').value =
|
||||
'请求数据格式错误:' + e.message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dataParam) {
|
||||
const decoded = dataParam;
|
||||
try {
|
||||
document.getElementById('data').value = JSON.stringify(
|
||||
JSON.parse(decoded),
|
||||
null,
|
||||
2,
|
||||
|
||||
var axiosConfig = {
|
||||
url: '/api?timestamp=' + Date.now(),
|
||||
method: method,
|
||||
};
|
||||
|
||||
if (timeout > 0) {
|
||||
axiosConfig.timeout = timeout;
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
axiosConfig.params = {
|
||||
uri: uri,
|
||||
crypto: crypto,
|
||||
data: JSON.stringify(parsedData),
|
||||
headers: Object.keys(headers).length ? JSON.stringify(headers) : undefined,
|
||||
};
|
||||
} else {
|
||||
axiosConfig.data = {
|
||||
uri: uri,
|
||||
crypto: crypto,
|
||||
data: parsedData,
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
var res = await axios(axiosConfig);
|
||||
document.getElementById('result').value = JSON.stringify(
|
||||
res.data,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
document.getElementById('data').value = decoded;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('result').value =
|
||||
'Request failed: ' + error.message;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ===== 从 URL 查询参数恢复 =====
|
||||
(function fillFromQuery() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (!params.toString()) {
|
||||
// 没有查询参数,用默认 header
|
||||
defaultHeaders.forEach(function (h) {
|
||||
addHeaderRow(h.key, h.value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var uri = params.get('uri');
|
||||
var crypto = params.get('crypto');
|
||||
var dataParam = params.get('data');
|
||||
var method = params.get('method');
|
||||
var timeout = params.get('timeout');
|
||||
var headersParam = params.get('headers');
|
||||
var kvParams = params.get('kvParams');
|
||||
var paramMode = params.get('paramMode');
|
||||
|
||||
if (uri) document.getElementById('uri').value = uri;
|
||||
if (method) {
|
||||
var methodSelect = document.getElementById('method');
|
||||
if (
|
||||
[].slice.call(methodSelect.options).some(function (opt) {
|
||||
return opt.value === method;
|
||||
})
|
||||
) {
|
||||
methodSelect.value = method;
|
||||
}
|
||||
}
|
||||
if (timeout) document.getElementById('timeout').value = timeout;
|
||||
|
||||
if (crypto) {
|
||||
var cryptoSelect = document.getElementById('crypto');
|
||||
if (
|
||||
[].slice.call(cryptoSelect.options).some(function (opt) {
|
||||
return opt.value === crypto;
|
||||
})
|
||||
) {
|
||||
cryptoSelect.value = crypto;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataParam) {
|
||||
try {
|
||||
document.getElementById('data').value = JSON.stringify(
|
||||
JSON.parse(dataParam),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (_) {
|
||||
document.getElementById('data').value = dataParam;
|
||||
}
|
||||
}
|
||||
|
||||
if (headersParam) {
|
||||
try {
|
||||
var parsed = JSON.parse(headersParam);
|
||||
Object.keys(parsed).forEach(function (k) {
|
||||
addHeaderRow(k, parsed[k]);
|
||||
});
|
||||
} catch (_) {
|
||||
// fallback: 用默认 header
|
||||
defaultHeaders.forEach(function (h) {
|
||||
addHeaderRow(h.key, h.value);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
defaultHeaders.forEach(function (h) {
|
||||
addHeaderRow(h.key, h.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (kvParams) {
|
||||
try {
|
||||
var parsed = JSON.parse(kvParams);
|
||||
// 先填充 kv 列表,再切换到 kv 模式
|
||||
Object.keys(parsed).forEach(function (k) {
|
||||
addKvRow(k, String(parsed[k]));
|
||||
});
|
||||
if (paramMode === 'kv') {
|
||||
switchParamTab('kv');
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (paramMode === 'kv' && dataParam) {
|
||||
// 从 data 参数同步到键值对模式
|
||||
try {
|
||||
var data = JSON.parse(dataParam);
|
||||
Object.keys(data).forEach(function (k) {
|
||||
addKvRow(k, String(data[k]));
|
||||
});
|
||||
switchParamTab('kv');
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API 参数和返回内容解析</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radio-item input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-item label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button + button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.result-section label {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.decode-result {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #f9f9f9;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eee;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.decode-result .json-key { color: #881391; }
|
||||
.decode-result .json-string { color: #1a7f37; }
|
||||
.decode-result .json-number { color: #0550ae; }
|
||||
.decode-result .json-boolean { color: #cf222e; }
|
||||
.decode-result .json-null { color: #656d76; }
|
||||
|
||||
.example-section {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.example-section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.example-section img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* new elements for multi-crypto support */
|
||||
.crypto-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.crypto-bar .bar-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.crypto-btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.crypto-btn:hover {
|
||||
border-color: #333;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.crypto-btn.active {
|
||||
border-color: #333;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.crypto-btn .badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
.crypto-btn.active .badge {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.mode-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-row .mode-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.mode-row .radio-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #999;
|
||||
}
|
||||
|
||||
.info-hint.warn {
|
||||
border-left-color: #e67e22;
|
||||
background: #fef9f0;
|
||||
color: #d35400;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-row button + button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
button.btn-secondary {
|
||||
background: #e8e8e8;
|
||||
color: #444;
|
||||
}
|
||||
button.btn-secondary:hover {
|
||||
background: #d5d5d5;
|
||||
}
|
||||
button.btn-secondary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
button.btn-success {
|
||||
background: #4caf50;
|
||||
}
|
||||
button.btn-success:hover {
|
||||
background: #43a047;
|
||||
}
|
||||
button.btn-success:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.result-section .section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-section .section-header label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 12px;
|
||||
padding: 3px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.example-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.example-card {
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.example-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.example-card pre {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e8e8e8;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.example-card .tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tag-eapi { background: #e3f2fd; color: #1565c0; }
|
||||
.tag-weapi { background: #fce4ec; color: #c62828; }
|
||||
.tag-linuxapi { background: #e8f5e9; color: #2e7d32; }
|
||||
.tag-xeapi { background: #f3e5f5; color: #7b1fa2; }
|
||||
.tag-api { background: #fff3e0; color: #e65100; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.example-grid { grid-template-columns: 1fr; }
|
||||
.crypto-bar { gap: 4px; }
|
||||
.crypto-btn { padding: 4px 10px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="container">
|
||||
<h1>API 参数和返回内容解析</h1>
|
||||
|
||||
<!-- 加密方式选择 -->
|
||||
<div class="crypto-bar">
|
||||
<span class="bar-label">加密方式</span>
|
||||
<button
|
||||
v-for="c in cryptoList"
|
||||
:key="c.value"
|
||||
class="crypto-btn"
|
||||
:class="{ active: crypto === c.value }"
|
||||
@click="selectCrypto(c.value)"
|
||||
>
|
||||
{{ c.label }}
|
||||
<span class="badge">{{ c.badge }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mode-row">
|
||||
<span class="mode-label">数据类型</span>
|
||||
<div class="radio-group">
|
||||
<div class="radio-item">
|
||||
<input
|
||||
type="radio"
|
||||
id="mode-req"
|
||||
:value="true"
|
||||
v-model="isReq"
|
||||
:disabled="cryptoInfo.reqDisabled"
|
||||
/>
|
||||
<label for="mode-req">请求数据 request</label>
|
||||
</div>
|
||||
<div class="radio-item">
|
||||
<input
|
||||
type="radio"
|
||||
id="mode-resp"
|
||||
:value="false"
|
||||
v-model="isReq"
|
||||
:disabled="cryptoInfo.respDisabled"
|
||||
/>
|
||||
<label for="mode-resp">返回数据 response</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dataInput">{{ cryptoInfo.inputLabel }}</label>
|
||||
<textarea
|
||||
id="dataInput"
|
||||
v-model="encryptedData"
|
||||
:rows="crypto === 'xeapi' ? 8 : 10"
|
||||
:placeholder="cryptoInfo.placeholder"
|
||||
></textarea>
|
||||
<div class="info-hint" :class="{ warn: cryptoInfo.warning }">
|
||||
{{ cryptoInfo.hint }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button @click="decrypt">解密</button>
|
||||
<button class="btn-success" @click="sendToApi" :disabled="!canSend">
|
||||
填入 API 调试
|
||||
</button>
|
||||
<button class="btn-secondary" @click="loadExample">加载示例</button>
|
||||
<button class="btn-secondary" @click="clearAll">清空</button>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="section-header">
|
||||
<label>解密结果:</label>
|
||||
<button
|
||||
v-if="result && result !== '{}' && result !== 'null'"
|
||||
class="copy-btn"
|
||||
@click="copyResult"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<pre class="decode-result" v-html="highlightedResult"></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h2>使用示例</h2>
|
||||
<img src="/static/eapi_params.png" alt="请求示例" />
|
||||
<img src="/static/eapi_response.png" alt="响应示例" />
|
||||
|
||||
<div class="example-grid">
|
||||
<div class="example-card">
|
||||
<span class="tag tag-eapi">eapi</span>
|
||||
<h3>请求参数解密</h3>
|
||||
<pre>输入 hex 字符串,解密后得到请求 URL 和参数</pre>
|
||||
<button class="copy-btn" @click="loadExampleData('eapi_req')">加载示例</button>
|
||||
</div>
|
||||
<div class="example-card">
|
||||
<span class="tag tag-linuxapi">linuxapi</span>
|
||||
<h3>请求 eparams 解密</h3>
|
||||
<pre>AES-ECB 解密 linuxapi 的 eparams 参数(hex)</pre>
|
||||
<button class="copy-btn" @click="loadExampleData('linuxapi_req')">加载示例</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://fastly.jsdelivr.net/npm/axios"></script>
|
||||
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
|
||||
|
||||
<script>
|
||||
const cryptoInfos = {
|
||||
eapi: {
|
||||
label: 'eapi',
|
||||
badge: 'AES-ECB',
|
||||
inputLabel: '十六进制字符串(Hex)',
|
||||
placeholder: '粘贴 eapi 加密后的 Hex 字符串...',
|
||||
hint: 'eapi 使用 AES-ECB 加密,密钥 e82ckenh8dichen8。请求格式: url-36cd479b6b5-data-36cd479b6b5-md5',
|
||||
warning: false,
|
||||
reqDisabled: false,
|
||||
respDisabled: false,
|
||||
},
|
||||
weapi: {
|
||||
label: 'weapi',
|
||||
badge: 'AES-CBC+RSA',
|
||||
inputLabel: '十六进制字符串(Hex)',
|
||||
placeholder: '粘贴 weapi 返回数据的 Hex 字符串(e_r=true 时)...',
|
||||
hint: 'weapi 请求解密需要 RSA 私钥,暂不支持。仅支持返回数据解密(同 eapi 的 AES-ECB)',
|
||||
warning: true,
|
||||
reqDisabled: true,
|
||||
respDisabled: false,
|
||||
},
|
||||
linuxapi: {
|
||||
label: 'linuxapi',
|
||||
badge: 'AES-ECB',
|
||||
inputLabel: '十六进制字符串(Hex)',
|
||||
placeholder: '粘贴 linuxapi 的 eparams (Hex 格式)...',
|
||||
hint: 'linuxapi 请求 eparams 使用 AES-ECB 加密,密钥 rFgB&h#%2?^eDg:Q。返回为明文 JSON',
|
||||
warning: false,
|
||||
reqDisabled: false,
|
||||
respDisabled: false,
|
||||
},
|
||||
xeapi: {
|
||||
label: 'xeapi',
|
||||
badge: 'X25519+AES',
|
||||
inputLabel: 'Base64 编码字符串',
|
||||
placeholder: '粘贴 xeapi 返回数据的 Base64 原始二进制...',
|
||||
hint: 'xeapi 请求涉及 X25519 ECDH 密钥交换,暂不支持。返回数据使用 AES-ECB + eapiKey 加密,可能带 gzip',
|
||||
warning: true,
|
||||
reqDisabled: true,
|
||||
respDisabled: false,
|
||||
},
|
||||
api: {
|
||||
label: 'api',
|
||||
badge: '明文',
|
||||
inputLabel: 'JSON 文本',
|
||||
placeholder: '粘贴 api 的请求或返回 JSON...',
|
||||
hint: 'api(明文)不经加密,直接传递 JSON 数据',
|
||||
warning: false,
|
||||
reqDisabled: false,
|
||||
respDisabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const examples = {
|
||||
eapi_req:
|
||||
'AD96DDB984491E79B6F429DD650C6E2AE524627AC223AC9A123C66BB0997965950FED137544A93DFC718E16F57C8C121AF537086F395570A5602A3922366D11964DAFACD7830AACABF62E5650E67F457E79C1D2E13502391FC3487216CC5BF8681843FCB8E05559487EB18AAC1BE0EFEA4F7B6A050478366153A9426C238B8869600B275704555A9EB94C92E4F3FDABE9E0BCE07645410D0AA7B675698A4CAE6CD3620633ABF0B849A4244CC8DFC5DB2646D5EA9B3954E62BFEF19AFEAFDDC34E55C3E9A1DD3167CF53D443617108141',
|
||||
linuxapi_req:
|
||||
'A0D9583F4C5FF68DE851D2893A49DE98CC059A8845B664AA2459CEA7271A2C0E5BCA8A188E1BE398DB4C9A3FC117E19C9BAC491F454D17D403C2389476AB0FF4296B00294AD1EBDA141C188DF918F6B9599DAA5739928FD52B4AE580D8657903CE3C6633D2E46AD242408AE219B8191E',
|
||||
}
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
crypto: 'eapi',
|
||||
encryptedData: '',
|
||||
result: '{}',
|
||||
isReq: true,
|
||||
cryptoList: [
|
||||
{ value: 'eapi', label: 'eapi', badge: 'AES-ECB' },
|
||||
{ value: 'weapi', label: 'weapi', badge: 'AES-CBC+RSA' },
|
||||
{ value: 'linuxapi', label: 'linuxapi', badge: 'AES-ECB' },
|
||||
{ value: 'xeapi', label: 'xeapi', badge: 'X25519+AES' },
|
||||
{ value: 'api', label: 'api', badge: '明文' },
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadExample()
|
||||
},
|
||||
computed: {
|
||||
cryptoInfo() {
|
||||
return cryptoInfos[this.crypto] || cryptoInfos.eapi
|
||||
},
|
||||
isRequestMode() {
|
||||
return this.isReq === true || this.isReq === 'true'
|
||||
},
|
||||
canSend() {
|
||||
if (!this.isRequestMode) return false
|
||||
if (!this.result || this.result === '{}' || this.result === 'null') return false
|
||||
try {
|
||||
JSON.parse(this.result)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
highlightedResult() {
|
||||
if (!this.result || this.result === '') return ''
|
||||
try {
|
||||
const parsed = typeof this.result === 'string' ? JSON.parse(this.result) : this.result
|
||||
const json = JSON.stringify(parsed, null, 2)
|
||||
return this.syntaxHighlight(json)
|
||||
} catch (error) {
|
||||
return this.escapeHtml(String(this.result))
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
crypto(val) {
|
||||
const info = cryptoInfos[val]
|
||||
if (this.isReq && info.reqDisabled) {
|
||||
this.isReq = false
|
||||
} else if (!this.isReq && info.respDisabled) {
|
||||
this.isReq = true
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectCrypto(val) {
|
||||
this.crypto = val
|
||||
},
|
||||
syntaxHighlight(json) {
|
||||
const escaped = this.escapeHtml(json)
|
||||
return escaped
|
||||
.replace(/(?:"(?:\\.|[^"\\])*")\s*:/g, '<span class="json-key">$&</span>')
|
||||
.replace(/:(\s*)(?:"(?:\\.|[^"\\])*")/g, ':<span class="json-string">$1$2</span>')
|
||||
.replace(/:\s*(\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
|
||||
.replace(/:\s*(true|false)/g, ': <span class="json-boolean">$1</span>')
|
||||
.replace(/:\s*(null)/g, ': <span class="json-null">$1</span>')
|
||||
},
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
},
|
||||
formatResult(value) {
|
||||
if (value == null || value === '') return ''
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
},
|
||||
async decrypt() {
|
||||
if (!this.encryptedData || !this.encryptedData.trim()) {
|
||||
alert('请先输入要解密的数据')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await axios({
|
||||
url: `/decrypt?crypto=${this.crypto}&isReq=${this.isReq}×tamp=${Date.now()}`,
|
||||
method: 'post',
|
||||
data: { data: this.encryptedData },
|
||||
})
|
||||
this.result = JSON.stringify(res.data.data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const msg = error?.response?.data?.message || error?.message || '解密失败,数据格式错误'
|
||||
alert(msg)
|
||||
this.result = '{}'
|
||||
}
|
||||
},
|
||||
sendToApi() {
|
||||
if (!this.canSend) return
|
||||
const payload = JSON.parse(this.result)
|
||||
const params = new URLSearchParams()
|
||||
const uri = payload.uri || payload.url || payload.path || ''
|
||||
params.set('uri', uri)
|
||||
params.set('crypto', this.crypto)
|
||||
const data = payload.params || payload.data || payload.body || payload.payload || payload.request || {}
|
||||
params.set('data', JSON.stringify(data))
|
||||
window.open(`/api.html?${params.toString()}`, '_blank')
|
||||
},
|
||||
loadExample() {
|
||||
switch (this.crypto) {
|
||||
case 'eapi':
|
||||
this.encryptedData = examples.eapi_req
|
||||
break
|
||||
case 'linuxapi':
|
||||
this.isReq = true
|
||||
this.encryptedData = examples.linuxapi_req
|
||||
break
|
||||
case 'xeapi':
|
||||
this.isReq = false
|
||||
this.encryptedData = ''
|
||||
break
|
||||
default:
|
||||
this.encryptedData = ''
|
||||
}
|
||||
if (this.encryptedData) this.decrypt()
|
||||
},
|
||||
loadExampleData(type) {
|
||||
switch (type) {
|
||||
case 'eapi_req':
|
||||
this.crypto = 'eapi'
|
||||
this.isReq = true
|
||||
this.encryptedData = examples.eapi_req
|
||||
break
|
||||
case 'linuxapi_req':
|
||||
this.crypto = 'linuxapi'
|
||||
this.isReq = true
|
||||
this.encryptedData = examples.linuxapi_req
|
||||
break
|
||||
}
|
||||
if (this.encryptedData) this.decrypt()
|
||||
},
|
||||
clearAll() {
|
||||
this.encryptedData = ''
|
||||
this.result = '{}'
|
||||
},
|
||||
copyResult() {
|
||||
const text = typeof this.result === 'string' ? this.result : JSON.stringify(this.result, null, 2)
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('已复制到剪贴板')
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
app.mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -255,9 +255,13 @@
|
||||
}
|
||||
logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`)
|
||||
for (var song of resp.data.result) {
|
||||
logs.write(
|
||||
`[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>`
|
||||
)
|
||||
var a = document.createElement('a');
|
||||
a.href = 'https://music.163.com/song?id=' + encodeURIComponent(song.song.id);
|
||||
a.target = '_blank';
|
||||
a.textContent = song.song.name + ' - ' + song.song.album.name + ' (' + (song.startTime / 1000) + 's)';
|
||||
logs.appendChild(document.createTextNode('[result] '));
|
||||
logs.appendChild(a);
|
||||
logs.appendChild(document.createElement('br'));
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# 网易云音乐 API Enhanced
|
||||
# NeteaseCloudMusicAPI Enhanced
|
||||
|
||||
> 🔍 网易云音乐API Node.js服务的复兴项目
|
||||
> 🎉 全网收集最全的网易云音乐api接口 基于[NeteaseCloudMusicAPI](https://github.com/binaryify/NeteaseCloudMusicApi)的复刻版本
|
||||
|
||||
- 基于原版网易云API新增更多有趣的功能
|
||||
- 具备登录接口,多达200多个接口
|
||||
- 更完善的文档
|
||||
- ⚡ 四种加密模式 · 后端代理
|
||||
- 🪛 具备多达200多个接口
|
||||
- 📄 更完善的文档
|
||||
|
||||
|
||||
[Github](https://github.com/neteasecloudmusicapienhanced/api-enhanced)
|
||||
[Get Started](#neteasecloudmusicapienhanced)
|
||||
[前往本家](https://github.com/binaryify/NeteaseCloudMusicApi)
|
||||
[快速开始](#neteasecloudmusicapienhanced)
|
||||
|
||||

|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+581
-54
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 858 KiB |
@@ -108,7 +108,7 @@
|
||||
name: '网易云音乐 API Enhanced',
|
||||
repo: 'https://github.com/neteasecloudmusicapienhanced/api-enhanced',
|
||||
coverpage: true,
|
||||
homepage: 'https://cdn.jsdelivr.net/gh/NeteaseCloudMusicApiEnhanced/api-enhanced@main/public/docs/home.md',
|
||||
homepage: 'home.md',
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/[email protected]/lib/docsify.min.js"></script>
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>eapi 参数和返回内容解析</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radio-item input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-item label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button + button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.result-section label {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.decode-result {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #f9f9f9;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eee;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.example-section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.example-section img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="container">
|
||||
<h1>eapi 参数和返回内容解析</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hexString">十六进制字符串</label>
|
||||
<textarea id="hexString" v-model="hexString" rows="10"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="radio-group">
|
||||
<div class="radio-item">
|
||||
<input type="radio" id="req" name="format" v-model="isReq" value="true">
|
||||
<label for="req">请求数据 request params</label>
|
||||
</div>
|
||||
<div class="radio-item">
|
||||
<input type="radio" id="resp" name="format" v-model="isReq" value="false">
|
||||
<label for="resp">返回数据 response 二进制数据</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="decrypt">解密</button>
|
||||
<button @click="sendToApi" :disabled="!canSend" :class="[{ 'opacity-50 cursor-not-allowed pointer-events-none': !canSend },]">填入 API 调试</button>
|
||||
|
||||
<div class="result-section">
|
||||
<label>解密结果:</label>
|
||||
<pre class="decode-result">{{ formatResult(result) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="example-section">
|
||||
<h2>使用示例</h2>
|
||||
<img src="/static/eapi_params.png" alt="请求示例" />
|
||||
<img src="/static/eapi_response.png" alt="响应示例" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://fastly.jsdelivr.net/npm/axios"></script>
|
||||
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
|
||||
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
hexString: 'AD96DDB984491E79B6F429DD650C6E2AE524627AC223AC9A123C66BB0997965950FED137544A93DFC718E16F57C8C121AF537086F395570A5602A3922366D11964DAFACD7830AACABF62E5650E67F457E79C1D2E13502391FC3487216CC5BF8681843FCB8E05559487EB18AAC1BE0EFEA4F7B6A050478366153A9426C238B8869600B275704555A9EB94C92E4F3FDABE9E0BCE07645410D0AA7B675698A4CAE6CD3620633ABF0B849A4244CC8DFC5DB2646D5EA9B3954E62BFEF19AFEAFDDC34E55C3E9A1DD3167CF53D443617108141',
|
||||
result: '{}',
|
||||
isReq: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.decrypt()
|
||||
},
|
||||
computed: {
|
||||
isRequestMode() {
|
||||
return this.isReq === true || this.isReq === 'true'
|
||||
},
|
||||
canSend() {
|
||||
if (!this.isRequestMode) return false
|
||||
if (!this.result || this.result === '{}' || this.result === 'null') return false
|
||||
try {
|
||||
JSON.parse(this.result)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatResult(value) {
|
||||
if (value == null || value === '') return ''
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
},
|
||||
async decrypt() {
|
||||
try {
|
||||
const res = await axios({
|
||||
url: `/eapi/decrypt?isReq=${this.isReq}×tamp=${Date.now()}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
hexString: this.hexString
|
||||
}
|
||||
})
|
||||
this.result = JSON.stringify(res.data.data)
|
||||
console.log(res.data);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert(error?.response?.data?.message || '解密失败,数据格式错误')
|
||||
}
|
||||
},
|
||||
sendToApi() {
|
||||
if (!this.canSend) return
|
||||
const payload = JSON.parse(this.result)
|
||||
const params = new URLSearchParams()
|
||||
params.set('uri', payload.uri || payload.url || payload.path || '')
|
||||
params.set('crypto', 'eapi')
|
||||
const data =
|
||||
payload.params ||
|
||||
payload.data ||
|
||||
payload.body ||
|
||||
payload.payload ||
|
||||
payload.request ||
|
||||
{}
|
||||
params.set('data', JSON.stringify(data))
|
||||
window.open(`/api.html?${params.toString()}`, '_blank')
|
||||
},
|
||||
}
|
||||
})
|
||||
app.mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,496 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>免费听 — 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #333;
|
||||
--muted: #666;
|
||||
--border: #ddd;
|
||||
--bg: #f5f5f5;
|
||||
--panel: #ffffff;
|
||||
--accent: #333;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 40px auto; padding: 0 20px; }
|
||||
@media (max-width: 480px) {
|
||||
.container { margin: 20px auto; padding: 0 16px; }
|
||||
header.site-header h1 { font-size: 22px; }
|
||||
.block { padding: 16px; }
|
||||
}
|
||||
header.site-header { margin-bottom: 24px; }
|
||||
header.site-header h1 { font-size: 24px; font-weight: 600; margin: 0; }
|
||||
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
|
||||
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||
.block h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
|
||||
.block .desc { margin: 0 0 12px; color: var(--muted); font-size: 13px; }
|
||||
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 200px; overflow: auto; }
|
||||
pre.data { max-height: 360px; }
|
||||
.row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
|
||||
button:hover { background: #eee; }
|
||||
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
|
||||
button.primary:hover { opacity: .85; }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
button.danger { background: #a11; color: #fff; border-color: #a11; }
|
||||
button.danger:hover { opacity: .85; }
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); }
|
||||
.tag.ok { background: #e6f7e6; color: #1a7a1a; border-color: #b7e6b7; }
|
||||
.tag.no { background: #ffe6e6; color: #a11; border-color: #f5c6c6; }
|
||||
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
|
||||
.flex { display: flex; align-items: center; gap: 10px; }
|
||||
.flex.sb { justify-content: space-between; }
|
||||
.step { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.step .num { width: 26px; height: 26px; border-radius: 50%; background: var(--fg); color: var(--panel); display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; flex-shrink: 0; margin-top: 1px; }
|
||||
.step .num.done { background: #1a7a1a; }
|
||||
.step .body { flex: 1; min-width: 0; }
|
||||
input[type=text] { width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); font-size: 14px; font-family: inherit; margin-top: 6px; }
|
||||
input[type=text]:focus { outline: 2px solid var(--fg); outline-offset: -1px; border-color: transparent; }
|
||||
footer.site-footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
|
||||
footer.site-footer a { color: var(--fg); text-decoration: none; }
|
||||
footer.site-footer a:hover { border-bottom: 1px dotted var(--fg); }
|
||||
.info-grid { display: grid; grid-template-columns: 80px 1fr; gap: 4px 12px; font-size: 13px; margin-top: 8px; }
|
||||
.info-grid .lbl { color: var(--muted); }
|
||||
|
||||
/* ---- 剩余时长计时器 ---- */
|
||||
.timer-wrap { text-align: center; padding: 12px 0 4px; }
|
||||
.timer-row { display: flex; align-items: baseline; justify-content: center; gap: 2px; font-variant-numeric: tabular-nums; }
|
||||
.timer-num { font-size: 36px; font-weight: 700; letter-spacing: 1px; line-height: 1.2; font-family: 'SF Mono', 'Courier New', monospace; min-width: 2ch; text-align: center; }
|
||||
.timer-label { font-size: 13px; color: var(--muted); margin-right: 12px; }
|
||||
.timer-label:last-child { margin-right: 0; }
|
||||
.timer-sep { font-size: 28px; font-weight: 300; color: #bbb; margin: 0 2px; }
|
||||
.timer-unit { display: flex; flex-direction: column; align-items: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header class="site-header">
|
||||
<h1>🎧 免费听 · 看广告获取时长</h1>
|
||||
<p class="sub">模拟 App 内看激励视频广告 → 领取免费听权益的完整流程</p>
|
||||
</header>
|
||||
|
||||
<!-- 步骤 1: 注册 Token -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s1num">1</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>注册反作弊 Token</h3>
|
||||
<span id="s1tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">调用易盾接口获取实时 token,广告接口需要此 token 才能正常响应</p>
|
||||
<pre id="s1pre">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step1" class="primary">① 注册 Token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2: 获取广告 -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s2num">2</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>获取广告 & 提取 reqId</h3>
|
||||
<span id="s2tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">请求广告位,从返回的 extJson.contextInfo.req_id 提取用于领取权益的标识</p>
|
||||
<pre id="s2pre" class="data">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step2" class="primary" disabled>② 获取广告</button>
|
||||
<button id="step2b" disabled>换一个广告位</button>
|
||||
</div>
|
||||
<div class="info-grid" id="s2info" style="display:none">
|
||||
<div class="lbl">广告 ID</div><div id="s2adId">-</div>
|
||||
<div class="lbl">reqId</div><div id="s2reqId">-</div>
|
||||
<div class="lbl">文案</div><div id="s2text">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 3: 领取权益 -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s3num">3</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>领取免费听权益</h3>
|
||||
<span id="s3tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">传入 reqId 及相关参数,领取免费听权益(模拟点击广告后的领取请求)</p>
|
||||
<pre id="s3pre" class="data">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step3" class="primary" disabled>③ 领取权益</button>
|
||||
</div>
|
||||
<div class="info-grid" id="s3info" style="display:none">
|
||||
<div class="lbl">结果</div><div id="s3result">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一键全流程 -->
|
||||
<div class="block">
|
||||
<div class="flex sb">
|
||||
<h3>一键运行</h3>
|
||||
<span id="allTag" class="tag no">就绪</span>
|
||||
</div>
|
||||
<p class="desc" style="margin-top:4px">按顺序自动执行以上三个步骤</p>
|
||||
<div class="row">
|
||||
<button id="runAll" class="primary">▶ 一键获取免费听时长</button>
|
||||
<button id="clearAll" class="danger">✕ 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剩余时长计时器 -->
|
||||
<div class="block" id="rightsBlock">
|
||||
<div class="flex sb">
|
||||
<h3 id="rightsTitleDisplay">🕐 免费听时长剩余</h3>
|
||||
<span id="rightsTag" class="tag no">未查询</span>
|
||||
</div>
|
||||
<p class="desc" id="rightsSubDesc">查询你的免费听权益剩余时长</p>
|
||||
<div class="timer-wrap">
|
||||
<div class="timer-row" id="timerRow">
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerDays">--</span>
|
||||
<span class="timer-label">天</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerHours">--</span>
|
||||
<span class="timer-label">时</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerMinutes">--</span>
|
||||
<span class="timer-label">分</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerSeconds">--</span>
|
||||
<span class="timer-label">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="justify-content:center">
|
||||
<button id="queryRightsBtn" class="primary">⟳ 查询剩余时长</button>
|
||||
<button id="claim30Btn">领取30分钟</button>
|
||||
</div>
|
||||
<div class="info-grid" id="rightsInfo" style="display:none">
|
||||
<div class="lbl">状态</div><div id="rightsStatus">-</div>
|
||||
<div class="lbl">结束时间</div><div id="rightsEndTimeDisplay">-</div>
|
||||
<div class="lbl">今日已覆盖</div><div id="rightsCoverToday">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<a href="/">← 返回首页</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const base = location.origin
|
||||
|
||||
function setTag(id, type, text) {
|
||||
const el = $(id)
|
||||
el.className = 'tag ' + type
|
||||
el.textContent = text
|
||||
}
|
||||
|
||||
function setNum(id, done) {
|
||||
$(id).className = 'num' + (done ? ' done' : '')
|
||||
}
|
||||
|
||||
function nocache(url) {
|
||||
const sep = url.indexOf('?') > -1 ? '&' : '?'
|
||||
return url + sep + '_t=' + Date.now()
|
||||
}
|
||||
|
||||
// ===== 步骤 1: 注册 Token =====
|
||||
async function step1() {
|
||||
const btn = $('step1')
|
||||
btn.disabled = true
|
||||
setTag('s1tag', 'busy', '注册中…')
|
||||
setNum('s1num', false)
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/register/checktoken/v3?refresh=1'))
|
||||
const data = await res.json()
|
||||
$('s1pre').textContent = JSON.stringify(data, null, 2)
|
||||
if (data.token) {
|
||||
setTag('s1tag', 'ok', '已注册')
|
||||
setNum('s1num', true)
|
||||
$('step2').disabled = false
|
||||
$('step2b').disabled = false
|
||||
return data.token
|
||||
}
|
||||
setTag('s1tag', 'no', '失败')
|
||||
return null
|
||||
} catch (e) {
|
||||
$('s1pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s1tag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 步骤 2: 获取广告 =====
|
||||
async function step2(typeId) {
|
||||
const btn = $('step2')
|
||||
const btnB = $('step2b')
|
||||
btn.disabled = btnB.disabled = true
|
||||
setTag('s2tag', 'busy', '获取中…')
|
||||
setNum('s2num', false)
|
||||
$('s2info').style.display = 'none'
|
||||
try {
|
||||
let url = base + '/ad/get' + (typeId ? '?type_ids=' + encodeURIComponent(typeId) : '')
|
||||
const res = await fetch(nocache(url))
|
||||
const data = await res.json()
|
||||
$('s2pre').textContent = JSON.stringify(data, null, 2)
|
||||
|
||||
if (data.code === 200 && data.extra && data.extra.reqId) {
|
||||
setTag('s2tag', 'ok', '已获取')
|
||||
setNum('s2num', true)
|
||||
$('s2info').style.display = 'grid'
|
||||
|
||||
const adKeys = data.ads ? Object.keys(data.ads) : []
|
||||
let adId = '-', text = '-'
|
||||
if (adKeys.length > 0) {
|
||||
const ad = data.ads[adKeys[0]]
|
||||
adId = ad.ad_id || '-'
|
||||
text = ad.text || '-'
|
||||
}
|
||||
|
||||
$('s2adId').textContent = adId
|
||||
$('s2reqId').textContent = data.extra.reqId
|
||||
$('s2text').textContent = text
|
||||
|
||||
$('step3').disabled = false
|
||||
return data.extra.reqId
|
||||
}
|
||||
setTag('s2tag', 'no', '未获取到 reqId')
|
||||
return null
|
||||
} catch (e) {
|
||||
$('s2pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s2tag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btnB.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('step2').onclick = () => step2()
|
||||
$('step2b').onclick = () => step2('["400002_0"]')
|
||||
|
||||
// ===== 步骤 3: 领取权益 =====
|
||||
async function step3() {
|
||||
const btn = $('step3')
|
||||
btn.disabled = true
|
||||
setTag('s3tag', 'busy', '领取中…')
|
||||
setNum('s3num', false)
|
||||
$('s3info').style.display = 'none'
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/ad/listening/rights/gain'))
|
||||
const data = await res.json()
|
||||
$('s3pre').textContent = JSON.stringify(data, null, 2)
|
||||
|
||||
if (data.code === 200) {
|
||||
setTag('s3tag', 'ok', '已领取')
|
||||
setNum('s3num', true)
|
||||
$('s3info').style.display = 'grid'
|
||||
const body = data.data || {}
|
||||
$('s3result').textContent = JSON.stringify(body).substring(0, 200)
|
||||
// 领取成功后自动刷新剩余时长
|
||||
queryRights()
|
||||
return true
|
||||
}
|
||||
setTag('s3tag', 'no', '领取失败')
|
||||
return false
|
||||
} catch (e) {
|
||||
$('s3pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s3tag', 'no', e.message)
|
||||
return false
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('step3').onclick = step3
|
||||
|
||||
// ===== 一键运行 =====
|
||||
$('runAll').onclick = async function () {
|
||||
const btn = this
|
||||
btn.disabled = true
|
||||
setTag('allTag', 'busy', '运行中…')
|
||||
|
||||
await step1()
|
||||
await step2()
|
||||
await step3()
|
||||
|
||||
setTag('allTag', 'ok', '完成')
|
||||
btn.disabled = false
|
||||
}
|
||||
|
||||
// ===== 清空 =====
|
||||
$('clearAll').onclick = function () {
|
||||
const steps = ['s1', 's2', 's3']
|
||||
const tags = { s1: '未开始', s2: '未开始', s3: '未开始' }
|
||||
steps.forEach(s => {
|
||||
setTag(s + 'tag', 'no', tags[s])
|
||||
setNum(s + 'num', false)
|
||||
$(s + 'pre').textContent = '(尚未执行)'
|
||||
const info = $(s + 'info')
|
||||
if (info) info.style.display = 'none'
|
||||
})
|
||||
$('step1').disabled = false
|
||||
$('step2').disabled = true
|
||||
$('step2b').disabled = true
|
||||
$('step3').disabled = true
|
||||
setTag('allTag', 'no', '就绪')
|
||||
$('runAll').disabled = false
|
||||
|
||||
// 重置计时器
|
||||
stopCountdown()
|
||||
$('timerDays').textContent = '--'
|
||||
$('timerHours').textContent = '--'
|
||||
$('timerMinutes').textContent = '--'
|
||||
$('timerSeconds').textContent = '--'
|
||||
setTag('rightsTag', 'no', '未查询')
|
||||
$('rightsInfo').style.display = 'none'
|
||||
$('rightsTitleDisplay').textContent = '🕐 免费听时长剩余'
|
||||
$('rightsSubDesc').textContent = '查询你的免费听权益剩余时长'
|
||||
$('runAll').disabled = true
|
||||
}
|
||||
|
||||
// ===== 剩余时长计时器 =====
|
||||
let countdownTimer = null
|
||||
let remainingMs = 0
|
||||
|
||||
/** 毫秒 → { d, h, m, s } */
|
||||
function parseMs(ms) {
|
||||
if (ms <= 0) return { d: 0, h: 0, m: 0, s: 0 }
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
return {
|
||||
d: Math.floor(totalSec / 86400),
|
||||
h: Math.floor((totalSec % 86400) / 3600),
|
||||
m: Math.floor((totalSec % 3600) / 60),
|
||||
s: totalSec % 60
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimer(ms) {
|
||||
const t = parseMs(ms)
|
||||
$('timerDays').textContent = String(t.d).padStart(2, '0')
|
||||
$('timerHours').textContent = String(t.h).padStart(2, '0')
|
||||
$('timerMinutes').textContent = String(t.m).padStart(2, '0')
|
||||
$('timerSeconds').textContent = String(t.s).padStart(2, '0')
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(ms) {
|
||||
stopCountdown()
|
||||
remainingMs = Math.max(0, ms)
|
||||
renderTimer(remainingMs)
|
||||
if (remainingMs <= 0) {
|
||||
setTag('rightsTag', 'no', '已用完')
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingMs = Math.max(0, remainingMs - 1000)
|
||||
renderTimer(remainingMs)
|
||||
if (remainingMs <= 0) {
|
||||
stopCountdown()
|
||||
setTag('rightsTag', 'no', '已过期')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function queryRights() {
|
||||
const btn = $('queryRightsBtn')
|
||||
btn.disabled = true
|
||||
setTag('rightsTag', 'busy', '查询中…')
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/ad/listening/rights'))
|
||||
const data = await res.json()
|
||||
if (data.code === 200 && data.data) {
|
||||
const d = data.data
|
||||
$('rightsTitleDisplay').textContent = '🕐 ' + (d.title || '免费听时长剩余')
|
||||
$('rightsSubDesc').textContent = d.vipInfoContent || '查询你的免费听权益剩余时长'
|
||||
$('rightsInfo').style.display = 'grid'
|
||||
|
||||
// 状态
|
||||
const status = d.status || ''
|
||||
$('rightsStatus').textContent = status
|
||||
|
||||
// 结束时间
|
||||
if (d.rightsEndTime) {
|
||||
const endDate = new Date(d.rightsEndTime)
|
||||
$('rightsEndTimeDisplay').textContent = endDate.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
|
||||
// 今日已覆盖
|
||||
$('rightsCoverToday').textContent = d.rightsCoverToday ? '是 ✓' : '否'
|
||||
|
||||
// 倒计时
|
||||
if (d.rightsRemainingTime > 0) {
|
||||
startCountdown(d.rightsRemainingTime)
|
||||
setTag('rightsTag', 'ok', '已解锁 ✓')
|
||||
} else {
|
||||
renderTimer(0)
|
||||
setTag('rightsTag', 'no', '已用完')
|
||||
}
|
||||
|
||||
// 领取按钮(仅当 step3 可用时启用)
|
||||
if (d.cardContent && d.cardContent.actionTitle && $('step3').disabled === false) {
|
||||
$('claim30Btn').disabled = false
|
||||
$('claim30Btn').textContent = d.cardContent.actionTitle
|
||||
} else {
|
||||
$('claim30Btn').disabled = true
|
||||
}
|
||||
|
||||
if (d.rightsUpperLimit) {
|
||||
$('rightsStatus').textContent += ' (已达上限)'
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
setTag('rightsTag', 'no', '查询失败')
|
||||
return null
|
||||
} catch (e) {
|
||||
setTag('rightsTag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('queryRightsBtn').onclick = queryRights
|
||||
|
||||
$('claim30Btn').onclick = async function () {
|
||||
const ok = await step3()
|
||||
if (ok) await queryRights()
|
||||
}
|
||||
|
||||
// 页面加载后自动查询一次
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(queryRights, 300)
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+8
-2
@@ -92,10 +92,16 @@ curl -s {origin}/search?keywords=网易云</code></pre>
|
||||
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
|
||||
<a href="/cloud.html">云盘上传</a> ·
|
||||
<a href="/playlist_import.html">歌单导入</a> ·
|
||||
<a href="/eapi_decrypt.html">EAPI 解密</a> ·
|
||||
<a href="/api_decrypt.html">API 解密</a> ·
|
||||
<a href="/listen_together_host.html">一起听示例</a> ·
|
||||
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
|
||||
<a href="/avatar_update.html">头像更新示例</a>
|
||||
<a href="/avatar_update.html">头像更新示例</a> ·
|
||||
<a href="/scrobble.html">听歌打卡示例</a> ·
|
||||
<a href="/yidun.html">获取 CheckToken</a> ·
|
||||
<a href="/free_listen.html">免费听权益</a> ·
|
||||
<a href="/yunbei_task.html">云贝广告任务</a> ·
|
||||
<a href="/ugc.html">云小编任务中心</a> ·
|
||||
<a href="/ugc_lottery.html">云小编每日抽奖</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -148,12 +148,12 @@
|
||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||
clearInterval(timer)
|
||||
} else if (statusRes.code === 801) {
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
updateStatus('等待手机扫码...', 'waiting')
|
||||
} else if (statusRes.code === 802) {
|
||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
} else if (statusRes.code === 803) {
|
||||
clearInterval(timer)
|
||||
updateStatus('授权登录成功!', 'success')
|
||||
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||
await getLoginStatus(statusRes.cookie)
|
||||
localStorage.setItem('cookie', statusRes.cookie)
|
||||
}
|
||||
|
||||
+3
-3
@@ -148,12 +148,12 @@
|
||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||
clearInterval(timer)
|
||||
} else if (statusRes.code === 801) {
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
updateStatus('等待手机扫码...', 'waiting')
|
||||
} else if (statusRes.code === 802) {
|
||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
} else if (statusRes.code === 803) {
|
||||
clearInterval(timer)
|
||||
updateStatus('授权登录成功!', 'success')
|
||||
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||
await getLoginStatus(statusRes.cookie)
|
||||
localStorage.setItem('cookie', statusRes.cookie)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user