mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-06-27 21:25:08 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
| fc5a76d5e2 | |||
| fbc34a1846 | |||
| 3ef5ea341b | |||
| 4195c41171 | |||
| 232cc3d916 | |||
| 1b4af30a49 | |||
|
|
39e0e0718d | ||
|
|
01d391ac91 | ||
|
|
85d70ba9dd | ||
|
|
3cfecbcc1f | ||
|
|
c6e9746ed6 | ||
|
|
d6961ecb4d | ||
|
|
02242eecf6 | ||
| 269456def3 | |||
|
|
6175d55f89 |
@ -8,6 +8,9 @@ ENABLE_PROXY = false
|
|||||||
## 代理配置
|
## 代理配置
|
||||||
PROXY_URL = "https://your-proxy-url.com/?proxy="
|
PROXY_URL = "https://your-proxy-url.com/?proxy="
|
||||||
|
|
||||||
|
### 随机IP设置
|
||||||
|
## 启用随机中国IP(默认关闭);启用后,所有请求默认使用随机中国IP,除非请求参数randomCNIP显式关闭
|
||||||
|
ENABLE_RANDOM_CN_IP = false
|
||||||
|
|
||||||
### UnblockNeteaseMusic 设置项
|
### UnblockNeteaseMusic 设置项
|
||||||
## 启用全局解灰, 无论是否调用参数都会使用解灰(不推荐开启)
|
## 启用全局解灰, 无论是否调用参数都会使用解灰(不推荐开启)
|
||||||
|
|||||||
43
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
43
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -14,17 +14,17 @@ body:
|
|||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
label: 确认事项
|
label: 确认事项
|
||||||
description: 在提交Bug报告前,请确认以下事项
|
description: |
|
||||||
|
在提交Bug报告前,请确认以下事项:
|
||||||
|
|
||||||
|
- 我已经搜索了现有的issues,确认这不是重复问题
|
||||||
|
- 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
|
||||||
|
- 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
|
||||||
|
- 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
|
||||||
|
- 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
|
||||||
|
|
||||||
options:
|
options:
|
||||||
- label: 我已经搜索了现有的issues,确认这不是重复问题
|
- label: 我已确认以上事项
|
||||||
required: true
|
|
||||||
- label: 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
|
|
||||||
required: true
|
|
||||||
- label: 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
|
|
||||||
required: true
|
|
||||||
- label: 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
|
|
||||||
required: true
|
|
||||||
- label: 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
|
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
@ -36,29 +36,14 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
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
|
- type: dropdown
|
||||||
id: deployment
|
id: deployment
|
||||||
attributes:
|
attributes:
|
||||||
label: 部署平台
|
label: 部署方式
|
||||||
description:
|
description: 此项目支持本地部署和云平台部署,您使用的是哪种方式?
|
||||||
options:
|
options:
|
||||||
- 我使用的自己的服务器部署
|
- 本机直接运行 (node / pm2)
|
||||||
|
- 本机 Docker 部署
|
||||||
- Vercel
|
- Vercel
|
||||||
- Heroku
|
- Heroku
|
||||||
- Railway
|
- Railway
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,5 +1,8 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
|
- name: ↑请尽量使用议题模板创建议题↑
|
||||||
|
url: https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/issues/new/choose
|
||||||
|
about: 选择适合的议题模板可以帮助我们更快地定位和解决问题
|
||||||
- name: 提问的艺术
|
- name: 提问的艺术
|
||||||
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
|
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
|
||||||
about: 默认所有 Issues 发起者均已了解此处的内容
|
about: 默认所有 Issues 发起者均已了解此处的内容
|
||||||
54
.github/workflows/Build_Image.yml
vendored
54
.github/workflows/Build_Image.yml
vendored
@ -1,54 +0,0 @@
|
|||||||
name: Publish Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
# 读取 package.json 的版本号
|
|
||||||
- name: Read package version
|
|
||||||
id: pkg
|
|
||||||
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
moefurina/ncm-api:latest
|
|
||||||
moefurina/ncm-api:${{ env.VERSION }}
|
|
||||||
ghcr.io/neteasecloudmusicapienhanced/ncm-api:latest
|
|
||||||
ghcr.io/neteasecloudmusicapienhanced/ncm-api:${{ env.VERSION }}
|
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
|
||||||
109
.github/workflows/build-and-pr.yml
vendored
109
.github/workflows/build-and-pr.yml
vendored
@ -1,109 +0,0 @@
|
|||||||
name: Build and Create PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch: # 手动触发
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
platform: linux
|
|
||||||
target: node18-linux-x64
|
|
||||||
output: precompiled/app
|
|
||||||
- os: windows-latest
|
|
||||||
platform: win
|
|
||||||
target: node18-win-x64
|
|
||||||
output: precompiled/app.exe
|
|
||||||
- os: macos-latest
|
|
||||||
platform: macos
|
|
||||||
target: node18-macos-x64
|
|
||||||
output: precompiled/app
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
- name: Build for ${{ matrix.platform }}
|
|
||||||
run: |
|
|
||||||
npm run pkg${{ matrix.platform }}
|
|
||||||
env:
|
|
||||||
PKG_TARGET: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: app-${{ matrix.platform }}
|
|
||||||
path: ${{ matrix.output }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
create-pr:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'workflow_dispatch' # 只在手动触发时创建PR
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: precompiled
|
|
||||||
|
|
||||||
- name: Display structure of downloaded files
|
|
||||||
run: ls -R
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
|
|
||||||
- name: Create new branch and commit
|
|
||||||
run: |
|
|
||||||
git config --global user.name 'github-actions[bot]'
|
|
||||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
|
||||||
|
|
||||||
BRANCH_NAME="auto-build-$(date +%Y%m%d-%H%M%S)"
|
|
||||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
|
||||||
git checkout -b $BRANCH_NAME
|
|
||||||
|
|
||||||
# 复制并整理下载的文件
|
|
||||||
mkdir -p precompiled
|
|
||||||
cp app-win/* precompiled/ || true
|
|
||||||
cp app-linux/* precompiled/ || true
|
|
||||||
cp app-macos/* precompiled/ || true
|
|
||||||
|
|
||||||
# 提交更改
|
|
||||||
git add precompiled/
|
|
||||||
git commit -m "Auto-build: Add compiled binaries for win, linux, macos" || exit 0
|
|
||||||
|
|
||||||
# 推送到远程仓库
|
|
||||||
git push origin $BRANCH_NAME
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { data: pullRequest } = await github.rest.pulls.create({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
title: 'Auto-build: Add compiled binaries for win, linux, macos',
|
|
||||||
head: '${{ env.BRANCH_NAME }}',
|
|
||||||
base: 'main',
|
|
||||||
body: 'This PR contains newly built binaries for Windows, Linux, and macOS platforms.'
|
|
||||||
});
|
|
||||||
console.log(`Created PR #${pullRequest.number}: ${pullRequest.html_url}`);
|
|
||||||
53
.github/workflows/build-dev.yml
vendored
Normal file
53
.github/workflows/build-dev.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
name: Build Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # 手动触发
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
platform: linux
|
||||||
|
target: node18-linux-x64
|
||||||
|
output: precompiled/app
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
target: node18-win-x64
|
||||||
|
output: precompiled/app.exe
|
||||||
|
- os: macos-latest
|
||||||
|
platform: macos
|
||||||
|
target: node18-macos-x64
|
||||||
|
output: precompiled/app
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Build for ${{ matrix.platform }}
|
||||||
|
run: |
|
||||||
|
npm run pkg${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
PKG_TARGET: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: app-${{ matrix.platform }}
|
||||||
|
path: ${{ matrix.output }}
|
||||||
|
if-no-files-found: error
|
||||||
38
.github/workflows/npm.yml
vendored
38
.github/workflows/npm.yml
vendored
@ -1,38 +0,0 @@
|
|||||||
# .github/workflows/release.yml
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
# 发布到 NPM Registry
|
|
||||||
- name: Setup Node.js for NPM
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
- run: npm install
|
|
||||||
- name: Publish to NPM
|
|
||||||
run: npm publish --access public
|
|
||||||
|
|
||||||
# 发布到 GitHub Packages
|
|
||||||
- name: Setup Node.js for GitHub Packages
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
|
||||||
- name: Publish to GitHub Packages
|
|
||||||
run: npm publish --access public
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ github.token }}
|
|
||||||
98
.github/workflows/release-on-version-change.yml
vendored
98
.github/workflows/release-on-version-change.yml
vendored
@ -103,7 +103,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 24
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.platform }}-binary
|
name: ${{ matrix.platform }}-binary
|
||||||
path: release-artifacts/*
|
path: release-artifacts/*
|
||||||
@ -165,17 +165,16 @@ jobs:
|
|||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Download build artifacts
|
- name: Download build artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Flatten artifacts
|
- name: List flattened artifacts
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p final-artifacts
|
mkdir -p final-artifacts
|
||||||
|
cp release-artifacts/* final-artifacts/
|
||||||
find release-artifacts -type f -exec cp {} final-artifacts/ \;
|
|
||||||
|
|
||||||
ls -lah final-artifacts
|
ls -lah final-artifacts
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
@ -230,4 +229,87 @@ jobs:
|
|||||||
body_path: release-notes.md
|
body_path: release-notes.md
|
||||||
files: final-artifacts/*
|
files: final-artifacts/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
|
publish-docker:
|
||||||
|
name: Publish Docker Image
|
||||||
|
needs: detect
|
||||||
|
|
||||||
|
if: |
|
||||||
|
needs.detect.outputs.should_release == 'true' &&
|
||||||
|
needs.detect.outputs.tag_exists != 'true'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Read package version
|
||||||
|
id: pkg
|
||||||
|
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
moefurina/ncm-api:latest
|
||||||
|
moefurina/ncm-api:${{ env.VERSION }}
|
||||||
|
ghcr.io/neteasecloudmusicapienhanced/ncm-api:latest
|
||||||
|
ghcr.io/neteasecloudmusicapienhanced/ncm-api:${{ env.VERSION }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
|
||||||
|
publish-npm:
|
||||||
|
name: Publish to npm
|
||||||
|
needs: detect
|
||||||
|
|
||||||
|
if: |
|
||||||
|
needs.detect.outputs.should_release == 'true' &&
|
||||||
|
needs.detect.outputs.tag_exists != 'true'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: pnpm publish --access public --provenance
|
||||||
2
.github/workflows/sync.yml
vendored
2
.github/workflows/sync.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
|||||||
# Step 2: run the sync action
|
# Step 2: run the sync action
|
||||||
- name: Sync upstream changes
|
- name: Sync upstream changes
|
||||||
id: sync
|
id: sync
|
||||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2
|
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.3
|
||||||
with:
|
with:
|
||||||
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
|
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
|
||||||
upstream_sync_branch: main
|
upstream_sync_branch: main
|
||||||
|
|||||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Agent Instructions for NeteaseCloudMusicApiEnhanced
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
- **Package Manager**: Use `pnpm` (not npm or yarn).
|
||||||
|
- **Node Version**: Requires Node.js 18 or later.
|
||||||
|
|
||||||
|
## Developer Commands
|
||||||
|
- **Install dependencies**: `pnpm i`
|
||||||
|
- **Start server**: `pnpm start` or `node app.js`
|
||||||
|
- **Start dev server**: `pnpm dev` (uses nodemon)
|
||||||
|
- **Run tests**: `pnpm test` (uses Mocha)
|
||||||
|
- **Linting**: `pnpm lint` (check) or `pnpm lint-fix` (auto-fix)
|
||||||
|
|
||||||
|
## Architecture & Entrypoints
|
||||||
|
- **Executable Server**: `app.js` is the main entrypoint for running the API server.
|
||||||
|
- **Module Exports**: `main.js` is the entrypoint when the project is imported as a Node.js dependency.
|
||||||
|
- **API Endpoints**: Located in the `module/` directory. Each file typically corresponds to an API route.
|
||||||
|
- **Core Utilities**: Request handling, encryption, and core utilities are found in the `util/` directory.
|
||||||
|
|
||||||
|
## Important Gotchas & Quirks
|
||||||
|
- **Environment Variables**: The server defaults to port 3000 but can be overridden with the `PORT` environment variable.
|
||||||
|
- **Proxy Variables**: Be very careful with proxy environment variables (`http_proxy`, `https_proxy`, `no_proxy`). The request library (like axios) will automatically pick these up. If they point to an unavailable proxy (especially common in Docker environments), requests will fail silently or throw connection errors.
|
||||||
|
- **Code Style**: The project uses ESLint and Prettier. Always run `pnpm lint-fix` before committing changes to ensure formatting consistency.
|
||||||
1418
CHANGELOG.MD
1418
CHANGELOG.MD
File diff suppressed because it is too large
Load Diff
@ -13,4 +13,4 @@ RUN yarn --network-timeout=100000
|
|||||||
|
|
||||||
EXPOSE 3000
|
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]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
@ -131,6 +131,7 @@ $ sudo docker run -d -p 3000:3000 ncm-api
|
|||||||
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。可填写单个源,或使用逗号分隔多个源(例如 `https://a.com,https://b.com`)。 |
|
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。可填写单个源,或使用逗号分隔多个源(例如 `https://a.com,https://b.com`)。 |
|
||||||
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
|
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
|
||||||
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
|
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
|
||||||
|
| **ENABLE_RANDOM_CN_IP** | `false` | 是否默认启用随机中国IP。启用后,所有请求默认使用随机中国IP,除非请求参数 `randomCNIP` 显式关闭。 |
|
||||||
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
|
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
|
||||||
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
|
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
|
||||||
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
|
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
|
||||||
@ -214,7 +215,7 @@ pnpm test
|
|||||||
|
|
||||||
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
|
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
|
||||||
|
|
||||||
感谢大佬们为逆向eapi, weapi等加密算法所做的贡献
|
感谢大佬们为逆向eapi, weapi, xeapi等加密算法所做的贡献
|
||||||
|
|
||||||
项目参考:
|
项目参考:
|
||||||
|
|
||||||
@ -224,6 +225,8 @@ pnpm test
|
|||||||
|
|
||||||
[chaunsin/netease-cloud-music](https://github.com/chaunsin/netease-cloud-music)
|
[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 生态
|
### SDK 生态
|
||||||
|
|
||||||
| 语言 | 作者 | 地址 | 类型 |
|
| 语言 | 作者 | 地址 | 类型 |
|
||||||
@ -243,6 +246,8 @@ pnpm test
|
|||||||
|
|
||||||
- [Yueby/music-together](https://github.com/Yueby/music-together)
|
- [Yueby/music-together](https://github.com/Yueby/music-together)
|
||||||
|
|
||||||
|
- [chthollyphlie/folia-major](https://github.com/chthollyphile/folia-major)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE)
|
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const fs = require('fs')
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { register_anonimous } = require('./main')
|
const { register_anonimous } = require('./main')
|
||||||
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
||||||
|
const { getXeapiPublicKey } = require('./util/xeapiKey')
|
||||||
const tmpPath = require('os').tmpdir()
|
const tmpPath = require('os').tmpdir()
|
||||||
|
|
||||||
async function generateConfig() {
|
async function generateConfig() {
|
||||||
@ -20,5 +21,21 @@ async function generateConfig() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
let currentPublicKey = {}
|
||||||
|
try {
|
||||||
|
currentPublicKey = JSON.parse(
|
||||||
|
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
|
||||||
|
)
|
||||||
|
} catch (_) {}
|
||||||
|
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.resolve(tmpPath, 'xeapi_public_key'),
|
||||||
|
JSON.stringify(publicKey),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = generateConfig
|
module.exports = generateConfig
|
||||||
|
|||||||
721
interface.d.ts
vendored
721
interface.d.ts
vendored
@ -1841,3 +1841,724 @@ export function voice_lyric(
|
|||||||
id: number | string
|
id: number | string
|
||||||
} & RequestBaseConfig,
|
} & RequestBaseConfig,
|
||||||
): Promise<Response>
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function aidj_content_rcmd(
|
||||||
|
params: {
|
||||||
|
latitude?: string | number
|
||||||
|
longitude?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function album_privilege(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function artist_detail_dynamic(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function artist_follow_count(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function broadcast_category_region_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function broadcast_channel_collect_list(
|
||||||
|
params: MultiPageConfig & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function broadcast_channel_currentinfo(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function broadcast_channel_list(
|
||||||
|
params: {
|
||||||
|
categoryId?: string | number
|
||||||
|
regionId?: string | number
|
||||||
|
lastId?: string | number
|
||||||
|
score?: string | number
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function broadcast_sub(
|
||||||
|
params: {
|
||||||
|
t: SubAction
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function chart_detail(
|
||||||
|
params: {
|
||||||
|
chartCode: string | number
|
||||||
|
targetId: string | number
|
||||||
|
targetType: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function chart_song_detail(
|
||||||
|
params: {
|
||||||
|
chartCode: string | number
|
||||||
|
targetId: string | number
|
||||||
|
targetType: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function cloud_import(
|
||||||
|
params: {
|
||||||
|
md5: string
|
||||||
|
id?: string | number
|
||||||
|
bitrate?: string | number
|
||||||
|
fileSize?: string | number
|
||||||
|
artist?: string
|
||||||
|
album?: string
|
||||||
|
song?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function cloud_lyric_get(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
sid: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function cloud_upload_complete(
|
||||||
|
params: {
|
||||||
|
songId: string | number
|
||||||
|
resourceId: string | number
|
||||||
|
md5: string
|
||||||
|
filename: string
|
||||||
|
song?: string
|
||||||
|
artist?: string
|
||||||
|
album?: string
|
||||||
|
bitrate?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function cloud_upload_token(
|
||||||
|
params: {
|
||||||
|
md5: string
|
||||||
|
fileSize: string | number
|
||||||
|
filename: string
|
||||||
|
bitrate?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function comment_info_list(
|
||||||
|
params: {
|
||||||
|
ids?: string
|
||||||
|
type?: CommentType
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function comment_report(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
cid: string | number
|
||||||
|
reason: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function creator_authinfo_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function dj_difm_all_style_channel(
|
||||||
|
params: {
|
||||||
|
sources?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function dj_difm_channel_subscribe(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function dj_difm_channel_unsubscribe(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function dj_difm_playing_tracks_list(
|
||||||
|
params: {
|
||||||
|
channelId: string | number
|
||||||
|
limit?: string | number
|
||||||
|
source?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function dj_difm_subscribe_channels_get(
|
||||||
|
params: {
|
||||||
|
sources?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fanscenter_basicinfo_age_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fanscenter_basicinfo_gender_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fanscenter_basicinfo_province_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fanscenter_overview_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fanscenter_trend_list(
|
||||||
|
params: {
|
||||||
|
startTime?: string | number
|
||||||
|
endTime?: string | number
|
||||||
|
type?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function lbs_city_code(
|
||||||
|
params: {
|
||||||
|
bizCode?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listen_data_realtime_report(
|
||||||
|
params: {
|
||||||
|
type?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listen_data_report(
|
||||||
|
params: {
|
||||||
|
type?: string
|
||||||
|
endTime?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listen_data_today_song(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listen_data_total(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function listen_data_year_report(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_accept(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
inviterId: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_end(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_heatbeat(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
songId: string | number
|
||||||
|
playStatus: string | number
|
||||||
|
progress: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_play_command(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
commandType: string | number
|
||||||
|
progress?: string | number
|
||||||
|
playStatus: string | number
|
||||||
|
formerSongId: string | number
|
||||||
|
targetSongId: string | number
|
||||||
|
clientSeq: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_room_check(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_room_create(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_sync_list_command(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
commandType: string | number
|
||||||
|
userId: string | number
|
||||||
|
version: string | number
|
||||||
|
randomList: string
|
||||||
|
displayList: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function listentogether_sync_playlist_get(
|
||||||
|
params: {
|
||||||
|
roomId: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function mlog_music_rcmd(
|
||||||
|
params: {
|
||||||
|
mvid?: string | number
|
||||||
|
songid?: string | number
|
||||||
|
limit?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function music_first_listen_info(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function personal_fm_mode(
|
||||||
|
params: {
|
||||||
|
mode: string
|
||||||
|
submode?: string
|
||||||
|
limit?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playlist_category_list(
|
||||||
|
params: {
|
||||||
|
cat?: string
|
||||||
|
limit?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playlist_detail_rcmd_get(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playlist_import_name_task_create(
|
||||||
|
params: {
|
||||||
|
local?: string
|
||||||
|
importStarPlaylist?: boolean
|
||||||
|
['name']?: string
|
||||||
|
['description']?: string
|
||||||
|
['id']?: string | number
|
||||||
|
['url']?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playlist_import_task_status(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playlist_privacy(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function playmode_song_vector(
|
||||||
|
params: {
|
||||||
|
ids: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function radio_sport_get(
|
||||||
|
params: {
|
||||||
|
bpm?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function recent_listen_list(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function recommend_songs_dislike(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function register_anonimous(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_resource_list(
|
||||||
|
params: {
|
||||||
|
tag: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_resource_list_more(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_resource_sub(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
cancel?: string | boolean
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_resource_sub_list(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_tag_list(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function sati_timescene_resources_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function search_match(
|
||||||
|
params: {
|
||||||
|
title?: string
|
||||||
|
album?: string
|
||||||
|
artist?: string
|
||||||
|
duration?: string | number
|
||||||
|
md5?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function search_suggest_pc(
|
||||||
|
params: {
|
||||||
|
keyword: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function send_album(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
msg?: string
|
||||||
|
user_ids: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function send_song(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
msg?: string
|
||||||
|
user_ids: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_chorus(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_creators(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_downlist(
|
||||||
|
params: MultiPageConfig & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_download_url_v1(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
level: SoundQualityType
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_dynamic_cover(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_like(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
like?: string | boolean
|
||||||
|
uid?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_like_check(
|
||||||
|
params: {
|
||||||
|
ids: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_lyrics_mark(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_lyrics_mark_add(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
markId?: string
|
||||||
|
data?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_lyrics_mark_del(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_lyrics_mark_user_page(
|
||||||
|
params: MultiPageConfig & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_monthdownlist(
|
||||||
|
params: MultiPageConfig & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_music_detail(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_red_count(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_singledownlist(
|
||||||
|
params: MultiPageConfig & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_url_match(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
source?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function song_url_ncmget(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function song_url_v1_302(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
level: SoundQualityType
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function starpick_comments_summary(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function summary_annual(
|
||||||
|
params: {
|
||||||
|
year: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function threshold_detail_get(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_album_get(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_artist_get(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_artist_search(
|
||||||
|
params: {
|
||||||
|
keyword: string
|
||||||
|
limit?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_detail(
|
||||||
|
params: {
|
||||||
|
auditStatus?: string
|
||||||
|
type?: string | number
|
||||||
|
sortBy?: string
|
||||||
|
order?: string
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_mv_get(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_song_get(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function ugc_user_devote(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function user_detail_new(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_follow_mixed(
|
||||||
|
params: {
|
||||||
|
size?: string | number
|
||||||
|
cursor?: string | number
|
||||||
|
scene?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_medal(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_mutualfollow_get(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_playlist_collect(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_playlist_create(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_social_status(
|
||||||
|
params: {
|
||||||
|
uid: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_social_status_edit(
|
||||||
|
params: {
|
||||||
|
type: string | number
|
||||||
|
iconUrl?: string
|
||||||
|
content?: string
|
||||||
|
actionUrl?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_social_status_rcmd(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function user_social_status_support(
|
||||||
|
params: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function verify_getQr(
|
||||||
|
params: {
|
||||||
|
vid: string | number
|
||||||
|
type: string | number
|
||||||
|
token: string
|
||||||
|
evid: string
|
||||||
|
sign: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function verify_qrcodestatus(
|
||||||
|
params: {
|
||||||
|
qr: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function vip_sign(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function vip_sign_info(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function vip_tasks_v1(
|
||||||
|
params: {
|
||||||
|
id?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voice_detail(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voice_upload(
|
||||||
|
params: {
|
||||||
|
songFile: {
|
||||||
|
name: string
|
||||||
|
data: string | Buffer
|
||||||
|
}
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voicelist_detail(
|
||||||
|
params: {
|
||||||
|
id: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voicelist_list(
|
||||||
|
params: {
|
||||||
|
voiceListId: string | number
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voicelist_my_created(
|
||||||
|
params: {
|
||||||
|
limit?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voicelist_search(
|
||||||
|
params: {
|
||||||
|
keyword?: string
|
||||||
|
limit?: string | number
|
||||||
|
offset?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function voicelist_trans(
|
||||||
|
params: {
|
||||||
|
radioId?: string | number
|
||||||
|
programId?: string | number
|
||||||
|
position?: string | number
|
||||||
|
} & MultiPageConfig &
|
||||||
|
RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|||||||
97
module/decrypt.js
Normal file
97
module/decrypt.js
Normal file
@ -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, 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}` },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
// 私信和通知接口
|
// 私信和通知接口
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
module.exports = (query, request) => {
|
module.exports = (query, request) => {
|
||||||
const data = {}
|
const data = {}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// 获取游客cookie
|
||||||
|
|
||||||
const CryptoJS = require('crypto-js')
|
const CryptoJS = require('crypto-js')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
@ -37,7 +39,7 @@ module.exports = async (query, request) => {
|
|||||||
let result = await request(
|
let result = await request(
|
||||||
`/api/register/anonimous`,
|
`/api/register/anonimous`,
|
||||||
data,
|
data,
|
||||||
createOption(query, 'weapi'),
|
createOption(query, 'xeapi'),
|
||||||
)
|
)
|
||||||
if (result.body.code === 200) {
|
if (result.body.code === 200) {
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
74
module/register_xeapikey.js
Normal file
74
module/register_xeapikey.js
Normal file
@ -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.1.65',
|
||||||
|
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.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)',
|
||||||
|
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
|
||||||
|
},
|
||||||
|
data: new URLSearchParams(data).toString(),
|
||||||
|
proxy: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.data ||
|
||||||
|
res.data.code !== 200 ||
|
||||||
|
!res.data.data ||
|
||||||
|
!res.data.data.encryptedData
|
||||||
|
) {
|
||||||
|
throw new Error('xeapi public key request failed')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!res.data.data.signature ||
|
||||||
|
encrypt.xeapiSign(res.data.data.timestamp, nonce) !==
|
||||||
|
res.data.data.signature
|
||||||
|
) {
|
||||||
|
throw new Error('xeapi public key response signature mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData)
|
||||||
|
if (!publicKey.sk) {
|
||||||
|
throw new Error('xeapi public key response missing sk')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...publicKey,
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
cookie: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
49
module/relay_play_state_submit.js
Normal file
49
module/relay_play_state_submit.js
Normal file
@ -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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,8 +1,42 @@
|
|||||||
// 听歌打卡
|
// 听歌打卡
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
module.exports = (query, request) => {
|
const { APP_CONF } = require('../util/config.json')
|
||||||
const data = {
|
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([
|
logs: JSON.stringify([
|
||||||
{
|
{
|
||||||
action: 'play',
|
action: 'play',
|
||||||
@ -15,12 +49,30 @@ module.exports = (query, request) => {
|
|||||||
type: 'song',
|
type: 'song',
|
||||||
wifi: 0,
|
wifi: 0,
|
||||||
source: 'list',
|
source: 'list',
|
||||||
mainsite: 1,
|
mainsite: '1',
|
||||||
content: '',
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
module/scrobble_v1.js
Normal file
128
module/scrobble_v1.js
Normal file
@ -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}` },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
module/song_cloud_download.js
Normal file
9
module/song_cloud_download.js
Normal file
@ -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'))
|
||||||
|
}
|
||||||
@ -53,5 +53,9 @@ module.exports = async (query, request) => {
|
|||||||
if (data.level == 'sky') {
|
if (data.level == 'sky') {
|
||||||
data.immerseType = 'c51'
|
data.immerseType = 'c51'
|
||||||
}
|
}
|
||||||
return request(`/api/song/enhance/player/url/v1`, data, createOption(query))
|
return request(
|
||||||
|
`/api/song/enhance/player/url/v1`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,5 +3,9 @@
|
|||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
module.exports = (query, request) => {
|
module.exports = (query, request) => {
|
||||||
const data = {}
|
const data = {}
|
||||||
return request(`/api/vip-center-bff/task/sign`, data, createOption(query))
|
return request(
|
||||||
|
`/api/vip-center-bff/task/sign`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'weapi'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
14
module/vip_sign_detail.js
Normal file
14
module/vip_sign_detail.js
Normal file
@ -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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
13
module/vip_sign_history.js
Normal file
13
module/vip_sign_history.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// 黑胶乐签打卡历史
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
type: '0',
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
`/api/vipnewcenter/app/minidesk/music/sign/pc`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// 黑胶乐签签到信息
|
// 黑胶乐签未来签到信息
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
module.exports = (query, request) => {
|
module.exports = (query, request) => {
|
||||||
|
|||||||
14
module/vip_tasks_v1.js
Normal file
14
module/vip_tasks_v1.js
Normal file
@ -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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
32
package.json
32
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neteasecloudmusicapienhanced/api",
|
"name": "@neteasecloudmusicapienhanced/api",
|
||||||
"version": "4.33.0",
|
"version": "4.36.1",
|
||||||
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
|
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon app.js",
|
"dev": "nodemon app.js",
|
||||||
@ -9,6 +9,8 @@
|
|||||||
"lint": "eslint \"**/*.{js,ts}\"",
|
"lint": "eslint \"**/*.{js,ts}\"",
|
||||||
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
|
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
|
||||||
"prepare": "husky install",
|
"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",
|
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
|
||||||
"pkglinux": "pkg . -t node18-linux-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"
|
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
|
||||||
@ -41,6 +43,14 @@
|
|||||||
],
|
],
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"types": "./interface.d.ts",
|
"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": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -65,14 +75,14 @@
|
|||||||
"data"
|
"data"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.1",
|
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.18.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
"gzip": "^0.1.0",
|
"gzip": "^0.1.0",
|
||||||
"music-metadata": "^11.12.3",
|
"music-metadata": "^11.13.0",
|
||||||
"node-forge": "^1.4.0",
|
"node-forge": "^1.4.0",
|
||||||
"pac-proxy-agent": "^7.2.0",
|
"pac-proxy-agent": "^7.2.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@ -87,13 +97,13 @@
|
|||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express-fileupload": "^1.5.1",
|
"@types/express-fileupload": "^1.5.1",
|
||||||
"@types/mocha": "^10.0.10",
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.9.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||||
"@typescript-eslint/parser": "^8.59.4",
|
"@typescript-eslint/parser": "^8.61.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-html": "^8.1.4",
|
"eslint-plugin-html": "^8.1.4",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"intelli-espower-loader": "^1.1.0",
|
"intelli-espower-loader": "^1.1.0",
|
||||||
@ -102,7 +112,11 @@
|
|||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"power-assert": "^1.6.1",
|
"power-assert": "^1.6.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
493
pnpm-lock.yaml
generated
493
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -132,6 +132,7 @@
|
|||||||
<option value="eapi">eapi</option>
|
<option value="eapi">eapi</option>
|
||||||
<option value="api">api</option>
|
<option value="api">api</option>
|
||||||
<option value="linuxapi">linuxapi</option>
|
<option value="linuxapi">linuxapi</option>
|
||||||
|
<option value="xeapi">xeapi</option>
|
||||||
<option value="" selected>(默认)</option>
|
<option value="" selected>(默认)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
695
public/api_decrypt.html
Normal file
695
public/api_decrypt.html
Normal file
@ -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>
|
||||||
@ -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)
|
[Github](https://github.com/neteasecloudmusicapienhanced/api-enhanced)
|
||||||
[Get Started](#neteasecloudmusicapienhanced)
|
[前往本家](https://github.com/binaryify/NeteaseCloudMusicApi)
|
||||||
|
[快速开始](#neteasecloudmusicapienhanced)
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
BIN
public/docs/aigen.png
Normal file
BIN
public/docs/aigen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
网易云音乐 NodeJS API Enhanced
|
网易云音乐 NodeJS API Enhanced
|
||||||
|
|
||||||
最后更新于: 2026.2.15
|
|
||||||
|
|
||||||
## 灵感来自
|
## 灵感来自
|
||||||
|
|
||||||
[disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music)
|
[disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music)
|
||||||
@ -90,6 +88,7 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr
|
|||||||
5. 直接点`Continue`
|
5. 直接点`Continue`
|
||||||
6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可
|
6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可
|
||||||
|
|
||||||
|
|
||||||
## 腾讯云 serverless 部署
|
## 腾讯云 serverless 部署
|
||||||
|
|
||||||
因 `Vercel` 在国内访问太慢(不绑定自己的域名的情况下),在此提供腾讯云 serverless 部署方法
|
因 `Vercel` 在国内访问太慢(不绑定自己的域名的情况下),在此提供腾讯云 serverless 部署方法
|
||||||
@ -103,6 +102,7 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr
|
|||||||
5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目
|
5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目
|
||||||
6. 启动文件填入:
|
6. 启动文件填入:
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
export PORT=9000
|
export PORT=9000
|
||||||
@ -115,6 +115,7 @@ export PORT=9000
|
|||||||
- 腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费
|
- 腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费
|
||||||
- 当前(2024-08-24), 用此法创建的话, 会`默认`关联一个"日志服务-日志主题"(创建过程中没有提醒), 此服务是计量收费的
|
- 当前(2024-08-24), 用此法创建的话, 会`默认`关联一个"日志服务-日志主题"(创建过程中没有提醒), 此服务是计量收费的
|
||||||
|
|
||||||
|
|
||||||
## 可以使用代理
|
## 可以使用代理
|
||||||
|
|
||||||
在 query 参数中加上 proxy=your-proxy 即可让这一次的请求使用 proxy
|
在 query 参数中加上 proxy=your-proxy 即可让这一次的请求使用 proxy
|
||||||
@ -182,6 +183,7 @@ request 相关的环境变量
|
|||||||
5. no_proxy
|
5. no_proxy
|
||||||
6. NO_PROXY
|
6. NO_PROXY
|
||||||
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker pull moefurina/ncm-api
|
docker pull moefurina/ncm-api
|
||||||
|
|
||||||
@ -211,14 +213,20 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
|
|||||||
|
|
||||||
## 调试工具
|
## 调试工具
|
||||||
|
|
||||||
- `eapi` 请求参数或返回内容可在 `/eapi_decrypt.html` 里解析
|
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
|
||||||
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
|
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
|
||||||
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
|
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
|
||||||
|
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数)
|
||||||
|
|
||||||
|
|
||||||
## 接口文档
|
## 接口文档
|
||||||
|
|
||||||
### 调用前须知
|
### 调用前须知
|
||||||
|
|
||||||
|
AI 生成的图,仅供娱乐()
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
!> 本项目不提供线上 demo, 只提供在线文档服务, 请不要轻易信任使用他人提供的公开服务,以免发生安全问题,泄露自己的账号和密码
|
!> 本项目不提供线上 demo, 只提供在线文档服务, 请不要轻易信任使用他人提供的公开服务,以免发生安全问题,泄露自己的账号和密码
|
||||||
|
|
||||||
!> 为使用方便,降低门槛, 文档示例接口直接使用了 GET 请求,本项目同时支持 GET/POST 请按实际需求使用 (POST 请求 url 必须添加时间戳,使每次请求 url 不一样,不然请求会被缓存)
|
!> 为使用方便,降低门槛, 文档示例接口直接使用了 GET 请求,本项目同时支持 GET/POST 请按实际需求使用 (POST 请求 url 必须添加时间戳,使每次请求 url 不一样,不然请求会被缓存)
|
||||||
@ -261,20 +269,16 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
|
|||||||
|
|
||||||
!> ~~因网易增加了网易云盾验证,密码登录暂时不要使用,尽量使用短信验证码登录和二维码登录,否则调用某些接口会触发需要验证的错误~~
|
!> ~~因网易增加了网易云盾验证,密码登录暂时不要使用,尽量使用短信验证码登录和二维码登录,否则调用某些接口会触发需要验证的错误~~
|
||||||
|
|
||||||
!> ~~二开作者再注: 现在二维码登录也无法使用了, 网易云官方最近查的太严了, 现在尝试调用会提示环境异常, 如果各位有绕过的方法请一定开`Pull Request`~~
|
!> 二开作者注: 在`v4.29.18`版本中修复了短信登录的问题, 现在可以正常使用了
|
||||||
|
|
||||||
!> ~~二开作者注: 二维码登录现在是修复了, 但是密码登录和短信登录还是不行, 如果各位有绕过的方法请一定开`Pull Request`~~
|
|
||||||
|
|
||||||
!> 二开作者注: 在`v4.29.18`版本中修复了密码登录和短信登录的问题, 现在可以正常使用了
|
|
||||||
|
|
||||||
#### 1. 手机登录
|
#### 1. 手机登录
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`phone`: 手机号码
|
`phone`: 手机号码
|
||||||
|
|
||||||
`password`: 密码
|
`password`: 密码
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`countrycode`: 国家码,用于国外手机号登录,例如美国传入:`1`
|
`countrycode`: 国家码,用于国外手机号登录,例如美国传入:`1`
|
||||||
|
|
||||||
`md5_password`: md5 加密后的密码,传入后 `password` 参数将失效
|
`md5_password`: md5 加密后的密码,传入后 `password` 参数将失效
|
||||||
@ -433,11 +437,11 @@ body {
|
|||||||
|
|
||||||
### 检测手机号码是否已注册
|
### 检测手机号码是否已注册
|
||||||
|
|
||||||
说明 : 调用此接口 ,可检测手机号码是否已注册
|
说明 : 调用此接口 ,可检测手机号码是否已注册
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`phone` : 手机号码
|
`phone` : 手机号码
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`countrycode`: 国家码,用于国外手机号,例如美国传入:`1` ,默认 86 即中国
|
`countrycode`: 国家码,用于国外手机号,例如美国传入:`1` ,默认 86 即中国
|
||||||
|
|
||||||
**接口地址 :** `/cellphone/existence/check`
|
**接口地址 :** `/cellphone/existence/check`
|
||||||
@ -446,7 +450,7 @@ body {
|
|||||||
|
|
||||||
### 初始化昵称
|
### 初始化昵称
|
||||||
|
|
||||||
说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称
|
说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`nickname` : 昵称
|
`nickname` : 昵称
|
||||||
|
|
||||||
@ -701,7 +705,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
说明 : 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传图片 formData(name 为'imgFile'),可更新歌单封面(参考:https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/playlist_cover_update.html)
|
说明 : 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传图片 formData(name 为'imgFile'),可更新歌单封面(参考:https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/playlist_cover_update.html)
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`id`: 歌单 id 3143833470
|
`id`: 歌单 id 3143833470
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
@ -731,7 +735,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
说明 : 登录后调用此接口,可以根据歌曲 id 顺序调整歌曲顺序
|
说明 : 登录后调用此接口,可以根据歌曲 id 顺序调整歌曲顺序
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`pid`: 歌单 id
|
`pid`: 歌单 id
|
||||||
|
|
||||||
`ids`: 歌曲 id 列表
|
`ids`: 歌曲 id 列表
|
||||||
@ -1211,6 +1215,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
> 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲
|
> 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲
|
||||||
|
|
||||||
|
|
||||||
### 歌单详情动态
|
### 歌单详情动态
|
||||||
|
|
||||||
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
|
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
|
||||||
@ -1404,7 +1409,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
### 歌单收藏者
|
### 歌单收藏者
|
||||||
|
|
||||||
说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者
|
说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`id` : 歌单 id
|
`id` : 歌单 id
|
||||||
@ -1497,6 +1502,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
- (可能存在)JSON 歌曲元数据
|
- (可能存在)JSON 歌曲元数据
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
{"t":0,"c":[{"tx":"作曲: "},{"tx":"柳重言","li":"http://p1.music.126.net/Icj0IcaOjH2ZZpyAM-QGoQ==/6665239487822533.jpg","or":"orpheus://nm/artist/home?id=228547&type=artist"}]}
|
{"t":0,"c":[{"tx":"作曲: "},{"tx":"柳重言","li":"http://p1.music.126.net/Icj0IcaOjH2ZZpyAM-QGoQ==/6665239487822533.jpg","or":"orpheus://nm/artist/home?id=228547&type=artist"}]}
|
||||||
{"t":5403,"c":[{"tx":"编曲: "},{"tx":"Alex San","li":"http://p1.music.126.net/pSbvYkrzZ1RFKqoh-fA9AQ==/109951166352922615.jpg","or":"orpheus://nm/artist/home?id=28984845&type=artist"}]}
|
{"t":5403,"c":[{"tx":"编曲: "},{"tx":"Alex San","li":"http://p1.music.126.net/pSbvYkrzZ1RFKqoh-fA9AQ==/109951166352922615.jpg","or":"orpheus://nm/artist/home?id=28984845&type=artist"}]}
|
||||||
@ -1513,6 +1519,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
* 逐字歌词
|
* 逐字歌词
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[16210,3460](16210,670,0)还(16880,410,0)没...
|
[16210,3460](16210,670,0)还(16880,410,0)没...
|
||||||
~~~~1 ~~~2 ~~~~3 ~~4 5 ~6 (...)
|
~~~~1 ~~~2 ~~~~3 ~~4 5 ~6 (...)
|
||||||
@ -1523,7 +1530,7 @@ tags: 歌单标签
|
|||||||
1. 歌词行显示开始时间戳 (毫秒)
|
1. 歌词行显示开始时间戳 (毫秒)
|
||||||
2. 歌词行显示总时长(毫秒)
|
2. 歌词行显示总时长(毫秒)
|
||||||
3. 逐字显示开始时间戳 (毫秒)
|
3. 逐字显示开始时间戳 (毫秒)
|
||||||
4. 逐字显示时长 (厘秒/0.01s)
|
4. 逐字显示时长 (毫秒)
|
||||||
5. 未知
|
5. 未知
|
||||||
6. 文字
|
6. 文字
|
||||||
|
|
||||||
@ -1589,7 +1596,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
说明 : 调用此接口 , 传入资源 parentCommentId 和资源类型 type 和资源 id 参数, 可获得该资源的歌曲楼层评论
|
说明 : 调用此接口 , 传入资源 parentCommentId 和资源类型 type 和资源 id 参数, 可获得该资源的歌曲楼层评论
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`parentCommentId`: 楼层评论 id
|
`parentCommentId`: 楼层评论 id
|
||||||
|
|
||||||
`id` : 资源 id
|
`id` : 资源 id
|
||||||
@ -1805,7 +1812,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
说明 : 调用此接口 , 传入资源类型和资源 id,以及排序方式,可获取对应资源的评论
|
说明 : 调用此接口 , 传入资源类型和资源 id,以及排序方式,可获取对应资源的评论
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`id` : 资源 id, 如歌曲 id,mv id
|
`id` : 资源 id, 如歌曲 id,mv id
|
||||||
|
|
||||||
`type`: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型
|
`type`: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型
|
||||||
@ -1828,7 +1835,7 @@ tags: 歌单标签
|
|||||||
7: 电台
|
7: 电台
|
||||||
```
|
```
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`pageNo`:分页参数,第 N 页,默认为 1
|
`pageNo`:分页参数,第 N 页,默认为 1
|
||||||
|
|
||||||
`pageSize`:分页参数,每页多少条数据,默认 20
|
`pageSize`:分页参数,每页多少条数据,默认 20
|
||||||
@ -2201,7 +2208,7 @@ privilege:权限相关信息
|
|||||||
|
|
||||||
说明 : 调用此接口 , 可获得已收藏专辑列表
|
说明 : 调用此接口 , 可获得已收藏专辑列表
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`limit`: 取出数量 , 默认为 25
|
`limit`: 取出数量 , 默认为 25
|
||||||
|
|
||||||
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*25, 其中 25 为 limit 的值 , 默认
|
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*25, 其中 25 为 limit 的值 , 默认
|
||||||
@ -2493,6 +2500,30 @@ privilege:权限相关信息
|
|||||||
|
|
||||||
**调用例子 :** `/scrobble?id=518066366&sourceid=36780169&time=291`
|
**调用例子 :** `/scrobble?id=518066366&sourceid=36780169&time=291`
|
||||||
|
|
||||||
|
#### 听歌打卡 V2 (NCBL 加密版)
|
||||||
|
|
||||||
|
说明 : 调用此接口,使用桌面客户端 NCBL 加密日志格式上报听歌记录
|
||||||
|
|
||||||
|
**必选参数 :** `id`: 歌曲 id, `time`: 播放时长(秒)
|
||||||
|
|
||||||
|
**可选参数 :** `sourceid`: 来源列表 id, `source`: 来源名称(默认 list), `name`: 歌曲名, `artist`: 艺术家, `bitrate`: 码率(默认 320), `level`: 音质等级(默认 exhigh), `total`: 歌曲总时长(秒)
|
||||||
|
|
||||||
|
**接口地址 :** `/scrobble/v1`
|
||||||
|
|
||||||
|
**调用例子 :** `/scrobble/v1?id=518066366&sourceid=36780169&time=291`
|
||||||
|
|
||||||
|
### 提交歌曲播放状态
|
||||||
|
|
||||||
|
说明 : 调用此接口可提交歌曲播放状态,支持会话追踪和播放模式记录,未传入 `sessionId` 时后端会自动生成
|
||||||
|
|
||||||
|
**必选参数 :** `id`: 歌曲 id
|
||||||
|
|
||||||
|
**可选参数 :** `sessionId`: 播放会话 ID(12 位大写字母和数字),不传则自动生成, `progress`: 播放进度(秒),默认 0, `playMode`: 播放模式,默认 `list_loop`, `type`: 资源类型,默认 `song`
|
||||||
|
|
||||||
|
**接口地址 :** `/relay/play/state/submit`
|
||||||
|
|
||||||
|
**调用例子 :** `/relay/play/state/submit?id=518066366&progress=30`
|
||||||
|
|
||||||
### 热门歌手
|
### 热门歌手
|
||||||
|
|
||||||
说明 : 调用此接口 , 可获取热门歌手数据
|
说明 : 调用此接口 , 可获取热门歌手数据
|
||||||
@ -2510,7 +2541,7 @@ privilege:权限相关信息
|
|||||||
|
|
||||||
说明 : 调用此接口 , 可获取全部 mv
|
说明 : 调用此接口 , 可获取全部 mv
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`area`: 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部
|
`area`: 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部
|
||||||
`type`: 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部
|
`type`: 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部
|
||||||
|
|
||||||
@ -2592,7 +2623,7 @@ privilege:权限相关信息
|
|||||||
|
|
||||||
**接口地址 :** `/program/recommend`
|
**接口地址 :** `/program/recommend`
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
`limit`: 取出数量 , 默认为 10
|
`limit`: 取出数量 , 默认为 10
|
||||||
|
|
||||||
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*10, 其中 10 为 limit 的值 , 默认
|
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*10, 其中 10 为 limit 的值 , 默认
|
||||||
@ -2874,6 +2905,7 @@ type : 地区
|
|||||||
- 适合 Vercel、Netlify 等有请求体限制的平台
|
- 适合 Vercel、Netlify 等有请求体限制的平台
|
||||||
- 需要前端配合实现
|
- 需要前端配合实现
|
||||||
|
|
||||||
|
|
||||||
#### 客户端直传相关接口
|
#### 客户端直传相关接口
|
||||||
|
|
||||||
**获取上传凭证**
|
**获取上传凭证**
|
||||||
@ -2920,6 +2952,7 @@ type : 地区
|
|||||||
- `artist`: 艺术家
|
- `artist`: 艺术家
|
||||||
- `album`: 专辑名
|
- `album`: 专辑名
|
||||||
|
|
||||||
|
|
||||||
#### 客户端直传流程
|
#### 客户端直传流程
|
||||||
|
|
||||||
1. 客户端计算文件 MD5
|
1. 客户端计算文件 MD5
|
||||||
@ -2927,11 +2960,12 @@ type : 地区
|
|||||||
3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl`
|
3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl`
|
||||||
4. 调用 `/cloud/upload/complete` 完成导入
|
4. 调用 `/cloud/upload/complete` 完成导入
|
||||||
|
|
||||||
|
|
||||||
### 云盘歌曲信息匹配纠正
|
### 云盘歌曲信息匹配纠正
|
||||||
|
|
||||||
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
|
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
`uid`: 用户 id
|
`uid`: 用户 id
|
||||||
|
|
||||||
`sid`: 云盘的歌曲 id
|
`sid`: 云盘的歌曲 id
|
||||||
@ -3402,7 +3436,7 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
|||||||
|
|
||||||
`limit` : 返回数量 , 默认为 30
|
`limit` : 返回数量 , 默认为 30
|
||||||
|
|
||||||
`offset` : 偏移数量,用于分页 , 如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
|
`offset` : 偏移数量,用于分页 , 如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
|
||||||
**接口地址 :** `/album/list`
|
**接口地址 :** `/album/list`
|
||||||
|
|
||||||
**调用例子 :** `/album/list?limit=10`
|
**调用例子 :** `/album/list?limit=10`
|
||||||
@ -3561,7 +3595,7 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
|||||||
|
|
||||||
**可选参数 :** `limit`: 取出评论数量 , 默认为 10
|
**可选参数 :** `limit`: 取出评论数量 , 默认为 10
|
||||||
|
|
||||||
`offset`: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)\*10, 其中 10 为 limit 的值
|
`offset`: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)\*10, 其中 10 为 limit 的值
|
||||||
**接口地址 :** `/yunbei/tasks/expense`
|
**接口地址 :** `/yunbei/tasks/expense`
|
||||||
|
|
||||||
**调用例子 :** `/yunbei/tasks/expense?limit=1`
|
**调用例子 :** `/yunbei/tasks/expense?limit=1`
|
||||||
@ -4199,6 +4233,7 @@ ONLINE 已发布
|
|||||||
- `voiceFeeType: 0`:返回免费的声音
|
- `voiceFeeType: 0`:返回免费的声音
|
||||||
- `voiceFeeType: 1`:返回收费的声音
|
- `voiceFeeType: 1`:返回收费的声音
|
||||||
|
|
||||||
|
|
||||||
### 播客声音详情
|
### 播客声音详情
|
||||||
|
|
||||||
说明: 获取播客里的声音详情
|
说明: 获取播客里的声音详情
|
||||||
@ -5031,7 +5066,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
**调用例子:** `/vip/sign`
|
**调用例子:** `/vip/sign`
|
||||||
|
|
||||||
### 黑胶乐签打卡信息
|
### 黑胶乐签未来打卡信息
|
||||||
|
|
||||||
说明: 登录后调用此接口, 获取黑胶乐签打卡信息
|
说明: 登录后调用此接口, 获取黑胶乐签打卡信息
|
||||||
|
|
||||||
@ -5120,7 +5155,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明 : 登录后调用此接口, 获取我创建的博客声音
|
说明 : 登录后调用此接口, 获取我创建的博客声音
|
||||||
|
|
||||||
**可选参数 :**
|
**可选参数 :**
|
||||||
|
|
||||||
`limit` : 返回数量 , 默认为 20
|
`limit` : 返回数量 , 默认为 20
|
||||||
|
|
||||||
@ -5133,7 +5168,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 获取DIFM电台分类
|
说明: 调用此接口, 获取DIFM电台分类
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||||
|
|
||||||
@ -5145,7 +5180,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 获取DIFM电台收藏列表
|
说明: 调用此接口, 获取DIFM电台收藏列表
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||||
|
|
||||||
@ -5181,7 +5216,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 获取DIFM播放列表
|
说明: 调用此接口, 获取DIFM播放列表
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`source`: 来源, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
`source`: 来源, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||||
|
|
||||||
@ -5215,7 +5250,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 获取标签下资源列表; 接口返回的`trackId`可以用于请求`/song/url/v1`接口,用于获取声音的下载地址
|
说明: 调用此接口, 获取标签下资源列表; 接口返回的`trackId`可以用于请求`/song/url/v1`接口,用于获取声音的下载地址
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`tag`: 标签, 由标签列表接口得到
|
`tag`: 标签, 由标签列表接口得到
|
||||||
|
|
||||||
@ -5227,7 +5262,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 查看同类推荐
|
说明: 调用此接口, 查看同类推荐
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
||||||
|
|
||||||
@ -5247,7 +5282,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口, 收藏声音
|
说明: 调用此接口, 收藏声音
|
||||||
|
|
||||||
**必选参数 :**
|
**必选参数 :**
|
||||||
|
|
||||||
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
||||||
|
|
||||||
@ -5263,7 +5298,7 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明: 调用此接口,获取跑步漫游的歌曲信息
|
说明: 调用此接口,获取跑步漫游的歌曲信息
|
||||||
|
|
||||||
**必选参数:**
|
**必选参数:**
|
||||||
|
|
||||||
`bpm`: 步频
|
`bpm`: 步频
|
||||||
|
|
||||||
@ -5339,6 +5374,56 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
说明 : 调用此接口,可获取城市榜、城市风格榜等指定维度音乐排行榜歌曲列表
|
说明 : 调用此接口,可获取城市榜、城市风格榜等指定维度音乐排行榜歌曲列表
|
||||||
|
|
||||||
|
**必选参数 :**
|
||||||
|
|
||||||
|
`chartCode`: 榜单编码,如 `CITY_SONG_CHART`、`CITY_STYLE_SONG_CHART`
|
||||||
|
|
||||||
|
`targetId`: 目标 id,城市榜如 `110000`,城市风格榜如 北京华语流行榜 `110000_1020`。城市风格榜格式通常为 `城市 id_曲风 id`,其中曲风 id 可通过[曲风列表](#曲风列表)接口 `/style/list` 获取。城市榜的城市列表可通过[多级行政区划数据](#多级行政区划数据)接口传入 `bizCode=chart` 获取;城市风格榜的城市列表可通过该接口传空 `bizCode` 获取
|
||||||
|
|
||||||
|
`targetType`: 目标类型,如 `CITY`、`CITY_STYLE`
|
||||||
|
|
||||||
|
**接口地址 :** `/chart/song/detail`
|
||||||
|
|
||||||
|
**调用例子 :** `/chart/song/detail?chartCode=CITY_STYLE_SONG_CHART&targetId=110000_1020&targetType=CITY_STYLE`
|
||||||
|
|
||||||
|
### 会员任务 - 新版
|
||||||
|
|
||||||
|
说明 : 登录后调用此接口, 获取会员任务
|
||||||
|
|
||||||
|
**可选参数** `id`: 用户 id, 传入后可获取指定用户的会员任务, 不传入则获取当前登录用户的会员任务
|
||||||
|
|
||||||
|
**接口地址 :** `/vip/task/v1`
|
||||||
|
|
||||||
|
**调用例子 :** `/vip/task/v1` `/vip/task/v1?id=32953014`
|
||||||
|
|
||||||
|
### 黑胶乐签详情
|
||||||
|
|
||||||
|
说明 : 登录后调用此接口, 传入时间戳, 获取黑胶乐签详情
|
||||||
|
|
||||||
|
**必选参数 :** `timestamp`: 时间戳, 单位毫秒, 如 `1704067200000` 表示 2024 年 12 月 31 日 0 点 (不传入会出现随机的乐签详情)
|
||||||
|
|
||||||
|
**接口地址 :** `/vip/sign/detail`
|
||||||
|
|
||||||
|
**调用例子 :** `/vip/sign/detail`
|
||||||
|
|
||||||
|
### 黑胶乐签历史
|
||||||
|
|
||||||
|
说明 : 登录后调用此接口, 获取黑胶乐签历史
|
||||||
|
|
||||||
|
**接口地址 :** `/vip/sign/history`
|
||||||
|
|
||||||
|
**调用例子 :** `/vip/sign/history`
|
||||||
|
|
||||||
|
### 直接获取云盘歌曲下载链接
|
||||||
|
|
||||||
|
说明 : 调用此接口, 传入云盘歌曲 id, 可直接获取云盘歌曲下载链接
|
||||||
|
|
||||||
|
**必选参数 :** `id`: 云盘歌曲 id
|
||||||
|
|
||||||
|
**接口地址 :** `/song/cloud/download`
|
||||||
|
|
||||||
|
**调用例子 :** `/song/cloud/download?id=123456789`
|
||||||
|
|
||||||
## 离线访问此文档
|
## 离线访问此文档
|
||||||
|
|
||||||
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
|
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 858 KiB |
@ -108,7 +108,7 @@
|
|||||||
name: '网易云音乐 API Enhanced',
|
name: '网易云音乐 API Enhanced',
|
||||||
repo: 'https://github.com/neteasecloudmusicapienhanced/api-enhanced',
|
repo: 'https://github.com/neteasecloudmusicapienhanced/api-enhanced',
|
||||||
coverpage: true,
|
coverpage: true,
|
||||||
homepage: 'https://cdn.jsdelivr.net/gh/NeteaseCloudMusicApiEnhanced/api-enhanced@main/public/docs/home.md',
|
homepage: 'home.md',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="https://unpkg.com/docsify@4.11.3/lib/docsify.min.js"></script>
|
<script src="https://unpkg.com/docsify@4.11.3/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>
|
|
||||||
@ -92,10 +92,11 @@ curl -s {origin}/search?keywords=网易云</code></pre>
|
|||||||
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
|
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
|
||||||
<a href="/cloud.html">云盘上传</a> ·
|
<a href="/cloud.html">云盘上传</a> ·
|
||||||
<a href="/playlist_import.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="/listen_together_host.html">一起听示例</a> ·
|
||||||
<a href="/playlist_cover_update.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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -148,12 +148,12 @@
|
|||||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
} else if (statusRes.code === 801) {
|
} else if (statusRes.code === 801) {
|
||||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
updateStatus('等待手机扫码...', 'waiting')
|
||||||
} else if (statusRes.code === 802) {
|
} else if (statusRes.code === 802) {
|
||||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||||
} else if (statusRes.code === 803) {
|
} else if (statusRes.code === 803) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
updateStatus('授权登录成功!', 'success')
|
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||||
await getLoginStatus(statusRes.cookie)
|
await getLoginStatus(statusRes.cookie)
|
||||||
localStorage.setItem('cookie', statusRes.cookie)
|
localStorage.setItem('cookie', statusRes.cookie)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,12 +148,12 @@
|
|||||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
} else if (statusRes.code === 801) {
|
} else if (statusRes.code === 801) {
|
||||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
updateStatus('等待手机扫码...', 'waiting')
|
||||||
} else if (statusRes.code === 802) {
|
} else if (statusRes.code === 802) {
|
||||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||||
} else if (statusRes.code === 803) {
|
} else if (statusRes.code === 803) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
updateStatus('授权登录成功!', 'success')
|
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||||
await getLoginStatus(statusRes.cookie)
|
await getLoginStatus(statusRes.cookie)
|
||||||
localStorage.setItem('cookie', statusRes.cookie)
|
localStorage.setItem('cookie', statusRes.cookie)
|
||||||
}
|
}
|
||||||
|
|||||||
480
public/scrobble.html
Normal file
480
public/scrobble.html
Normal file
@ -0,0 +1,480 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>听歌打卡 - 网易云音乐 API Enhanced</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: 520px;
|
||||||
|
margin: 40px 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: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link:hover {
|
||||||
|
color: #333;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tabs ---- */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #333;
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Form ---- */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label .tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label .tag.r {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
label .tag.o {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fill {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fill span {
|
||||||
|
color: #0066cc;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-fill span:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.info {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link a {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>听歌打卡</h1>
|
||||||
|
<p class="subtitle">同步听歌记录至网易云音乐,增加听歌排行计数</p>
|
||||||
|
|
||||||
|
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
|
||||||
|
|
||||||
|
<!-- Cookie (共享) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cookie">Cookie <span class="tag o">可选</span></label>
|
||||||
|
<textarea id="cookie" placeholder="留空则默认读取本地存储的登录态" rows="2"></textarea>
|
||||||
|
<div class="quick-fill">
|
||||||
|
<span onclick="loadLocalCookie()">读取本地 Cookie</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="v0" onclick="switchTab('v0')">原版</button>
|
||||||
|
<button class="tab-btn" data-tab="v1" onclick="switchTab('v1')">V2 (NCBL 加密版)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Tab: v0 (原版 /scrobble) ============ -->
|
||||||
|
<div class="tab-content active" id="tab-v0">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>歌曲 ID <span class="tag r">必填</span></label>
|
||||||
|
<input type="text" id="v0-songId" placeholder="例如: 2756058128" value="2756058128" />
|
||||||
|
<div class="quick-fill">
|
||||||
|
示例:
|
||||||
|
<span onclick="fillV0('2756058128', '288651229')">妖精小姐的魔法邀约</span>
|
||||||
|
<span onclick="fillV0('2637402867', '251025018')">Echoes of Memoria</span>
|
||||||
|
<span onclick="fillV0('36307815', '3394198')">Lose Control</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>来源 ID (歌单或专辑 ID) <span class="tag r">必填</span></label>
|
||||||
|
<input type="text" id="v0-sourceId" placeholder="例如: 2756058128" value="2756058128" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>播放时间 (秒) <span class="tag o">可选</span></label>
|
||||||
|
<input type="number" id="v0-time" placeholder="300" value="300" />
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="submitV0()">立即打卡 (原版)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Tab: v1 (NCBL 加密版 /scrobble/v1) ============ -->
|
||||||
|
<div class="tab-content" id="tab-v1">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>歌曲 ID <span class="tag r">必填</span></label>
|
||||||
|
<input type="text" id="v1-songId" placeholder="例如: 518066366" value="518066366" />
|
||||||
|
<div class="quick-fill">
|
||||||
|
示例:
|
||||||
|
<span onclick="fillV1('518066366', '36780169', '291')">默认示例</span>
|
||||||
|
<span onclick="fillV1('2756058128', '288651229', '300')">妖精小姐的魔法邀约</span>
|
||||||
|
<span onclick="fillV1('2637402867', '251025018', '300')">Echoes of Memoria</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>播放时间 (秒) <span class="tag r">必填</span></label>
|
||||||
|
<input type="number" id="v1-time" placeholder="例如: 291" value="291" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>来源 ID <span class="tag o">可选</span></label>
|
||||||
|
<input type="text" id="v1-sourceId" placeholder="歌单或专辑 ID" value="36780169" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>歌曲名 <span class="tag o">可选</span></label>
|
||||||
|
<input type="text" id="v1-name" placeholder="歌曲名称" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>艺术家 <span class="tag o">可选</span></label>
|
||||||
|
<input type="text" id="v1-artist" placeholder="歌手名" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>码率 <span class="tag o">可选</span></label>
|
||||||
|
<input type="number" id="v1-bitrate" placeholder="默认 320" value="320" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>音质等级 <span class="tag o">可选</span></label>
|
||||||
|
<input type="text" id="v1-level" placeholder="默认 exhigh" value="exhigh" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>歌曲总时长 (秒) <span class="tag o">可选</span></label>
|
||||||
|
<input type="number" id="v1-total" placeholder="留空则与播放时间相同" />
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="submitV1()">立即打卡 (NCBL 加密版)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div id="result" class="result" style="display: none;"></div>
|
||||||
|
|
||||||
|
<div class="footer-link">
|
||||||
|
<a href="/">返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
|
||||||
|
<script>
|
||||||
|
/* ---- DOM refs ---- */
|
||||||
|
const cookieInput = document.getElementById('cookie')
|
||||||
|
const resultDiv = document.getElementById('result')
|
||||||
|
|
||||||
|
/* ---- init ---- */
|
||||||
|
if (localStorage.getItem('cookie')) {
|
||||||
|
cookieInput.value = localStorage.getItem('cookie')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab switching ---- */
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name))
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name))
|
||||||
|
hideResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cookie ---- */
|
||||||
|
function loadLocalCookie() {
|
||||||
|
const local = localStorage.getItem('cookie')
|
||||||
|
if (local) {
|
||||||
|
cookieInput.value = local
|
||||||
|
showResult('已成功读取本地 Cookie', 'success')
|
||||||
|
} else {
|
||||||
|
showResult('未在本地发现登录 Cookie,请先登录或手动粘贴', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Quick fill ---- */
|
||||||
|
function fillV0(id, sourceId) {
|
||||||
|
document.getElementById('v0-songId').value = id
|
||||||
|
document.getElementById('v0-sourceId').value = sourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillV1(id, sourceId, time) {
|
||||||
|
document.getElementById('v1-songId').value = id
|
||||||
|
document.getElementById('v1-sourceId').value = sourceId
|
||||||
|
document.getElementById('v1-time').value = time
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Result ---- */
|
||||||
|
function hideResult() {
|
||||||
|
resultDiv.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(message, type) {
|
||||||
|
resultDiv.textContent = message
|
||||||
|
resultDiv.className = 'result ' + type
|
||||||
|
resultDiv.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableAll(disabled) {
|
||||||
|
document.querySelectorAll('.btn').forEach(b => b.disabled = disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Submit: 原版 /scrobble ---- */
|
||||||
|
async function submitV0() {
|
||||||
|
const id = document.getElementById('v0-songId').value.trim()
|
||||||
|
const sourceid = document.getElementById('v0-sourceId').value.trim()
|
||||||
|
const time = document.getElementById('v0-time').value.trim() || '300'
|
||||||
|
|
||||||
|
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
|
||||||
|
if (!sourceid) { showResult('请输入来源 ID !', 'error'); return }
|
||||||
|
|
||||||
|
disableAll(true)
|
||||||
|
showResult('打卡中,请稍候...', 'info')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = { id, sourceid: sourceid || id, time, timestamp: Date.now() }
|
||||||
|
const cookie = cookieInput.value.trim()
|
||||||
|
if (cookie) params.cookie = cookie
|
||||||
|
|
||||||
|
const res = await axios({ method: 'get', url: '/scrobble', params })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
showResult('✅ 打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
|
||||||
|
} else {
|
||||||
|
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errData = error.response ? error.response.data : { error: error.message }
|
||||||
|
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
|
||||||
|
} finally {
|
||||||
|
disableAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Submit: NCBL 加密版 /scrobble/v1 ---- */
|
||||||
|
async function submitV1() {
|
||||||
|
const id = document.getElementById('v1-songId').value.trim()
|
||||||
|
const time = document.getElementById('v1-time').value.trim()
|
||||||
|
|
||||||
|
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
|
||||||
|
if (!time || isNaN(+time) || +time <= 0) { showResult('请输入有效的播放时长(秒)!', 'error'); return }
|
||||||
|
|
||||||
|
disableAll(true)
|
||||||
|
showResult('打卡中(NCBL 加密),请稍候...', 'info')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
id,
|
||||||
|
time,
|
||||||
|
sourceid: document.getElementById('v1-sourceId').value.trim() || undefined,
|
||||||
|
name: document.getElementById('v1-name').value.trim() || undefined,
|
||||||
|
artist: document.getElementById('v1-artist').value.trim() || undefined,
|
||||||
|
bitrate: document.getElementById('v1-bitrate').value.trim() || undefined,
|
||||||
|
level: document.getElementById('v1-level').value.trim() || undefined,
|
||||||
|
total: document.getElementById('v1-total').value.trim() || undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
const cookie = cookieInput.value.trim()
|
||||||
|
if (cookie) params.cookie = cookie
|
||||||
|
|
||||||
|
// 清理 undefined 参数
|
||||||
|
Object.keys(params).forEach(k => params[k] === undefined && delete params[k])
|
||||||
|
|
||||||
|
const res = await axios({ method: 'get', url: '/scrobble/v1', params })
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
showResult('✅ NCBL 加密打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
|
||||||
|
} else {
|
||||||
|
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errData = error.response ? error.response.data : { error: error.message }
|
||||||
|
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
|
||||||
|
} finally {
|
||||||
|
disableAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 992 KiB |
BIN
public/static/2170.png
Normal file
BIN
public/static/2170.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
577
scripts/format-docs.js
Normal file
577
scripts/format-docs.js
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 📝 Markdown 文档格式化工具喵~
|
||||||
|
* 支持格式化标题、代码块、空行和缩进!
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/format-docs.js # 格式化默认文档 (public/docs/home.md)
|
||||||
|
* node scripts/format-docs.js <文件路径> # 格式化指定文件
|
||||||
|
* node scripts/format-docs.js --dir <目录> # 格式化整个目录的 .md 文件
|
||||||
|
* node scripts/format-docs.js --check # 只检查不写入 (dry-run)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// ======================== 配置 ========================
|
||||||
|
const CONFIG = {
|
||||||
|
maxConsecutiveBlankLines: 2, // 最大连续空行数
|
||||||
|
codeBlockLang: true, // 代码块是否保留语言标记
|
||||||
|
headingSpaceBefore: true, // 标题前是否确保空行
|
||||||
|
headingSpaceAfter: true, // 标题后是否确保空行
|
||||||
|
listIndent: 2, // 列表缩进空格数
|
||||||
|
encodeSpecialChars: false, // 是否编码特殊字符
|
||||||
|
removeTrailingSpaces: true, // 是否删除行尾空格
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILE = path.resolve(__dirname, '..', 'public', 'docs', 'home.md')
|
||||||
|
|
||||||
|
// ======================== 颜色工具 ========================
|
||||||
|
const color = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`
|
||||||
|
const green = color('32')
|
||||||
|
const cyan = color('36')
|
||||||
|
const yellow = color('33')
|
||||||
|
const red = color('31')
|
||||||
|
const bold = color('1')
|
||||||
|
const dim = color('2')
|
||||||
|
|
||||||
|
// ======================== 核心格式化函数 ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Markdown 的块结构,返回块数组
|
||||||
|
* 块类型: 'heading', 'code', 'list', 'paragraph', 'empty', 'hr', 'blockquote', 'table', 'html'
|
||||||
|
*/
|
||||||
|
function parseBlocks(lines) {
|
||||||
|
const blocks = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]
|
||||||
|
const trimmed = line.trim()
|
||||||
|
|
||||||
|
// 空行
|
||||||
|
if (trimmed === '') {
|
||||||
|
blocks.push({ type: 'empty', lines: [line], raw: line })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块 (``` 或 ~~~)
|
||||||
|
if (/^```/.test(trimmed) || /^~~~/.test(trimmed)) {
|
||||||
|
const marker = trimmed.match(/^(```|~~~)/)[1]
|
||||||
|
const lang = trimmed.slice(marker.length).trim()
|
||||||
|
const start = i
|
||||||
|
i++
|
||||||
|
while (i < lines.length && !lines[i].trim().startsWith(marker)) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < lines.length) i++ // 跳过结束标记
|
||||||
|
const codeLines = lines.slice(start, i)
|
||||||
|
blocks.push({
|
||||||
|
type: 'code',
|
||||||
|
lines: codeLines,
|
||||||
|
lang,
|
||||||
|
marker,
|
||||||
|
raw: codeLines.join('\n'),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
|
||||||
|
if (headingMatch) {
|
||||||
|
const level = headingMatch[1].length
|
||||||
|
blocks.push({
|
||||||
|
type: 'heading',
|
||||||
|
lines: [line],
|
||||||
|
level,
|
||||||
|
text: headingMatch[2],
|
||||||
|
raw: line,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
|
||||||
|
blocks.push({ type: 'hr', lines: [line], raw: line })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 注释或标签
|
||||||
|
if (/^<!--/.test(trimmed) || /^<\w+/.test(trimmed)) {
|
||||||
|
const start = i
|
||||||
|
i++
|
||||||
|
while (i < lines.length) {
|
||||||
|
const t = lines[i].trim()
|
||||||
|
if (/-->/.test(t) || /<\/\w+>/.test(t)) {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const htmlLines = lines.slice(start, i)
|
||||||
|
blocks.push({ type: 'html', lines: htmlLines, raw: htmlLines.join('\n') })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用块 (注: 空行不吞噬,留给后续解析,避免干扰上下文的空行判断)
|
||||||
|
if (/^>/.test(trimmed)) {
|
||||||
|
const start = i
|
||||||
|
i++
|
||||||
|
while (i < lines.length && lines[i].trimStart().startsWith('>')) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const quoteLines = lines.slice(start, i)
|
||||||
|
blocks.push({
|
||||||
|
type: 'blockquote',
|
||||||
|
lines: quoteLines,
|
||||||
|
raw: quoteLines.join('\n'),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格
|
||||||
|
if (
|
||||||
|
/\|/.test(trimmed) &&
|
||||||
|
lines[i + 1] &&
|
||||||
|
/^\|[\s\-:]+\|/.test(lines[i + 1].trim())
|
||||||
|
) {
|
||||||
|
const start = i
|
||||||
|
i += 2
|
||||||
|
while (i < lines.length && /\|/.test(lines[i].trim())) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const tableLines = lines.slice(start, i)
|
||||||
|
blocks.push({
|
||||||
|
type: 'table',
|
||||||
|
lines: tableLines,
|
||||||
|
raw: tableLines.join('\n'),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表项(有序或无序)
|
||||||
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line)) {
|
||||||
|
const start = i
|
||||||
|
i++
|
||||||
|
while (i < lines.length) {
|
||||||
|
const t = lines[i].trim()
|
||||||
|
if (t === '') {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 缩进 continuation
|
||||||
|
if (/^\s{2,}/.test(lines[i])) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const listLines = lines.slice(start, i)
|
||||||
|
blocks.push({ type: 'list', lines: listLines, raw: listLines.join('\n') })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通段落
|
||||||
|
const start = i
|
||||||
|
i++
|
||||||
|
while (i < lines.length && lines[i].trim() !== '') {
|
||||||
|
// 如果遇到新的块元素则停止
|
||||||
|
const t = lines[i].trim()
|
||||||
|
if (
|
||||||
|
/^#{1,6}\s/.test(t) ||
|
||||||
|
/^```/.test(t) ||
|
||||||
|
/^~~~/.test(t) ||
|
||||||
|
/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])
|
||||||
|
)
|
||||||
|
break
|
||||||
|
// 分割线
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) break
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const paraLines = lines.slice(start, i)
|
||||||
|
blocks.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
lines: paraLines,
|
||||||
|
raw: paraLines.join('\n'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文档
|
||||||
|
*/
|
||||||
|
function formatMarkdown(input, options = {}) {
|
||||||
|
const cfg = { ...CONFIG, ...options }
|
||||||
|
const lines = input.split('\n')
|
||||||
|
let blocks = parseBlocks(lines)
|
||||||
|
|
||||||
|
// ---------- 格式化步骤 ----------
|
||||||
|
|
||||||
|
// 1. 确保标题前后有空行
|
||||||
|
if (cfg.headingSpaceBefore || cfg.headingSpaceAfter) {
|
||||||
|
blocks = blocks.map((block, idx) => {
|
||||||
|
if (block.type !== 'heading') return block
|
||||||
|
|
||||||
|
const newLines = [...block.lines]
|
||||||
|
|
||||||
|
// 标题前加空行(如果不是第一个块且前一个不是空行)
|
||||||
|
if (
|
||||||
|
cfg.headingSpaceBefore &&
|
||||||
|
idx > 0 &&
|
||||||
|
blocks[idx - 1].type !== 'empty'
|
||||||
|
) {
|
||||||
|
newLines.unshift('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题后加空行(如果不是最后一个块且后一个不是空行)
|
||||||
|
if (
|
||||||
|
cfg.headingSpaceAfter &&
|
||||||
|
idx < blocks.length - 1 &&
|
||||||
|
blocks[idx + 1].type !== 'empty'
|
||||||
|
) {
|
||||||
|
newLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...block, lines: newLines }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 格式化代码块
|
||||||
|
blocks = blocks.map((block, idx) => {
|
||||||
|
if (block.type !== 'code') return block
|
||||||
|
|
||||||
|
const newLines = [...block.lines]
|
||||||
|
|
||||||
|
// 先提取代码块标记信息 (第一行是 ``` 或 ~~~)
|
||||||
|
const markerMatch = newLines[0].trim().match(/^(```|~~~)/)
|
||||||
|
if (!markerMatch) return block // 安全兜底
|
||||||
|
const marker = markerMatch[1]
|
||||||
|
const lang = newLines[0].trim().slice(marker.length).trim().toLowerCase()
|
||||||
|
|
||||||
|
// 标准化语言标记
|
||||||
|
const indent = newLines[0].match(/^\s*/)[0]
|
||||||
|
newLines[0] = lang ? `${indent}${marker}${lang}` : `${indent}${marker}`
|
||||||
|
|
||||||
|
// 确保代码块前后有空行(插在标准化之后,因为只动第一行)
|
||||||
|
if (
|
||||||
|
idx > 0 &&
|
||||||
|
blocks[idx - 1].type !== 'empty' &&
|
||||||
|
blocks[idx - 1].type !== 'code'
|
||||||
|
) {
|
||||||
|
newLines.unshift('')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
idx < blocks.length - 1 &&
|
||||||
|
blocks[idx + 1].type !== 'empty' &&
|
||||||
|
blocks[idx + 1].type !== 'code'
|
||||||
|
) {
|
||||||
|
newLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...block, lines: newLines }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 压缩多余空行
|
||||||
|
blocks = compressEmptyLines(blocks, cfg.maxConsecutiveBlankLines)
|
||||||
|
|
||||||
|
// 4. 删除行尾空格
|
||||||
|
if (cfg.removeTrailingSpaces) {
|
||||||
|
blocks = blocks.map((block) => ({
|
||||||
|
...block,
|
||||||
|
lines: block.lines.map((l) => l.replace(/[ \t]+$/, '')),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 修复列表缩进
|
||||||
|
blocks = blocks.map((block) => {
|
||||||
|
if (block.type !== 'list') return block
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
lines: block.lines.map((line) => {
|
||||||
|
const trimmed = line.trimStart()
|
||||||
|
// 检测列表标记
|
||||||
|
if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) {
|
||||||
|
const indent = line.length - line.trimStart().length
|
||||||
|
// 如果是顶层列表项,确保缩进为0
|
||||||
|
if (indent % cfg.listIndent !== 0) {
|
||||||
|
const normalizedIndent =
|
||||||
|
Math.round(indent / cfg.listIndent) * cfg.listIndent
|
||||||
|
return ' '.repeat(normalizedIndent) + trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新拼装
|
||||||
|
const resultLines = blocks.flatMap((b) => b.lines)
|
||||||
|
|
||||||
|
// 处理文件开头和结尾的空行
|
||||||
|
while (resultLines.length > 0 && resultLines[0] === '') resultLines.shift()
|
||||||
|
while (resultLines.length > 0 && resultLines[resultLines.length - 1] === '')
|
||||||
|
resultLines.pop()
|
||||||
|
resultLines.push('') // 文件结尾留一个空行
|
||||||
|
|
||||||
|
return resultLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩连续空行块
|
||||||
|
*/
|
||||||
|
function compressEmptyLines(blocks, maxBlank) {
|
||||||
|
const result = []
|
||||||
|
let blankCount = 0
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'empty') {
|
||||||
|
blankCount++
|
||||||
|
if (blankCount <= maxBlank) {
|
||||||
|
result.push(block)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blankCount = 0
|
||||||
|
result.push(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 统计信息 ========================
|
||||||
|
|
||||||
|
function getStats(input) {
|
||||||
|
const lines = input.split('\n')
|
||||||
|
const blocks = parseBlocks(lines)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLines: lines.length,
|
||||||
|
nonEmptyLines: lines.filter((l) => l.trim() !== '').length,
|
||||||
|
headings: blocks.filter((b) => b.type === 'heading').length,
|
||||||
|
codeBlocks: blocks.filter((b) => b.type === 'code').length,
|
||||||
|
lists: blocks.filter((b) => b.type === 'list').length,
|
||||||
|
tables: blocks.filter((b) => b.type === 'table').length,
|
||||||
|
blockquotes: blocks.filter((b) => b.type === 'blockquote').length,
|
||||||
|
hr: blocks.filter((b) => b.type === 'hr').length,
|
||||||
|
characters: input.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printStats(stats) {
|
||||||
|
console.log(bold('\n📊 文档统计信息:'))
|
||||||
|
console.log(` 总行数: ${cyan(String(stats.totalLines))}`)
|
||||||
|
console.log(` 非空行数: ${cyan(String(stats.nonEmptyLines))}`)
|
||||||
|
console.log(` 字符数: ${cyan(String(stats.characters))}`)
|
||||||
|
console.log(` 标题数: ${cyan(String(stats.headings))}`)
|
||||||
|
console.log(` 代码块数: ${cyan(String(stats.codeBlocks))}`)
|
||||||
|
console.log(` 列表数: ${cyan(String(stats.lists))}`)
|
||||||
|
console.log(` 表格数: ${cyan(String(stats.tables))}`)
|
||||||
|
console.log(` 引用块数: ${cyan(String(stats.blockquotes))}`)
|
||||||
|
console.log(` 分割线数: ${cyan(String(stats.hr))}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 文件处理 ========================
|
||||||
|
|
||||||
|
function readFile(filePath) {
|
||||||
|
try {
|
||||||
|
let content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
// 统一换行符为 \n,避免 Windows 的 \r\n 导致 diff 不稳定
|
||||||
|
content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
return content
|
||||||
|
} catch (err) {
|
||||||
|
console.error(red(`❌ 无法读取文件: ${filePath}`))
|
||||||
|
console.error(dim(err.message))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(filePath, content) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8')
|
||||||
|
console.log(green(`✅ 已写入: ${filePath}`))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(red(`❌ 写入失败: ${filePath}`))
|
||||||
|
console.error(dim(err.message))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFile(filePath, options) {
|
||||||
|
const relativePath = path.relative(process.cwd(), filePath)
|
||||||
|
console.log(bold(`\n📄 处理文件: ${cyan(relativePath)}`))
|
||||||
|
|
||||||
|
const input = readFile(filePath)
|
||||||
|
const statsBefore = getStats(input)
|
||||||
|
|
||||||
|
if (options.verbose) {
|
||||||
|
console.log(dim(' 格式化前:'))
|
||||||
|
printStats(statsBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatMarkdown(input, options)
|
||||||
|
const statsAfter = getStats(formatted)
|
||||||
|
|
||||||
|
const hasChanges = input !== formatted
|
||||||
|
|
||||||
|
if (options.check) {
|
||||||
|
if (hasChanges) {
|
||||||
|
console.log(yellow(' ⚠️ 文件需要格式化 (dry-run, 未写入)'))
|
||||||
|
const added = statsAfter.totalLines - statsBefore.totalLines
|
||||||
|
console.log(dim(` 行数变化: ${added > 0 ? '+' : ''}${added}`))
|
||||||
|
} else {
|
||||||
|
console.log(green(' ✅ 文件已格式良好'))
|
||||||
|
}
|
||||||
|
return hasChanges ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
writeFile(filePath, formatted)
|
||||||
|
console.log(green(' ✨ 格式化完成!'))
|
||||||
|
if (options.verbose) {
|
||||||
|
printStats(statsAfter)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(green(' ✅ 文档已经格式良好,无需修改'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function processDirectory(dirPath, options) {
|
||||||
|
let exitCode = 0
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(dirPath)
|
||||||
|
.filter((f) => f.endsWith('.md'))
|
||||||
|
.map((f) => path.join(dirPath, f))
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log(yellow(`⚠️ 在 ${dirPath} 中未找到 .md 文件`))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
bold(`\n📁 扫描目录: ${cyan(dirPath)} (${files.length} 个文件)\n`),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const ret = processFile(file, options)
|
||||||
|
if (ret !== 0) exitCode = ret
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== CLI ========================
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(
|
||||||
|
bold(`
|
||||||
|
📝 Markdown 文档格式化工具 v1.0.0
|
||||||
|
|
||||||
|
${cyan('用法:')}
|
||||||
|
node scripts/format-docs.js [文件路径] [选项]
|
||||||
|
|
||||||
|
${cyan('参数:')}
|
||||||
|
文件路径 要格式化的 .md 文件 (默认: public/docs/home.md)
|
||||||
|
|
||||||
|
${cyan('选项:')}
|
||||||
|
--dir, -d <目录> 格式化整个目录下的所有 .md 文件
|
||||||
|
--check, -c dry-run 模式,只检查不写入
|
||||||
|
--verbose, -v 显示详细统计信息
|
||||||
|
--help, -h 显示帮助信息
|
||||||
|
|
||||||
|
${cyan('示例:')}
|
||||||
|
node scripts/format-docs.js
|
||||||
|
node scripts/format-docs.js README.md
|
||||||
|
node scripts/format-docs.js --dir docs/
|
||||||
|
node scripts/format-docs.js --check
|
||||||
|
`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const options = {
|
||||||
|
file: null,
|
||||||
|
dir: null,
|
||||||
|
check: false,
|
||||||
|
verbose: false,
|
||||||
|
help: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
switch (args[i]) {
|
||||||
|
case '--dir':
|
||||||
|
case '-d':
|
||||||
|
options.dir = args[++i]
|
||||||
|
break
|
||||||
|
case '--check':
|
||||||
|
case '-c':
|
||||||
|
options.check = true
|
||||||
|
break
|
||||||
|
case '--verbose':
|
||||||
|
case '-v':
|
||||||
|
options.verbose = true
|
||||||
|
break
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
options.help = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if (!args[i].startsWith('--') && !args[i].startsWith('-')) {
|
||||||
|
options.file = path.resolve(process.cwd(), args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 主函数 ========================
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const options = parseArgs()
|
||||||
|
|
||||||
|
if (options.help) {
|
||||||
|
printHelp()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bold(`\n${cyan('🐱 文档格式化工具')} ${dim('(用❤️制作)')}\n`))
|
||||||
|
|
||||||
|
if (options.dir) {
|
||||||
|
const dirPath = path.resolve(process.cwd(), options.dir)
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
console.error(red(`❌ 目录不存在: ${dirPath}`))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return processDirectory(dirPath, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = options.file || DEFAULT_FILE
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(red(`❌ 文件不存在: ${filePath}`))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return processFile(filePath, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 启动 ========================
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const exitCode = main()
|
||||||
|
process.exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatMarkdown, getStats, parseBlocks }
|
||||||
37
server.js
37
server.js
@ -10,6 +10,7 @@ const { cookieToJson } = require('./util/index')
|
|||||||
const fileUpload = require('express-fileupload')
|
const fileUpload = require('express-fileupload')
|
||||||
const decode = require('safe-decode-uri-component')
|
const decode = require('safe-decode-uri-component')
|
||||||
const logger = require('./util/logger.js')
|
const logger = require('./util/logger.js')
|
||||||
|
const { APP_CONF } = require('./util/config.json')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The version check result.
|
* The version check result.
|
||||||
@ -299,12 +300,17 @@ async function constructServer(moduleDefs) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let usedCrypto = ''
|
||||||
const moduleResponse = await moduleDef.module(query, (...params) => {
|
const moduleResponse = await moduleDef.module(query, (...params) => {
|
||||||
// 参数注入客户端IP
|
|
||||||
const obj = [...params]
|
const obj = [...params]
|
||||||
const options = obj[2] || {}
|
const options = obj[2] || {}
|
||||||
if (!options.randomCNIP) {
|
usedCrypto = options.crypto || ''
|
||||||
let ip = req.ip
|
let ip = ''
|
||||||
|
|
||||||
|
if (options.randomCNIP) {
|
||||||
|
ip = global.cnIp
|
||||||
|
} else {
|
||||||
|
ip = req.ip
|
||||||
|
|
||||||
if (ip.substring(0, 7) == '::ffff:') {
|
if (ip.substring(0, 7) == '::ffff:') {
|
||||||
ip = ip.substring(7)
|
ip = ip.substring(7)
|
||||||
@ -312,16 +318,19 @@ async function constructServer(moduleDefs) {
|
|||||||
if (ip == '::1') {
|
if (ip == '::1') {
|
||||||
ip = global.cnIp
|
ip = global.cnIp
|
||||||
}
|
}
|
||||||
// logger.info('Requested from ip:', ip)
|
}
|
||||||
obj[2] = {
|
|
||||||
...options,
|
obj[2] = {
|
||||||
ip,
|
...options,
|
||||||
}
|
ip,
|
||||||
}
|
}
|
||||||
|
|
||||||
return request(...obj)
|
return request(...obj)
|
||||||
})
|
})
|
||||||
logger.info(`Request Success: ${decode(req.originalUrl)}`)
|
const displayCrypto = usedCrypto || (APP_CONF.encrypt ? 'eapi' : 'api')
|
||||||
|
logger.info(
|
||||||
|
`Request Success: [${displayCrypto}] ${decode(req.originalUrl)}`,
|
||||||
|
)
|
||||||
|
|
||||||
// 夹带私货部分:如果开启了通用解锁,并且是获取歌曲URL的接口,则尝试解锁(如果需要的话)ヾ(≧▽≦*)o
|
// 夹带私货部分:如果开启了通用解锁,并且是获取歌曲URL的接口,则尝试解锁(如果需要的话)ヾ(≧▽≦*)o
|
||||||
if (
|
if (
|
||||||
@ -416,7 +425,7 @@ async function serveNcmApi(options) {
|
|||||||
options.checkVersion &&
|
options.checkVersion &&
|
||||||
checkVersion().then(({ npmVersion, ourVersion, status }) => {
|
checkVersion().then(({ npmVersion, ourVersion, status }) => {
|
||||||
if (status == VERSION_CHECK_RESULT.NOT_LATEST) {
|
if (status == VERSION_CHECK_RESULT.NOT_LATEST) {
|
||||||
logger.info(
|
logger.warn(
|
||||||
`最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`,
|
`最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -438,11 +447,9 @@ async function serveNcmApi(options) {
|
|||||||
╠═╣╠═╝║ ║╣ ║║║╠═╣╠═╣║║║║ ║╣ ║║
|
╠═╣╠═╝║ ║╣ ║║║╠═╣╠═╣║║║║ ║╣ ║║
|
||||||
╩ ╩╩ ╩ ╚═╝╝╚╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝
|
╩ ╩╩ ╩ ╚═╝╝╚╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝
|
||||||
`)
|
`)
|
||||||
logger.info(`
|
logger.info(
|
||||||
- Server started successfully @ http://${host ? host : 'localhost'}:${port}
|
`Server started successfully @ http://${host ? host : 'localhost'}:${port}`,
|
||||||
- Environment: ${process.env.NODE_ENV || 'development'}
|
)
|
||||||
- Node Version: ${process.version}
|
|
||||||
- Process ID: ${process.pid}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return appExt
|
return appExt
|
||||||
|
|||||||
@ -11,7 +11,10 @@
|
|||||||
},
|
},
|
||||||
"APP_CONF": {
|
"APP_CONF": {
|
||||||
"apiDomain": "https://interface.music.163.com",
|
"apiDomain": "https://interface.music.163.com",
|
||||||
|
"xeapiDomain": "https://interface3.music.163.com",
|
||||||
"domain": "https://music.163.com",
|
"domain": "https://music.163.com",
|
||||||
|
"clDomian": "https://clientlog.music.163.com",
|
||||||
|
"clDomian3": "https://clientlog3.music.163.com",
|
||||||
"encrypt": true,
|
"encrypt": true,
|
||||||
"encryptResponse": false,
|
"encryptResponse": false,
|
||||||
"clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9",
|
"clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9",
|
||||||
|
|||||||
167
util/crypto.js
167
util/crypto.js
@ -1,4 +1,5 @@
|
|||||||
const CryptoJS = require('crypto-js')
|
const CryptoJS = require('crypto-js')
|
||||||
|
const crypto = require('crypto')
|
||||||
const forge = require('node-forge')
|
const forge = require('node-forge')
|
||||||
const zlib = require('zlib')
|
const zlib = require('zlib')
|
||||||
const iv = '0102030405060708'
|
const iv = '0102030405060708'
|
||||||
@ -9,6 +10,13 @@ const publicKey = `-----BEGIN PUBLIC KEY-----
|
|||||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB
|
||||||
-----END PUBLIC KEY-----`
|
-----END PUBLIC KEY-----`
|
||||||
const eapiKey = 'e82ckenh8dichen8'
|
const eapiKey = 'e82ckenh8dichen8'
|
||||||
|
const xeapiStaticKey = Buffer.from(
|
||||||
|
'ab1d5a430f6bb04a3f01e81ddd72bd916d5ce591248ac128714806d7f8fb1b84',
|
||||||
|
'hex',
|
||||||
|
)
|
||||||
|
const xeapiSignKey =
|
||||||
|
'mUHCwVNWJbunMqAHf5MImuirT6plvs6VSFW62MGHstFQxhBGdEoIhLItH3djc4+FB/OKty3+lL2rGeoFBpVe5g=='
|
||||||
|
const x25519SpkiPrefix = Buffer.from('302a300506032b656e032100', 'hex')
|
||||||
|
|
||||||
const aesEncrypt = (text, mode, key, iv, format = 'base64') => {
|
const aesEncrypt = (text, mode, key, iv, format = 'base64') => {
|
||||||
let encrypted = CryptoJS.AES.encrypt(
|
let encrypted = CryptoJS.AES.encrypt(
|
||||||
@ -141,13 +149,172 @@ const decrypt = (cipher) => {
|
|||||||
return decryptedBytes
|
return decryptedBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aesEcbEncrypt = (key, plaintext) => {
|
||||||
|
const cipher = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null)
|
||||||
|
return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const aesEcbDecrypt = (key, ciphertext) => {
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
`aes-${key.length * 8}-ecb`,
|
||||||
|
key,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const createX25519PublicKey = (raw) => {
|
||||||
|
// Node's crypto API expects X25519 public keys as DER SubjectPublicKeyInfo.
|
||||||
|
// The Android SDK stores only the 32-byte raw key, so prepend the fixed
|
||||||
|
// RFC 8410 SPKI header for id-X25519 before importing it.
|
||||||
|
return crypto.createPublicKey({
|
||||||
|
key: Buffer.concat([x25519SpkiPrefix, raw]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deriveX25519AesKey = (sharedSecret, ephemeralPublicKey) => {
|
||||||
|
const prk = crypto
|
||||||
|
.createHmac('sha256', Buffer.alloc(32))
|
||||||
|
.update(sharedSecret.length ? sharedSecret : Buffer.alloc(32))
|
||||||
|
.digest()
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', prk)
|
||||||
|
.update(Buffer.concat([ephemeralPublicKey, Buffer.from([1])]))
|
||||||
|
.digest()
|
||||||
|
.subarray(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapiSign = (timestamp, nonce) => {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', xeapiSignKey)
|
||||||
|
.update(String(timestamp) + nonce)
|
||||||
|
.digest('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapiMidTransform = (ciphertext) => {
|
||||||
|
const random = crypto.randomBytes(16)
|
||||||
|
const xored = Buffer.alloc(ciphertext.length)
|
||||||
|
for (let i = 0; i < ciphertext.length; i++) {
|
||||||
|
xored[i] = ciphertext[i] ^ random[i & 0x0f]
|
||||||
|
}
|
||||||
|
const b64 = Buffer.from(xored.toString('base64'))
|
||||||
|
const rot = b64.length ? (random[0] & 0x0f) % b64.length : 0
|
||||||
|
return Buffer.concat([random, b64.subarray(rot), b64.subarray(0, rot)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapiEncryptS = (dynamicKey, publicKeyState, os) => {
|
||||||
|
const peerRaw = Buffer.from(publicKeyState.publicKey, 'base64')
|
||||||
|
const peerKey = createX25519PublicKey(peerRaw)
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519')
|
||||||
|
const ephemeralRaw = Buffer.from(
|
||||||
|
publicKey.export({ format: 'der', type: 'spki' }),
|
||||||
|
).subarray(-32)
|
||||||
|
const sharedSecret = crypto.diffieHellman({
|
||||||
|
privateKey,
|
||||||
|
publicKey: peerKey,
|
||||||
|
})
|
||||||
|
const aesKey = deriveX25519AesKey(sharedSecret, ephemeralRaw)
|
||||||
|
const iv = crypto.randomBytes(12)
|
||||||
|
const cipher = crypto.createCipheriv('aes-128-gcm', aesKey, iv)
|
||||||
|
const plaintext = Buffer.from(
|
||||||
|
`${dynamicKey.toString('base64')}|${os}|${publicKeyState.sk || ''}`,
|
||||||
|
)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
|
||||||
|
return Buffer.concat([ephemeralRaw, iv, encrypted, cipher.getAuthTag()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildXeapiPlaintext = (uri, data, options = {}) => {
|
||||||
|
const fields = {}
|
||||||
|
const contentType =
|
||||||
|
options.contentType || 'application/x-www-form-urlencoded;charset=utf-8'
|
||||||
|
const mediaType = contentType.split(';', 1)[0].toLowerCase()
|
||||||
|
if (mediaType !== 'application/x-www-form-urlencoded') {
|
||||||
|
fields.contentType = contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (options.method || 'POST').toUpperCase()
|
||||||
|
if (method !== 'POST') fields.method = method
|
||||||
|
|
||||||
|
const url = new URL(uri, 'https://interface.music.163.com')
|
||||||
|
if (url.search) fields.queryString = url.search.slice(1)
|
||||||
|
|
||||||
|
if (data !== undefined && data !== null) {
|
||||||
|
const bodyData = { ...data }
|
||||||
|
delete bodyData.e_r
|
||||||
|
const body = Buffer.from(new URLSearchParams(bodyData).toString())
|
||||||
|
fields.body = body.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.queryString) {
|
||||||
|
fields.queryString += '&e_r=true'
|
||||||
|
} else {
|
||||||
|
fields.queryString = 'e_r=true'
|
||||||
|
}
|
||||||
|
return JSON.stringify(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapi = (uri, data, options = {}) => {
|
||||||
|
const publicKeyState = options.publicKeyState
|
||||||
|
if (!publicKeyState) {
|
||||||
|
throw new Error('xeapi publicKeyState is required')
|
||||||
|
}
|
||||||
|
const activeSessionKey = options.sessionKey
|
||||||
|
? Buffer.from(String(options.sessionKey))
|
||||||
|
: null
|
||||||
|
const activeSessionId = options.sessionId || ''
|
||||||
|
const dynamicKey = activeSessionKey || crypto.randomBytes(16)
|
||||||
|
const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options))
|
||||||
|
|
||||||
|
const b = aesEcbEncrypt(
|
||||||
|
dynamicKey,
|
||||||
|
xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)),
|
||||||
|
)
|
||||||
|
const s = xeapiEncryptS(dynamicKey, publicKeyState, options.os || 'android')
|
||||||
|
const r = aesEcbEncrypt(
|
||||||
|
xeapiStaticKey,
|
||||||
|
Buffer.from(
|
||||||
|
`${publicKeyState.version}|${activeSessionKey ? activeSessionId : ''}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
B: b.toString('base64'),
|
||||||
|
S: s.toString('base64'),
|
||||||
|
R: r.toString('base64'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapiResDecrypt = (body) => {
|
||||||
|
const decrypted = aesEcbDecrypt(eapiKey, body)
|
||||||
|
const plaintext =
|
||||||
|
decrypted[0] === 0x1f && decrypted[1] === 0x8b
|
||||||
|
? zlib.gunzipSync(decrypted)
|
||||||
|
: decrypted
|
||||||
|
return JSON.parse(plaintext.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const xeapiDecryptPublicKey = (encryptedData) => {
|
||||||
|
return JSON.parse(
|
||||||
|
aesEcbDecrypt(
|
||||||
|
xeapiStaticKey,
|
||||||
|
Buffer.from(encryptedData, 'base64'),
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
weapi,
|
weapi,
|
||||||
linuxapi,
|
linuxapi,
|
||||||
eapi,
|
eapi,
|
||||||
|
xeapi,
|
||||||
decrypt,
|
decrypt,
|
||||||
aesEncrypt,
|
aesEncrypt,
|
||||||
aesDecrypt,
|
aesDecrypt,
|
||||||
eapiReqDecrypt,
|
eapiReqDecrypt,
|
||||||
eapiResDecrypt,
|
eapiResDecrypt,
|
||||||
|
xeapiSign,
|
||||||
|
xeapiResDecrypt,
|
||||||
|
xeapiDecryptPublicKey,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,9 +62,9 @@ const chinaIPRanges = (function loadChinaIPRanges() {
|
|||||||
// attach total for convenience
|
// attach total for convenience
|
||||||
arr.totalCount = total
|
arr.totalCount = total
|
||||||
|
|
||||||
logger.info(
|
// logger.info(
|
||||||
`Loaded ${arr.length} Chinese IP ranges from china_ip_ranges.txt, total ${total} IPs`,
|
// `Loaded ${arr.length} Chinese IP ranges from china_ip_ranges.txt, total ${total} IPs`,
|
||||||
)
|
// )
|
||||||
return arr
|
return arr
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load china_ip_ranges.txt:', error.message)
|
logger.error('Failed to load china_ip_ranges.txt:', error.message)
|
||||||
|
|||||||
481
util/ncbl.js
Normal file
481
util/ncbl.js
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
// NCBL 加密工具 —— 复制自 @netease-report-listen-song
|
||||||
|
// 提供 ChaCha20 / RSA-256 / NCBL 加密 / PLV+PLD 构建 / 设备上下文提取
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const zlib = require('zlib')
|
||||||
|
const axios = require('axios')
|
||||||
|
const { APP_CONF } = require('./config.json')
|
||||||
|
const DOMAIN3 = APP_CONF.clDomian3
|
||||||
|
|
||||||
|
// ---- ChaCha20 纯 JS 实现 ----
|
||||||
|
const SIGMA = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
|
||||||
|
|
||||||
|
const rotl = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0
|
||||||
|
|
||||||
|
const quarterRound = (s, a, b, c, d) => {
|
||||||
|
s[a] = (s[a] + s[b]) >>> 0
|
||||||
|
s[d] ^= s[a]
|
||||||
|
s[d] = rotl(s[d], 16)
|
||||||
|
s[c] = (s[c] + s[d]) >>> 0
|
||||||
|
s[b] ^= s[c]
|
||||||
|
s[b] = rotl(s[b], 12)
|
||||||
|
s[a] = (s[a] + s[b]) >>> 0
|
||||||
|
s[d] ^= s[a]
|
||||||
|
s[d] = rotl(s[d], 8)
|
||||||
|
s[c] = (s[c] + s[d]) >>> 0
|
||||||
|
s[b] ^= s[c]
|
||||||
|
s[b] = rotl(s[b], 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chachaBlock = (key, counter, nonce) => {
|
||||||
|
const state = new Uint32Array(16)
|
||||||
|
state[0] = SIGMA[0]
|
||||||
|
state[1] = SIGMA[1]
|
||||||
|
state[2] = SIGMA[2]
|
||||||
|
state[3] = SIGMA[3]
|
||||||
|
for (let i = 0; i < 8; i++) state[4 + i] = key.readUInt32LE(i * 4)
|
||||||
|
state[12] = counter >>> 0
|
||||||
|
state[13] = nonce.readUInt32LE(0)
|
||||||
|
state[14] = nonce.readUInt32LE(4)
|
||||||
|
state[15] = nonce.readUInt32LE(8)
|
||||||
|
|
||||||
|
const work = state.slice()
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
quarterRound(work, 0, 4, 8, 12)
|
||||||
|
quarterRound(work, 1, 5, 9, 13)
|
||||||
|
quarterRound(work, 2, 6, 10, 14)
|
||||||
|
quarterRound(work, 3, 7, 11, 15)
|
||||||
|
quarterRound(work, 0, 5, 10, 15)
|
||||||
|
quarterRound(work, 1, 6, 11, 12)
|
||||||
|
quarterRound(work, 2, 7, 8, 13)
|
||||||
|
quarterRound(work, 3, 4, 9, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = Buffer.allocUnsafe(64)
|
||||||
|
for (let i = 0; i < 16; i++)
|
||||||
|
out.writeUInt32LE((work[i] + state[i]) >>> 0, i * 4)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const chacha20 = (key, counter, nonce, data) => {
|
||||||
|
const out = Buffer.allocUnsafe(data.length)
|
||||||
|
for (let off = 0; off < data.length; off += 64) {
|
||||||
|
const ks = chachaBlock(key, (counter + (off >>> 6)) >>> 0, nonce)
|
||||||
|
const end = Math.min(off + 64, data.length)
|
||||||
|
for (let i = off; i < end; i++) out[i] = data[i] ^ ks[i - off]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RSA-256 Key Wrap (网易 NCBL 专用) ----
|
||||||
|
// 256-bit 模数 N 已被因式分解,故可直接计算私钥
|
||||||
|
const RSA_N =
|
||||||
|
0xfd90bd466ff9bc8a3fec2fbcf263b90d5c564879fa5d7aab89b31c1d5cb4139dn
|
||||||
|
const RSA_E = 65537n
|
||||||
|
|
||||||
|
const beToBig = (buf) => {
|
||||||
|
let n = 0n
|
||||||
|
for (const b of buf) n = (n << 8n) | BigInt(b)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
const bigToBe = (n, len) => {
|
||||||
|
const out = Buffer.alloc(len)
|
||||||
|
for (let i = len - 1; i >= 0; i--) {
|
||||||
|
out[i] = Number(n & 0xffn)
|
||||||
|
n >>= 8n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const modPow = (base, exp, mod) => {
|
||||||
|
let result = 1n
|
||||||
|
base %= mod
|
||||||
|
while (exp > 0n) {
|
||||||
|
if (exp & 1n) result = (result * base) % mod
|
||||||
|
base = (base * base) % mod
|
||||||
|
exp >>= 1n
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsaWrap = (keyA) => {
|
||||||
|
return bigToBe(modPow(beToBig(keyA), RSA_E, RSA_N), 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NCBL 加密格式 ----
|
||||||
|
const MAGIC = Buffer.from('NCBL', 'ascii')
|
||||||
|
const NCBL_VERSION = 3
|
||||||
|
const HEADER_FIXED_LEN = 70
|
||||||
|
const META_BLOCK_TYPE = 0x4343
|
||||||
|
const DEFAULT_MAX_FRAME = 0x8000
|
||||||
|
|
||||||
|
const getCompress = () => {
|
||||||
|
if (typeof zlib.zstdCompressSync === 'function') {
|
||||||
|
return { compress: (buf) => zlib.zstdCompressSync(buf), name: 'zstd' }
|
||||||
|
}
|
||||||
|
return { compress: (buf) => zlib.gzipSync(buf), name: 'gzip' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptNCBL = (meta, body, opts = {}) => {
|
||||||
|
const metaBuf = Buffer.isBuffer(meta) ? meta : Buffer.from(meta, 'utf-8')
|
||||||
|
const bodyBuf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf-8')
|
||||||
|
const maxFrame = opts.maxFrame || DEFAULT_MAX_FRAME
|
||||||
|
|
||||||
|
const keyA = opts.keyA || crypto.randomBytes(32)
|
||||||
|
if (keyA[0] >= 0xa3) keyA[0] = 0xa2
|
||||||
|
|
||||||
|
const keyB = rsaWrap(keyA)
|
||||||
|
|
||||||
|
const uuid = opts.uuid || crypto.randomBytes(16)
|
||||||
|
if (!opts.uuid) {
|
||||||
|
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
||||||
|
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
||||||
|
}
|
||||||
|
const nonce = uuid.subarray(0, 12)
|
||||||
|
const counter = uuid.readUInt32LE(12) >>> 2
|
||||||
|
const baseSeq = opts.baseSeq || crypto.randomBytes(2).readUInt16LE(0)
|
||||||
|
|
||||||
|
const metaCipher = chacha20(keyB, counter, nonce, metaBuf)
|
||||||
|
const metaBlock = Buffer.concat([
|
||||||
|
(() => {
|
||||||
|
const h = Buffer.allocUnsafe(4)
|
||||||
|
h.writeUInt16LE(META_BLOCK_TYPE, 0)
|
||||||
|
h.writeUInt16LE(metaCipher.length, 2)
|
||||||
|
return h
|
||||||
|
})(),
|
||||||
|
metaCipher,
|
||||||
|
])
|
||||||
|
const headerLen = HEADER_FIXED_LEN + metaBlock.length
|
||||||
|
|
||||||
|
const { compress } = getCompress()
|
||||||
|
const compressed = compress(bodyBuf)
|
||||||
|
|
||||||
|
const frames = []
|
||||||
|
let seq = baseSeq
|
||||||
|
for (let off = 0; off < compressed.length || off === 0; off += maxFrame) {
|
||||||
|
const slice = compressed.subarray(off, off + maxFrame)
|
||||||
|
const cipher = chacha20(keyA, counter, nonce, slice)
|
||||||
|
const head = Buffer.allocUnsafe(6)
|
||||||
|
head.writeUInt16LE(cipher.length, 0)
|
||||||
|
head.writeUInt32LE(seq >>> 0, 2)
|
||||||
|
frames.push(head, cipher)
|
||||||
|
seq++
|
||||||
|
if (compressed.length === 0) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailing = Buffer.concat(frames)
|
||||||
|
const frameCount = seq - baseSeq
|
||||||
|
|
||||||
|
const header = Buffer.alloc(HEADER_FIXED_LEN)
|
||||||
|
MAGIC.copy(header, 0)
|
||||||
|
header.writeUInt32LE(NCBL_VERSION, 4)
|
||||||
|
header.writeUInt16LE(headerLen, 8)
|
||||||
|
uuid.copy(header, 10)
|
||||||
|
keyB.copy(header, 26)
|
||||||
|
header.writeUInt32LE(baseSeq >>> 0, 58)
|
||||||
|
header.writeUInt32LE((baseSeq + frameCount - 1) >>> 0, 62)
|
||||||
|
header.writeUInt32LE(trailing.length, 66)
|
||||||
|
|
||||||
|
return Buffer.concat([header, metaBlock, trailing])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 日志记录格式 ----
|
||||||
|
const FIELD_SEP = '\x01'
|
||||||
|
|
||||||
|
const buildRecord = ({ time, action, data }) => {
|
||||||
|
const json = typeof data === 'string' ? data : JSON.stringify(data)
|
||||||
|
return [time, action, json].join(FIELD_SEP)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRecords = (records) => records.map(buildRecord).join('')
|
||||||
|
|
||||||
|
// ---- PLV / PLD 构建器 (桌面客户端格式) ----
|
||||||
|
const buildPlv = (ctx, song, source) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const addRefer = `[F:63][${now}#933#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][23][cell_pc_songlist_song:2|page_pc_songlist_songflow|page_mine_like_music][${song.id}:song:x:x|:::|${source.id}:list::]`
|
||||||
|
const multiRefers = [
|
||||||
|
'[F:26][s][18][_ai]',
|
||||||
|
'[F:26][s][12][_ai]',
|
||||||
|
`[F:63][${now}#933#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][8][cell_pc_main_tab_entrance:6|page_pc_main_tab][我喜欢的音乐:spm::|:::]`,
|
||||||
|
'[F:26][s][5][_ai]',
|
||||||
|
'[F:26][s][0][_ai]',
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'circulation',
|
||||||
|
download: 0,
|
||||||
|
alg: '',
|
||||||
|
status: 'front',
|
||||||
|
id: String(song.id),
|
||||||
|
bitrate: song.bitrate,
|
||||||
|
type: 'song',
|
||||||
|
is_listentogether: 0,
|
||||||
|
source: source.name,
|
||||||
|
is_heart: 0,
|
||||||
|
resource_ratio: '',
|
||||||
|
resource_time: song.time,
|
||||||
|
musiceffect_id: '',
|
||||||
|
app_mode: 2,
|
||||||
|
bitrate_level: song.level,
|
||||||
|
_addrefer: addRefer,
|
||||||
|
_multirefers: multiRefers,
|
||||||
|
vipType: ctx.auth.vipType,
|
||||||
|
fee: 1,
|
||||||
|
file: 4,
|
||||||
|
rightSource: 0,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourcetype: source.type,
|
||||||
|
libra_abt: '',
|
||||||
|
channel: ctx.app.channel,
|
||||||
|
curStartChannel: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPld = (ctx, song, source, played) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const addRefer = `[F:63][${now}#616#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][92][btn_pc_cover_play|cell_pc_songlist_song:6|page_pc_songlist_songflow|page_mine_like_music][:::|${song.id}:song:x:x|:::|${source.id}:list::]`
|
||||||
|
const multiRefers = [
|
||||||
|
'[F:26][s][87][_ai]',
|
||||||
|
'[F:26][s][81][_ai]',
|
||||||
|
'[F:26][s][75][_ai]',
|
||||||
|
'[F:26][s][69][_ai]',
|
||||||
|
'[F:26][s][63][_ai]',
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'circulation',
|
||||||
|
download: 0,
|
||||||
|
alg: '',
|
||||||
|
status: 'front',
|
||||||
|
id: String(song.id),
|
||||||
|
time: played,
|
||||||
|
type: 'song',
|
||||||
|
is_listentogether: 0,
|
||||||
|
source: source.name,
|
||||||
|
is_heart: 0,
|
||||||
|
realtime: played,
|
||||||
|
resource_ratio: '',
|
||||||
|
resource_time: song.time,
|
||||||
|
musiceffect_id: '1001',
|
||||||
|
app_mode: 1,
|
||||||
|
lyriceffect: 'default',
|
||||||
|
displayMode: 'classic',
|
||||||
|
bitrate: song.bitrate,
|
||||||
|
bitrate_level: song.level,
|
||||||
|
_addrefer: addRefer,
|
||||||
|
_multirefers: multiRefers,
|
||||||
|
vipType: ctx.auth.vipType,
|
||||||
|
fee: 8,
|
||||||
|
file: 4,
|
||||||
|
rightSource: 0,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourcetype: source.type,
|
||||||
|
end: 'interrupt',
|
||||||
|
libra_abt: '',
|
||||||
|
channel: ctx.app.channel,
|
||||||
|
curStartChannel: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cookie → 设备/认证上下文 转换 ----
|
||||||
|
const extractContext = (cookieObj) => {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
id: cookieObj.appid || '',
|
||||||
|
urs: '',
|
||||||
|
pid: '',
|
||||||
|
nsm: cookieObj.WEVNSM || '1.0.0',
|
||||||
|
cid:
|
||||||
|
cookieObj.WNMCID ||
|
||||||
|
`${crypto.randomBytes(3).toString('hex')}.${Date.now()}.01.0`,
|
||||||
|
channel: cookieObj.channel || 'netease',
|
||||||
|
version: cookieObj.appver || '3.1.35',
|
||||||
|
versionCode: cookieObj.versioncode || '205293',
|
||||||
|
buildCode: cookieObj.buildver || '',
|
||||||
|
buildType: 'release',
|
||||||
|
packageId: '',
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
id: cookieObj.deviceId || cookieObj.sDeviceId || '',
|
||||||
|
ti: cookieObj.NMTID || '',
|
||||||
|
sign: cookieObj.clientSign || '',
|
||||||
|
model: cookieObj.mode || cookieObj.mobilename || '',
|
||||||
|
nnid: cookieObj._ntes_nnid || ',',
|
||||||
|
nuid: cookieObj._ntes_nuid || '',
|
||||||
|
csrf: cookieObj.__csrf || '',
|
||||||
|
systemType: cookieObj.os || 'pc',
|
||||||
|
systemVersion:
|
||||||
|
cookieObj.osver ||
|
||||||
|
'Microsoft-Windows-10-Professional-build-19045-64bit',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
id: cookieObj.uid || '',
|
||||||
|
token: cookieObj.MUSIC_U || '',
|
||||||
|
sessionId: cookieObj['JSESSIONID-WYYY'] || '',
|
||||||
|
vipType: cookieObj.vipType || '',
|
||||||
|
},
|
||||||
|
startTime: Date.now(),
|
||||||
|
processId: Math.floor(Math.random() * 90000) + 10000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 query.cookie 解析 cookie 对象
|
||||||
|
// 支持字符串 ("key=val; key2=val2") 或已解析的对象
|
||||||
|
// 注意:不做 URL decode!MUSIC_U 中的 '+' 会被 decodeURIComponent 错误地转为空格
|
||||||
|
const parseCookie = (cookie) => {
|
||||||
|
if (typeof cookie === 'object' && cookie !== null) return cookie
|
||||||
|
if (typeof cookie === 'string') {
|
||||||
|
const obj = {}
|
||||||
|
cookie.split(';').forEach((part) => {
|
||||||
|
const idx = part.indexOf('=')
|
||||||
|
if (idx > 0) {
|
||||||
|
const key = part.substring(0, idx).trim()
|
||||||
|
const val = part.substring(idx + 1).trim()
|
||||||
|
if (key) obj[key] = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 辅助函数 ----
|
||||||
|
const randomUUID = () => {
|
||||||
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID().replace(/-/g, '')
|
||||||
|
}
|
||||||
|
return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/x/g, () =>
|
||||||
|
Math.floor(Math.random() * 16).toString(16),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomHex = (len) => crypto.randomBytes(len / 2).toString('hex')
|
||||||
|
|
||||||
|
// ---- HTTP 上传工具 (NCBL 专用) ----
|
||||||
|
const buildMultipart = (payload) => {
|
||||||
|
const boundary = randomUUID()
|
||||||
|
const fileName = `op_${Math.floor(Math.random() * 90000) + 10000}_0_${Math.floor(Math.random() * 4294967295) + 1}`
|
||||||
|
|
||||||
|
const CRLF = '\r\n'
|
||||||
|
const headerLines = [
|
||||||
|
`--${boundary}`,
|
||||||
|
`Content-Disposition: form-data; name="file"; filename="${fileName}"`,
|
||||||
|
'Content-Type: multipart/form-data',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
].join(CRLF)
|
||||||
|
const footer = `${CRLF}--${boundary}--${CRLF}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
boundary,
|
||||||
|
fileName,
|
||||||
|
multipartBody: Buffer.concat([
|
||||||
|
Buffer.from(headerLines, 'utf-8'),
|
||||||
|
payload,
|
||||||
|
Buffer.from(footer, 'utf-8'),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCookieStr = (ctx) => {
|
||||||
|
const parts = [
|
||||||
|
`JSESSIONID-WYYY=${ctx.auth.sessionId}`,
|
||||||
|
`MUSIC_U=${ctx.auth.token}`,
|
||||||
|
`NMTID=${ctx.device.ti}`,
|
||||||
|
`WEVNSM=${ctx.app.nsm}`,
|
||||||
|
`WNMCID=${ctx.app.cid}`,
|
||||||
|
`__csrf=${ctx.device.csrf}`,
|
||||||
|
`__remember_me=true`,
|
||||||
|
`_iuqxldmzr_=33`,
|
||||||
|
`_ntes_nnid=${ctx.device.nnid}`,
|
||||||
|
`_ntes_nuid=${ctx.device.nuid}`,
|
||||||
|
`appver=${ctx.app.version}.${ctx.app.versionCode}`,
|
||||||
|
`channel=${ctx.app.channel}`,
|
||||||
|
`clientSign=${ctx.device.sign}`,
|
||||||
|
`deviceId=${ctx.device.id}`,
|
||||||
|
`mode=${ctx.device.model}`,
|
||||||
|
`ntes_kaola_ad=1`,
|
||||||
|
`os=${ctx.device.systemType}`,
|
||||||
|
`osver=${ctx.device.systemVersion}`,
|
||||||
|
]
|
||||||
|
return parts.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMetaJson = (ctx) =>
|
||||||
|
JSON.stringify({
|
||||||
|
'JSESSIONID-WYYY': ctx.auth.sessionId,
|
||||||
|
MUSIC_U: ctx.auth.token,
|
||||||
|
NMTID: ctx.device.ti,
|
||||||
|
WEVNSM: ctx.app.nsm,
|
||||||
|
WNMCID: ctx.app.cid,
|
||||||
|
__csrf: ctx.device.csrf,
|
||||||
|
_iuqxldmzr_: '33',
|
||||||
|
_ntes_nnid: ctx.device.nnid,
|
||||||
|
_ntes_nuid: ctx.device.nuid,
|
||||||
|
appver: `${ctx.app.version}.${ctx.app.versionCode}`,
|
||||||
|
channel: ctx.app.channel,
|
||||||
|
clientSign: ctx.device.sign,
|
||||||
|
deviceId: ctx.device.id,
|
||||||
|
mode: ctx.device.model,
|
||||||
|
ntes_kaola_ad: '1',
|
||||||
|
os: ctx.device.systemType,
|
||||||
|
osver: ctx.device.systemVersion,
|
||||||
|
})
|
||||||
|
|
||||||
|
const doUpload = async (ctx, metaJson, body, cookieStr, label) => {
|
||||||
|
const uploadUrl = DOMAIN3 + '/api/clientlog/encrypt/upload?multiupload=true'
|
||||||
|
|
||||||
|
const payload = encryptNCBL(metaJson, body)
|
||||||
|
const { boundary, fileName, multipartBody } = buildMultipart(payload)
|
||||||
|
|
||||||
|
const resp = await axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: uploadUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
Referer: 'https://music.163.com/di',
|
||||||
|
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/${ctx.app.version}`,
|
||||||
|
'Accept-Encoding': 'gzip,deflate',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.8',
|
||||||
|
Cookie: cookieStr,
|
||||||
|
},
|
||||||
|
data: multipartBody,
|
||||||
|
maxBodyLength: 10 * 1024 * 1024,
|
||||||
|
timeout: 15000,
|
||||||
|
validateStatus: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const respBody = resp.data
|
||||||
|
const code = respBody?.code
|
||||||
|
const success =
|
||||||
|
code === 200 && respBody?.data?.successfiles?.includes?.(fileName)
|
||||||
|
|
||||||
|
return { success, fileName, payload, respBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 导出 ----
|
||||||
|
module.exports = {
|
||||||
|
chacha20,
|
||||||
|
rsaWrap,
|
||||||
|
encryptNCBL,
|
||||||
|
getCompress,
|
||||||
|
MAGIC,
|
||||||
|
NCBL_VERSION,
|
||||||
|
HEADER_FIXED_LEN,
|
||||||
|
META_BLOCK_TYPE,
|
||||||
|
DEFAULT_MAX_FRAME,
|
||||||
|
buildRecord,
|
||||||
|
buildRecords,
|
||||||
|
FIELD_SEP,
|
||||||
|
buildPlv,
|
||||||
|
buildPld,
|
||||||
|
extractContext,
|
||||||
|
parseCookie,
|
||||||
|
randomUUID,
|
||||||
|
randomHex,
|
||||||
|
buildMultipart,
|
||||||
|
buildCookieStr,
|
||||||
|
buildMetaJson,
|
||||||
|
doUpload,
|
||||||
|
}
|
||||||
@ -5,7 +5,10 @@ const createOption = (query, crypto = '') => {
|
|||||||
ua: query.ua || '',
|
ua: query.ua || '',
|
||||||
proxy: query.proxy,
|
proxy: query.proxy,
|
||||||
realIP: query.realIP,
|
realIP: query.realIP,
|
||||||
randomCNIP: query.randomCNIP || false,
|
randomCNIP:
|
||||||
|
process.env.ENABLE_RANDOM_CN_IP === 'true'
|
||||||
|
? !['false', false].includes(query.randomCNIP)
|
||||||
|
: ['true', true].includes(query.randomCNIP),
|
||||||
e_r: query.e_r || undefined,
|
e_r: query.e_r || undefined,
|
||||||
domain: query.domain || '',
|
domain: query.domain || '',
|
||||||
checkToken: query.checkToken || false,
|
checkToken: query.checkToken || false,
|
||||||
|
|||||||
100
util/request.js
100
util/request.js
@ -2,6 +2,7 @@
|
|||||||
const encrypt = require('./crypto')
|
const encrypt = require('./crypto')
|
||||||
const CryptoJS = require('crypto-js')
|
const CryptoJS = require('crypto-js')
|
||||||
const { default: axios } = require('axios')
|
const { default: axios } = require('axios')
|
||||||
|
const logger = require('./logger')
|
||||||
const { PacProxyAgent } = require('pac-proxy-agent')
|
const { PacProxyAgent } = require('pac-proxy-agent')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
@ -23,6 +24,20 @@ const anonymous_token = fs.readFileSync(
|
|||||||
path.resolve(tmpPath, './anonymous_token'),
|
path.resolve(tmpPath, './anonymous_token'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
)
|
)
|
||||||
|
const xeapiPublicKeyPath = path.resolve(tmpPath, './xeapi_public_key')
|
||||||
|
let xeapi_public_key = null
|
||||||
|
const loadXeapiPublicKey = () => {
|
||||||
|
if (!xeapi_public_key && fs.existsSync(xeapiPublicKeyPath)) {
|
||||||
|
try {
|
||||||
|
xeapi_public_key = JSON.parse(
|
||||||
|
fs.readFileSync(xeapiPublicKeyPath, 'utf-8'),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[ERR]', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xeapi_public_key
|
||||||
|
}
|
||||||
|
|
||||||
// 预先绑定常用函数和常量
|
// 预先绑定常用函数和常量
|
||||||
const floor = Math.floor
|
const floor = Math.floor
|
||||||
@ -73,6 +88,12 @@ const osMap = {
|
|||||||
osver: '16.2',
|
osver: '16.2',
|
||||||
channel: 'distribution',
|
channel: 'distribution',
|
||||||
},
|
},
|
||||||
|
osx: {
|
||||||
|
os: 'osx',
|
||||||
|
appver: '3.1.10.5100',
|
||||||
|
osver: '15.5',
|
||||||
|
channel: 'netease',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预先定义userAgentMap
|
// 预先定义userAgentMap
|
||||||
@ -95,9 +116,13 @@ const userAgentMap = {
|
|||||||
// 预先定义常量
|
// 预先定义常量
|
||||||
const DOMAIN = APP_CONF.domain
|
const DOMAIN = APP_CONF.domain
|
||||||
const API_DOMAIN = APP_CONF.apiDomain
|
const API_DOMAIN = APP_CONF.apiDomain
|
||||||
|
const XEAPI_DOMAIN = APP_CONF.xeapiDomain
|
||||||
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
|
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
|
||||||
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
|
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
|
||||||
|
|
||||||
|
let xeapiSessionId = ''
|
||||||
|
let xeapiSessionKey = ''
|
||||||
|
|
||||||
// chooseUserAgent函数
|
// chooseUserAgent函数
|
||||||
const chooseUserAgent = (crypto, uaType = 'pc') => {
|
const chooseUserAgent = (crypto, uaType = 'pc') => {
|
||||||
return (userAgentMap[crypto] && userAgentMap[crypto][uaType]) || ''
|
return (userAgentMap[crypto] && userAgentMap[crypto][uaType]) || ''
|
||||||
@ -216,6 +241,52 @@ const createRequest = (uri, data, options) => {
|
|||||||
url = (options.domain || DOMAIN) + '/api/linux/forward'
|
url = (options.domain || DOMAIN) + '/api/linux/forward'
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'xeapi':
|
||||||
|
const xeapiPublicKey = loadXeapiPublicKey()
|
||||||
|
if (!xeapiPublicKey) {
|
||||||
|
throw new Error('xeapi public key is missing')
|
||||||
|
}
|
||||||
|
const xeapiOs = cookie.os === 'android' ? cookie.os : 'android'
|
||||||
|
const xeapiAppver =
|
||||||
|
cookie.os === 'android' && cookie.appver ? cookie.appver : '9.1.65'
|
||||||
|
const xeapiOsver =
|
||||||
|
cookie.os === 'android' && cookie.osver ? cookie.osver : '16'
|
||||||
|
const xeapiBuildver = cookie.buildver || now().toString().substr(0, 10)
|
||||||
|
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'android')
|
||||||
|
headers['X-Client-Enc-State'] = 'ENCRYPTED'
|
||||||
|
headers['x-aeapi'] = true
|
||||||
|
headers['content-type'] =
|
||||||
|
'application/x-www-form-urlencoded;charset=utf-8'
|
||||||
|
headers['x-deviceid'] = cookie.deviceId
|
||||||
|
headers['x-os'] = xeapiOs
|
||||||
|
headers['x-osver'] = xeapiOsver
|
||||||
|
headers['x-appver'] = xeapiAppver
|
||||||
|
headers['x-sdeviceid'] = cookie.sDeviceId || cookie.deviceId
|
||||||
|
headers['x-buildver'] = xeapiBuildver
|
||||||
|
if (cookie.MUSIC_U) headers['x-music-u'] = cookie.MUSIC_U
|
||||||
|
const xeapiCookie = {
|
||||||
|
...cookie,
|
||||||
|
os: xeapiOs,
|
||||||
|
osver: xeapiOsver,
|
||||||
|
appver: xeapiAppver,
|
||||||
|
buildver: xeapiBuildver,
|
||||||
|
deviceId: cookie.deviceId,
|
||||||
|
sDeviceId: cookie.sDeviceId || cookie.deviceId,
|
||||||
|
}
|
||||||
|
headers['Cookie'] = cookieObjToString(xeapiCookie)
|
||||||
|
url = (options.domain || XEAPI_DOMAIN) + '/xeapi/' + uri.substr(5)
|
||||||
|
encryptData = encrypt.xeapi(uri, data, {
|
||||||
|
...options,
|
||||||
|
publicKeyState: xeapiPublicKey,
|
||||||
|
sessionId: xeapiSessionId,
|
||||||
|
sessionKey: xeapiSessionKey,
|
||||||
|
appver: xeapiAppver,
|
||||||
|
deviceId: cookie.deviceId,
|
||||||
|
os: xeapiOs,
|
||||||
|
uid: cookie.uid || cookie.userId || '',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
case 'eapi':
|
case 'eapi':
|
||||||
case 'api':
|
case 'api':
|
||||||
// header创建
|
// header创建
|
||||||
@ -241,7 +312,11 @@ const createRequest = (uri, data, options) => {
|
|||||||
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
|
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
|
||||||
|
|
||||||
headers['Cookie'] = createHeaderCookie(header)
|
headers['Cookie'] = createHeaderCookie(header)
|
||||||
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'iphone')
|
headers['User-Agent'] =
|
||||||
|
options.ua ||
|
||||||
|
(cookie.os === 'osx'
|
||||||
|
? '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'
|
||||||
|
: chooseUserAgent('api', 'iphone'))
|
||||||
|
|
||||||
if (crypto === 'eapi') {
|
if (crypto === 'eapi') {
|
||||||
// headers['x-aeapi'] = true // 服务器会使用gzip压缩返回值
|
// headers['x-aeapi'] = true // 服务器会使用gzip压缩返回值
|
||||||
@ -259,7 +334,6 @@ const createRequest = (uri, data, options) => {
|
|||||||
console.log('[ERR]', 'Unknown Crypto:', crypto)
|
console.log('[ERR]', 'Unknown Crypto:', crypto)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// console.log(url);
|
|
||||||
// settings创建
|
// settings创建
|
||||||
let settings = {
|
let settings = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -272,7 +346,8 @@ const createRequest = (uri, data, options) => {
|
|||||||
|
|
||||||
// 使用返回值加密
|
// 使用返回值加密
|
||||||
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
|
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
|
||||||
if (use_e_r) {
|
const use_xeapi = crypto === 'xeapi'
|
||||||
|
if (use_e_r || use_xeapi) {
|
||||||
settings.encoding = null
|
settings.encoding = null
|
||||||
settings.responseType = 'arraybuffer'
|
settings.responseType = 'arraybuffer'
|
||||||
}
|
}
|
||||||
@ -319,8 +394,25 @@ const createRequest = (uri, data, options) => {
|
|||||||
x.replace(/\s*Domain=[^(;|$)]+;*/, ''),
|
x.replace(/\s*Domain=[^(;|$)]+;*/, ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
|
||||||
|
|
||||||
|
// logger.debug(`[${crypto}]`, uri)
|
||||||
|
// logger.debug(`[${crypto}] encrypted data:`, JSON.stringify(encryptData))
|
||||||
|
// logger.debug(
|
||||||
|
// `[RAW] [${crypto}]`,
|
||||||
|
// use_xeapi
|
||||||
|
// ? Buffer.from(body).toString('base64')
|
||||||
|
// : body.toString('hex').toUpperCase(),
|
||||||
|
// )
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (use_e_r) {
|
if (use_xeapi) {
|
||||||
|
if (res.headers['x-encr-ssid'] && res.headers['x-encr-sskey']) {
|
||||||
|
xeapiSessionId = res.headers['x-encr-ssid']
|
||||||
|
xeapiSessionKey = res.headers['x-encr-sskey']
|
||||||
|
}
|
||||||
|
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body))
|
||||||
|
} else if (use_e_r) {
|
||||||
answer.body = encrypt.eapiResDecrypt(
|
answer.body = encrypt.eapiResDecrypt(
|
||||||
body.toString('hex').toUpperCase(),
|
body.toString('hex').toUpperCase(),
|
||||||
headers['x-aeapi'],
|
headers['x-aeapi'],
|
||||||
|
|||||||
24
util/xeapiKey.js
Normal file
24
util/xeapiKey.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const registerXeapiKey = require('../module/register_xeapikey')
|
||||||
|
|
||||||
|
const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => {
|
||||||
|
const result = await registerXeapiKey(
|
||||||
|
{
|
||||||
|
deviceId,
|
||||||
|
currentKeyVersion: currentPublicKey.version || '',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const publicKey = result.body
|
||||||
|
if (!publicKey.sk && currentPublicKey.sk) {
|
||||||
|
publicKey.sk = currentPublicKey.sk
|
||||||
|
}
|
||||||
|
if (!publicKey.sk) {
|
||||||
|
throw new Error('xeapi public key response missing sk')
|
||||||
|
}
|
||||||
|
return publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getXeapiPublicKey,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user