Compare commits

...

33 Commits

Author SHA1 Message Date
d7c0db9455
fix: 错误的docker配置 2026-08-09 11:36:53 +08:00
c1d14a86d9
Merge pull request #237 from NeteaseCloudMusicApiEnhanced/fix/yidun
refactor: 重写checkToken V2模块
2026-08-09 11:21:39 +08:00
a2a2a40d71
fixes 2026-08-07 13:45:01 +08:00
543660fcdf
fix: pkg打包node18兼容 2026-08-07 12:13:43 +08:00
98a95002b2
fix: node18兼容 2026-08-07 12:01:11 +08:00
c4a3d83abe
refactor: 重写checkToken V2模块 2026-08-07 11:52:54 +08:00
7753b5de41
Merge pull request #235 from NeteaseCloudMusicApiEnhanced/feat/yunbei_task
feat: 新增云贝任务相关接口
2026-08-06 16:58:07 +08:00
c1fe1eb925
Merge branch 'pr/235' 2026-08-06 16:55:27 +08:00
3357317a1d
upd 2026-08-06 16:51:51 +08:00
a08f425ae7
Merge branch 'main' into feat/yunbei_task 2026-08-06 16:45:11 +08:00
5b780addba
ci: test ci with the workflow file 2026-08-05 20:44:21 +08:00
fc9f41839c
ci: test ci with workflow file 2026-08-05 19:44:08 +08:00
5859944563
format docs 2026-08-05 18:31:49 +08:00
aaa6459e55
remove disclaimer 2026-08-05 18:13:14 +08:00
a898e1d86c
feat: 新增云贝任务相关接口 2026-08-05 18:11:31 +08:00
4045f1ad3f
Merge branch 'pr/227' 2026-08-02 16:40:25 +08:00
e6214a3f73
Merge branch 'pr/229' 2026-08-02 16:39:14 +08:00
686e8bf969
revert: revert some changes 2026-08-02 16:37:46 +08:00
anupamme
9214cb945f fix: V-001 security vulnerability
Automated security fix generated by OrbisAI Security
2026-08-02 08:35:02 +00:00
a79a2d9ef6
fix: 修正workflow文件 2026-08-02 16:19:40 +08:00
7788dev
9608da9b96 fix(voice): load created podcasts in upload example 2026-08-02 12:52:25 +08:00
7788dev
93b3e624d8 feat(voice): support cover image uploads 2026-08-02 11:53:51 +08:00
15f515c1ba
ci(test): test ci with the workflow file 2026-08-02 11:27:45 +08:00
anupamme
e255e8f334 fix: V-001 security vulnerability
Automated security fix generated by OrbisAI Security
2026-08-02 03:16:22 +00:00
16aa61f98e
ci(test): test ci workflow file 2026-08-02 11:00:13 +08:00
a939003b92
ci(test): test ci workflow file 2026-08-02 10:46:19 +08:00
8f4873f2e2
perf: 更新接口定义 2026-08-01 19:11:37 +08:00
a8b45a8b8b
Merge branch 'pr/225' 2026-08-01 17:34:54 +08:00
1a70281c76
ci: 更新问题管理工作流 2026-08-01 17:20:19 +08:00
tanjuntao
f5f4ff6eda feat: add complete current-user event listing 2026-08-01 10:25:00 +08:00
tanjuntao
eb1b5ba0ea feat: add event privacy endpoint 2026-08-01 10:25:00 +08:00
6732fc7c32
ci(test): test ci workflow file 2026-07-31 22:21:34 +08:00
6ce6b84015
fix: 修复分享笔记接口 2026-07-26 22:39:58 +08:00
44 changed files with 3134 additions and 456 deletions

5
.codegraph/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore

View File

@ -14,15 +14,16 @@ body:
id: terms id: terms
attributes: attributes:
label: 确认事项 label: 确认事项
description: 在提交功能请求前,请确认以下事项 description: |
在提交功能请求前,请确认以下事项
- 我已经搜索了现有的issues确认这不是重复请求
- 我已经查看了项目文档
- 该项目符合该项目的目标和范围
- 我确认我提供的接口请求是合理且有意义的
options: options:
- label: 我已经搜索了现有的issues确认这不是重复请求 - label: 我已确认以上事项
required: true
- label: 我已经查看了项目文档
required: true
- label: 该项目符合该项目的目标和范围
required: true
- label: 我确认我提供的接口请求是合理且有意义的
required: true required: true

67
.github/workflows/ci-check.yml vendored Normal file
View File

@ -0,0 +1,67 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Lint / Unit / Docs
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 22, 24]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
# frozen-lockfile: 顺带校验 package.json 与 pnpm-lock.yaml 是否一致
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
# 纯单测,不需要网络,稳定必过
- name: Unit tests
run: pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit
- name: Docs format check
run: pnpm docs:check
integration:
# server.test.js 会启动真实服务器并请求真实网易云 API
# 可能因上游风控/限流偶发失败,因此不阻塞合并
name: Integration tests (real NetEase API)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Run full test suite
run: pnpm test

View File

@ -6,8 +6,6 @@ permissions:
on: on:
issues: issues:
types: [opened, labeled] types: [opened, labeled]
issue_comment:
types: [created]
jobs: jobs:
welcome-new-issues: welcome-new-issues:
@ -19,38 +17,15 @@ jobs:
with: with:
issue-number: ${{ github.event.issue.number }} issue-number: ${{ github.event.issue.number }}
body: | body: |
## 快速链接 感谢您在该仓库提出问题!我们非常重视您的反馈,并希望尽快为您提供帮助。
在我们处理您的问题之前,您可以先查看以下资源: 如果您在一周左右的时间里没有收到我们的回复,请在下方留言,以便我们注意到。我们的大部分审核都是志愿者,有时难免会有疏漏。
你可能会想要尝试以下方法来获得更快的帮助:
- 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org) - 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org)
- 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk) - 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk)
- 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题 - 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题
handle-help-wanted:
if: contains(github.event.issue.labels.*.name, 'question')
runs-on: ubuntu-latest
steps:
- name: Add help resources
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
🆘 需要帮助?
这里有一些可能对您有用的资源:
📚 **文档资源**
- [项目文档](https://neteasecloudmusicapienhanced.js.org)
🔍 **常见问题**
- 搜索[已关闭的issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues?q=is%3Aissue+is%3Aclosed) 看看是否有类似的问题已经被解答。
💬 **即时帮助**
- 加入QQ群https://qm.qq.com/q/TpeP9Uv2yk
如果以上资源无法解决您的问题,请提供更多详细信息,我们会尽快为您解答!
stale-issues: stale-issues:
if: github.event.action == 'opened' if: github.event.action == 'opened'
runs-on: ubuntu-latest runs-on: ubuntu-latest

59
.github/workflows/opencode-auto.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: OpenCode Automation
on:
issues:
types: [opened]
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:
# 排除机器人自己的触发,防止自我循环
if: |
github.actor != 'takanashi-hoshino-agent[bot]' &&
github.actor != 'github-actions[bot]' &&
github.actor != 'opencode-agent[bot]'
runs-on: ubuntu-latest
steps:
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
persist-credentials: false
- 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: ${{ secrets.OPENCODE_API_KEY }}
ISSUE_PROMPT: ${{ secrets.OPENCODE_PROMPT }}
PR_PROMPT: |
${{ secrets.OPENCODE_PROMPT }}
${{ secrets.PR_REVIEW_PROMPT }}
run: node scripts/opencode-automation.mjs

56
.github/workflows/opencode.yml vendored Normal file
View File

@ -0,0 +1,56 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
bot:
if: |
(contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')) &&
github.actor != 'opencode-agent[bot]' &&
github.actor != 'takanashi-hoshino-agent[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
git config --global user.name "takanashi-hoshino-agent[bot]"
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
model: opencode/deepseek-v4-flash-free
use_github_token: true
prompt: |
${{ secrets.OPENCODE_PROMPT }}

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ anonymous_token
.vercel .vercel
.env .env
IFLOW.md IFLOW.md
precompiled

View File

@ -1,23 +1,40 @@
# Agent Instructions for NeteaseCloudMusicApiEnhanced # NeteaseCloudMusicApiEnhanced Agent 说明
## Quick Start ## 快速开始
- **Package Manager**: Use `pnpm` (not npm or yarn). - **包管理器**:开发与 CI 使用 `pnpm``Dockerfile` 也用 pnpm`pnpm@9 --frozen-lockfile`,匹配 `pnpm-lock.yaml`)。仓库没有 `yarn.lock`,不要引入 yarn。
- **Node Version**: Requires Node.js 18 or later. - **Node 版本**README 推荐 Node 22+`package.json``engines` 声明 `>=12`CI/打包在 Node 1824 上运行。现代 Node 均可。
- **环境变量**`server.js` 调用了 `dotenv.config()`,本地 `.env` 会被自动加载;所有支持的变量见 `.env.prod.example`
## Developer Commands ## 常用命令
- **Install dependencies**: `pnpm i` - 安装依赖:`pnpm i`
- **Start server**: `pnpm start` or `node app.js` - 启动服务:`pnpm start`(等价 `node app.js`);热重载开发:`pnpm dev`nodemon
- **Start dev server**: `pnpm dev` (uses nodemon) - 跑测试:`pnpm test`Mocha超时 60s
- **Run tests**: `pnpm test` (uses Mocha) - Lint`pnpm lint`;自动修复:`pnpm lint-fix`
- **Linting**: `pnpm lint` (check) or `pnpm lint-fix` (auto-fix) - 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
## Architecture & Entrypoints ## 架构
- **Executable Server**: `app.js` is the main entrypoint for running the API server. - `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`
- **Module Exports**: `main.js` is the entrypoint when the project is imported as a Node.js dependency. - `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_``/`,如 `album_new.js``/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js``special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000/`HOST`
- **API Endpoints**: Located in the `module/` directory. Each file typically corresponds to an API route. - `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server``serveNcmApi``getModulesDefinitions`
- **Core Utilities**: Request handling, encryption, and core utilities are found in the `util/` directory. - `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))``createOption``util/option.js`,负责 crypto、cookie回退到 `NETEASE_COOKIE`、proxy、realIP/randomCNIP、headers、timeout。
- `util/request.js`——唯一的对外 HTTP 层axios。按 `crypto``api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token``xeapi_public_key`
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel`vercel.json`)和 ESM 导入使用。
## Important Gotchas & Quirks ## 新增/修改接口
- **Environment Variables**: The server defaults to port 3000 but can be overridden with the `PORT` environment variable. - 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
- **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. - 照抄同目录模块的写法(选对 `crypto`),用 `createOption(query)` 生成请求选项。
- **Code Style**: The project uses ESLint and Prettier. Always run `pnpm lint-fix` before committing changes to ensure formatting consistency. - 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
## 测试
- `pnpm test``server.test.js` + `main.test.js``server.test.js``before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测。
- 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。
- 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "<describe/it 名字>" server.test.js main.test.js --exit`
## 坑与注意
- **改 `package.json` 的 `version` 会触发自动发布**push 到 `main` 后会自动打 GitHub Release`pkg` 二进制)、构建并推送 Docker 镜像Docker Hub + GHCR`pnpm publish` 到 npm。别顺手改版本号。
- **没有实际 git hooks**`package.json` 里配了 `lint-staged`,但 `.husky/` 下没有真正的 hookcommit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`
- **代理环境变量已失效**README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
- **启动令牌在系统临时目录**`anonymous_token``xeapi_public_key` 存放在 `os.tmpdir()``util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
- **ESLint 9 flat config**`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制2 空格缩进、单引号、分号、`endOfLine: auto`)。

View File

@ -2,14 +2,19 @@ FROM node:lts-alpine
RUN apk add --no-cache tini RUN apk add --no-cache tini
ENV NODE_ENV production ENV NODE_ENV=production
RUN npm install -g pnpm@9
USER node USER node
WORKDIR /app WORKDIR /app
COPY --chown=node:node . ./ COPY --chown=node:node . ./
RUN yarn --network-timeout=100000 # --prod 模式下 husky 不会被安装prepare 脚本会因找不到 husky 而失败,
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
EXPOSE 3000 EXPOSE 3000

294
interface.d.ts vendored
View File

@ -37,6 +37,41 @@ export const enum SubAction {
unsub = 0, unsub = 0,
} }
export function ad_get(
params: { type_ids?: string } & RequestBaseConfig,
): Promise<Response>
export function ad_listening_rights(
params: RequestBaseConfig,
): Promise<Response>
export function ad_listening_rights_gain(
params: {
reqUid?: string
uid?: string | number
exposureTime?: string | number
clickTime?: string | number
extraRightsType?: string | number
playContinuously?: boolean | string
source?: string | number
creativeType?: string | number
rightsGainMethod?: string | number
extraRightsGainMethod?: string | number
extraRightsGainDuration?: string | number
nextRightsGainDuration?: string | number
rightsGainType?: string | number
rightsGainDuration?: string | number
gainMethodStep?: string | number
generalRightsInfo?: string
rightsExtJson?: string
appInfo?: string
contextInfo?: string
installed?: string | number
sniffTime?: string | number
type_ids?: string
} & RequestBaseConfig,
): Promise<Response>
export function activate_init_profile( export function activate_init_profile(
params: { nickname: string } & RequestBaseConfig, params: { nickname: string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@ -119,6 +154,14 @@ export function album_sublist(
params: MultiPageConfig & RequestBaseConfig, params: MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function api(
params: {
uri: string
data?: string | Record<string, unknown>
crypto?: string
} & RequestBaseConfig,
): Promise<Response>
export function artist_album( export function artist_album(
params: { id: string | number } & MultiPageConfig & RequestBaseConfig, params: { id: string | number } & MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@ -271,10 +314,21 @@ export function batch(
params: { [index: string]: unknown } & RequestBaseConfig, params: { [index: string]: unknown } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function captcha_safe_sent(
params: { ctcode?: number | string } & RequestBaseConfig,
): Promise<Response>
export function captcha_sent( export function captcha_sent(
params: { phone: string; ctcode?: number | string } & RequestBaseConfig, params: { phone: string; ctcode?: number | string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function captcha_sent_v1(
params: {
phone: number | string
ctcode?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function captcha_verify( export function captcha_verify(
params: { params: {
ctcode?: number | string ctcode?: number | string
@ -387,6 +441,14 @@ export function comment(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_add(
params: {
id: string | number
type: CommentType
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_album( export function comment_album(
params: { params: {
id: string | number id: string | number
@ -395,6 +457,14 @@ export function comment_album(
RequestBaseConfig, RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_delete(
params: {
id: string | number
type: CommentType
cid: string | number
} & RequestBaseConfig,
): Promise<Response>
export function comment_dj( export function comment_dj(
params: { params: {
id: string | number id: string | number
@ -469,6 +539,15 @@ export function comment_playlist(
RequestBaseConfig, RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function comment_reply(
params: {
id: string | number
type: CommentType
cid: string | number
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_video( export function comment_video(
params: { params: {
id: string | number id: string | number
@ -490,6 +569,21 @@ export function daily_signin(
params: { type?: DailySigninType } & RequestBaseConfig, params: { type?: DailySigninType } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function decrypt(
params: {
crypto?: string
data?: string
hexString?: string
isReq?: boolean | string
} & RequestBaseConfig,
): Promise<Response>
export function device_kickoff(
params: { deviceKey: string | number; captcha?: string } & RequestBaseConfig,
): Promise<Response>
export function device_list(params: RequestBaseConfig): Promise<Response>
export function digitalAlbum_ordering( export function digitalAlbum_ordering(
params: { params: {
payment: string payment: string
@ -618,6 +712,10 @@ export function dj_toplist_popular(
params: { limit?: string | number } & RequestBaseConfig, params: { limit?: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function eapi_decrypt(
params: { hexString: string; isReq?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function event( export function event(
params: { pagesize?: number; lasttime?: number } & RequestBaseConfig, params: { pagesize?: number; lasttime?: number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@ -626,6 +724,20 @@ export function event_del(
params: { evId: string | number } & RequestBaseConfig, params: { evId: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export const enum EventPrivacy {
everyone = 0,
following = 1,
onlyMe = 2,
mutualFollowing = 6,
}
export function event_privacy(
params: {
evId: string | number
privacy: EventPrivacy
} & RequestBaseConfig,
): Promise<Response>
export function event_forward( export function event_forward(
params: { params: {
forwords: string forwords: string
@ -665,6 +777,8 @@ export function hot_topic(
params: MultiPageConfig & RequestBaseConfig, params: MultiPageConfig & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function inner_version(params: RequestBaseConfig): Promise<Response>
export function like( export function like(
params: { params: {
like?: 'true' | 'false' | boolean like?: 'true' | 'false' | boolean
@ -678,6 +792,10 @@ export function likelist(
params: { uid: string | number } & RequestBaseConfig, params: { uid: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function listentogether_status(
params: RequestBaseConfig,
): Promise<Response>
export function login( export function login(
params: { email: string; password: string } & RequestBaseConfig, params: { email: string; password: string } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@ -724,6 +842,17 @@ export function lyric_new(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function middle_play_do_lottery(
params: {
activityId?: string | number
drawCount?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function middle_play_lottery_remain_chance(
params: { activityId?: string | number } & RequestBaseConfig,
): Promise<Response>
export function msg_comments( export function msg_comments(
params: { params: {
uid: string | number uid: string | number
@ -946,6 +1075,18 @@ export function register_cellphone(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function register_checktoken_v2(
params: { refresh?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function register_checktoken_v3(
params: { refresh?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function register_xeapikey(
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
): Promise<Response>
export function related_allvideo( export function related_allvideo(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
@ -954,6 +1095,67 @@ export function related_playlist(
params: { id: string | number } & RequestBaseConfig, params: { id: string | number } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function relay_play_state_submit(
params: {
id: string | number
sessionId?: string
progress?: string | number
playMode?: string
type?: string
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_activity_collect(
params: { activityId?: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_activity_get(
params: RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_info_get(
params: { examType: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_question_single_get(
params: {
examType: string | number
taskId: string | number
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_result_get(
params: {
examType: string | number
taskId: string | number
} & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_start(
params: { examType: string | number } & RequestBaseConfig,
): Promise<Response>
export function rep_ugc_exam_submit(
params: {
examType: string | number
taskId: string | number
questionId: string | number
answer: string
} & RequestBaseConfig,
): Promise<Response>
declare const rep_ugc_user_collect_vip: (
params: { activityId?: string | number } & RequestBaseConfig,
) => Promise<Response>
export { rep_ugc_user_collect_vip as 'rep_ugc_user_collect-vip' }
export function rep_ugc_user_get(params: RequestBaseConfig): Promise<Response>
export function rep_ugc_user_sign(params: RequestBaseConfig): Promise<Response>
export function rep_ugc_user_vip(params: RequestBaseConfig): Promise<Response>
export const enum ResourceType { export const enum ResourceType {
mv = 1, mv = 1,
dj = 4, dj = 4,
@ -979,6 +1181,22 @@ export function scrobble(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function scrobble_v1(
params: {
id: string | number
time: string | number
total?: string | number
sourceid?: string | number
sourceId?: string | number
source?: string
name?: string
artist?: string
bitrate?: string | number
level?: string
vip?: boolean | string
} & RequestBaseConfig,
): Promise<Response>
export function search( export function search(
params: { params: {
keywords: string keywords: string
@ -1267,6 +1485,25 @@ export function user_event(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export interface UserEventAllResponse {
code: number
events: Record<string, unknown>[]
/** Upstream account statistic; it can include events no longer returned. */
size: number | null
/** Number of unique events in `events`. */
retrievedCount: number
/** Positive difference between `size` and `retrievedCount`. */
unavailableCount: number | null
sizeMismatch: boolean | null
pageCount: number
more: false
lasttime: string | number | null
}
export function user_event_all(
params: RequestBaseConfig,
): Promise<Response<UserEventAllResponse>>
export function user_followeds( export function user_followeds(
params: { params: {
uid: string | number uid: string | number
@ -1433,6 +1670,23 @@ export function yunbei_task_finish(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function yunbei_ad_task_list(
params: RequestBaseConfig,
): Promise<Response>
export function yunbei_ad_task_recommend_song(
params: {
offset?: number | string
limit?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function yunbei_ad_task_finish(
params: {
yunbeiAmount?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function msg_recentcontact(params: RequestBaseConfig): Promise<Response> export function msg_recentcontact(params: RequestBaseConfig): Promise<Response>
export function hug_comment( export function hug_comment(
@ -2292,6 +2546,10 @@ export function song_chorus(
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function song_cloud_download(
params: { id: string | number } & RequestBaseConfig,
): Promise<Response>
export function song_creators( export function song_creators(
params: { params: {
id: string | number id: string | number
@ -2403,6 +2661,18 @@ export function threshold_detail_get(
params: RequestBaseConfig, params: RequestBaseConfig,
): Promise<Response> ): Promise<Response>
export function thinktank_audit_resource_detail(
params: { type?: string | number } & RequestBaseConfig,
): Promise<Response>
export function thinktank_audit_resource_update(
params: {
type?: string | number
taskId: string | number
judgement: string | number
} & RequestBaseConfig,
): Promise<Response>
export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response> export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response>
export function ugc_album_get( export function ugc_album_get(
@ -2529,6 +2799,14 @@ export function verify_qrcodestatus(
export function vip_sign(params: RequestBaseConfig): Promise<Response> export function vip_sign(params: RequestBaseConfig): Promise<Response>
export function vip_sign_detail(
params: { timestamp: string | number } & RequestBaseConfig,
): Promise<Response>
export function vip_sign_history(
params: { type?: string | number } & RequestBaseConfig,
): Promise<Response>
export function vip_sign_info(params: RequestBaseConfig): Promise<Response> export function vip_sign_info(params: RequestBaseConfig): Promise<Response>
export function vip_tasks_v1( export function vip_tasks_v1(
@ -2549,6 +2827,22 @@ export function voice_upload(
name: string name: string
data: string | Buffer data: string | Buffer
} }
imgFile?: {
name: string
data: string | Buffer
}
voiceListId: string | number
coverImgId?: string | number
categoryId: string | number
secondCategoryId: string | number
description: string
songName?: string
privacy?: string | number
publishTime?: string | number
autoPublish?: string | number
autoPublishText?: string
orderNo?: string | number
composedSongs?: string
} & RequestBaseConfig, } & RequestBaseConfig,
): Promise<Response> ): Promise<Response>

View File

@ -17,9 +17,24 @@ module.exports = async (query, request) => {
try { try {
if (raw?.ads) { if (raw?.ads) {
const ad = Object.values(raw.ads)[0] const ad = Object.values(raw.ads)[0]
if (ad?.extJson) { // 逆向 v9.5.61:客户端从 ad.adExtMap["req_id"] 取 reqUid
const ext = JSON.parse(ad.extJson) if (ad?.adExtMap) {
reqId = ext?.contextInfo?.req_id || '' if (typeof ad.adExtMap === 'string') {
try {
reqId = JSON.parse(ad.adExtMap).req_id || ''
} catch (_) {}
} else {
reqId = ad.adExtMap.req_id || ''
}
}
// 兜底adLogId.requestId / ad.reqId / extJson.contextInfo.req_id
if (!reqId && ad?.adLogId?.requestId) reqId = ad.adLogId.requestId
if (!reqId && ad?.reqId) reqId = ad.reqId
if (!reqId && ad?.extJson) {
try {
const ext = JSON.parse(ad.extJson)
reqId = ext?.contextInfo?.req_id || ''
} catch (_) {}
} }
} }
} catch (_) {} } catch (_) {}

View File

@ -1,65 +1,229 @@
// 看广告免费听歌 - 领取免费听权益 // 看广告免费听歌 - 领取免费听权益
// 请求流程(基于逆向网易云音乐 v9.5.45 RN Hermes 源码 // 请求流程(基于逆向网易云音乐 v9.5.61 原生 Kotlin 源码classes5/18/19.dex
// 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid // 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid
// 2. 调用本接口传入 reqUid 及相关权益参数,领取免费听权益 // 2. 调用本接口传入 reqUid 及相关权益参数,领取权益
// 3. 服务器返回 gainFlag 等标识,用于展示领取结果 // 3. 服务器返回 gainFlag 等标识,用于展示领取结果
//
// 调用链(逆向还原):
// AdDSLIncentiveVideoRightsHelper.requestRightsGainInner
// → AdDSLUtils.requestRightGain(ad, exposeTime, clickTime, creativeType, cb)
// → 构造 ListeningRightRequestParams21 字段)→ JSON.stringify
// → body = { "reqParam": "..." } → POST /api/ad/listening/rights/gain
// (注意:客户端 AdDSLIncentiveVideoRightsHelper 写死 creativeType=36
//
// 接口不只领取听歌时长,还包含"看视频得云贝"等权益:
// rightsGainMethod=6 (LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益) 时,
// 弹窗 AdLaxinSegmentedGuideDialog 展示 "获得X云贝"2000 云贝等),
// 权益数值由广告下发的 AdLaxinSegmentedRightsGuidePopup.rightsValue 决定,
// extraRightsType 作为权益类型随本接口上传。
//
// rightsGainMethod 枚举h80/a
// 1=EXPOSE 曝光, 2=EXPOSE_CLICK 曝光+点击, 3=EXPOSE_DOWNLOAD 曝光+下载,
// 4=CLICK_STAY 点击+停留, 5=EXPOSURE_OR_CLICK_STAY 曝光或点击停留,
// 6=LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const adGet = require('./ad_get.js') const adGet = require('./ad_get.js')
// 安全 JSON 解析(字符串字段可能是 JSON 文本)
function safeParse(str, fallback) {
if (typeof str !== 'string') return fallback
try {
return JSON.parse(str)
} catch (_) {
return fallback
}
}
module.exports = async (query, request) => { module.exports = async (query, request) => {
const time = Date.now() const time = Date.now()
// 如果未传入 reqUid自动从 ad_get 获取最新广告的 req_id // 从广告对象自动补齐请求字段。
// 实测 v9.5.61 真实 API 返回(/ad/get确认的字段路径
// reqUid = ad.extJson.contextInfo.req_id真实响应中唯一存在
// contextInfo = ad.extJson.contextInfo完整对象序列化
// generalRightsInfo = ad.generalRightsInfo字符串 JSON
// creativeType = ad.creativeType
// rightsGainMethod / extraRightsType / rightsGainDuration / rightsGainType /
// extraRightsGainMethod / extraRightsGainDuration / nextRightsGainDuration /
// rightsExtJson / source / rightsUpperLimit / qualified
// = ad.generalRightsInfo字符串解析后取值
// (逆向类字段 adExtMap/adLogId/listeningRightHintInfo 真实响应中不存在,仅兜底)
// sniffTime = method==3 或 6 时 currentTimeMillis
let reqUid = query.reqUid || '' let reqUid = query.reqUid || ''
if (!reqUid) { let contextInfo = query.contextInfo
let creativeType = query.creativeType
let generalRightsInfo = query.generalRightsInfo
// 广告 hint 配置:仅当调用方未显式传参时用广告下发值兜底
const hint = {}
if (!reqUid || contextInfo === undefined || creativeType === undefined) {
try { try {
const adRes = await adGet( const adRes = await adGet(
{ ...query, type_ids: query.type_ids || '["400002_0"]' }, { ...query, type_ids: query.type_ids || '["400002_0"]' },
request, request,
) )
reqUid = adRes?.body?.extra?.reqId || '' const ad = Object.values(adRes?.body?.ads || {})[0]
if (!ad) throw new Error('ads 为空(未登录或广告位无广告)')
// reqUid真实 API 返回中 req_id 在 ad.extJson.contextInfo.req_id实测 v9.5.61
// (逆向类字段 adExtMap/adLogId 在真实响应中不存在,仅作兜底)
if (!reqUid) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const extMap =
typeof ad?.adExtMap === 'string'
? safeParse(ad.adExtMap, {})
: ad?.adExtMap
reqUid =
ext?.contextInfo?.req_id ||
extMap?.req_id ||
ad?.adLogId?.requestId ||
ad?.reqId ||
adRes?.body?.extra?.reqId ||
''
}
// contextInfo真实 API 返回中在 ad.extJson.contextInfo实测 v9.5.61
if (contextInfo === undefined) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const ci =
ext?.contextInfo || ad?.adLogId?.contextInfo || ad?.showContext
if (ci) {
contextInfo = typeof ci === 'string' ? ci : JSON.stringify(ci)
}
}
if (creativeType === undefined && ad?.creativeType !== undefined) {
creativeType = ad.creativeType
}
if (generalRightsInfo === undefined && ad?.generalRightsInfo) {
generalRightsInfo =
typeof ad.generalRightsInfo === 'string'
? ad.generalRightsInfo
: JSON.stringify(ad.generalRightsInfo)
}
// 缓存广告下发的领取配置(后续用于兜底)
// 实测 v9.5.61rightsGainMethod/rightsUpperLimit/qualified 等在
// ad.generalRightsInfo字符串ad.listeningRightHintInfo 真实响应中不存在
const hintCfg =
typeof ad?.listeningRightHintInfo === 'string'
? safeParse(ad.listeningRightHintInfo, {})
: ad?.listeningRightHintInfo || {}
const gri =
typeof ad?.generalRightsInfo === 'string'
? safeParse(ad.generalRightsInfo, {})
: ad?.generalRightsInfo || {}
Object.assign(hint, hintCfg, gri)
console.log(`自动获取 reqUid: ${reqUid}`) console.log(`自动获取 reqUid: ${reqUid}`)
} catch (e) { } catch (e) {
// 获取广告失败,后续请求会因缺少 reqUid 被拒绝 // 获取广告失败,后续请求会因缺少 reqUid 被拒绝
} }
} }
const rightsGainMethod = query.rightsGainMethod
? parseInt(query.rightsGainMethod)
: hint.rightsGainMethod || 2
const rightsParam = { const rightsParam = {
// 必填: 广告请求 ID自动从 ad_get 获取 // 必填: 广告请求 ID自动从 ad_get 获取
reqUid, reqUid,
// 广告创意类型 (默认 1) // 曝光时间戳
creativeType: parseInt(query.creativeType || 2), exposureTime: query.exposureTime ? parseInt(query.exposureTime) : time,
// 时间戳 // 当前登录用户 ID原版客户端从 Profile.getUserId() 获取)
exposureTime: query.exposureTime || time, userId: query.uid ? parseInt(query.uid) : undefined,
clickTime: query.clickTime || time,
// 权益领取方式 // 点击时间戳
rightsGainMethod: parseInt(query.rightsGainMethod || 2), clickTime: query.clickTime ? parseInt(query.clickTime) : time,
// 权益时长相关 // 额外权益类型(拉新分段权益等,决定发放云贝/时长/下载,来自广告配置)
rightsGainDuration: query.rightsGainDuration extraRightsType: query.extraRightsType
? parseInt(query.rightsGainDuration) ? parseInt(query.extraRightsType)
: undefined, : hint.extraRightsType !== undefined
? parseInt(hint.extraRightsType)
: undefined,
// 是否连续播放(默认 false
playContinuously: query.playContinuously ? true : false,
// 来源标识(原版从 ad.listeningRightHintInfo.source 读取)
source: query.source
? parseInt(query.source)
: hint.source !== undefined
? parseInt(hint.source)
: undefined,
// 广告创意类型(激励视频场景=36优先取 query / 广告对象)
creativeType: creativeType !== undefined ? parseInt(creativeType) : 36,
// 权益领取方式1~6 枚举,见文件头注释)
rightsGainMethod,
// 权益扩展方式与时长
extraRightsGainMethod: query.extraRightsGainMethod extraRightsGainMethod: query.extraRightsGainMethod
? parseInt(query.extraRightsGainMethod) ? parseInt(query.extraRightsGainMethod)
: undefined, : hint.extraRightsGainMethod !== undefined
? parseInt(hint.extraRightsGainMethod)
: undefined,
extraRightsGainDuration: query.extraRightsGainDuration extraRightsGainDuration: query.extraRightsGainDuration
? parseInt(query.extraRightsGainDuration) ? parseInt(query.extraRightsGainDuration)
: undefined, : hint.extraRightsGainDuration !== undefined
? parseInt(hint.extraRightsGainDuration)
: undefined,
nextRightsGainDuration: query.nextRightsGainDuration nextRightsGainDuration: query.nextRightsGainDuration
? parseInt(query.nextRightsGainDuration) ? parseInt(query.nextRightsGainDuration)
: hint.nextRightsGainDuration !== undefined
? parseInt(hint.nextRightsGainDuration)
: undefined,
// 权益类型(含 RIGHTS_GAIN_TYPE_CURRENT_DAY 等取值)
rightsGainType: query.rightsGainType
? parseInt(query.rightsGainType)
: hint.rightsGainType !== undefined
? parseInt(hint.rightsGainType)
: undefined,
// 权益时长
rightsGainDuration: query.rightsGainDuration
? parseInt(query.rightsGainDuration)
: hint.rightsGainDuration !== undefined
? parseInt(hint.rightsGainDuration)
: undefined,
// 领取步骤UNGAIN/GAINING/GAIN_FINISHED 对应的值)
gainMethodStep: query.gainMethodStep
? parseInt(query.gainMethodStep)
: undefined, : undefined,
// 来源标识 | 权益扩展信息 // 通用权益信息ad.generalRightsInfo 序列化 JSON
source: query.source || undefined, generalRightsInfo,
rightsExtJson: query.rightsExtJson || undefined,
// 权益扩展信息
rightsExtJson: query.rightsExtJson || hint.rightsExtJson || undefined,
// 应用信息(下载类广告) // 应用信息(下载类广告)
appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined, appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined,
// 广告上下文ad.adLogId.contextInfo客户端真实来源
contextInfo,
// 应用是否已安装(下载类广告)
installed: query.installed ? parseInt(query.installed) : undefined, installed: query.installed ? parseInt(query.installed) : undefined,
// 嗅探时间逆向确认classes5.dex AdDSLUtils.requestRightGain
// rightsGainMethod==3(曝光+下载) 或 6(LAXIN) 时传 currentTimeMillis
sniffTime:
rightsGainMethod === 3 || rightsGainMethod === 6
? query.sniffTime
? parseInt(query.sniffTime)
: time
: undefined,
} }
// 清理 undefined 字段 // 清理 undefined 字段
@ -67,7 +231,7 @@ module.exports = async (query, request) => {
if (rightsParam[key] === undefined) delete rightsParam[key] if (rightsParam[key] === undefined) delete rightsParam[key]
}) })
// 将参数序列化为 reqParam 字符串 (与 RN 源码完全一致) // 将参数序列化为 reqParam 字符串 (与原生源码一致)
const data = { const data = {
reqParam: JSON.stringify(rightsParam), reqParam: JSON.stringify(rightsParam),
} }

View File

@ -48,7 +48,7 @@ module.exports = async (query, request) => {
case 'linuxapi': { case 'linuxapi': {
if (isReq) { if (isReq) {
const pureHex = data.replace(/\s/g, '') const pureHex = data.replace(/\s/g, '')
const decrypted = aesDecrypt(pureHex, linuxapiKey, '', 'hex') const decrypted = aesDecrypt(pureHex, 'ecb', linuxapiKey, '', 'hex')
result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8)) result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8))
} else { } else {
result = typeof data === 'string' ? JSON.parse(data) : data result = typeof data === 'string' ? JSON.parse(data) : data

34
module/event_privacy.js Normal file
View File

@ -0,0 +1,34 @@
// 修改本人动态的可见权限
const createOption = require('../util/option.js')
const PRIVACY_VALUES = new Set([0, 1, 2, 6])
module.exports = (query, request) => {
const eventId = String(query.evId ?? '').trim()
const rawPrivacy = String(query.privacy ?? '').trim()
const privacy = Number(rawPrivacy)
if (
!eventId ||
!rawPrivacy ||
!Number.isInteger(privacy) ||
!PRIVACY_VALUES.has(privacy)
) {
return Promise.resolve({
status: 400,
body: {
code: 400,
message: 'evId is required and privacy must be one of 0, 1, 2, 6',
},
cookie: [],
})
}
const data = {
eventId,
privacy,
}
return request(`/api/event/privacy/op`, data, createOption(query))
}

View File

@ -19,6 +19,6 @@ module.exports = (query, request) => {
return request( return request(
`/api/middle/play/do/lottery`, `/api/middle/play/do/lottery`,
data, data,
createOption(query, 'eapi', 'v3'), createOption(query, 'eapi', 'v2'),
) )
} }

View File

@ -1,32 +1,145 @@
// 易盾反作弊 Token 注册端点 // 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用 // 通过易盾官方 Watchman SDKWeb 版,跑在 jsdom 模拟的浏览器环境里)
// 实时调用 getToken(businessId) 获取反作弊 token供后续带 checkToken 的请求使用
// //
// GET /register/checktoken → 返回当前 token尚无则自动获取 // GET /register/checktoken/v2 → 实时获取新 token不缓存
// POST /register/checktoken → 强制刷新 token // POST /register/checktoken/v2 → 实时获取新 token
// GET /register/checktoken?refresh=1 → 强制刷新
// //
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token
// 避免反作弊 token 复用触发风控。
//
// 注jsdom 固定用 v24engines >=18其依赖链为纯 CJS可被 pkg 静态
// 分析自动打包v30+ 引入纯 ESM 依赖且要求 Node >=22无法在 CI(18-24) 与
// pkg(node18) 目标下运行。
//
const { JSDOM, VirtualConsole } = require('jsdom')
const { default: axios } = require('axios') const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('../util/config.json')
const logger = require('../util/logger')
const URL = APP_CONF.dunDomainV2 + '/v2/config/js?pn=YD00000558929251' // 网易云音乐在易盾的 productNumber 与 businessId
let _token = '' const PRODUCT_NUMBER = 'YD00000558929251'
const BUSINESS_ID = 'bd5d2f973ef74cd2a61325a412ae54d9'
const TOOL_JS_URL = `${APP_CONF.dunStaticDomain}/tool.min.js`
async function fetch() { // 最小 HTML 外壳,模拟网页环境
const res = await axios.get(URL, { timeout: 10000 }) const HTML =
const data = res.data '<!doctype html><html><head><meta charset="UTF-8"></head><body></body></html>'
if (data && data.code === 200 && data.result && data.result.conf) {
return data.result.conf let toolJs = ''
} let wm = null // Watchman 实例(进程内复用,可反复 getToken
throw new Error('易盾返回异常: ' + JSON.stringify(data).substring(0, 200)) let dom = null // jsdom 实例(失败时 close 释放活动句柄,避免泄漏)
let initPromise = null
// 获取 tool.min.js内存缓存避免每次初始化重复下载
async function getToolJs() {
if (toolJs) return toolJs
const res = await axios.get(TOOL_JS_URL, { timeout: 10000 })
toolJs = String(res.data)
return toolJs
} }
// 端点处理 // 初始化 Watchman进程内只初始化一次实例可反复 getToken
module.exports = async (query) => { async function ensureWatchman() {
const refresh = query.refresh === '1' || query.refresh === 'true' if (wm) return wm
let token = refresh ? null : _token if (initPromise) return initPromise
if (!token) {
token = await fetch() initPromise = (async () => {
_token = token const js = await getToolJs()
const virtualConsole = new VirtualConsole()
virtualConsole.on('jsdomError', () => {})
dom = new JSDOM(HTML, {
url: 'https://music.163.com/',
referrer: 'https://music.163.com/',
contentType: 'text/html',
runScripts: 'dangerously',
resources: 'usable', // 允许动态加载 watchman.min.js / JSONP
pretendToBeVisual: true,
virtualConsole,
beforeParse(window) {
// 抹掉 headless 特征,避免易盾风控误判
Object.defineProperty(window.navigator, 'webdriver', {
get: () => undefined,
})
window.chrome = { runtime: {} }
window.navigator.languages = ['zh-CN', 'zh']
window.navigator.plugins = [1, 2, 3, 4, 5]
},
})
const script = dom.window.document.createElement('script')
script.textContent = js
dom.window.document.body.appendChild(script)
return new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
reject(new Error('watchman 初始化超时'))
}, 15000)
dom.window.initWatchman({
auto: true,
productNumber: PRODUCT_NUMBER,
onload(instance) {
if (settled) return
settled = true
clearTimeout(timer)
wm = instance
resolve(instance)
},
onerror(...args) {
if (settled) return
settled = true
clearTimeout(timer)
reject(new Error('watchman 初始化失败'))
},
})
})
})()
try {
return await initPromise
} catch (e) {
// 失败路径:关闭 jsdom 释放 rAF/子资源/定时器等活动句柄,防止每次失败泄漏约 30MB
if (dom) {
dom.window.close()
dom = null
}
initPromise = null
wm = null
throw e
}
}
// 获取新 token
async function fetchToken() {
const instance = await ensureWatchman()
const raw = instance.getInstance()
await new Promise((resolve) => {
const timer = setTimeout(() => resolve(), 15000)
raw.I(() => {
clearTimeout(timer)
resolve()
})
})
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(''), 15000)
instance.getToken(BUSINESS_ID, (tk) => {
clearTimeout(timer)
resolve(typeof tk === 'string' ? tk : '')
})
})
}
// 端点处理:每次实时获取新 token
module.exports = async () => {
let token = ''
try {
token = await fetchToken()
} catch (e) {
logger.warn('[checkToken v2]', e.message)
} }
return { return {
status: 200, status: 200,
@ -34,24 +147,12 @@ module.exports = async (query) => {
} }
} }
// 模块加载时自动获取一次 Token确保 request.js 使用时不为空 // 给 request.js 读取用:每次调用实时获取新 token不缓存
fetch() module.exports.getToken = async () => {
.then((token) => { try {
_token = token return await fetchToken()
}) } catch (e) {
.catch(() => { logger.warn('[checkToken v2]', e.message)
// 静默失败,后续请求时会重试 return ''
})
// 给 request.js 读取用
module.exports.getToken = () => {
if (!_token) {
// 如果 Token 为空,异步触发获取
fetch()
.then((token) => {
_token = token
})
.catch(() => {})
} }
return _token
} }

View File

@ -1,15 +1,16 @@
// 易盾反作弊 Token 注册端点 // 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用 // 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
// //
// GET /register/checktoken → 返回当前 token尚无则自动获取 // GET /register/checktoken/v3 → 实时获取新 token不缓存
// POST /register/checktoken → 强制刷新 token // POST /register/checktoken/v3 → 实时获取新 token
// GET /register/checktoken?refresh=1 → 强制刷新 //
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token
// 避免反作弊 token 复用触发风控。
// //
const { default: axios } = require('axios') const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('../util/config.json')
const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251' const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251'
let _token = ''
async function fetch() { async function fetch() {
const res = await axios.get(URL, { timeout: 10000 }) const res = await axios.get(URL, { timeout: 10000 })
@ -19,13 +20,13 @@ async function fetch() {
throw new Error('易盾返回异常: ' + body.substring(0, 100)) throw new Error('易盾返回异常: ' + body.substring(0, 100))
} }
// 端点处理 // 端点处理:每次实时获取新 token
module.exports = async (query) => { module.exports = async () => {
const refresh = query.refresh === '1' || query.refresh === 'true' let token = ''
let token = refresh ? null : _token try {
if (!token) {
token = await fetch() token = await fetch()
_token = token } catch (e) {
// token 获取失败时返回空,由调用方决定是否重试
} }
return { return {
status: 200, status: 200,
@ -33,5 +34,11 @@ module.exports = async (query) => {
} }
} }
// 给 request.js 读取用 // 给 request.js 读取用:每次调用实时获取新 token不缓存
module.exports.getToken = () => _token module.exports.getToken = async () => {
try {
return await fetch()
} catch (e) {
return ''
}
}

View File

@ -17,7 +17,7 @@ module.exports = async (query, request) => {
const currentKeyVersion = query.currentKeyVersion || '' const currentKeyVersion = query.currentKeyVersion || ''
const data = { const data = {
appVersion: '9.1.65', appVersion: '9.5.61',
currentKeyVersion, currentKeyVersion,
deviceId, deviceId,
nonce, nonce,
@ -35,7 +35,7 @@ module.exports = async (query, request) => {
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get', url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
headers: { headers: {
'User-Agent': 'User-Agent':
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)', 'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '', Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
}, },
data: new URLSearchParams(data).toString(), data: new URLSearchParams(data).toString(),

View File

@ -7,5 +7,9 @@ module.exports = (query, request) => {
msg: query.msg || '', msg: query.msg || '',
id: query.id || '', id: query.id || '',
} }
return request(`/api/share/friends/resource`, data, createOption(query)) return request(
`/api/share/friends/resource`,
data,
createOption(query, 'xeapi', 'v3'),
)
} }

View File

@ -4,9 +4,10 @@ const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = { const data = {
getcounts: true, getcounts: true,
time: query.lasttime || -1, time: query.lasttime ?? -1,
limit: query.limit || 30, limit: query.limit ?? 30,
total: false, total: false,
fromRN: 'true',
} }
return request(`/api/event/get/${query.uid}`, data, createOption(query)) return request(`/api/event/get/${query.uid}`, data, createOption(query))
} }

135
module/user_event_all.js Normal file
View File

@ -0,0 +1,135 @@
// 获取当前登录用户可被上游枚举的全部动态
const userAccount = require('./user_account.js')
const userEvent = require('./user_event.js')
const PAGE_SIZE = 100
const MAX_PAGES = 1000
const errorResponse = (message, cookie = []) => ({
status: 502,
body: {
code: 502,
message,
},
cookie,
})
module.exports = async (query, request) => {
const accountResult = await userAccount(query, request)
const uid =
accountResult.body?.account?.id || accountResult.body?.profile?.userId
if (!uid) {
if (
accountResult.status !== 200 ||
(accountResult.body?.code && accountResult.body.code !== 200)
) {
return accountResult
}
return {
status: 401,
body: {
code: 401,
message: 'A valid login cookie is required',
},
cookie: accountResult.cookie || [],
}
}
const cookies = [...(accountResult.cookie || [])]
const events = []
const eventIds = new Set()
const cursors = new Set()
let lasttime = -1
let more = true
let pageCount = 0
let size = null
while (more) {
pageCount += 1
const pageResult = await userEvent(
{
...query,
uid,
lasttime,
limit: PAGE_SIZE,
},
request,
)
cookies.push(...(pageResult.cookie || []))
if (pageResult.status !== 200 || pageResult.body?.code !== 200) {
return {
...pageResult,
cookie: cookies,
}
}
if (pageCount === 1) {
const reportedSize = pageResult.body.size
const numericSize = Number(reportedSize)
size =
reportedSize != null && Number.isFinite(numericSize)
? numericSize
: null
}
for (const event of pageResult.body.events || []) {
if (event?.id == null) {
events.push(event)
continue
}
const eventId = String(event.id)
if (!eventIds.has(eventId)) {
eventIds.add(eventId)
events.push(event)
}
}
more = Boolean(pageResult.body.more)
lasttime = pageResult.body.lasttime
if (more) {
const cursor = String(lasttime ?? '')
if (!cursor || cursors.has(cursor)) {
return errorResponse(
'Upstream event pagination cursor stalled',
cookies,
)
}
cursors.add(cursor)
}
if (more && pageCount >= MAX_PAGES) {
return errorResponse(
`Upstream event pagination exceeded ${MAX_PAGES} pages`,
cookies,
)
}
}
const retrievedCount = events.length
const unavailableCount =
size == null ? null : Math.max(size - retrievedCount, 0)
return {
status: 200,
body: {
code: 200,
events,
size,
retrievedCount,
unavailableCount,
sizeMismatch: size == null ? null : size !== retrievedCount,
pageCount,
more: false,
lasttime: lasttime ?? null,
},
cookie: cookies,
}
}

View File

@ -2,6 +2,7 @@ const { default: axios } = require('axios')
const fs = require('fs') const fs = require('fs')
var xml2js = require('xml2js') var xml2js = require('xml2js')
const uploadPlugin = require('../plugins/upload')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper') const { getFileExtension, readFileChunk } = require('../util/fileHelper')
@ -19,15 +20,7 @@ function createDupkey() {
return s.join('') return s.join('')
} }
module.exports = async (query, request) => { module.exports = async (query, request, dependencies = {}) => {
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
if (!query.songFile) { if (!query.songFile) {
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
@ -38,6 +31,19 @@ module.exports = async (query, request) => {
}) })
} }
const axiosRequest = dependencies.axios || axios
const uploadImage = dependencies.uploadPlugin || uploadPlugin
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const coverImgId = query.imgFile
? (await uploadImage(query, request)).imgId
: query.coverImgId
const tokenRes = await request( const tokenRes = await request(
`/api/nos/token/alloc`, `/api/nos/token/alloc`,
{ {
@ -53,7 +59,7 @@ module.exports = async (query, request) => {
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F') const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
const docId = tokenRes.body.result.docId const docId = tokenRes.body.result.docId
const res = await axios({ const res = await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: { headers: {
@ -94,7 +100,7 @@ module.exports = async (query, request) => {
) )
} }
const res3 = await axios({ const res3 = await axiosRequest({
method: 'put', method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@ -117,7 +123,7 @@ module.exports = async (query, request) => {
} }
completeStr += '</CompleteMultipartUpload>' completeStr += '</CompleteMultipartUpload>'
await axios({ await axiosRequest({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
@ -128,29 +134,29 @@ module.exports = async (query, request) => {
data: completeStr, data: completeStr,
}) })
const voiceData = JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
])
await request( await request(
`/api/voice/workbench/voice/batch/upload/preCheck`, `/api/voice/workbench/voice/batch/upload/preCheck`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),
@ -163,25 +169,7 @@ module.exports = async (query, request) => {
`/api/voice/workbench/voice/batch/upload/v2`, `/api/voice/workbench/voice/batch/upload/v2`,
{ {
dupkey: createDupkey(), dupkey: createDupkey(),
voiceData: JSON.stringify([ voiceData,
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
}, },
{ {
...createOption(query), ...createOption(query),

View File

@ -1,5 +1,9 @@
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
module.exports = (query, request) => { module.exports = (query, request) => {
const data = {} const data = {}
return request(`/api/pointmall/user/sign`, data, createOption(query, 'weapi')) return request(
`/api/pointmall/user/sign`,
data,
createOption(query, 'xeapi', 'v3'),
)
} }

View File

@ -0,0 +1,26 @@
// 云贝广告任务 - 完成任务领取云贝
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// POST /api/ad/power/yunbei/distribution/create
// 参数: yunbeiAmount (单次可得云贝, 客户端从 list 接口的 singleAmount 取值, 当前为 150)
// 返回: true (领取成功)
//
// 实测验证 (2026-08-05):
// - 仅传 yunbeiAmount 即可成功领取, 无需真实听歌/看视频
// - 单日上限 10 次 × 150 云贝 = 1500 云贝/天
// - 超限返回 code:400 "单日完成任务数已达上限"
// - 无频率限制, 800ms 间隔连续调用均成功
//
// 建议: 领取前先调 yunbei_ad_task_list 查询 times, 达到 10 次即停止
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
yunbeiAmount: query.yunbeiAmount || 150,
}
return request(
`/api/ad/power/yunbei/distribution/create`,
data,
createOption(query, 'weapi'),
)
}

View File

@ -0,0 +1,16 @@
// 云贝广告任务 - 查询今日任务状态
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// GET /api/ad/power/yunbei/distribution/list
// 返回: { times: 今日已完成次数, amount: 今日累计云贝, singleAmount: 单次可得云贝 }
// 注意: 单日上限 10 次, 单次 150 云贝 (每天最多 1500)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/ad/power/yunbei/distribution/list`,
data,
createOption(query, 'weapi'),
)
}

View File

@ -0,0 +1,20 @@
// 云贝广告任务 - 获取推荐歌曲
// 逆向来源: 云贝任务中心 H5 (st.music.163.com/yunbei-listen) main.js
// POST /api/ad/power/yunbei/distribution/recommend/song
// 参数: offset (默认 0), limit (默认 10, 客户端一次 10 首)
// 返回: 推荐歌曲数组 [{ songId, songName, artistName, albumUrl, songChorusStartTime, likeFlag, alg }]
// 注意: alg 均为 alg_payrec_yunBei_*, 为"听歌得云贝"任务专属推荐
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
offset: query.offset || 0,
limit: query.limit || 10,
}
return request(
`/api/ad/power/yunbei/distribution/recommend/song`,
data,
createOption(query, 'weapi'),
)
}

View File

@ -1,10 +1,11 @@
import { banner, lyric } from '@neteasecloudmusicapienhanced/api' import { banner, lyric } from '@neteasecloudmusicapienhanced/api'
import { logger } from '../util/logger.js' import type { Response } from '@neteasecloudmusicapienhanced/api'
banner({ type: 0 }).then((res) => { import logger from '../util/logger.js'
banner({ type: 0 }).then((res: Response) => {
logger.info(res) logger.info(res)
}) })
lyric({ lyric({
id: '33894312', id: '33894312',
}).then((res) => { }).then((res: Response) => {
logger.info(res) logger.info(res)
}) })

View File

@ -0,0 +1,48 @@
const {
login_cellphone,
yunbei_ad_task_list,
yunbei_ad_task_recommend_song,
yunbei_ad_task_finish,
} = require('../main')
// 云贝广告任务(听歌/看视频得云贝)完整流程示例
// 1. list - 查询今日任务状态(次数/云贝)
// 2. finish - 领取云贝, 仅需传 yunbeiAmount(单次 150), 无需真实听歌/看视频
// 3. recommend/song - 可选, 获取推荐歌曲列表
// 注意: 单日上限 10 次 x 150 = 1500 云贝/天
async function main() {
const login = await login_cellphone({
phone: '手机号',
password: '密码',
})
const cookie = login.body.cookie
// 1. 查询今日任务状态
const list = await yunbei_ad_task_list({ cookie })
const { times, amount, singleAmount } = list.body
console.log(
`今日已完成 ${times} 次, 累计 ${amount} 云贝, 单次可得 ${singleAmount} 云贝`,
)
if (times >= 10) {
console.log('已达单日上限, 明天再来吧')
return
}
// 2. 领取云贝(单次 150)
const finish = await yunbei_ad_task_finish({
yunbeiAmount: singleAmount,
cookie,
})
console.log('领取结果:', finish.body)
// 3. 获取推荐歌曲(听歌任务专属推荐, 可选)
const rcmd = await yunbei_ad_task_recommend_song({
offset: 0,
limit: 10,
cookie,
})
console.log('推荐歌曲:', rcmd.body.map((s) => s.songName).join(', '))
}
main()

View File

@ -1,6 +1,6 @@
{ {
"name": "@neteasecloudmusicapienhanced/api", "name": "@neteasecloudmusicapienhanced/api",
"version": "4.39.0", "version": "4.40.0",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护", "description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"scripts": { "scripts": {
"dev": "nodemon app.js", "dev": "nodemon app.js",
@ -75,13 +75,14 @@
"data" "data"
], ],
"dependencies": { "dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.4", "@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.0",
"axios": "^1.18.1", "axios": "^1.19.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",
"jsdom": "^24.1.3",
"music-metadata": "^11.14.0", "music-metadata": "^11.14.0",
"node-forge": "^1.4.0", "node-forge": "^1.4.0",
"pac-proxy-agent": "^7.2.0", "pac-proxy-agent": "^7.2.0",
@ -89,7 +90,7 @@
"safe-decode-uri-component": "^1.2.1", "safe-decode-uri-component": "^1.2.1",
"tunnel": "^0.0.6", "tunnel": "^0.0.6",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"yargs": "^18.0.0" "yargs": "^18.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.6", "@eslint/eslintrc": "^3.3.6",
@ -98,17 +99,17 @@
"@types/express-fileupload": "^1.5.1", "@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",
"@types/node": "25.9.2", "@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.65.0", "@typescript-eslint/parser": "^8.66.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.1.4",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"globals": "^17.7.0", "globals": "^17.9.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0", "intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"mocha": "^11.7.6", "mocha": "^11.8.0",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"pkg": "^5.8.1", "pkg": "^5.8.1",
"power-assert": "^1.6.1", "power-assert": "^1.6.1",

567
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -255,9 +255,13 @@
} }
logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`) logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`)
for (var song of resp.data.result) { for (var song of resp.data.result) {
logs.write( var a = document.createElement('a');
`[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>` a.href = 'https://music.163.com/song?id=' + encodeURIComponent(song.song.id);
) a.target = '_blank';
a.textContent = song.song.name + ' - ' + song.song.album.name + ' (' + (song.startTime / 1000) + 's)';
logs.appendChild(document.createTextNode('[result] '));
logs.appendChild(a);
logs.appendChild(document.createElement('br'));
} }
}) })
}) })

View File

@ -826,6 +826,8 @@ tags: 歌单标签
`lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据 `lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据
接口会传入官方客户端使用的 `fromRN=true`,登录用户读取自己的动态时可获得网易云允许本人查看的非公开动态。返回的 `size` 是网易云账户侧统计值,不保证等于分页后实际可获取的动态数量。
**接口地址 :** `/user/event` **接口地址 :** `/user/event`
**调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743` **调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743`
@ -843,6 +845,38 @@ tags: 歌单标签
41、21 分享视频 41、21 分享视频
``` ```
### 获取当前登录用户的全部可枚举动态
说明 : 登录后调用此接口,会组合 `/user/account``/user/event` 的原子能力:先从 Cookie 取得当前用户 id再自动跟随 `lasttime` 游标读取至 `more=false`。接口返回网易云允许当前用户本人查看的公开及非公开动态,不能读取其他用户的私密动态。
本接口不接受 `limit``lasttime`,一次请求会完成全部上游分页。账号动态较多时,请预留足够的请求时间。
数量字段说明:
- `size`:网易云返回的账户统计数量,可能包含已删除、被屏蔽、资源失效或旧类型且不再下发的记录。
- `retrievedCount`:本次实际取得的唯一动态数量,始终等于 `events.length`
- `unavailableCount``size - retrievedCount` 的正差值;缺失记录没有返回 id无法继续读取或修改。
- `sizeMismatch``size``retrievedCount` 是否不一致。若上游未返回 `size`,相关字段为 `null`
- `pageCount`:本次实际请求的上游分页数量。
每条动态的 `privacySetting` 表示当前可见权限:`0` 为所有人,`1` 为我关注的人,`2` 为仅自己,`6` 为互相关注的人。
**接口地址 :** `/user/event/all`
**调用例子 :** `/user/event/all`
### 修改动态可见权限
说明 : 登录后调用此接口,可以修改当前账号本人发布的单条动态的可见权限。此接口只负责一次原子修改;上游没有批量修改接口。如需批量操作,可先调用 `/user/event/all` 并按 `privacySetting` 筛选,再由调用方逐条调用本接口,同时自行处理限速、失败重试和部分成功。
**必选参数 :** `evId` : 动态 id
`privacy` : 目标可见权限。`0` 为所有人,`1` 为我关注的人,`2` 为仅自己,`6` 为互相关注的人
**接口地址 :** `/event/privacy`
**调用例子 :** `/event/privacy?evId=6712917601&privacy=0`
### 转发用户动态 ### 转发用户动态
说明 : 登录后调用此接口 ,可以转发用户动态 说明 : 登录后调用此接口 ,可以转发用户动态
@ -1230,7 +1264,6 @@ tags: 歌单标签
> 如果你设置 limit=50&offset=100你就会得到第 101-150 首歌曲 > 如果你设置 limit=50&offset=100你就会得到第 101-150 首歌曲
### 歌单详情动态 ### 歌单详情动态
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数 说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
@ -3592,6 +3625,36 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子 :** `/yunbei/task/finish?userTaskId=5146243240&depositCode=0` **调用例子 :** `/yunbei/task/finish?userTaskId=5146243240&depositCode=0`
### 云贝广告任务 - 今日任务状态
说明 :登录后调用此接口可查询云贝广告任务("听歌/看视频得云贝")今日状态。逆向自云贝任务中心 H5 页面st.music.163.com/yunbei-listen。返回 `times`(今日已完成次数)、`amount`(今日累计云贝)、`singleAmount`(单次可得云贝)。单日上限 10 次。
**接口地址 :** `/yunbei/task/list/v1`
**调用例子 :** `/yunbei/task/list/v1`
### 云贝广告任务 - 获取推荐歌曲
说明 :登录后调用此接口可获取云贝广告任务的推荐歌曲列表。返回数组项含 `songId``songName``artistName``albumUrl``songChorusStartTime``likeFlag``alg`(均为 `alg_payrec_yunBei_*`)。
**可选参数 :** `offset`: 偏移数量,默认为 0
`limit`: 取出数量,默认为 10客户端每次固定取 10 首)
**接口地址 :** `/yunbei/task/recommend/song`
**调用例子 :** `/yunbei/task/recommend/song` `/yunbei/task/recommend/song?offset=0&limit=10`
### 云贝广告任务 - 完成任务领取云贝
说明 :登录后调用此接口可完成任务并领取云贝。实测仅需传 `yunbeiAmount`(单次云贝数,客户端从 `list` 接口的 `singleAmount` 取值,当前为 150即可成功领取无需真实听歌/看视频。单日上限 10 次 × 150 = 1500 云贝/天,超限返回 `code:400 "单日完成任务数已达上限"`。建议领取前先调用 `/yunbei/task/list/v1` 查询今日剩余次数。
**可选参数 :** `yunbeiAmount`: 单次云贝数,默认为 150
**接口地址 :** `/yunbei/task/finish/v1`
**调用例子 :** `/yunbei/task/finish/v1?yunbeiAmount=150`
### 云贝收入 ### 云贝收入
说明 :登录后调用此接口可获取云贝收入 说明 :登录后调用此接口可获取云贝收入
@ -4337,27 +4400,33 @@ ONLINE 已发布
### 播客上传声音 ### 播客上传声音
说明: 可以上传声音到播客,例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a> 说明: 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传声音文件 formData(name 为`songFile`),可通过 formData(name 为`imgFile`)同时上传声音封面。例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a>
**接口地址:** `/voice/upload` **接口地址:** `/voice/upload`
**必选参数:** **必选参数:**
`voiceListId`: 播客 id
`coverImgId`: 播客封面 `songFile`: 声音文件
`voiceListId`: 播客 id
`categoryId`: 分类 id `categoryId`: 分类 id
`secondCategoryId`:次级分类 id `secondCategoryId`: 次级分类 id
`description`: 声音介绍 `description`: 声音介绍
**可选参数:** **可选参数:**
`imgFile`: 声音封面图片文件,上传后会自动生成图片 id。与`coverImgId`同时传入时,优先使用`imgFile`
`coverImgId`: 已上传的声音封面图片 id,未传入`imgFile`时使用该值
`songName`: 声音名称 `songName`: 声音名称
`privacy`: 设为隐私声音,播客如果是隐私博客,则必须设为 1 `privacy`: 设为隐私声音,播客如果是隐私客,则必须设为 1
`publishTime`:默认立即发布,定时发布的话需传入时间戳 `publishTime`: 默认立即发布,定时发布的话需传入时间戳
`autoPublish`: 是否发布动态,是则传入 1 `autoPublish`: 是否发布动态,是则传入 1
@ -5154,7 +5223,6 @@ let data = encodeURIComponent(
**调用例子:** `/broadcast/sub?id=5&t=1` **调用例子:** `/broadcast/sub?id=5&t=1`
### 用户的创建歌单列表 ### 用户的创建歌单列表
说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表 说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表
@ -5229,7 +5297,6 @@ let data = encodeURIComponent(
**调用例子 :** `/voicelist/my/created` **调用例子 :** `/voicelist/my/created`
### DIFM电台 - 分类 ### DIFM电台 - 分类
说明: 调用此接口, 获取DIFM电台分类 说明: 调用此接口, 获取DIFM电台分类
@ -5408,7 +5475,7 @@ let data = encodeURIComponent(
**接口地址 :** `/comment/report` **接口地址 :** `/comment/report`
**调用例子 :* `/comment/report?id=2058263032&cid=123456789&reason=人身攻击` *_调用例子 :_ `/comment/report?id=2058263032&cid=123456789&reason=人身攻击`
### 多级行政区划数据 ### 多级行政区划数据
@ -5498,13 +5565,23 @@ let data = encodeURIComponent(
**调用例子 :** `/ad/get` **调用例子 :** `/ad/get`
### 获取30分钟免费听歌时长 ### 看广告领取权益(免费听歌时长 / 云贝等)
说明 : 登录后调用此接口, 获取30分钟免费听歌时长 说明 : 登录后调用此接口, 领取广告权益。权益类型由广告平台下发的配置决定, 不仅限于 30 分钟免费听歌时长, 还包括"看视频获得最高 2000 云贝"等拉新分段权益(`rightsGainMethod=6`)。除下方常用参数外, 权益类型/时长/扩展权益等其余字段会自动从广告下发配置补齐, 无需手动传入。
!> 警告: 通过调取接口出现的任何问题由调用者自行承担 !> 警告: 通过调取接口出现的任何问题由调用者自行承担
**可选参数 :** `reqUid` 通过`/ad/get` 获取的广告ID **可选参数 :**
`reqUid` : 广告请求 ID, 通过 `/ad/get` 获取, 未传时自动获取
`uid` : 当前登录用户 ID, 不传时服务端从 Cookie 识别
`rightsGainMethod` : 权益领取方式, `1`: 曝光, `2`: 曝光+点击(默认), `3`: 曝光+下载, `4`: 点击+停留, `5`: 曝光或点击停留, `6`: 拉新曝光/下载分段权益(看视频得云贝)
`type_ids` : 广告位类型, 默认 `["400002_0"]`
`creativeType` : 广告创意类型, 激励视频场景为 `36`, 默认 `36`
**接口地址 :** `/ad/listening/rights/gain` **接口地址 :** `/ad/listening/rights/gain`

View File

@ -99,6 +99,7 @@ curl -s {origin}/search?keywords=网易云</code></pre>
<a href="/scrobble.html">听歌打卡示例</a> · <a href="/scrobble.html">听歌打卡示例</a> ·
<a href="/yidun.html">获取 CheckToken</a> · <a href="/yidun.html">获取 CheckToken</a> ·
<a href="/free_listen.html">免费听权益</a> · <a href="/free_listen.html">免费听权益</a> ·
<a href="/yunbei_task.html">云贝广告任务</a> ·
<a href="/ugc.html">云小编任务中心</a> · <a href="/ugc.html">云小编任务中心</a> ·
<a href="/ugc_lottery.html">云小编每日抽奖</a> <a href="/ugc_lottery.html">云小编每日抽奖</a>
</div> </div>

View File

@ -217,8 +217,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>选择文件</label> <label for="songFile">选择声音文件</label>
<input type="file" name="songFile" accept="audio/*" /> <input id="songFile" type="file" name="songFile" accept="audio/*" />
</div>
<div class="form-group">
<label for="imgFile">选择声音封面(可选)</label>
<input id="imgFile" type="file" name="imgFile" accept="image/*" />
</div> </div>
<button class="btn" @click="submit">上传</button> <button class="btn" @click="submit">上传</button>
@ -251,29 +256,36 @@
methods: { methods: {
submit() { submit() {
console.info('submit') console.info('submit')
const file = document.querySelector('input[type=file]').files[0] const songFile = document.querySelector('input[name=songFile]').files[0]
if (!file) { const imgFile = document.querySelector('input[name=imgFile]').files[0]
alert('请选择文件') if (!songFile) {
alert('请选择声音文件')
return return
} }
this.upload(file) this.upload(songFile, imgFile)
}, },
async getData() { async getData() {
this.loading = true this.loading = true
try { try {
const res = await axios({ const res = await axios({
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`, url: `/voicelist/my/created?limit=100&cookie=${localStorage.getItem(
'cookie',
)}`,
}) })
console.info(res.data.data) console.info(res.data.data)
this.voicelist = res.data.data.list || [] this.voicelist = res.data.data.data || []
this.voicelist.forEach(async (i) => { this.voicelist.forEach(async (i) => {
try { try {
const res2 = await axios({ const res2 = await axios({
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`, url: `/voicelist/list?voiceListId=${
i.voiceListId
}&limit=5&cookie=${localStorage.getItem('cookie')}`,
}) })
i.voiceListData = res2.data.data.list || [] i.voiceListData = res2.data.data
? res2.data.data.list || []
: []
console.info(res2) console.info(res2)
} catch (err) { } catch (err) {
console.error('获取播客详情失败:', err) console.error('获取播客详情失败:', err)
@ -286,14 +298,17 @@
} }
}, },
upload(file) { upload(songFile, imgFile) {
if (!this.currentVoice) { if (!this.currentVoice) {
alert('请先选择播客列表') alert('请先选择播客列表')
return return
} }
var formData = new FormData() var formData = new FormData()
formData.append('songFile', file) formData.append('songFile', songFile)
if (imgFile) {
formData.append('imgFile', imgFile)
}
axios({ axios({
method: 'post', method: 'post',
@ -312,7 +327,7 @@
data: formData, data: formData,
}) })
.then((res) => { .then((res) => {
alert(`${file.name} 上传成功`) alert(`${songFile.name} 上传成功`)
}) })
.catch((err) => { .catch((err) => {
console.error('上传失败:', err) console.error('上传失败:', err)

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Token 注册器 — 网易云音乐 API Enhanced</title> <title>易盾 Watchman 调试器 — 网易云音乐 API Enhanced</title>
<style> <style>
:root { :root {
--fg: #333; --fg: #333;
@ -17,7 +17,7 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
.container { max-width: 720px; margin: 40px auto; padding: 0 20px; } .container { max-width: 760px; margin: 40px auto; padding: 0 20px; }
@media (max-width: 480px) { @media (max-width: 480px) {
.container { margin: 20px auto; padding: 0 16px; } .container { margin: 20px auto; padding: 0 16px; }
header.site-header h1 { font-size: 22px; } header.site-header h1 { font-size: 22px; }
@ -28,8 +28,12 @@
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; } .sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } .block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
.block h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; } .block h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 160px; overflow: auto; } .field { margin-bottom: 10px; }
.row { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
input[type="text"] { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Courier New', monospace; color: var(--fg); background: #fafafa; }
input[type="text"]:focus { outline: none; border-color: #888; background: #fff; }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 220px; overflow: auto; }
.row { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; } button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
button:hover { background: #eee; } button:hover { background: #eee; }
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); } button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
@ -41,32 +45,65 @@
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; } .tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
.flex { display: flex; align-items: center; gap: 10px; } .flex { display: flex; align-items: center; gap: 10px; }
.flex.sb { justify-content: space-between; } .flex.sb { justify-content: space-between; }
.mt-2 { margin-top: 14px; } .hint { font-size: 12px; color: var(--muted); margin-top: 8px; }
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; } footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
</style> </style>
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<header class="site-header"> <header class="site-header">
<h1>🔑 Token 注册器</h1> <h1>🛡️ 易盾 Watchman 调试器</h1>
<p class="sub">从易盾获取反作弊 token 并注册到服务器,供广告等需要 checkToken 的接口使用</p> <p class="sub">
直接调用易盾官方 Web SDKtool.min.js获取反作弊 token。
<code>module/register_checktoken_v2.js</code> 内部jsdom 模拟同一套 SDK逻辑一致可用于对照验证。
</p>
</header> </header>
<!-- Watchman 初始化 -->
<div class="block"> <div class="block">
<div class="flex sb"> <div class="flex sb">
<h3>当前 Token</h3> <h3>① 初始化 Watchman</h3>
<span id="statusTag" class="tag no">未注册</span> <span id="initTag" class="tag no">未初始化</span>
</div>
<div class="field">
<label for="pnInput">productNumber易盾产品号</label>
<input type="text" id="pnInput" value="YD00000558929251" spellcheck="false">
</div> </div>
<pre id="tokenDisplay">(空)</pre>
<div class="row"> <div class="row">
<button id="btnGet" class="primary">获取 Token</button> <button id="btnInit" class="primary">初始化</button>
<button id="btnRefresh">强制刷新</button> <button id="btnReset">重置</button>
</div> </div>
</div> </div>
<!-- 获取 Token -->
<div class="block"> <div class="block">
<h3>服务器响应</h3> <div class="flex sb">
<pre id="respDisplay">{}</pre> <h3>② 获取 Token</h3>
<span id="getTag" class="tag no"></span>
</div>
<div class="field">
<label for="bidInput">businessId业务 ID来自网易 clientcfg 下发)</label>
<input type="text" id="bidInput" value="bd5d2f973ef74cd2a61325a412ae54d9" spellcheck="false">
</div>
<div class="row">
<button id="btnGet" class="primary" disabled>获取 Token</button>
<button id="btnCopy" disabled>复制</button>
<span class="hint">覆盖 /api/playlist/subscribe、/api/song/like、/api/user/follow 等接口</span>
</div>
<pre id="tokenDisplay">(尚未获取)</pre>
</div>
<!-- 服务器端对照 -->
<div class="block">
<div class="flex sb">
<h3>③ 服务器端对照(/register/checktoken/v2</h3>
<span id="srvTag" class="tag no"></span>
</div>
<div class="row">
<button id="btnSrv">获取服务器端 Token</button>
<span class="hint">node + jsdom 模拟同一套 SDK 的实时结果</span>
</div>
<pre id="srvDisplay">(尚未获取)</pre>
</div> </div>
<footer class="site-footer"> <footer class="site-footer">
@ -74,46 +111,121 @@
</footer> </footer>
</main> </main>
<script src="https://acstatic-dun.126.net/tool.min.js"></script>
<script> <script>
const $ = (id) => document.getElementById(id) const $ = (id) => document.getElementById(id)
const base = location.origin
async function call(refresh) { const DEFAULTS = {
const btn = $('btnGet') pn: 'YD00000558929251',
const btnR = $('btnRefresh') bid: 'bd5d2f973ef74cd2a61325a412ae54d9',
btn.disabled = btnR.disabled = true
const tag = $('statusTag')
tag.className = 'tag busy'
tag.textContent = '获取中…'
try {
const url = base + '/register/checktoken/v2' + (refresh ? '?refresh=1' : '')
const res = await fetch(url)
const data = await res.json()
$('respDisplay').textContent = JSON.stringify(data, null, 2)
if (data.token) {
$('tokenDisplay').textContent = data.token
tag.className = 'tag ok'
tag.textContent = '已注册'
} else {
$('tokenDisplay').textContent = '(获取失败)'
tag.className = 'tag no'
tag.textContent = '失败'
}
} catch (e) {
$('respDisplay').textContent = JSON.stringify({ error: e.message }, null, 2)
tag.className = 'tag no'
tag.textContent = e.message
}
btn.disabled = btnR.disabled = false
} }
$('btnGet').onclick = () => call(false) let wm = null // Watchman 实例
$('btnRefresh').onclick = () => call(true)
call(false) const setTag = (id, text, state) => {
const tag = $(id)
tag.textContent = text
tag.className = 'tag ' + state // ok / no / busy
}
// ① 初始化
function initShield() {
if (typeof window.initWatchman !== 'function') {
alert('未找到 initWatchman请确认 tool.min.js 已加载CDN 是否可访问)')
return
}
const pn = $('pnInput').value.trim() || DEFAULTS.pn
setTag('initTag', '初始化中…', 'busy')
$('btnInit').disabled = true
window.initWatchman({
auto: true,
productNumber: pn,
onload(instance) {
wm = instance
setTag('initTag', '已就绪', 'ok')
$('btnInit').disabled = false
$('btnGet').disabled = false
},
onerror() {
wm = null
setTag('initTag', '初始化失败', 'no')
$('btnInit').disabled = false
$('btnGet').disabled = true
},
})
}
function resetShield() {
wm = null
setTag('initTag', '未初始化', 'no')
setTag('getTag', '—', 'no')
$('btnGet').disabled = true
$('btnCopy').disabled = true
$('tokenDisplay').textContent = '(尚未获取)'
}
// ② 获取 token
function getToken() {
if (!wm) {
alert('请先完成初始化')
return
}
const bid = $('bidInput').value.trim() || DEFAULTS.bid
setTag('getTag', '获取中…', 'busy')
$('btnGet').disabled = true
wm.getToken(bid, (tk) => {
if (typeof tk === 'string' && tk) {
$('tokenDisplay').textContent = tk
setTag('getTag', 'OK', 'ok')
$('btnCopy').disabled = false
} else {
$('tokenDisplay').textContent = '(获取失败)'
setTag('getTag', '失败', 'no')
$('btnCopy').disabled = true
}
$('btnGet').disabled = false
})
}
// ③ 服务器端对照
async function fetchServerToken() {
setTag('srvTag', '获取中…', 'busy')
$('btnSrv').disabled = true
try {
const res = await fetch(
location.origin + '/register/checktoken/v2?t=' + Date.now(),
)
const data = await res.json()
$('srvDisplay').textContent = data.token
? data.token
: JSON.stringify(data, null, 2)
setTag('srvTag', data.token ? 'OK' : '失败', data.token ? 'ok' : 'no')
} catch (e) {
$('srvDisplay').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('srvTag', e.message, 'no')
}
$('btnSrv').disabled = false
}
// 复制
async function copyToken() {
const txt = $('tokenDisplay').textContent
try {
await navigator.clipboard.writeText(txt)
setTag('getTag', '已复制', 'ok')
} catch (e) {
setTag('getTag', '复制失败', 'no')
}
}
$('btnInit').onclick = initShield
$('btnReset').onclick = resetShield
$('btnGet').onclick = getToken
$('btnCopy').onclick = copyToken
$('btnSrv').onclick = fetchServerToken
// 自动初始化
initShield()
</script> </script>
</body> </body>
</html> </html>

622
public/yunbei_task.html Normal file
View File

@ -0,0 +1,622 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>云贝广告任务 — 网易云音乐 API Enhanced</title>
<style>
:root {
--fg: #333;
--muted: #666;
--border: #ddd;
--bg: #f5f5f5;
--panel: #ffffff;
--accent: #333;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
@media (max-width: 480px) {
.container {
margin: 20px auto;
padding: 0 16px;
}
header.site-header h1 {
font-size: 22px;
}
.block {
padding: 16px;
}
}
header.site-header {
margin-bottom: 24px;
}
header.site-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.sub {
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.block {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.block h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.block .desc {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
}
pre {
margin: 0;
background: #f9f9f9;
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Courier New', monospace;
font-size: 13px;
max-height: 200px;
overflow: auto;
}
pre.data {
max-height: 360px;
}
.row {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
button {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: var(--panel);
color: var(--fg);
transition: all 0.15s;
}
button:hover {
background: #eee;
}
button.primary {
background: var(--fg);
color: var(--panel);
border-color: var(--fg);
}
button.primary:hover {
opacity: 0.85;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.danger {
background: #a11;
color: #fff;
border-color: #a11;
}
button.danger:hover {
opacity: 0.85;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
}
.tag.ok {
background: #e6f7e6;
color: #1a7a1a;
border-color: #b7e6b7;
}
.tag.no {
background: #ffe6e6;
color: #a11;
border-color: #f5c6c6;
}
.tag.busy {
background: #fff3cd;
color: #856404;
border-color: #ffeeba;
}
.flex {
display: flex;
align-items: center;
gap: 10px;
}
.flex.sb {
justify-content: space-between;
}
.step {
display: flex;
gap: 12px;
align-items: flex-start;
}
.step .num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--fg);
color: var(--panel);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
margin-top: 1px;
}
.step .num.done {
background: #1a7a1a;
}
.step .body {
flex: 1;
min-width: 0;
}
.info-grid {
display: grid;
grid-template-columns: 80px 1fr;
gap: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.info-grid .lbl {
color: var(--muted);
}
footer.site-footer {
margin-top: 32px;
padding-top: 12px;
border-top: 1px solid var(--border);
color: var(--muted);
text-align: center;
font-size: 13px;
}
footer.site-footer a {
color: var(--fg);
text-decoration: none;
}
footer.site-footer a:hover {
border-bottom: 1px dotted var(--fg);
}
/* ---- 今日进度条 ---- */
.progress-wrap {
margin-top: 10px;
}
.progress-bar {
height: 10px;
border-radius: 5px;
background: #eee;
border: 1px solid var(--border);
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
border-radius: 5px;
background: #1a7a1a;
transition: width 0.4s ease;
}
.progress-text {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
/* ---- 歌曲列表 ---- */
.song-list {
margin-top: 10px;
}
.song-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
background: #fafafa;
}
.song-item img {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
background: #eee;
}
.song-item .song-name {
font-size: 14px;
font-weight: 500;
}
.song-item .song-artist {
font-size: 12px;
color: var(--muted);
}
.song-item .song-like {
margin-left: auto;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<main class="container">
<header class="site-header">
<h1>🪙 云贝广告任务 · 看视频得云贝</h1>
<p class="sub">
云贝任务中心「看广告视频得云贝」— 查询今日状态、领取云贝、获取推荐歌曲
</p>
</header>
<!-- 步骤 1: 查询今日任务状态 -->
<div class="block">
<div class="step">
<div class="num" id="s1num">1</div>
<div class="body">
<div class="flex sb">
<h3>查询今日任务状态</h3>
<span id="s1tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用
/yunbei/task/list/v1查看今日已完成次数、累计云贝与单次可得云贝
</p>
<div class="row">
<button id="step1" class="primary">① 查询状态</button>
</div>
<div class="info-grid" id="s1info" style="display: none">
<div class="lbl">已完成</div>
<div id="s1times">-</div>
<div class="lbl">累计云贝</div>
<div id="s1amount">-</div>
<div class="lbl">单次可得</div>
<div id="s1single">-</div>
</div>
<div class="progress-wrap" id="s1progress" style="display: none">
<div class="progress-bar">
<div class="progress-fill" id="s1fill"></div>
</div>
<div class="progress-text" id="s1ptext"></div>
</div>
<pre id="s1pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 步骤 2: 领取云贝 -->
<div class="block">
<div class="step">
<div class="num" id="s2num">2</div>
<div class="body">
<div class="flex sb">
<h3>领取云贝</h3>
<span id="s2tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用 /yunbei/task/finish/v1 完成任务并领取云贝。实测仅需传
yunbeiAmount单次 150无需真实听歌/看视频。单日上限 10 次 ×
150 = 1500 云贝/天。
</p>
<div class="row">
<button id="step2" class="primary" disabled>
② 领取一次 (+150)
</button>
<button id="step2x" disabled>连续领取至上限</button>
</div>
<div class="info-grid" id="s2info" style="display: none">
<div class="lbl">本次结果</div>
<div id="s2result">-</div>
<div class="lbl">今日累计</div>
<div id="s2amount">-</div>
</div>
<pre id="s2pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 步骤 3: 获取推荐歌曲 -->
<div class="block">
<div class="step">
<div class="num" id="s3num">3</div>
<div class="body">
<div class="flex sb">
<h3>获取推荐歌曲</h3>
<span id="s3tag" class="tag no">未开始</span>
</div>
<p class="desc">
调用
/yunbei/task/recommend/song获取「听歌得云贝」任务专属推荐alg
均为 alg_payrec_yunBei_*
</p>
<div class="row">
<button id="step3" class="primary" disabled>③ 获取推荐</button>
</div>
<div class="song-list" id="s3list" style="display: none"></div>
<pre id="s3pre">(尚未执行)</pre>
</div>
</div>
</div>
<!-- 一键全流程 -->
<div class="block">
<div class="flex sb">
<h3>一键运行</h3>
<span id="allTag" class="tag no">就绪</span>
</div>
<p class="desc" style="margin-top: 4px">
查询今日状态 → 领取一次云贝 → 获取推荐歌曲
</p>
<div class="row">
<button id="runAll" class="primary">▶ 一键运行</button>
<button id="clearAll" class="danger">✕ 清空</button>
</div>
</div>
<footer class="site-footer">
<a href="/">← 返回首页</a>
</footer>
</main>
<script>
const $ = (id) => document.getElementById(id)
const base = location.origin
function setTag(id, type, text) {
const el = $(id)
el.className = 'tag ' + type
el.textContent = text
}
function setNum(id, done) {
$(id).className = 'num' + (done ? ' done' : '')
}
function nocache(url) {
const sep = url.indexOf('?') > -1 ? '&' : '?'
return url + sep + '_t=' + Date.now()
}
/** 今日进度展示: times/10 */
function renderProgress(times, singleAmount) {
const pct = Math.min(100, Math.round((times / 10) * 100))
$('s1fill').style.width = pct + '%'
$('s1ptext').textContent =
'今日进度: ' +
times +
' / 10 次' +
(times >= 10
? '(已达上限,明天再来吧)'
: '(剩余 ' + (10 - times) + ' 次 × ' + singleAmount + ' 云贝)')
}
// ===== 步骤 1: 查询状态 =====
async function step1() {
const btn = $('step1')
btn.disabled = true
setTag('s1tag', 'busy', '查询中…')
setNum('s1num', false)
try {
const res = await fetch(nocache(base + '/yunbei/task/list/v1'))
const data = await res.json()
$('s1pre').textContent = JSON.stringify(data, null, 2)
const body = data.data || {}
if (data.code === 200 && typeof body.times === 'number') {
setTag('s1tag', 'ok', '已查询')
setNum('s1num', true)
$('s1info').style.display = 'grid'
$('s1times').textContent = body.times
$('s1amount').textContent = body.amount + ' 云贝'
$('s1single').textContent = body.singleAmount + ' 云贝'
$('s1progress').style.display = 'block'
renderProgress(body.times, body.singleAmount)
$('step2').disabled = body.times >= 10
$('step2x').disabled = body.times >= 10
$('step3').disabled = false
window.__singleAmount = body.singleAmount || 150
return body
}
setTag('s1tag', 'no', '查询失败')
return null
} catch (e) {
$('s1pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s1tag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
$('step1').onclick = step1
// ===== 步骤 2: 领取云贝 =====
async function claimOnce() {
const btn = $('step2')
btn.disabled = true
$('step2x').disabled = true
setTag('s2tag', 'busy', '领取中…')
setNum('s2num', false)
try {
const amount = window.__singleAmount || 150
const res = await fetch(
nocache(base + '/yunbei/task/finish/v1?yunbeiAmount=' + amount),
)
const data = await res.json()
$('s2pre').textContent = JSON.stringify(data, null, 2)
if (data.code === 200) {
setTag('s2tag', 'ok', '已领取 +' + amount)
setNum('s2num', true)
$('s2info').style.display = 'grid'
$('s2result').textContent = String(data.data)
await step1()
return true
}
setTag('s2tag', 'no', '领取失败: ' + (data.message || data.code))
$('s2info').style.display = 'none'
return false
} catch (e) {
$('s2pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s2tag', 'no', e.message)
return false
} finally {
btn.disabled = false
$('step2x').disabled = false
}
}
$('step2').onclick = claimOnce
// 连续领取至上限
$('step2x').onclick = async function () {
const btn = this
btn.disabled = true
$('step2').disabled = true
setTag('s2tag', 'busy', '连续领取中…')
let okCount = 0
let failed = false
for (let i = 0; i < 10; i++) {
const state = await step1()
if (!state) {
failed = true
break
}
if (state.times >= 10) break
const ok = await claimOnce()
if (!ok) {
failed = true
break
}
okCount++
}
setTag(
's2tag',
failed ? 'no' : 'ok',
failed ? '中断' : '已领满 ' + okCount + ' 次',
)
btn.disabled = false
$('step2').disabled = false
}
// ===== 步骤 3: 获取推荐歌曲 =====
$('step3').onclick = async function () {
const btn = this
btn.disabled = true
setTag('s3tag', 'busy', '获取中…')
setNum('s3num', false)
$('s3list').style.display = 'none'
try {
const res = await fetch(
nocache(base + '/yunbei/task/recommend/song?offset=0&limit=10'),
)
const data = await res.json()
$('s3pre').textContent = JSON.stringify(data, null, 2)
const list = data.data || []
if (data.code === 200 && Array.isArray(list) && list.length) {
setTag('s3tag', 'ok', '共 ' + list.length + ' 首')
setNum('s3num', true)
const box = $('s3list')
box.style.display = 'block'
box.innerHTML = ''
list.forEach((s) => {
const item = document.createElement('div')
item.className = 'song-item'
item.innerHTML =
'<img src="' +
(s.albumUrl || '') +
'" alt="" onerror="this.style.visibility=\'hidden\'">' +
'<div><div class="song-name">' +
(s.songName || '-') +
'</div>' +
'<div class="song-artist">' +
(s.artistName || '-') +
'</div></div>' +
'<div class="song-like">' +
(s.likeFlag ? '♥ 已喜欢' : '♡ 未喜欢') +
'</div>'
box.appendChild(item)
})
return list
}
setTag('s3tag', 'no', '获取失败')
return null
} catch (e) {
$('s3pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s3tag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
// ===== 一键运行 =====
$('runAll').onclick = async function () {
const btn = this
btn.disabled = true
setTag('allTag', 'busy', '运行中…')
await step1()
const state = window.__singleAmount ? true : false
if (state) await claimOnce()
await $('step3').onclick()
setTag('allTag', 'ok', '完成')
btn.disabled = false
}
// ===== 清空 =====
$('clearAll').onclick = function () {
const steps = ['s1', 's2', 's3']
const tags = { s1: '未开始', s2: '未开始', s3: '未开始' }
steps.forEach((s) => {
setTag(s + 'tag', 'no', tags[s])
setNum(s + 'num', false)
$(s + 'pre').textContent = '(尚未执行)'
const info = $(s + 'info')
if (info) info.style.display = 'none'
})
$('s1progress').style.display = 'none'
$('s3list').style.display = 'none'
$('step1').disabled = false
$('step2').disabled = true
$('step2x').disabled = true
$('step3').disabled = true
setTag('allTag', 'no', '就绪')
$('runAll').disabled = false
}
</script>
</body>
</html>

View File

@ -0,0 +1,204 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const eventName = process.env.GITHUB_EVENT_NAME
const eventPath = process.env.GITHUB_EVENT_PATH
const token = process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY
const model = process.env.MODEL || 'opencode/deepseek-v4-flash-free'
const botLogin = process.env.BOT_LOGIN || 'takanashi-hoshino-agent[bot]'
if (!eventPath || !token || !repo) {
console.error(
'Missing required env: GITHUB_EVENT_PATH / GITHUB_TOKEN / GITHUB_REPOSITORY',
)
process.exit(1)
}
if (!process.env.OPENCODE_API_KEY) {
console.error('Missing OPENCODE_API_KEY')
process.exit(1)
}
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
const [owner, repoName] = repo.split('/')
const api = `https://api.github.com/repos/${owner}/${repoName}`
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'opencode-automation',
}
async function get(path) {
const res = await fetch(`${api}${path}`, { headers })
if (!res.ok) throw new Error(`GET ${path}: HTTP ${res.status}`)
return res.json()
}
async function post(path, body) {
const res = await fetch(`${api}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
const text = await res.text()
if (!res.ok) throw new Error(`POST ${path}: HTTP ${res.status} ${text}`)
return text ? JSON.parse(text) : null
}
/** 无头模式跑 opencode返回模型输出的文本 */
function runOpencode(prompt, contextFile) {
const args = ['run', '--auto', '-m', model, '--format', 'default', prompt]
if (contextFile) args.push('-f', contextFile)
console.log(
`Running: opencode run --auto -m ${model} (prompt ${prompt.length} chars${contextFile ? ', context attached' : ''})`,
)
const res = spawnSync('opencode', args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
})
if (res.error) throw res.error
if (res.status !== 0) {
if (res.stderr) console.error('opencode stderr:\n' + res.stderr)
throw new Error(`opencode exited with code ${res.status}`)
}
return (res.stdout || '').trim()
}
/** 该 issue/PR 是否已有 bot 评论(用于去重,避免 synchronize 重复评论) */
async function hasBotComment(target) {
const comments = await get(`/issues/${target}/comments?per_page=100`)
return comments.some((c) => c.user?.login === botLogin)
}
async function comment(target, body) {
if (!body) {
console.log('No output from agent, skip comment')
return
}
if (body.length > 60000) body = body.slice(0, 60000) + '\n\n_...(truncated)_'
await post(`/issues/${target}/comments`, { body })
console.log(`Commented on #${target}`)
}
async function handleIssue(issue) {
const n = issue.number
console.log(`Handling issue #${n}`)
if (await hasBotComment(n)) {
console.log(`Already handled by bot, skip`)
return
}
const comments = await get(`/issues/${n}/comments?per_page=100`)
const thread = comments
.filter((c) => c.user?.login !== botLogin)
.map(
(c) =>
`- **${c.user?.login}** (${c.created_at}):\n ${(c.body || '').replace(/\n/g, '\n ')}`,
)
.join('\n')
const ctx = [
`# Issue #${n}: ${issue.title}`,
``,
`- State: ${issue.state}`,
`- Author: ${issue.user?.login}`,
`- Created: ${issue.created_at}`,
``,
`## Body`,
``,
issue.body || '(empty)',
...(thread ? [`\n## Comments\n\n${thread}`] : []),
].join('\n')
const ctxFile = join(tmpdir(), 'opencode-issue-context.md')
writeFileSync(ctxFile, ctx)
const prompt =
process.env.ISSUE_PROMPT ||
[
`You are a maintainer bot for the ${repo} repository.`,
`Read the attached file for the issue context.`,
`If the issue has a clear fix or points to relevant docs, reply with documentation links and/or`,
`error-handling guidance for code examples. If nothing actionable, reply with exactly "NO_ACTION".`,
`Do NOT modify any files, do NOT commit, do NOT push. Output only the comment text.`,
].join('\n')
const output = runOpencode(prompt, ctxFile)
if (output === 'NO_ACTION' || output.startsWith('NO_ACTION')) {
console.log('Agent decided no action needed')
return
}
await comment(n, output)
}
async function handlePullRequest(pr) {
const n = pr.number
console.log(`Handling PR #${n}`)
if (await hasBotComment(n)) {
console.log(`Already handled by bot, skip`)
return
}
let files = []
try {
files = await get(`/pulls/${n}/files?per_page=100`)
} catch (e) {
console.warn(`Failed to fetch changed files: ${e.message}`)
}
const fileList = files
.map((f) => `- ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`)
.join('\n')
const ctx = [
`# PR #${n}: ${pr.title}`,
``,
`- State: ${pr.state}`,
`- Author: ${pr.user?.login}`,
`- Base: ${pr.base?.ref} <- Head: ${pr.head?.ref}`,
`- Created: ${pr.created_at}`,
`- Stats: +${pr.additions}/-${pr.deletions}, ${pr.changed_files} files`,
``,
`## Body`,
``,
pr.body || '(empty)',
...(fileList ? [`\n## Changed files\n\n${fileList}`] : []),
].join('\n')
const ctxFile = join(tmpdir(), 'opencode-pr-context.md')
writeFileSync(ctxFile, ctx)
const prompt =
process.env.PR_PROMPT ||
[
`You are a maintainer bot for the ${repo} repository.`,
`Read the attached file for the PR context. The PR branch is already checked out.`,
`Run \`git diff HEAD^1 HEAD\` (or \`git diff origin/${pr.base?.ref}...HEAD\`) to see the changes.`,
`Review the code changes: point out bugs, risks and improvements, and suggest fixes for obvious issues.`,
`If the PR looks good, say so concisely.`,
`Do NOT modify any files, do NOT commit, do NOT push. Output only the review comment text.`,
].join('\n')
const output = runOpencode(prompt, ctxFile)
await comment(n, output)
}
async function main() {
console.log(`Event: ${eventName} on ${repo}`)
if (eventName === 'issues') {
await handleIssue(event.issue)
} else if (eventName === 'pull_request') {
await handlePullRequest(event.pull_request)
} else {
console.log(`Unhandled event type: ${eventName}`)
}
}
main().catch((e) => {
console.error(`Automation failed: ${e.message}`)
process.exit(1)
})

View File

@ -0,0 +1,34 @@
const assert = require('assert')
const eventPrivacy = require('../module/event_privacy')
describe('event privacy modules', () => {
it('updates event privacy with the official client parameters', async () => {
let captured
const expected = { status: 200, body: { code: 200 }, cookie: [] }
const result = await eventPrivacy(
{ evId: '123456789012345678', privacy: '0', cookie: { MUSIC_U: 'x' } },
async (uri, data, options) => {
captured = { uri, data, options }
return expected
},
)
assert.strictEqual(result, expected)
assert.strictEqual(captured.uri, '/api/event/privacy/op')
assert.deepStrictEqual(captured.data, {
eventId: '123456789012345678',
privacy: 0,
})
assert.strictEqual(captured.options.crypto, '')
})
it('rejects unknown event privacy values before making a request', async () => {
let requested = false
const result = await eventPrivacy({ evId: '1', privacy: '3' }, async () => {
requested = true
})
assert.strictEqual(requested, false)
assert.strictEqual(result.status, 400)
})
})

145
test/user_event_all.test.js Normal file
View File

@ -0,0 +1,145 @@
const assert = require('assert')
const userEventAll = require('../module/user_event_all')
describe('all current user events module', () => {
it('resolves the current user and aggregates every upstream page', async () => {
const calls = []
const pages = [
{
status: 200,
body: {
code: 200,
events: [{ id: 1 }, { id: 2 }],
size: 5,
more: true,
lasttime: 100,
},
cookie: ['page=1'],
},
{
status: 200,
body: {
code: 200,
events: [{ id: 2 }, { id: 3 }],
size: 5,
more: true,
lasttime: 50,
},
cookie: ['page=2'],
},
{
status: 200,
body: {
code: 200,
events: [{ id: 4 }],
size: 1,
more: false,
lasttime: 0,
},
cookie: ['page=3'],
},
]
const result = await userEventAll(
{ cookie: { MUSIC_U: 'x' } },
async (uri, data, options) => {
calls.push({ uri, data, options })
if (uri === '/api/nuser/account/get') {
return {
status: 200,
body: { code: 200, account: { id: 42 } },
cookie: ['account=1'],
}
}
return pages.shift()
},
)
assert.deepStrictEqual(
result.body.events.map((event) => event.id),
[1, 2, 3, 4],
)
assert.strictEqual(result.body.size, 5)
assert.strictEqual(result.body.retrievedCount, 4)
assert.strictEqual(result.body.unavailableCount, 1)
assert.strictEqual(result.body.sizeMismatch, true)
assert.strictEqual(result.body.pageCount, 3)
assert.strictEqual(result.body.more, false)
assert.deepStrictEqual(result.cookie, [
'account=1',
'page=1',
'page=2',
'page=3',
])
assert.strictEqual(calls[0].uri, '/api/nuser/account/get')
assert.strictEqual(calls[0].options.crypto, 'weapi')
assert.deepStrictEqual(
calls.slice(1).map((call) => call.data),
[
{
getcounts: true,
time: -1,
limit: 100,
total: false,
fromRN: 'true',
},
{
getcounts: true,
time: 100,
limit: 100,
total: false,
fromRN: 'true',
},
{
getcounts: true,
time: 50,
limit: 100,
total: false,
fromRN: 'true',
},
],
)
})
it('requires a valid login cookie', async () => {
const result = await userEventAll({}, async () => ({
status: 200,
body: { code: 200, account: null, profile: null },
cookie: [],
}))
assert.strictEqual(result.status, 401)
assert.strictEqual(result.body.code, 401)
})
it('fails instead of returning a partial list when the cursor stalls', async () => {
let page = 0
const result = await userEventAll({}, async (uri) => {
if (uri === '/api/nuser/account/get') {
return {
status: 200,
body: { code: 200, account: { id: 42 } },
cookie: [],
}
}
page += 1
return {
status: 200,
body: {
code: 200,
events: [],
size: 1,
more: true,
lasttime: 100,
},
cookie: [],
}
})
assert.strictEqual(page, 2)
assert.strictEqual(result.status, 502)
assert.match(result.body.message, /cursor stalled/)
})
})

129
test/voice_upload.test.js Normal file
View File

@ -0,0 +1,129 @@
const assert = require('assert')
const voiceUpload = require('../module/voice_upload')
function createVoiceUploadHarness(uploadPlugin) {
const requestCalls = []
return {
requestCalls,
dependencies: {
uploadPlugin,
axios: async (options) => {
if (options.method === 'post' && options.url.endsWith('?uploads')) {
return {
data: '<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>',
}
}
if (options.method === 'put') {
return { headers: { etag: 'etag-1' } }
}
return { data: {} }
},
},
request: async (uri, data, options) => {
requestCalls.push({ uri, data, options })
if (uri === '/api/nos/token/alloc') {
return {
body: {
result: {
objectKey: 'voice/audio.mp3',
docId: 'audio-doc-id',
token: 'nos-token',
},
},
}
}
if (uri === '/api/voice/workbench/voice/batch/upload/v2') {
return { body: { data: { voiceId: 'voice-id' } } }
}
return { body: { code: 200 } }
},
}
}
function createVoiceUploadQuery(overrides = {}) {
const data = Buffer.from('audio')
return {
songFile: {
name: 'episode.mp3',
mimetype: 'audio/mpeg',
size: data.length,
data,
},
voiceListId: 'voice-list-id',
categoryId: 'category-id',
secondCategoryId: 'second-category-id',
description: 'episode description',
...overrides,
}
}
describe('voice upload cover', () => {
it('uses the uploaded image as the cover for every voice submission', async () => {
let uploadedQuery
const harness = createVoiceUploadHarness(async (query) => {
uploadedQuery = query
return { imgId: 'uploaded-cover-id' }
})
const query = createVoiceUploadQuery({
imgFile: {
name: 'cover.jpg',
mimetype: 'image/jpeg',
data: Buffer.from('image'),
},
coverImgId: 'fallback-cover-id',
})
const result = await voiceUpload(
query,
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploadedQuery, query)
assert.strictEqual(voiceCalls.length, 2)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'uploaded-cover-id')
})
assert.deepStrictEqual(result, {
status: 200,
body: {
code: 200,
data: { voiceId: 'voice-id' },
},
})
})
it('keeps using coverImgId when no image file is uploaded', async () => {
let uploaded = false
const harness = createVoiceUploadHarness(async () => {
uploaded = true
return { imgId: 'unexpected-cover-id' }
})
await voiceUpload(
createVoiceUploadQuery({ coverImgId: 'existing-cover-id' }),
harness.request,
harness.dependencies,
)
const voiceCalls = harness.requestCalls.filter((call) =>
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
)
assert.strictEqual(uploaded, false)
voiceCalls.forEach((call) => {
const [voiceData] = JSON.parse(call.data.voiceData)
assert.strictEqual(voiceData.coverImgId, 'existing-cover-id')
})
})
})

View File

@ -1,31 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2015", "target": "ES2015",
"module": "commonjs", "module": "node16",
"experimentalDecorators": true, "experimentalDecorators": true,
"ignoreDeprecations": "6.0", "moduleResolution": "node16",
"moduleResolution": "node", "lib": ["esnext", "esnext.asynciterable", "dom"],
"lib": [ "esModuleInterop": true,
"esnext", "allowJs": true,
"esnext.asynciterable", "sourceMap": true,
"dom" "strict": true,
], "noEmit": true,
"esModuleInterop": true, "baseUrl": ".",
"allowJs": true, "paths": {
"sourceMap": true, "~/*": ["./*"],
"strict": true, "@/*": ["./*"],
"noEmit": true, "@neteasecloudmusicapienhanced/api": ["./interface.d.ts"]
"baseUrl": ".", }
"paths": { },
"~/*": [ "exclude": ["node_modules"]
"./*" }
],
"@/*": [
"./*"
]
},
},
"exclude": [
"node_modules"
]
}

View File

@ -17,7 +17,7 @@
"clDomian": "https://clientlog.music.163.com", "clDomian": "https://clientlog.music.163.com",
"clDomian3": "https://clientlog3.music.163.com", "clDomian3": "https://clientlog3.music.163.com",
"dunDomainV3": "https://ac.dun.163yun.com", "dunDomainV3": "https://ac.dun.163yun.com",
"dunDomainV2": "https://ac.dun.163.com", "dunStaticDomain": "https://acstatic-dun.126.net",
"encrypt": true, "encrypt": true,
"encryptResponse": false, "encryptResponse": false,
"clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9", "clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9",

View File

@ -34,12 +34,12 @@ const aesEncrypt = (text, mode, key, iv, format = 'base64') => {
return encrypted.ciphertext.toString().toUpperCase() return encrypted.ciphertext.toString().toUpperCase()
} }
const aesDecrypt = (ciphertext, key, iv, format = 'base64') => { const aesDecrypt = (ciphertext, mode, key, iv, format = 'base64') => {
let bytes let bytes
if (format === 'base64') { if (format === 'base64') {
bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), { bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), {
iv: CryptoJS.enc.Utf8.parse(iv), iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.ECB, mode: CryptoJS.mode[mode.toUpperCase()],
padding: CryptoJS.pad.Pkcs7, padding: CryptoJS.pad.Pkcs7,
}) })
} else { } else {
@ -48,7 +48,7 @@ const aesDecrypt = (ciphertext, key, iv, format = 'base64') => {
CryptoJS.enc.Utf8.parse(key), CryptoJS.enc.Utf8.parse(key),
{ {
iv: CryptoJS.enc.Utf8.parse(iv), iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.ECB, mode: CryptoJS.mode[mode.toUpperCase()],
padding: CryptoJS.pad.Pkcs7, padding: CryptoJS.pad.Pkcs7,
}, },
) )
@ -97,7 +97,7 @@ const eapi = (url, object) => {
const eapiResDecrypt = (encryptedParams, aeapi = false) => { const eapiResDecrypt = (encryptedParams, aeapi = false) => {
// 使用aesDecrypt解密参数 // 使用aesDecrypt解密参数
try { try {
const decrypted = aesDecrypt(encryptedParams, eapiKey, '', 'hex') // WordArray const decrypted = aesDecrypt(encryptedParams, 'ecb', eapiKey, '', 'hex') // WordArray
if (aeapi) { if (aeapi) {
// 带压缩的解密:先转 Base64 再解压 // 带压缩的解密:先转 Base64 再解压
@ -120,6 +120,7 @@ const eapiReqDecrypt = (encryptedParams) => {
// 使用 aesDecrypt 解密参数 // 使用 aesDecrypt 解密参数
const decryptedData = aesDecrypt( const decryptedData = aesDecrypt(
encryptedParams, encryptedParams,
'ecb',
eapiKey, eapiKey,
'', '',
'hex', 'hex',

View File

@ -17,7 +17,7 @@ const {
generateRandomChineseIP, generateRandomChineseIP,
} = require('./index') } = require('./index')
const { URLSearchParams, URL } = require('url') const { URLSearchParams, URL } = require('url')
const { APP_CONF } = require('../util/config.json') const { APP_CONF } = require('./config.json')
const { const {
getToken: antiCheatTokenV2, getToken: antiCheatTokenV2,
} = require('../module/register_checktoken_v2') } = require('../module/register_checktoken_v2')
@ -114,7 +114,7 @@ const userAgentMap = {
api: { api: {
pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.1.29.205117', pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.1.29.205117',
android: android:
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)', 'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
iphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)', iphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)',
}, },
} }
@ -187,14 +187,16 @@ const generateRequestId = () => {
.padStart(4, '0')}` .padStart(4, '0')}`
} }
const createRequest = (uri, data, options) => { const createRequest = async (uri, data, options) => {
let token = '' let token = ''
switch (options.checkToken) { switch (options.checkToken) {
case 'v2': case 'v2':
token = antiCheatTokenV2() // 每次实时获取反作弊 token不缓存
token = await antiCheatTokenV2()
break break
case 'v3': case 'v3':
token = antiCheatTokenV3() // 每次实时获取反作弊 token不缓存
token = await antiCheatTokenV3()
break break
} }