mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-10-20 22:51:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c7dad6db | ||
|
|
2db6684453 | ||
|
|
90de64140f | ||
|
|
2aab9957df | ||
|
|
373d5eedb3 | ||
|
|
c1cbdcb962 | ||
|
|
37b64ab01e | ||
|
|
95367213f7 | ||
|
|
bd407cee81 | ||
|
|
a91ea8f832 | ||
|
|
4a45a14aa7 | ||
|
|
acbea014be | ||
|
|
8ad30b591d | ||
|
|
cb449463fb | ||
|
|
91db12d0f5 | ||
|
|
135df9edda | ||
|
|
548f6c170a | ||
|
|
17c07960f9 | ||
|
|
c4d448641a | ||
|
|
77a9269828 | ||
|
|
9a16c89714 | ||
|
|
a8c781fd64 | ||
|
|
690b462577 | ||
|
|
25ac0c041f | ||
|
|
edef19c3fb | ||
|
|
d55d92cd00 | ||
|
|
d9253bf930 | ||
|
|
f5ce55bcb4 | ||
|
|
4480bd4359 | ||
|
|
e43b123c8a | ||
|
|
ed9bdd6ffa | ||
|
|
7b733e54ab | ||
|
|
edfaf6c0d1 | ||
|
|
f83ad951f6 | ||
|
|
22003c2c32 | ||
|
|
8376699f5e | ||
|
|
5c98a9a1ea | ||
|
|
7c17de7f09 | ||
|
|
a7e8d485f3 | ||
|
|
0957be6e76 |
@@ -34,10 +34,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: npm install -g pnpm@9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: pnpm install --frozen-lockfile
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
- name: Build for ${{ matrix.platform }}
|
- name: Build for ${{ matrix.platform }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Mark stale issues
|
- name: Mark stale issues
|
||||||
uses: actions/stale@v10
|
uses: actions/stale@v11
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: |
|
stale-issue-message: |
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
name: OpenCode Automation
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: auto-${{ github.event.issue.number || github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
automate:
|
|
||||||
# 排除其他机器人触发,防止自我循环;
|
|
||||||
# 但允许 takanashi-hoshino-agent 创建的 PR,这样 /oc 生成的测试 PR 也能被自动评审
|
|
||||||
if: |
|
|
||||||
github.actor != 'github-actions[bot]' &&
|
|
||||||
github.actor != 'opencode-agent[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Fetch config from CF Worker
|
|
||||||
id: cfg
|
|
||||||
uses: MoeFurina/pull-oc-config@v1
|
|
||||||
with:
|
|
||||||
url: https://bot-storage.interface.focalors.ltd/config
|
|
||||||
|
|
||||||
- name: Generate bot token
|
|
||||||
id: token
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
with:
|
|
||||||
app-id: ${{ steps.cfg.outputs.app_id }}
|
|
||||||
private-key: ${{ steps.cfg.outputs.app_private_key }}
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
token: ${{ steps.token.outputs.token }}
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: true
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install opencode
|
|
||||||
run: npm i -g opencode-ai@latest
|
|
||||||
|
|
||||||
- name: Run automation
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
|
|
||||||
MODEL: opencode/deepseek-v4-flash-free
|
|
||||||
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
|
|
||||||
ISSUE_PROMPT: ${{ steps.cfg.outputs.opencode_prompt }}
|
|
||||||
PR_PROMPT: |
|
|
||||||
${{ steps.cfg.outputs.opencode_prompt }}
|
|
||||||
${{ steps.cfg.outputs.pr_review_prompt }}
|
|
||||||
run: |
|
|
||||||
mkdir -p scripts
|
|
||||||
curl -fsSL "https://raw.githubusercontent.com/MoeFurina/pull-oc-config/v1/scripts/opencode-automation.mjs" \
|
|
||||||
-o scripts/opencode-automation.mjs
|
|
||||||
node scripts/opencode-automation.mjs
|
|
||||||
@@ -19,9 +19,10 @@ jobs:
|
|||||||
contains(github.event.comment.body, ' /opencode') ||
|
contains(github.event.comment.body, ' /opencode') ||
|
||||||
startsWith(github.event.comment.body, '/opencode')) &&
|
startsWith(github.event.comment.body, '/opencode')) &&
|
||||||
github.actor != 'opencode-agent[bot]' &&
|
github.actor != 'opencode-agent[bot]' &&
|
||||||
github.actor != 'takanashi-hoshino-agent[bot]' &&
|
github.actor != 'yukka-agent[bot]' &&
|
||||||
github.actor != 'github-actions[bot]'
|
github.actor != 'github-actions[bot]'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -33,9 +34,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate bot token
|
- name: Generate bot token
|
||||||
id: token
|
id: token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ steps.cfg.outputs.app_id }}
|
client-id: ${{ steps.cfg.outputs.app_id }}
|
||||||
private-key: ${{ steps.cfg.outputs.app_private_key }}
|
private-key: ${{ steps.cfg.outputs.app_private_key }}
|
||||||
owner: ${{ github.repository_owner }}
|
owner: ${{ github.repository_owner }}
|
||||||
|
|
||||||
@@ -48,19 +49,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Configure git identity
|
- name: Configure git identity
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "takanashi-hoshino-agent[bot]"
|
git config --global user.name "yukka-agent[bot]"
|
||||||
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
|
git config --global user.email "yukka-agent[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@latest
|
uses: anomalyco/opencode/github@latest
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
|
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
|
||||||
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
|
||||||
|
OPENCODE_PERMISSION: '{"permission":{"*":"allow","question":"deny"}}'
|
||||||
with:
|
with:
|
||||||
model: opencode/deepseek-v4-flash-free
|
model: opencode/muse-spark-1.3-contributor-free
|
||||||
use_github_token: true
|
use_github_token: true
|
||||||
prompt: |
|
prompt: |
|
||||||
${{ steps.cfg.outputs.opencode_prompt }}
|
${{ steps.cfg.outputs.opencode_prompt }}
|
||||||
|
|
||||||
## 用户指令(来自触发评论,请严格按此执行)
|
## 用户指令(来自触发评论,请严格按此执行)
|
||||||
${{ github.event.comment.body }}
|
${{ github.event.comment.body }}
|
||||||
|
|
||||||
|
## 用户要求Review时, 严格执行下面指令
|
||||||
|
${{ steps.cfg.outputs.pr_review_prompt }}
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
|
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
|
||||||
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
|
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
|
||||||
|
|
||||||
|
## 目录内非路由文件
|
||||||
|
- `plugins/`——内部上传辅助(`upload.js`、`songUpload.js`),被 `module/*` require,本身不是路由。
|
||||||
|
- `module_example/`——接口模板与 `main.js` 库调用示例(含 `test.js` 演示 `login_cellphone`→`song_url` 链路),不是运行时代码。
|
||||||
|
- `examples/get_static_moddef.js`——用 `getModulesDefinitions(..., false)`(`doRequire=false`)把全部路由导出为 `moddef.json`。改路由注册逻辑时可跑它核对。
|
||||||
|
- `module_types/` 为空目录;类型声明在根目录 `interface.d.ts`(`types` 字段)。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
|
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
|
||||||
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
|
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
|
||||||
@@ -21,6 +27,7 @@
|
|||||||
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
|
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
|
||||||
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
|
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
|
||||||
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
|
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
|
||||||
|
- `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。
|
||||||
|
|
||||||
## 新增/修改接口
|
## 新增/修改接口
|
||||||
- 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
|
- 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
|
||||||
@@ -28,13 +35,17 @@
|
|||||||
- 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
|
- 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测。
|
- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测,不需要网络。
|
||||||
|
- **CI 门禁只看稳定项**:`ci-check.yml` 在 Node 18/22/24 上跑 `pnpm lint` + `main.test.js` + `pnpm docs:check`;完整集成测试(`server.test.js`)是 `continue-on-error`,不阻塞合并。本地跑全量测试失败先怀疑上游风控。
|
||||||
- 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。
|
- 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。
|
||||||
- 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "<describe/it 名字>" server.test.js main.test.js --exit`
|
- 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "<describe/it 名字>" server.test.js main.test.js --exit`
|
||||||
|
- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit`
|
||||||
|
|
||||||
## 坑与注意
|
## 坑与注意
|
||||||
- **改 `package.json` 的 `version` 会触发自动发布**:push 到 `main` 后会自动打 GitHub Release(`pkg` 二进制)、构建并推送 Docker 镜像(Docker Hub + GHCR)、`pnpm publish` 到 npm。别顺手改版本号。
|
- **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。
|
||||||
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged`,但 `.husky/` 下没有真正的 hook,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
|
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
|
||||||
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
|
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
|
||||||
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
|
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
|
||||||
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。
|
- **`docs:check` 校验的是 `public/docs/home.md`(docsify 站点)**,不是 README;脚本默认只处理该文件,换行会被统一为 `\n`、连续空行压缩到 2 行,PR 改动它后记得跑 `pnpm docs:format`。
|
||||||
|
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`),并 `globalIgnores(['**/public/'])`——`public/` 下的前端页面不参与 lint。
|
||||||
|
- **`pnpm-workspace.yaml` 是单包工作区**(`packages: ['.']`),并带 `allowBuilds`(core-js/es5-ext)与 `minimumReleaseAgeExclude` 白名单;改依赖安装行为时先看它。
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const path = require('path')
|
|||||||
const { register_anonimous } = require('./main')
|
const { register_anonimous } = require('./main')
|
||||||
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
|
||||||
const { getXeapiPublicKey } = require('./util/xeapiKey')
|
const { getXeapiPublicKey } = require('./util/xeapiKey')
|
||||||
|
const { loadNeapiKey } = require('./util/neapiKey')
|
||||||
|
const registerNeapikey = require('./module/register_neapikey')
|
||||||
const tmpPath = require('os').tmpdir()
|
const tmpPath = require('os').tmpdir()
|
||||||
|
|
||||||
async function generateConfig() {
|
async function generateConfig() {
|
||||||
@@ -37,5 +39,17 @@ async function generateConfig() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const current = loadNeapiKey()
|
||||||
|
await registerNeapikey(
|
||||||
|
{
|
||||||
|
version: current ? current.version : 0,
|
||||||
|
behavior: current ? 'update' : 'open',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = generateConfig
|
module.exports = generateConfig
|
||||||
|
|||||||
Vendored
+33
@@ -584,6 +584,10 @@ export function device_kickoff(
|
|||||||
|
|
||||||
export function device_list(params: RequestBaseConfig): Promise<Response>
|
export function device_list(params: RequestBaseConfig): Promise<Response>
|
||||||
|
|
||||||
|
export function deviceinfo_center_upload(
|
||||||
|
params: { deviceName: string; name?: string } & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
export function digitalAlbum_ordering(
|
export function digitalAlbum_ordering(
|
||||||
params: {
|
params: {
|
||||||
payment: string
|
payment: string
|
||||||
@@ -1083,6 +1087,10 @@ export function register_checktoken_v3(
|
|||||||
params: { refresh?: boolean | string } & RequestBaseConfig,
|
params: { refresh?: boolean | string } & RequestBaseConfig,
|
||||||
): Promise<Response>
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function register_neapikey(
|
||||||
|
params: { version?: string | number; behavior?: string } & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
export function register_xeapikey(
|
export function register_xeapikey(
|
||||||
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
|
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
|
||||||
): Promise<Response>
|
): Promise<Response>
|
||||||
@@ -2882,3 +2890,28 @@ export function voicelist_trans(
|
|||||||
} & MultiPageConfig &
|
} & MultiPageConfig &
|
||||||
RequestBaseConfig,
|
RequestBaseConfig,
|
||||||
): Promise<Response>
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fans_group_detail(
|
||||||
|
params: {
|
||||||
|
groupId: string
|
||||||
|
scene?: string
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fans_group_feed_recommend(
|
||||||
|
params: {
|
||||||
|
fansGroupId: string
|
||||||
|
cursor?: string
|
||||||
|
size?: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fans_group_user_groups(
|
||||||
|
params?: RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|
||||||
|
export function fans_group_user_group_detail(
|
||||||
|
params: {
|
||||||
|
groupId: string | number
|
||||||
|
} & RequestBaseConfig,
|
||||||
|
): Promise<Response>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// 上报设备中心设备名称
|
||||||
|
// 官方 PC 端在登录成功后调用此接口,将当前登录设备在“登录设备管理”中的名称设置为自定义名称
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
module.exports = async (query, request) => {
|
||||||
|
const deviceName = String(query.deviceName || query.name || '').trim()
|
||||||
|
|
||||||
|
if (!deviceName) {
|
||||||
|
return Promise.reject({
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
code: 400,
|
||||||
|
msg: '缺少必要参数: deviceName',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
deviceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
return request(
|
||||||
|
`/api/deviceinfo/center/upload`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'eapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 乐迷团详情
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
groupId: query.groupId || query.id || '',
|
||||||
|
scene: query.scene || '',
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
'/api/social/fansgroup/bff/detail/get',
|
||||||
|
data,
|
||||||
|
createOption(query, 'eapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 乐迷团推荐笔记
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
artistSelf: query.artistSelf || '0',
|
||||||
|
fansGroupId: query.fansGroupId || query.groupId || '',
|
||||||
|
cursor: query.cursor || '0',
|
||||||
|
size: String(query.size || 10),
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
'/api/fans/group/feed/recommend/get',
|
||||||
|
data,
|
||||||
|
createOption(query, 'eapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 用户所处乐迷团详情
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
groupId: query.groupId || query.id || '',
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
'/api/social/fansgroup/bff/user/group/detail/get',
|
||||||
|
data,
|
||||||
|
createOption(query, 'eapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 获取当前登录用户加入的全部歌手乐迷团列表
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
return request(
|
||||||
|
'/api/social/fansgroup/bff/user/groups/get',
|
||||||
|
{},
|
||||||
|
createOption(query, 'eapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 每日风格推荐场景配置列表获取
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {}
|
||||||
|
return request(
|
||||||
|
`/api/homepage/daily/song/config/get`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 每日风格推荐场景配置设置
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
tags: JSON.stringify({
|
||||||
|
tagIds: String(query.tags).split(',').map(Number),
|
||||||
|
categoryId: Number(query.category),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
console.log(data)
|
||||||
|
return request(
|
||||||
|
`/api/homepage/daily/song/tag/save`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 每日推荐风格歌曲
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {}
|
||||||
|
return request(
|
||||||
|
`/api/homepage/category/daily/song/list`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// 每日推荐歌曲 - v1
|
||||||
|
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
module.exports = (query, request) => {
|
||||||
|
const data = {
|
||||||
|
ispush: query.ispush || false,
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
`/api/v3/discovery/recommend/songs`,
|
||||||
|
data,
|
||||||
|
createOption(query, 'xeapi'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { default: axios } = require('axios')
|
||||||
|
const { APP_CONF } = require('../util/config.json')
|
||||||
|
const { saveNeapiKey } = require('../util/neapiKey')
|
||||||
|
const { decodeConfig } = require('../util/neapiConfig')
|
||||||
|
|
||||||
|
const USER_AGENT =
|
||||||
|
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)'
|
||||||
|
|
||||||
|
module.exports = async (query, request) => {
|
||||||
|
const version = Number(query.version) || 0
|
||||||
|
const behavior = query.behavior === 'update' ? 'update' : 'open'
|
||||||
|
|
||||||
|
const res = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: APP_CONF.apiDomain + '/api/gorilla/algorithm/record',
|
||||||
|
params: { version, behavior },
|
||||||
|
headers: { 'User-Agent': USER_AGENT },
|
||||||
|
proxy: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.data || res.data.code !== 200 || !res.data.data) {
|
||||||
|
throw new Error('neapi config request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// file 为空表示本地已是最新,无需更新
|
||||||
|
const { file, circleTime } = res.data.data
|
||||||
|
const config = file
|
||||||
|
? saveNeapiKey(decodeConfig(Buffer.from(file, 'base64')), circleTime)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 只回版本与密钥偏移,密钥本身留在本地缓存
|
||||||
|
const keyOffsets = {}
|
||||||
|
if (config) {
|
||||||
|
keyOffsets.encrypt = config.keyBlob.indexOf(config.encryptKey)
|
||||||
|
keyOffsets.decrypt = config.keyBlob.indexOf(config.decryptKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
version: config ? config.version : res.data.data.version,
|
||||||
|
updated: Boolean(config),
|
||||||
|
circleTime,
|
||||||
|
keyOffsets,
|
||||||
|
},
|
||||||
|
cookie: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ module.exports = async (query, request) => {
|
|||||||
|
|
||||||
const res = await axios({
|
const res = await axios({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
|
url: APP_CONF.apiDomain + '/api/bsr/sk/get',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
|
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// 获取客户端歌曲下载链接 - v1
|
// 获取客户端歌曲下载链接 - v1
|
||||||
// 此版本不再采用 br 作为音质区分的标准
|
// 此版本不再采用 br 作为音质区分的标准
|
||||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
|
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
const { cookieToJson } = require('../util/index.js')
|
const { cookieToJson } = require('../util/index.js')
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// 歌曲链接 - v1
|
// 歌曲链接 - v1
|
||||||
// 此版本不再采用 br 作为音质区分的标准
|
// 此版本不再采用 br 作为音质区分的标准
|
||||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
|
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
|
||||||
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
|
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
|
||||||
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
|
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c512(新版c51类型)、ste2(新版环绕立体声类型)、aac2(新版aac类型)、c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
|
||||||
|
|
||||||
const logger = require('../util/logger.js')
|
const logger = require('../util/logger.js')
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// 获取客户端歌曲下载链接 - v1
|
// 获取客户端歌曲下载链接 - v1
|
||||||
// 此版本不再采用 br 作为音质区分的标准
|
// 此版本不再采用 br 作为音质区分的标准
|
||||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), vivid(臻音全景声), jymaster(超清母带) 进行音质判断
|
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
const { cookieToJson } = require('../util/index.js')
|
const { cookieToJson } = require('../util/index.js')
|
||||||
|
|||||||
+16
-15
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@neteasecloudmusicapienhanced/api",
|
"name": "@neteasecloudmusicapienhanced/api",
|
||||||
"version": "4.40.1",
|
"version": "4.41.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)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon app.js",
|
"dev": "nodemon app.js",
|
||||||
"start": "node app.js",
|
"start": "node app.js",
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"docs:format": "node scripts/format-docs.js",
|
"docs:format": "node scripts/format-docs.js",
|
||||||
"docs:check": "node scripts/format-docs.js --check",
|
"docs:check": "node scripts/format-docs.js --check",
|
||||||
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
|
"pkgwin": "node scripts/build-pkg.js . -t node18-win-x64 -C GZip -o precompiled/app",
|
||||||
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
|
"pkglinux": "node scripts/build-pkg.js . -t node18-linux-x64 -C GZip -o precompiled/app",
|
||||||
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
|
"pkgmacos": "node scripts/build-pkg.js . -t node18-macos-x64 -C GZip -o precompiled/app"
|
||||||
},
|
},
|
||||||
"bin": "./app.js",
|
"bin": "./app.js",
|
||||||
"pkg": {
|
"pkg": {
|
||||||
@@ -75,37 +75,38 @@
|
|||||||
"data"
|
"data"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.2",
|
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.5",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.20.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
|
"fzstd": "^0.1.1",
|
||||||
"gzip": "^0.1.0",
|
"gzip": "^0.1.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.16.1",
|
||||||
"node-forge": "^1.4.0",
|
"node-forge": "^1.4.0",
|
||||||
"pac-proxy-agent": "^7.2.0",
|
"pac-proxy-agent": "^7.2.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"safe-decode-uri-component": "^1.2.1",
|
"safe-decode-uri-component": "^1.2.1",
|
||||||
"tunnel": "^0.0.6",
|
"tunnel": "^0.0.6",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
"yargs": "^18.1.0"
|
"yargs": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.6",
|
"@eslint/eslintrc": "^3.3.7",
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express-fileupload": "^1.5.1",
|
"@types/express-fileupload": "^1.5.1",
|
||||||
"@types/mocha": "^10.0.10",
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/node": "25.9.2",
|
"@types/node": "25.9.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
"@typescript-eslint/eslint-plugin": "^8.71.0",
|
||||||
"@typescript-eslint/parser": "^8.67.0",
|
"@typescript-eslint/parser": "^8.71.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-html": "^8.1.4",
|
"eslint-plugin-html": "^8.2.1",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.13.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"intelli-espower-loader": "^1.1.0",
|
"intelli-espower-loader": "^1.1.0",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
"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.9.6",
|
"prettier": "^3.9.9",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
Generated
+340
-339
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
packages:
|
packages:
|
||||||
- '.'
|
- '.'
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
core-js: true
|
||||||
|
es5-ext: true
|
||||||
|
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@neteasecloudmusicapienhanced/unblockmusic-utils@0.4.2'
|
- '@neteasecloudmusicapienhanced/unblockmusic-utils@0.4.2'
|
||||||
|
|||||||
@@ -319,6 +319,7 @@
|
|||||||
<option value="api">api</option>
|
<option value="api">api</option>
|
||||||
<option value="linuxapi">linuxapi</option>
|
<option value="linuxapi">linuxapi</option>
|
||||||
<option value="xeapi">xeapi</option>
|
<option value="xeapi">xeapi</option>
|
||||||
|
<option value="neapi">neapi</option>
|
||||||
<option value="" selected>(默认)</option>
|
<option value="" selected>(默认)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+104
-6
@@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
|
|||||||
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
|
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
|
||||||
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
|
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
|
||||||
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
|
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
|
||||||
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数)
|
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新)
|
||||||
|
|
||||||
|
|
||||||
## 接口文档
|
## 接口文档
|
||||||
@@ -1304,10 +1304,10 @@ tags: 歌单标签
|
|||||||
|
|
||||||
**必选参数 :** `id` : 音乐 id
|
**必选参数 :** `id` : 音乐 id
|
||||||
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
||||||
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
|
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
|
||||||
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
|
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
|
||||||
|
|
||||||
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为 `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
|
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为`c512` => `新版c51类型`, `ste2` => `新版环绕立体声类型`, `aac2` => `新版aac类型`, `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
|
||||||
|
|
||||||
**接口地址 :** `/song/url/v1`
|
**接口地址 :** `/song/url/v1`
|
||||||
|
|
||||||
@@ -1321,7 +1321,7 @@ tags: 歌单标签
|
|||||||
|
|
||||||
**必选参数 :** `id` : 音乐 id
|
**必选参数 :** `id` : 音乐 id
|
||||||
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
||||||
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
|
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
|
||||||
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
|
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
|
||||||
|
|
||||||
**接口地址 :** `/song/url/v1/302`
|
**接口地址 :** `/song/url/v1/302`
|
||||||
@@ -2391,6 +2391,16 @@ privilege:权限相关信息
|
|||||||
|
|
||||||
**调用例子 :** `/recommend/songs`
|
**调用例子 :** `/recommend/songs`
|
||||||
|
|
||||||
|
### 获取每日推荐歌曲 - v1
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 )
|
||||||
|
|
||||||
|
**可选参数 :** `ispush`: 是否刷新日推 , 默认为 false
|
||||||
|
|
||||||
|
**接口地址 :** `/recommend/songs/v1`
|
||||||
|
|
||||||
|
**调用例子 :** `/recommend/songs/v1`
|
||||||
|
|
||||||
### 每日推荐歌曲-不感兴趣
|
### 每日推荐歌曲-不感兴趣
|
||||||
|
|
||||||
说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 )
|
说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 )
|
||||||
@@ -4675,7 +4685,7 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
|
|||||||
|
|
||||||
### 歌曲音质详情
|
### 歌曲音质详情
|
||||||
|
|
||||||
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出 `高清环绕声`、`沉浸环绕声`、`超清母带`等音质的信息
|
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出`高清臻音`、`杜比全景声`、`臻音全景声`、`超清母带`、`沉浸环绕声`等音质的信息
|
||||||
|
|
||||||
**必选参数:**
|
**必选参数:**
|
||||||
|
|
||||||
@@ -4796,7 +4806,7 @@ bitrate = Math.floor(br / 1000)
|
|||||||
|
|
||||||
**必选参数 :** `id` : 音乐 id
|
**必选参数 :** `id` : 音乐 id
|
||||||
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
|
||||||
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `vivid` => `臻音全景声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
|
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
|
||||||
|
|
||||||
**接口地址 :** `/song/download/url/v1`
|
**接口地址 :** `/song/download/url/v1`
|
||||||
|
|
||||||
@@ -5856,6 +5866,18 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
|
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
|
||||||
|
|
||||||
|
### 上报设备中心设备名称
|
||||||
|
|
||||||
|
说明: 登录后调用此接口, 传入 `deviceName`, 可修改当前设备在网易云登录设备管理列表里显示的名称
|
||||||
|
|
||||||
|
**必选参数 :** `deviceName`: 期望展示的设备名称
|
||||||
|
|
||||||
|
`name`: `deviceName` 的兼容别名, 二选一即可
|
||||||
|
|
||||||
|
**接口地址 :** `/deviceinfo/center/upload`
|
||||||
|
|
||||||
|
**调用例子 :** `/deviceinfo/center/upload?deviceName=XXXXX`
|
||||||
|
|
||||||
### 插播相似歌曲
|
### 插播相似歌曲
|
||||||
|
|
||||||
说明: 调用此接口, 传入歌曲 id, 可获取插播相似歌曲
|
说明: 调用此接口, 传入歌曲 id, 可获取插播相似歌曲
|
||||||
@@ -5866,6 +5888,82 @@ let data = encodeURIComponent(
|
|||||||
|
|
||||||
**调用例子 :** `/song/simi/get?id=39227633`
|
**调用例子 :** `/song/simi/get?id=39227633`
|
||||||
|
|
||||||
|
### 获取用户乐迷团列表
|
||||||
|
|
||||||
|
说明 : 登录后调用此接口 , 可获取当前登录用户已加入的全部歌手乐迷团列表
|
||||||
|
|
||||||
|
**接口地址 :** `/fans/group/user/groups`
|
||||||
|
|
||||||
|
**调用例子 :** `/fans/group/user/groups`
|
||||||
|
|
||||||
|
### 获取乐迷团详情
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可获取乐迷团详情
|
||||||
|
|
||||||
|
**必选参数 :** `groupId` : 乐迷团 ID
|
||||||
|
|
||||||
|
**可选参数 :** `scene` : 场景标识
|
||||||
|
|
||||||
|
**接口地址 :** `/fans/group/detail`
|
||||||
|
|
||||||
|
**调用例子 :** `/fans/group/detail?groupId=1872529203038486609`
|
||||||
|
|
||||||
|
### 获取用户在乐迷团的详情
|
||||||
|
|
||||||
|
说明 : 登录后调用此接口 , 可获取当前用户在指定乐迷团的详细成员信息及铭牌等级
|
||||||
|
|
||||||
|
**必选参数 :** `groupId` : 乐迷团 ID
|
||||||
|
|
||||||
|
**接口地址 :** `/fans/group/user/group/detail`
|
||||||
|
|
||||||
|
**调用例子 :** `/fans/group/user/group/detail?groupId=1755777360858267711`
|
||||||
|
|
||||||
|
### 获取乐迷团推荐笔记
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可获取指定乐迷团的推荐笔记
|
||||||
|
|
||||||
|
**必选参数 :** `fansGroupId` : 乐迷团 ID
|
||||||
|
|
||||||
|
**可选参数 :** `cursor` : 游标 , 默认为 0
|
||||||
|
|
||||||
|
`size` : 返回数量 , 默认为 10
|
||||||
|
|
||||||
|
**接口地址 :** `/fans/group/feed/recommend`
|
||||||
|
|
||||||
|
**调用例子 :** `/fans/group/feed/recommend?fansGroupId=1872529203038486609&size=10&cursor=0`
|
||||||
|
|
||||||
|
### 每日指定风格歌曲推荐
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐
|
||||||
|
|
||||||
|
**接口地址 :** `/recommend/category/songs`
|
||||||
|
|
||||||
|
**调用例子 :** `/recommend/category/songs`
|
||||||
|
|
||||||
|
### 获取每日指定风格歌曲推荐的风格列表
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐的风格列表
|
||||||
|
|
||||||
|
**接口地址 :** `/recommend/category/configs`
|
||||||
|
|
||||||
|
**调用例子 :** `/recommend/category/configs`
|
||||||
|
|
||||||
|
### 设置每日指定风格歌曲推荐的风格
|
||||||
|
|
||||||
|
说明 : 调用此接口 , 可设置每日指定风格歌曲推荐的风格
|
||||||
|
|
||||||
|
**必选参数 :**
|
||||||
|
|
||||||
|
`tags`: 风格标签, 可通过 `/recommend/category/configs` 获取
|
||||||
|
|
||||||
|
`category`: 风格标签的父ID, 可通过 `/recommend/category/configs` 获取
|
||||||
|
|
||||||
|
!> 只能设置当前CategoryId下的tags, 不能设置其他CategoryId下的tags, 否则会报错
|
||||||
|
|
||||||
|
**接口地址 :** `/recommend/category/config`
|
||||||
|
|
||||||
|
**调用例子 :** `/recommend/category/config?tags=10004,10015&categoryId=1000`
|
||||||
|
|
||||||
## 离线访问此文档
|
## 离线访问此文档
|
||||||
|
|
||||||
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
|
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Invoke the pkg packager (cross-platform, independent of node_modules layout).
|
||||||
|
*
|
||||||
|
* Background: on Windows, pnpm links node_modules/pkg to
|
||||||
|
* node_modules/.pnpm/pkg@<ver>/node_modules/pkg via a junction. Some CI
|
||||||
|
* environments (pnpm 11 + Windows runner) may fail to create that link,
|
||||||
|
* so executing node_modules/pkg/lib-es5/bin.js directly throws
|
||||||
|
* MODULE_NOT_FOUND.
|
||||||
|
*
|
||||||
|
* This script first resolves pkg via require.resolve and falls back to
|
||||||
|
* scanning the .pnpm physical directory, so pkg is found whether the
|
||||||
|
* junction exists or not.
|
||||||
|
*/
|
||||||
|
const { spawnSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function resolvePkgBin() {
|
||||||
|
try {
|
||||||
|
return require.resolve('pkg/lib-es5/bin.js')
|
||||||
|
} catch (err) {
|
||||||
|
// require.resolve failed (e.g. missing node_modules/pkg link): scan .pnpm
|
||||||
|
const pnpmDir = path.join(process.cwd(), 'node_modules', '.pnpm')
|
||||||
|
if (fs.existsSync(pnpmDir)) {
|
||||||
|
const candidates = fs
|
||||||
|
.readdirSync(pnpmDir)
|
||||||
|
.filter((dir) => dir.startsWith('pkg@'))
|
||||||
|
.map((dir) =>
|
||||||
|
path.join(pnpmDir, dir, 'node_modules', 'pkg', 'lib-es5', 'bin.js'),
|
||||||
|
)
|
||||||
|
.filter((file) => fs.existsSync(file))
|
||||||
|
if (candidates.length) {
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bin = resolvePkgBin()
|
||||||
|
if (!bin) {
|
||||||
|
console.error(
|
||||||
|
'[build-pkg] pkg executable not found; run `pnpm install --frozen-lockfile` first',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [bin, ...process.argv.slice(2)], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
process.exit(result.status === null ? 1 : result.status)
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"apiDomain": "https://interface.music.163.com",
|
"apiDomain": "https://interface.music.163.com",
|
||||||
"eapiDomain": "https://interfacepc.music.163.com",
|
"eapiDomain": "https://interfacepc.music.163.com",
|
||||||
"xeapiDomain": "https://interface3.music.163.com",
|
"xeapiDomain": "https://interface3.music.163.com",
|
||||||
|
"neapiDomain": "https://interface3.music.163.com",
|
||||||
"domain": "https://music.163.com",
|
"domain": "https://music.163.com",
|
||||||
"clDomian": "https://clientlog.music.163.com",
|
"clDomian": "https://clientlog.music.163.com",
|
||||||
"clDomian3": "https://clientlog3.music.163.com",
|
"clDomian3": "https://clientlog3.music.163.com",
|
||||||
|
|||||||
+106
-8
@@ -2,6 +2,7 @@ const CryptoJS = require('crypto-js')
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const forge = require('node-forge')
|
const forge = require('node-forge')
|
||||||
const zlib = require('zlib')
|
const zlib = require('zlib')
|
||||||
|
const zstd = require('./zstd')
|
||||||
const iv = '0102030405060708'
|
const iv = '0102030405060708'
|
||||||
const presetKey = '0CoJUm6Qyw8W8jud'
|
const presetKey = '0CoJUm6Qyw8W8jud'
|
||||||
const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
|
const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
|
||||||
@@ -151,7 +152,12 @@ const decrypt = (cipher) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const aesEcbEncrypt = (key, plaintext) => {
|
const aesEcbEncrypt = (key, plaintext) => {
|
||||||
const cipher = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null)
|
// 某些运行时不允许使用 null 作为 IV,因此使用 Buffer.alloc(0) ,同时与上下文deriveX25519AesKey等函数保持一致
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
`aes-${key.length * 8}-ecb`,
|
||||||
|
key,
|
||||||
|
Buffer.alloc(0),
|
||||||
|
)
|
||||||
return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
|
return Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +165,7 @@ const aesEcbDecrypt = (key, ciphertext) => {
|
|||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
`aes-${key.length * 8}-ecb`,
|
`aes-${key.length * 8}-ecb`,
|
||||||
key,
|
key,
|
||||||
null,
|
Buffer.alloc(0),
|
||||||
)
|
)
|
||||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
}
|
}
|
||||||
@@ -200,9 +206,19 @@ const xeapiMidTransform = (ciphertext) => {
|
|||||||
for (let i = 0; i < ciphertext.length; i++) {
|
for (let i = 0; i < ciphertext.length; i++) {
|
||||||
xored[i] = ciphertext[i] ^ random[i & 0x0f]
|
xored[i] = ciphertext[i] ^ random[i & 0x0f]
|
||||||
}
|
}
|
||||||
const b64 = Buffer.from(xored.toString('base64'))
|
const rot = xored.length ? (random[0] & 0x0f) % xored.length : 0
|
||||||
const rot = b64.length ? (random[0] & 0x0f) % b64.length : 0
|
return Buffer.concat([random, xored.subarray(rot), xored.subarray(0, rot)])
|
||||||
return Buffer.concat([random, b64.subarray(rot), b64.subarray(0, rot)])
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||||
|
|
||||||
|
const isUtf8 = (buf) => {
|
||||||
|
try {
|
||||||
|
decoder.decode(buf)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const xeapiEncryptS = (dynamicKey, publicKeyState, os) => {
|
const xeapiEncryptS = (dynamicKey, publicKeyState, os) => {
|
||||||
@@ -245,7 +261,13 @@ const buildXeapiPlaintext = (uri, data, options = {}) => {
|
|||||||
const bodyData = { ...data }
|
const bodyData = { ...data }
|
||||||
delete bodyData.e_r
|
delete bodyData.e_r
|
||||||
const body = Buffer.from(new URLSearchParams(bodyData).toString())
|
const body = Buffer.from(new URLSearchParams(bodyData).toString())
|
||||||
fields.body = body.toString('base64')
|
if (body.length) {
|
||||||
|
if (mediaType === 'application/x-www-form-urlencoded' && isUtf8(body)) {
|
||||||
|
fields.content = body.toString('utf8')
|
||||||
|
} else {
|
||||||
|
fields.body = body.toString('base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.queryString) {
|
if (fields.queryString) {
|
||||||
@@ -268,7 +290,7 @@ const xeapi = (uri, data, options = {}) => {
|
|||||||
const dynamicKey = activeSessionKey || crypto.randomBytes(16)
|
const dynamicKey = activeSessionKey || crypto.randomBytes(16)
|
||||||
const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options))
|
const plaintext = Buffer.from(buildXeapiPlaintext(uri, data, options))
|
||||||
|
|
||||||
const b = aesEcbEncrypt(
|
const c = aesEcbEncrypt(
|
||||||
dynamicKey,
|
dynamicKey,
|
||||||
xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)),
|
xeapiMidTransform(aesEcbEncrypt(xeapiStaticKey, plaintext)),
|
||||||
)
|
)
|
||||||
@@ -281,7 +303,7 @@ const xeapi = (uri, data, options = {}) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
B: b.toString('base64'),
|
C: c.toString('base64url'),
|
||||||
S: s.toString('base64'),
|
S: s.toString('base64'),
|
||||||
R: r.toString('base64'),
|
R: r.toString('base64'),
|
||||||
}
|
}
|
||||||
@@ -305,11 +327,86 @@ const xeapiDecryptPublicKey = (encryptedData) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NEAPI_MAGIC = Buffer.from('CSJM')
|
||||||
|
const NEAPI_NONCE_LEN = 12
|
||||||
|
const NEAPI_AD_LEN = 16
|
||||||
|
const NEAPI_TAG_LEN = 16
|
||||||
|
const NEAPI_HEADER_LEN = 4 + 1 + 1 + 2 + NEAPI_NONCE_LEN + 1 + NEAPI_AD_LEN + 4
|
||||||
|
|
||||||
|
// Header: magic, the config version as two big-endian bytes, then the
|
||||||
|
// nonce / associated data / sealed length.
|
||||||
|
const neapi = (data, config) => {
|
||||||
|
const nonce = crypto.randomBytes(NEAPI_NONCE_LEN)
|
||||||
|
const ad = crypto.randomBytes(NEAPI_AD_LEN)
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
'chacha20-poly1305',
|
||||||
|
config.encryptKey,
|
||||||
|
nonce,
|
||||||
|
{ authTagLength: NEAPI_TAG_LEN },
|
||||||
|
)
|
||||||
|
cipher.setAAD(ad)
|
||||||
|
|
||||||
|
const sealed = Buffer.concat([
|
||||||
|
cipher.update(zstd.compress(data)),
|
||||||
|
cipher.final(),
|
||||||
|
cipher.getAuthTag(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const header = Buffer.alloc(NEAPI_HEADER_LEN)
|
||||||
|
NEAPI_MAGIC.copy(header, 0)
|
||||||
|
header[4] = config.version >> 8
|
||||||
|
header[5] = config.version & 0xff
|
||||||
|
header.writeUInt16LE(nonce.length, 6)
|
||||||
|
nonce.copy(header, 8)
|
||||||
|
header[8 + nonce.length] = ad.length
|
||||||
|
ad.copy(header, 9 + nonce.length)
|
||||||
|
header.writeUInt32LE(sealed.length, 9 + nonce.length + ad.length)
|
||||||
|
|
||||||
|
return Buffer.concat([header, sealed]).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some error responses repeat the same document back to back.
|
||||||
|
const parseBody = (text) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch (error) {
|
||||||
|
const boundary = text.indexOf('}{')
|
||||||
|
if (boundary < 0) throw error
|
||||||
|
return JSON.parse(text.slice(0, boundary + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const neapiResDecrypt = (body, config) => {
|
||||||
|
const raw = Buffer.from(String(body).trim(), 'base64')
|
||||||
|
const nonceLen = raw.readUInt16LE(6)
|
||||||
|
const nonce = raw.subarray(8, 8 + nonceLen)
|
||||||
|
const adLen = raw[8 + nonceLen]
|
||||||
|
const ad = raw.subarray(9 + nonceLen, 9 + nonceLen + adLen)
|
||||||
|
const offset = 9 + nonceLen + adLen
|
||||||
|
const sealed = raw.subarray(offset + 4, offset + 4 + raw.readUInt32LE(offset))
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'chacha20-poly1305',
|
||||||
|
config.decryptKey,
|
||||||
|
nonce,
|
||||||
|
{ authTagLength: NEAPI_TAG_LEN },
|
||||||
|
)
|
||||||
|
decipher.setAAD(ad)
|
||||||
|
decipher.setAuthTag(sealed.subarray(sealed.length - NEAPI_TAG_LEN))
|
||||||
|
|
||||||
|
const compressed = Buffer.concat([
|
||||||
|
decipher.update(sealed.subarray(0, sealed.length - NEAPI_TAG_LEN)),
|
||||||
|
decipher.final(),
|
||||||
|
])
|
||||||
|
return parseBody(zstd.decompress(compressed).toString())
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
weapi,
|
weapi,
|
||||||
linuxapi,
|
linuxapi,
|
||||||
eapi,
|
eapi,
|
||||||
xeapi,
|
xeapi,
|
||||||
|
neapi,
|
||||||
decrypt,
|
decrypt,
|
||||||
aesEncrypt,
|
aesEncrypt,
|
||||||
aesDecrypt,
|
aesDecrypt,
|
||||||
@@ -318,4 +415,5 @@ module.exports = {
|
|||||||
xeapiSign,
|
xeapiSign,
|
||||||
xeapiResDecrypt,
|
xeapiResDecrypt,
|
||||||
xeapiDecryptPublicKey,
|
xeapiDecryptPublicKey,
|
||||||
|
neapiResDecrypt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const zstd = require('./zstd')
|
||||||
|
|
||||||
|
// gorilla/algorithm/record 返回的 base64 解码后:
|
||||||
|
// [0 .. 64) RSA 块 -> RSA_PKCS1 去填充得到 RC4 密钥
|
||||||
|
// [64 .. 68) u32 LE 载荷长度
|
||||||
|
// [68 .. ) RC4 载荷 -> zstd 解压得到 ECFG 容器
|
||||||
|
const CONFIG_PUBLIC_KEY = Buffer.from(
|
||||||
|
'3048024100e4e79c1cb27a9fab7d2c740e99e923ec51c009169ec3a380c60404' +
|
||||||
|
'e01b060f0160cdc631d9fb7fe53b228a5ff11bee42b965d28c6c2036193f4ccd' +
|
||||||
|
'c7ed853d110203010001',
|
||||||
|
'hex',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 容器里的 Lua 区块对指令和字符串做了逐字节异或混淆
|
||||||
|
const CODE_XOR = [
|
||||||
|
0x94b4f825, 0x977c6795, 0x327de32b, 0x16da5ba1, 0xcf55a2eb, 0x080bb445,
|
||||||
|
0x021af055, 0xfc56b8bd,
|
||||||
|
]
|
||||||
|
const STR_XOR = Buffer.from('aefda2d1832b518a7741c325fb2687f0', 'hex')
|
||||||
|
const CHUNK_SIGNATURE = Buffer.from('7f454c46', 'hex')
|
||||||
|
|
||||||
|
// 脚本用这两个标记区分请求方向与响应方向
|
||||||
|
const DIRECTION_ENCRYPT = 251
|
||||||
|
const DIRECTION_DECRYPT = 503
|
||||||
|
const KEY_LEN = 32
|
||||||
|
|
||||||
|
const T_NUMFLT = 3
|
||||||
|
const T_NUMINT = 19
|
||||||
|
const OP_LOADK = 1
|
||||||
|
const OP_GETUPVAL = 5
|
||||||
|
const OP_EQ = 31
|
||||||
|
|
||||||
|
const op = (ins) => ins & 0x3f
|
||||||
|
const argA = (ins) => (ins >>> 6) & 0xff
|
||||||
|
const argB = (ins) => (ins >>> 23) & 0x1ff
|
||||||
|
const argC = (ins) => (ins >>> 14) & 0x1ff
|
||||||
|
const argBx = (ins) => (ins >>> 14) & 0x3ffff
|
||||||
|
|
||||||
|
const rc4 = (key, data) => {
|
||||||
|
const s = Buffer.alloc(256)
|
||||||
|
for (let i = 0; i < 256; i++) s[i] = i
|
||||||
|
|
||||||
|
let j = 0
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
j = (j + s[i] + key[i % key.length]) & 0xff
|
||||||
|
;[s[i], s[j]] = [s[j], s[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = Buffer.allocUnsafe(data.length)
|
||||||
|
let i = 0
|
||||||
|
j = 0
|
||||||
|
for (let n = 0; n < data.length; n++) {
|
||||||
|
i = (i + 1) & 0xff
|
||||||
|
j = (j + s[i]) & 0xff
|
||||||
|
;[s[i], s[j]] = [s[j], s[i]]
|
||||||
|
out[n] = data[n] ^ s[(s[i] + s[j]) & 0xff]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrapContainer = (raw) => {
|
||||||
|
const key = crypto.publicDecrypt(
|
||||||
|
crypto.createPublicKey({
|
||||||
|
key: CONFIG_PUBLIC_KEY,
|
||||||
|
format: 'der',
|
||||||
|
type: 'pkcs1',
|
||||||
|
}),
|
||||||
|
raw.subarray(0, 64),
|
||||||
|
)
|
||||||
|
const size = raw.readUInt32LE(64)
|
||||||
|
return zstd.decompress(rc4(key, raw.subarray(68, 68 + size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChunkReader {
|
||||||
|
constructor(buf, pos = 0) {
|
||||||
|
this.buf = buf
|
||||||
|
this.pos = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
u8() {
|
||||||
|
return this.buf[this.pos++]
|
||||||
|
}
|
||||||
|
|
||||||
|
u32() {
|
||||||
|
const value = this.buf.readUInt32LE(this.pos)
|
||||||
|
this.pos += 4
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
i64() {
|
||||||
|
const value = this.buf.readBigInt64LE(this.pos)
|
||||||
|
this.pos += 8
|
||||||
|
return Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
f64() {
|
||||||
|
const value = this.buf.readDoubleLE(this.pos)
|
||||||
|
this.pos += 8
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
string() {
|
||||||
|
let size = this.u8()
|
||||||
|
if (size === 0) return null
|
||||||
|
if (size === 0xff) size = this.u32()
|
||||||
|
// 长度比实际字节数多 1(标准 luac 的写法)
|
||||||
|
const body = this.buf.subarray(this.pos, this.pos + size - 1)
|
||||||
|
this.pos += size - 1
|
||||||
|
const out = Buffer.allocUnsafe(body.length)
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
out[i] = body[i] ^ STR_XOR[i & 0x0f]
|
||||||
|
}
|
||||||
|
return out.toString('utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProto = (r) => {
|
||||||
|
const proto = {
|
||||||
|
source: r.string(),
|
||||||
|
linedefined: r.u32(),
|
||||||
|
lastlinedefined: r.u32(),
|
||||||
|
numparams: r.u8(),
|
||||||
|
isvararg: r.u8(),
|
||||||
|
maxstack: r.u8(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = r.u32()
|
||||||
|
proto.code = new Array(count)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
proto.code[i] = (r.u32() ^ CODE_XOR[i & 7]) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常量标签:0 nil / 1 boolean / 3 float / 19 integer / 4,20 string
|
||||||
|
// 只保留用得到的整数与字符串
|
||||||
|
count = r.u32()
|
||||||
|
proto.consts = new Array(count)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const tag = r.u8()
|
||||||
|
if (tag === 0) proto.consts[i] = null
|
||||||
|
else if (tag === 1) {
|
||||||
|
r.u8()
|
||||||
|
proto.consts[i] = null
|
||||||
|
} else if (tag === T_NUMFLT) {
|
||||||
|
r.f64()
|
||||||
|
proto.consts[i] = null
|
||||||
|
} else if (tag === T_NUMINT) proto.consts[i] = r.i64()
|
||||||
|
else proto.consts[i] = r.string()
|
||||||
|
}
|
||||||
|
|
||||||
|
count = r.u32()
|
||||||
|
proto.upvals = new Array(count)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
proto.upvals[i] = [r.u8(), r.u8()]
|
||||||
|
}
|
||||||
|
|
||||||
|
count = r.u32()
|
||||||
|
proto.protos = new Array(count)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
proto.protos[i] = loadProto(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试段跳过即可
|
||||||
|
count = r.u32()
|
||||||
|
r.pos += 4 * count
|
||||||
|
count = r.u32()
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
r.string()
|
||||||
|
r.u32()
|
||||||
|
r.u32()
|
||||||
|
}
|
||||||
|
count = r.u32()
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
r.string()
|
||||||
|
}
|
||||||
|
return proto
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChunk = (buf) => {
|
||||||
|
if (!buf.subarray(0, 4).equals(CHUNK_SIGNATURE)) {
|
||||||
|
throw new Error('neapi config is not a chunk')
|
||||||
|
}
|
||||||
|
const r = new ChunkReader(buf, 33)
|
||||||
|
r.u8() // 主闭包 upvalue 数量
|
||||||
|
return loadProto(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主函数用一连串 LOADK 装载所有标量,据此可以把 upvalue 追回常量值
|
||||||
|
const mainRegisters = (main) => {
|
||||||
|
const regs = new Map()
|
||||||
|
for (const ins of main.code) {
|
||||||
|
if (op(ins) === OP_LOADK) regs.set(argA(ins), main.consts[argBx(ins)])
|
||||||
|
}
|
||||||
|
return regs
|
||||||
|
}
|
||||||
|
|
||||||
|
// create_ctx 里方向 -> 密钥偏移的分支:
|
||||||
|
// if direction == <a> then offset = <upvalue>
|
||||||
|
// elseif direction == <b> then offset = <upvalue>
|
||||||
|
const extractKeyOffsets = (script) => {
|
||||||
|
const createCtx = script.protos[0]
|
||||||
|
const regs = mainRegisters(script)
|
||||||
|
const offsets = {}
|
||||||
|
let pending = null
|
||||||
|
|
||||||
|
for (const ins of createCtx.code) {
|
||||||
|
const code = op(ins)
|
||||||
|
if (code === OP_EQ) {
|
||||||
|
const c = argC(ins)
|
||||||
|
const constant = c & 0x100 && createCtx.consts[c & 0xff]
|
||||||
|
if (Number.isInteger(constant)) pending = constant
|
||||||
|
} else if (code === OP_GETUPVAL && pending !== null) {
|
||||||
|
const up = createCtx.upvals[argB(ins)]
|
||||||
|
const constant = up && up[0] === 1 && regs.get(up[1])
|
||||||
|
if (Number.isInteger(constant)) {
|
||||||
|
offsets[pending] = constant
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(DIRECTION_ENCRYPT in offsets) || !(DIRECTION_DECRYPT in offsets)) {
|
||||||
|
throw new Error('neapi config script has an unexpected direction table')
|
||||||
|
}
|
||||||
|
return offsets
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractKeyBlob = (script) => {
|
||||||
|
const blob = script.consts
|
||||||
|
.filter(
|
||||||
|
(c) => typeof c === 'string' && /^[A-Za-z0-9+/]{64,}={0,2}$/.test(c),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.length - a.length)[0]
|
||||||
|
return Buffer.from(blob, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeConfig = (raw) => {
|
||||||
|
const ecfg = unwrapContainer(raw)
|
||||||
|
// ECFG 头 4 字节 + 大端 u16 版本,之后是 Lua 区块
|
||||||
|
const version = ecfg.readUInt16BE(4)
|
||||||
|
const script = loadChunk(ecfg.subarray(6))
|
||||||
|
|
||||||
|
const offsets = extractKeyOffsets(script)
|
||||||
|
const keyBlob = extractKeyBlob(script)
|
||||||
|
const slice = (direction) =>
|
||||||
|
keyBlob.subarray(offsets[direction], offsets[direction] + KEY_LEN)
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
keyBlob,
|
||||||
|
encryptKey: slice(DIRECTION_ENCRYPT),
|
||||||
|
decryptKey: slice(DIRECTION_DECRYPT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
decodeConfig,
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// 配置会轮换,启动时由 /register/neapikey 拉取后缓存到这里
|
||||||
|
const neapiKeyPath = path.resolve(require('os').tmpdir(), 'neapi_key')
|
||||||
|
|
||||||
|
let neapi_key = null
|
||||||
|
|
||||||
|
const loadNeapiKey = () => {
|
||||||
|
if (!neapi_key && fs.existsSync(neapiKeyPath)) {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(fs.readFileSync(neapiKeyPath, 'utf-8'))
|
||||||
|
neapi_key = {
|
||||||
|
version: stored.version,
|
||||||
|
circleTime: stored.circleTime,
|
||||||
|
fetchedAt: stored.fetchedAt,
|
||||||
|
keyBlob: Buffer.from(stored.keyBlob, 'base64'),
|
||||||
|
encryptKey: Buffer.from(stored.encryptKey, 'base64'),
|
||||||
|
decryptKey: Buffer.from(stored.decryptKey, 'base64'),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[ERR]', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neapi_key
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNeapiKey = (config, circleTime) => {
|
||||||
|
const fetchedAt = Date.now()
|
||||||
|
fs.writeFileSync(
|
||||||
|
neapiKeyPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: config.version,
|
||||||
|
circleTime,
|
||||||
|
fetchedAt,
|
||||||
|
keyBlob: config.keyBlob.toString('base64'),
|
||||||
|
encryptKey: config.encryptKey.toString('base64'),
|
||||||
|
decryptKey: config.decryptKey.toString('base64'),
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
neapi_key = { ...config, circleTime, fetchedAt }
|
||||||
|
return neapi_key
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadNeapiKey,
|
||||||
|
saveNeapiKey,
|
||||||
|
}
|
||||||
+146
-21
@@ -18,12 +18,14 @@ const {
|
|||||||
} = require('./index')
|
} = require('./index')
|
||||||
const { URLSearchParams, URL } = require('url')
|
const { URLSearchParams, URL } = require('url')
|
||||||
const { APP_CONF } = require('./config.json')
|
const { APP_CONF } = require('./config.json')
|
||||||
|
const { loadNeapiKey } = require('./neapiKey')
|
||||||
const {
|
const {
|
||||||
getToken: antiCheatTokenV2,
|
getToken: antiCheatTokenV2,
|
||||||
} = require('../module/register_checktoken_v2')
|
} = require('../module/register_checktoken_v2')
|
||||||
const {
|
const {
|
||||||
getToken: antiCheatTokenV3,
|
getToken: antiCheatTokenV3,
|
||||||
} = require('../module/register_checktoken_v3')
|
} = require('../module/register_checktoken_v3')
|
||||||
|
const registerNeapikey = require('../module/register_neapikey')
|
||||||
|
|
||||||
// 预先读取匿名token并缓存
|
// 预先读取匿名token并缓存
|
||||||
const anonymous_token = fs.readFileSync(
|
const anonymous_token = fs.readFileSync(
|
||||||
@@ -68,6 +70,23 @@ const WNMCID = (function () {
|
|||||||
return `${randomString}.${now().toString()}.01.0`
|
return `${randomString}.${now().toString()}.01.0`
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
let NMTID = ''
|
||||||
|
let NMTID_RETRIES_LEFT = 3
|
||||||
|
|
||||||
|
// neapi 配置过期时只让一个请求去刷新,并发的其余请求共用同一次结果
|
||||||
|
let neapiUserStatus = ''
|
||||||
|
let neapiRefreshing = null
|
||||||
|
const refreshNeapiKey = (version) => {
|
||||||
|
if (!neapiRefreshing) {
|
||||||
|
neapiRefreshing = registerNeapikey({ version, behavior: 'update' }, null)
|
||||||
|
.catch((error) => console.log('[ERR]', error))
|
||||||
|
.finally(() => {
|
||||||
|
neapiRefreshing = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return neapiRefreshing
|
||||||
|
}
|
||||||
|
|
||||||
// 预先定义osMap
|
// 预先定义osMap
|
||||||
const osMap = {
|
const osMap = {
|
||||||
pc: {
|
pc: {
|
||||||
@@ -124,6 +143,7 @@ const DOMAIN = APP_CONF.domain
|
|||||||
const API_DOMAIN = APP_CONF.apiDomain
|
const API_DOMAIN = APP_CONF.apiDomain
|
||||||
const EAPI_DOMAIN = APP_CONF.eapiDomain
|
const EAPI_DOMAIN = APP_CONF.eapiDomain
|
||||||
const XEAPI_DOMAIN = APP_CONF.xeapiDomain
|
const XEAPI_DOMAIN = APP_CONF.xeapiDomain
|
||||||
|
const NEAPI_DOMAIN = APP_CONF.neapiDomain
|
||||||
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
|
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
|
||||||
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
|
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
|
||||||
|
|
||||||
@@ -136,7 +156,7 @@ const chooseUserAgent = (crypto, uaType = 'pc') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cookie处理
|
// cookie处理
|
||||||
const processCookieObject = (cookie, uri) => {
|
const processCookieObject = (cookie, crypto) => {
|
||||||
const _ntes_nuid = CryptoJS.lib.WordArray.random(32).toString()
|
const _ntes_nuid = CryptoJS.lib.WordArray.random(32).toString()
|
||||||
const os = osMap[cookie.os] || osMap['pc']
|
const os = osMap[cookie.os] || osMap['pc']
|
||||||
|
|
||||||
@@ -155,8 +175,14 @@ const processCookieObject = (cookie, uri) => {
|
|||||||
appver: cookie.appver || os.appver,
|
appver: cookie.appver || os.appver,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uri.indexOf('login') === -1) {
|
// 服务端下发条件为不带NMTID请求任意eapi加密方式接口
|
||||||
processedCookie['NMTID'] = CryptoJS.lib.WordArray.random(16).toString()
|
if (cookie.NMTID) {
|
||||||
|
processedCookie['NMTID'] = cookie.NMTID
|
||||||
|
} else if (NMTID) {
|
||||||
|
processedCookie['NMTID'] = NMTID
|
||||||
|
} else if (NMTID_RETRIES_LEFT <= 0 || crypto !== 'eapi') {
|
||||||
|
processedCookie['NMTID'] =
|
||||||
|
'00O' + CryptoJS.lib.WordArray.random(19).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!processedCookie.MUSIC_U) {
|
if (!processedCookie.MUSIC_U) {
|
||||||
@@ -205,6 +231,12 @@ const createRequest = async (uri, data, options) => {
|
|||||||
const headers = options.headers ? { ...options.headers } : {}
|
const headers = options.headers ? { ...options.headers } : {}
|
||||||
const ip = options.realIP || options.ip || ''
|
const ip = options.realIP || options.ip || ''
|
||||||
|
|
||||||
|
// 加密方式选择
|
||||||
|
let crypto = options.crypto
|
||||||
|
if (crypto === '') {
|
||||||
|
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
|
||||||
|
}
|
||||||
|
|
||||||
// IP头设置
|
// IP头设置
|
||||||
if (ip) {
|
if (ip) {
|
||||||
headers['X-Real-IP'] = ip
|
headers['X-Real-IP'] = ip
|
||||||
@@ -217,19 +249,13 @@ const createRequest = async (uri, data, options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof cookie === 'object') {
|
if (typeof cookie === 'object') {
|
||||||
cookie = processCookieObject(cookie, uri)
|
cookie = processCookieObject(cookie, crypto)
|
||||||
headers['Cookie'] = cookieObjToString(cookie)
|
headers['Cookie'] = cookieObjToString(cookie)
|
||||||
}
|
}
|
||||||
let url = ''
|
let url = ''
|
||||||
let encryptData = ''
|
let encryptData = ''
|
||||||
let crypto = options.crypto
|
|
||||||
const csrfToken = cookie['__csrf'] || ''
|
const csrfToken = cookie['__csrf'] || ''
|
||||||
|
|
||||||
// 加密方式选择
|
|
||||||
if (crypto === '') {
|
|
||||||
crypto = APP_CONF.encrypt ? 'eapi' : 'api'
|
|
||||||
}
|
|
||||||
|
|
||||||
const answer = { status: 500, body: {}, cookie: [] }
|
const answer = { status: 500, body: {}, cookie: [] }
|
||||||
|
|
||||||
data.e_r = toBoolean(
|
data.e_r = toBoolean(
|
||||||
@@ -312,6 +338,47 @@ const createRequest = async (uri, data, options) => {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'neapi':
|
||||||
|
const neapiKey = loadNeapiKey()
|
||||||
|
if (!neapiKey) {
|
||||||
|
throw new Error('neapi config is missing')
|
||||||
|
}
|
||||||
|
// 超过 circleTime 就后台刷新,本次仍用手上的配置
|
||||||
|
if (now() - neapiKey.fetchedAt >= neapiKey.circleTime) {
|
||||||
|
refreshNeapiKey(neapiKey.version)
|
||||||
|
}
|
||||||
|
const neapiCookie = {
|
||||||
|
osver: cookie.osver,
|
||||||
|
deviceId: cookie.deviceId,
|
||||||
|
os: cookie.os,
|
||||||
|
appver: cookie.appver,
|
||||||
|
versioncode: cookie.versioncode || '140',
|
||||||
|
mobilename: cookie.mobilename || '',
|
||||||
|
buildver: cookie.buildver || now().toString().substr(0, 10),
|
||||||
|
resolution: cookie.resolution || '1920x1080',
|
||||||
|
__csrf: csrfToken,
|
||||||
|
channel: cookie.channel,
|
||||||
|
requestId: generateRequestId(),
|
||||||
|
}
|
||||||
|
if (cookie.MUSIC_U) neapiCookie['MUSIC_U'] = cookie.MUSIC_U
|
||||||
|
if (cookie.MUSIC_A) neapiCookie['MUSIC_A'] = cookie.MUSIC_A
|
||||||
|
headers['Cookie'] = createHeaderCookie(neapiCookie)
|
||||||
|
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'android')
|
||||||
|
headers['content-type'] =
|
||||||
|
'application/x-www-form-urlencoded;charset=utf-8'
|
||||||
|
// 服务端按 X-ER 选密钥,配置过期时由下面的 777/999 分支重拉并重试
|
||||||
|
headers['X-ER'] = String(neapiKey.version)
|
||||||
|
if (neapiUserStatus) headers['X-USER-STATUS'] = neapiUserStatus
|
||||||
|
|
||||||
|
const neapiBody = { ...data }
|
||||||
|
delete neapiBody.e_r
|
||||||
|
encryptData = encrypt.neapi(
|
||||||
|
new URLSearchParams(neapiBody).toString(),
|
||||||
|
neapiKey,
|
||||||
|
)
|
||||||
|
url = (options.domain || NEAPI_DOMAIN) + '/neapi/' + uri.substr(5)
|
||||||
|
break
|
||||||
|
|
||||||
case 'eapi':
|
case 'eapi':
|
||||||
case 'api':
|
case 'api':
|
||||||
// header创建
|
// header创建
|
||||||
@@ -332,9 +399,8 @@ const createRequest = async (uri, data, options) => {
|
|||||||
|
|
||||||
if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U
|
if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U
|
||||||
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
|
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
|
||||||
if (options.checkToken) {
|
if (options.checkToken) header['X-antiCheatToken'] = token
|
||||||
header['X-antiCheatToken'] = token
|
if (crypto === 'eapi' && cookie.NMTID) header['NMTID'] = cookie.NMTID
|
||||||
}
|
|
||||||
|
|
||||||
headers['Cookie'] = createHeaderCookie(header)
|
headers['Cookie'] = createHeaderCookie(header)
|
||||||
headers['User-Agent'] =
|
headers['User-Agent'] =
|
||||||
@@ -359,12 +425,19 @@ const createRequest = async (uri, data, options) => {
|
|||||||
console.log('[ERR]', 'Unknown Crypto:', crypto)
|
console.log('[ERR]', 'Unknown Crypto:', crypto)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
|
||||||
|
const use_xeapi = crypto === 'xeapi'
|
||||||
|
const use_neapi = crypto === 'neapi'
|
||||||
|
|
||||||
// settings创建
|
// settings创建
|
||||||
let settings = {
|
let settings = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: url,
|
url: url,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
data: new URLSearchParams(encryptData).toString(),
|
// neapi 的请求体就是容器本身,不是表单字段
|
||||||
|
data: use_neapi
|
||||||
|
? encryptData
|
||||||
|
: new URLSearchParams(encryptData).toString(),
|
||||||
httpAgent: createHttpAgent(),
|
httpAgent: createHttpAgent(),
|
||||||
httpsAgent: createHttpsAgent(),
|
httpsAgent: createHttpsAgent(),
|
||||||
}
|
}
|
||||||
@@ -375,9 +448,7 @@ const createRequest = async (uri, data, options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用返回值加密
|
// 使用返回值加密
|
||||||
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
|
if (use_e_r || use_xeapi || use_neapi) {
|
||||||
const use_xeapi = crypto === 'xeapi'
|
|
||||||
if (use_e_r || use_xeapi) {
|
|
||||||
settings.encoding = null
|
settings.encoding = null
|
||||||
settings.responseType = 'arraybuffer'
|
settings.responseType = 'arraybuffer'
|
||||||
}
|
}
|
||||||
@@ -418,11 +489,60 @@ const createRequest = async (uri, data, options) => {
|
|||||||
}
|
}
|
||||||
// console.log(settings.headers);
|
// console.log(settings.headers);
|
||||||
axios(settings)
|
axios(settings)
|
||||||
.then((res) => {
|
.then(async (res) => {
|
||||||
const body = res.data
|
const body = res.data
|
||||||
answer.cookie = (res.headers['set-cookie'] || []).map((x) =>
|
const setCookies = res.headers['set-cookie'] || []
|
||||||
x.replace(/\s*Domain=[^(;|$)]+;*/, ''),
|
|
||||||
)
|
const cleanCookie = (x) => x.replace(/\s*Domain=[^(;|$)]+;*/, '')
|
||||||
|
|
||||||
|
// 仅对真正未携带 NMTID 的探测请求采集并消耗重试次数
|
||||||
|
if (
|
||||||
|
crypto === 'eapi' &&
|
||||||
|
!NMTID &&
|
||||||
|
NMTID_RETRIES_LEFT > 0 &&
|
||||||
|
!cookie.NMTID
|
||||||
|
) {
|
||||||
|
NMTID_RETRIES_LEFT--
|
||||||
|
answer.cookie = (
|
||||||
|
typeof res.headers['set-cookie'] === 'string'
|
||||||
|
? [res.headers['set-cookie']]
|
||||||
|
: res.headers['set-cookie'] || []
|
||||||
|
).map((x) => {
|
||||||
|
const cleaned = cleanCookie(x)
|
||||||
|
const match = x.match(/(?:^|;\s*)NMTID=([^;]+)/)
|
||||||
|
if (match) {
|
||||||
|
//不需要处理竞争, 官方客户端真实操作
|
||||||
|
NMTID = match[1]
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
answer.cookie = setCookies.map(cleanCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空值同样要记下来,否则状态恢复后会一直带着上一次的值
|
||||||
|
const userStatus = res.headers['x-user-status'] || ''
|
||||||
|
if (use_neapi) {
|
||||||
|
neapiUserStatus = userStatus
|
||||||
|
if (
|
||||||
|
(userStatus === '777' || userStatus === '999') &&
|
||||||
|
!options.neapiRetried
|
||||||
|
) {
|
||||||
|
// 配置已轮换:刷新后重试一次
|
||||||
|
await refreshNeapiKey(loadNeapiKey().version)
|
||||||
|
try {
|
||||||
|
resolve(
|
||||||
|
await createRequest(uri, data, {
|
||||||
|
...options,
|
||||||
|
neapiRetried: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
|
// debug: 统一注释块,需要时取消注释查看请求/返回的原始密文
|
||||||
|
|
||||||
@@ -442,6 +562,11 @@ const createRequest = async (uri, data, options) => {
|
|||||||
xeapiSessionKey = res.headers['x-encr-sskey']
|
xeapiSessionKey = res.headers['x-encr-sskey']
|
||||||
}
|
}
|
||||||
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body))
|
answer.body = encrypt.xeapiResDecrypt(Buffer.from(body))
|
||||||
|
} else if (use_neapi) {
|
||||||
|
answer.body = encrypt.neapiResDecrypt(
|
||||||
|
Buffer.from(body).toString(),
|
||||||
|
loadNeapiKey(),
|
||||||
|
)
|
||||||
} else if (use_e_r) {
|
} else if (use_e_r) {
|
||||||
answer.body = encrypt.eapiResDecrypt(
|
answer.body = encrypt.eapiResDecrypt(
|
||||||
body.toString('hex').toUpperCase(),
|
body.toString('hex').toUpperCase(),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const zlib = require('zlib')
|
||||||
|
const { decompress: fzstdDecompress } = require('fzstd')
|
||||||
|
|
||||||
|
// Node 22.15+ 自带 zstd。更早的版本(CI 含 18)用 fzstd 解压,压缩则退回到
|
||||||
|
// raw block 帧:内容原样存储,任何 zstd 解码器都能读,只是没有压缩率。
|
||||||
|
const hasNative = typeof zlib.zstdCompressSync === 'function'
|
||||||
|
|
||||||
|
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
||||||
|
const WINDOW_DESCRIPTOR = 0x50 // windowLog 20
|
||||||
|
const BLOCK_MAX = 0x20000 // zstd 单块上限 128 KiB
|
||||||
|
|
||||||
|
const compressRawFrame = (input) => {
|
||||||
|
const parts = [MAGIC, Buffer.from([0x00, WINDOW_DESCRIPTOR])]
|
||||||
|
let offset = 0
|
||||||
|
do {
|
||||||
|
const block = input.subarray(offset, offset + BLOCK_MAX)
|
||||||
|
const last = offset + BLOCK_MAX >= input.length
|
||||||
|
const header = Buffer.alloc(3)
|
||||||
|
header.writeUIntLE((block.length << 3) | (last ? 1 : 0), 0, 3)
|
||||||
|
parts.push(header, block)
|
||||||
|
offset += BLOCK_MAX
|
||||||
|
} while (offset < input.length)
|
||||||
|
return Buffer.concat(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const compress = (input) => {
|
||||||
|
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
|
||||||
|
return hasNative ? zlib.zstdCompressSync(buf) : compressRawFrame(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decompress = (input) => {
|
||||||
|
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input)
|
||||||
|
return hasNative
|
||||||
|
? zlib.zstdDecompressSync(buf)
|
||||||
|
: Buffer.from(fzstdDecompress(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compress,
|
||||||
|
decompress,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user