Compare commits

..
119 Commits
Author SHA1 Message Date
Takanashi Hoshino 19c7dad6db docs: 修正文档 2026-10-17 19:29:09 +08:00
Takanashi Hoshino 2db6684453 feat: 添加日推风格相关接口 (90de64140f) 2026-10-05 22:41:37 +08:00
Takanashi Hoshino 90de64140f feat: 添加日推风格相关接口 2026-10-05 22:38:41 +08:00
Takanashi Hoshino 2aab9957df bump 4.41.0 2026-10-03 20:50:35 +08:00
7N21 373d5eedb3 Merge pull request #278 from MT-SUPER-POWER/main
乐迷团相关接口补充
2026-10-03 20:45:47 +08:00
MT-SUPER-POWER c1cbdcb962 Merge remote-tracking branch 'upstream/HEAD' 2026-10-03 18:46:54 +08:00
MT-SUPER-POWER 37b64ab01e feat: 添加了'乐迷团'的相关接口 2026-10-03 18:42:36 +08:00
7N21 95367213f7 Merge pull request #276 from 1254qwer/fix/xeapi/csr
fix: xeapi 请求从 BSR 改为 CSR
2026-10-03 17:35:18 +08:00
1254qwer bd407cee81 fix: xeapi from BSR to CSR 2026-10-03 17:21:32 +08:00
7N21 a91ea8f832 Merge pull request #265 from cmachsocket/pr
compatibility fix of rumtime and legacy node
2026-10-02 23:44:50 +08:00
Takanashi Hoshino 4a45a14aa7 build: update denpendences 8ad30b591d 2026-10-01 18:19:20 +08:00
7N21 acbea014be Merge branch 'main' into pr 2026-10-01 16:02:25 +08:00
Takanashi Hoshino 8ad30b591d build: update dependences 2026-10-01 13:49:53 +08:00
Takanashi Hoshino cb449463fb Merge remote-tracking branch 'origin/dependabot/github_actions/actions/stale-11' 2026-09-30 22:33:18 +08:00
Takanashi Hoshino 91db12d0f5 Merge remote-tracking branch 'origin/dependabot/github_actions/actions/create-github-app-token-3' 2026-09-30 22:33:06 +08:00
7N21 135df9edda Merge pull request #274 from 1254qwer/feat/neapi
feat: 新增 neapi 算法支持
2026-09-30 20:19:55 +08:00
dependabot[bot] 548f6c170a build(deps): bump actions/create-github-app-token from 1 to 3
Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1 to 3.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-30 11:57:54 +00:00
Takanashi Hoshino 17c07960f9 remove ci 2026-09-30 19:56:15 +08:00
1254qwer c4d448641a feat: Add neapi support 2026-09-27 14:53:29 +08:00
cmachsocket 77a9269828 fix lint 2026-09-19 21:31:39 +08:00
cmachsocket 9a16c89714 compatibility fix of rumtime and legacy node 2026-09-14 21:13:16 +08:00
7N21 a8c781fd64 Merge pull request #260 from Cosmosuperbaka/main
feat: 新增登录设备管理中设备名称上报接口
2026-09-12 00:46:53 +08:00
Cosmosuperbaka 690b462577 docs: 补充设备名称上报接口的文档与类型声明 2026-09-12 00:38:43 +08:00
Cosmosuperbaka 25ac0c041f fix: 设备名称上报接口补充必填参数校验 2026-09-11 20:36:43 +08:00
Cosmosuperbaka edef19c3fb feat: 新增设备中心设备名称上报接口 2026-09-10 16:07:54 +08:00
Takanashi Hoshino d55d92cd00 ci: switch model 2026-09-04 20:01:15 +08:00
dependabot[bot] d9253bf930 build(deps): bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-01 12:47:33 +00:00
MoeFurina f5ce55bcb4 build: update dependence 2026-08-27 23:51:49 +08:00
MoeFurina 4480bd4359 ci: switch pnpm 9 2026-08-27 23:43:19 +08:00
MoeFurina e43b123c8a ci: fix pnpm 11 not work 2026-08-27 23:31:52 +08:00
MoeFurina ed9bdd6ffa ci: fix pnpm 11 not work 2026-08-27 23:14:52 +08:00
MoeFurina 7b733e54ab ci: fix pnpm 11 not work 2026-08-27 22:59:30 +08:00
MoeFurina edfaf6c0d1 ci: switch another model 2026-08-27 22:47:35 +08:00
Takanashi Hoshino f83ad951f6 Merge pull request #243 from cXp1r/main
fix: 修正NMTID来源
2026-08-25 12:22:34 +08:00
cXp1r 22003c2c32 fix: 修复NMTID保底有随机生成导致服务器永远不会下放, 致使重复进入分支浪费性能 2026-08-25 00:51:47 +08:00
cXp1r 8376699f5e fix: 修改NMTID采取逻辑和匹配逻辑 2026-08-24 17:46:19 +08:00
cXp1r 5c98a9a1ea fix: 修正NMTID来源 2026-08-24 16:26:12 +08:00
MoeFurina 7c17de7f09 ci: switch another model 2026-08-22 12:47:42 +08:00
Takanashi Hoshino a7e8d485f3 Merge pull request #242 from SteveCodeFox/main
fix: 音质相关说明
2026-08-20 18:10:41 +08:00
SteveCodeFox 0957be6e76 fix: "高清全景声" => "高清臻音"
add: "ste2", "c512", "aac2"三种沉浸声类型说明
2026-08-20 13:19:50 +08:00
MoeFurina 4a91f77e62 ci: fix pnpm has no pnpm-workspace.yaml 2026-08-18 20:18:41 +08:00
MoeFurina a7437f7ca4 feat: 更新歌曲相关接口 2026-08-18 20:11:30 +08:00
Takanashi Hoshino 498945c401 Merge pull request #241 from 1254qwer/fix/song-url-v1
fix: 支持获取臻音全景声
2026-08-18 19:30:02 +08:00
MoeFurina 7b17a08628 docs: 补全私人漫游模式文档内容 2026-08-18 19:23:30 +08:00
1254qwer 0b5c3927c9 fix: /song/url/v1/302 & /song/download/url/v1 支持臻音全景声 2026-08-17 09:15:51 +08:00
1254qwer 881267c56d fix: /song/url/v1 支持臻音全景声 2026-08-16 19:08:38 +08:00
MoeFurina d7c0db9455 fix: 错误的docker配置 2026-08-09 11:36:53 +08:00
Takanashi Hoshino c1d14a86d9 Merge pull request #237 from NeteaseCloudMusicApiEnhanced/fix/yidun
refactor: 重写checkToken V2模块
2026-08-09 11:21:39 +08:00
MoeFurina a2a2a40d71 fixes 2026-08-07 13:45:01 +08:00
MoeFurina 543660fcdf fix: pkg打包node18兼容 2026-08-07 12:13:43 +08:00
MoeFurina 98a95002b2 fix: node18兼容 2026-08-07 12:01:11 +08:00
MoeFurina c4a3d83abe refactor: 重写checkToken V2模块 2026-08-07 11:52:54 +08:00
Takanashi Hoshino 7753b5de41 Merge pull request #235 from NeteaseCloudMusicApiEnhanced/feat/yunbei_task
feat: 新增云贝任务相关接口
2026-08-06 16:58:07 +08:00
MoeFurina c1fe1eb925 Merge branch 'pr/235' 2026-08-06 16:55:27 +08:00
MoeFurina 3357317a1d upd 2026-08-06 16:51:51 +08:00
MoeFurina a08f425ae7 Merge branch 'main' into feat/yunbei_task 2026-08-06 16:45:11 +08:00
MoeFurina 5b780addba ci: test ci with the workflow file 2026-08-05 20:44:21 +08:00
MoeFurina fc9f41839c ci: test ci with workflow file 2026-08-05 19:44:08 +08:00
MoeFurina 5859944563 format docs 2026-08-05 18:31:49 +08:00
MoeFurina aaa6459e55 remove disclaimer 2026-08-05 18:13:14 +08:00
MoeFurina a898e1d86c feat: 新增云贝任务相关接口 2026-08-05 18:11:31 +08:00
MoeFurina 4045f1ad3f Merge branch 'pr/227' 2026-08-02 16:40:25 +08:00
MoeFurina e6214a3f73 Merge branch 'pr/229' 2026-08-02 16:39:14 +08:00
MoeFurina 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
MoeFurina 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
MoeFurina 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
MoeFurina 16aa61f98e ci(test): test ci workflow file 2026-08-02 11:00:13 +08:00
MoeFurina a939003b92 ci(test): test ci workflow file 2026-08-02 10:46:19 +08:00
MoeFurina 8f4873f2e2 perf: 更新接口定义 2026-08-01 19:11:37 +08:00
MoeFurina a8b45a8b8b Merge branch 'pr/225' 2026-08-01 17:34:54 +08:00
MoeFurina 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
MoeFurina 6732fc7c32 ci(test): test ci workflow file 2026-07-31 22:21:34 +08:00
MoeFurina 6ce6b84015 fix: 修复分享笔记接口 2026-07-26 22:39:58 +08:00
MoeFurina 63d89aa906 bump 4.39.0 2026-07-24 17:20:38 +08:00
MoeFurina 8c212be86b docs: 删减合并冲突出现的问题 2026-07-24 17:17:23 +08:00
MoeFurina 89de56d2a2 Merge pull request #215 from ZxwyWebSite/feat-lottery 2026-07-24 17:13:54 +08:00
MoeFurina d298025d7c Merge branch 'pr/215' 2026-07-24 17:12:50 +08:00
MoeFurina a3287a0180 Merge branch 'feat-lottery' of https://github.com/ZxwyWebSite/api-enhanced into pr/215 2026-07-24 17:11:34 +08:00
MoeFurina 9adfd50c58 Merge pull request #215 from ZxwyWebSite/feat-lottery 2026-07-24 17:08:42 +08:00
MoeFurina fda7c9e5b3 Merge branch 'pr/215' 2026-07-24 16:55:29 +08:00
MoeFurina a8266c6d7b feat: 显示器显示剩余时长 2026-07-24 16:48:53 +08:00
Zxwy 64a6b9677a feat: 新增云小编抽奖接口 2026-07-23 11:14:35 +08:00
MoeFurina 20d3a1a7a5 feat: 新增获取免费听市场状态 2026-07-22 20:07:08 +08:00
MoeFurina 4826c65042 feat: weapi支持checkToken 2026-07-22 19:40:12 +08:00
MoeFurina 7822ab08d3 feat: 新增评论相关接口 2026-07-22 19:18:18 +08:00
Takanashi Hoshino 41bd6d82ce Merge pull request #216 from Suxiaoqinx/main
feat(song/url-v1): 新增immerseType参数支持并更新文档
2026-07-19 21:20:40 +08:00
苏晓晴 6f8dd1d971 删除 package-lock.json 2026-07-19 17:45:39 +08:00
Suxiaoqinx ddd0af5474 修复(song_url_v1): 删除非sky等级的immerseType处理逻辑
移除原代码中非sky等级下处理query.immerseType的代码分支,仅在level为sky时设置immerseType参数
2026-07-19 10:04:08 +08:00
Suxiaoqinx fbd2e06419 feat(song/url-v1): 新增immerseType参数支持并更新文档
补充sky音质下immerseType参数的处理逻辑,更新文档说明与调用示例,注明参数仅在level为sky时生效
2026-07-19 09:54:55 +08:00
Zxwy 4442fb4341 feat: 新增云小编抽奖接口 2026-07-18 15:25:15 +08:00
MoeFurina 35d1c61cb4 revert: some changes 2026-07-17 20:41:49 +08:00
MoeFurina a183e153ef Merge branch 'pr/214' 2026-07-17 20:11:37 +08:00
MoeFurina 2fa78c3826 fix: 修复评论接口 2026-07-17 20:11:20 +08:00
Zxwy 5ae1eab14c feat: 新增云小编接口 2026-07-17 19:02:16 +08:00
MoeFurina 321c25bd7d style: 简化createOption 2026-07-15 21:06:50 +08:00
MoeFurina f527c1ef73 docs: 新增免费听接口文档 2026-07-15 16:23:25 +08:00
MoeFurina 0a5a1242f2 feat: 新增免费听接口 2026-07-15 16:14:19 +08:00
MoeFurina 6946dc8e14 docs: 修改运行要求 2026-07-12 10:42:50 +08:00
MoeFurina 22b78779ca Merge branch 'main' of https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced 2026-07-11 17:20:25 +08:00
MoeFurina 35607f5050 perf: 改进api调试页面 2026-07-11 17:20:14 +08:00
Takanashi Hoshino 60af95ac06 Merge pull request #210 from laoshuikaixue/feature/artist-new-song-playall
feat(artist): 添加关注歌手新歌播放全部&新作品接口
2026-07-11 17:04:55 +08:00
LaoShui 46073e5f50 docs(docs): 更新 sourceType 参数说明
- 明确 `1` 表示新歌,其他取值待确认
- 添加默认值 `1` 的标注
2026-07-10 23:07:31 +08:00
LaoShui bf12abe2f1 feat(artist): 添加关注歌手新作品(歌曲/MV)接口
- 新增 module/artist_new_song_mv_list_v2.js 模块实现接口逻辑
- 在文档中添加接口说明、参数详情和调用示例
- 在 TypeScript 定义文件中添加对应的接口类型声明
- 支持分页获取关注歌手的新歌曲和 MV 列表
- 实现时间游标和翻页功能支持
- 提供多种可选参数配置选项
2026-07-10 19:20:54 +08:00
LaoShui 1491b91829 feat(artist): 添加关注歌手新歌播放全部功能
- 新增 module/artist_new_song_playall.js 文件实现获取所有关注歌手最近50首新歌的功能
- 在文档中添加关注歌手最近新歌播放全部接口说明和调用示例
- 在类型定义文件中添加 artist_new_song_playall 函数声明
2026-07-10 18:56:50 +08:00
MoeFurina 63f669d7f8 fix: 黑胶乐签签到失败 (#197) 2026-06-30 14:34:58 +08:00
Furina's Little Ocean Act Theater d525204dda Merge pull request #199 from zhuixingzhe-baisheng/add-vip-growthpoint-getall
feat: add vip_growthpoint_getall - one-click claim all VIP growth rew…
2026-06-29 16:14:42 +08:00
Furina's Little Ocean Act Theater 6eb31984fd Merge pull request #200 from floatwoozy/feat/listen-data-song-play-rank
feat: 新增 listen_data_song_play_rank 歌曲播放排行接口
2026-06-29 16:14:11 +08:00
MoeFurina 9faeb0aa25 docs: 删减文档内容 2026-06-29 15:53:01 +08:00
floatwoozy f6db3e76e9 feat: 新增 listen_data_song_play_rank 歌曲播放排行接口
新增 /api/content/activity/listen/data/song/play/rank,返回某周/月
听歌排行(每首带 playCount),默认 Top20、不足则返回全部。与已有的
listen_data_today_song(今日)同族,补全「带次数的历史周/月榜」。

- module/listen_data_song_play_rank.js:type(week|month) + endTime 参数
- interface.d.ts:补类型声明
- public/docs/home.md:补接口文档
2026-06-25 03:25:20 +08:00
MonkeyCode Agent 9163d48f0a feat: add vip_growthpoint_getall - one-click claim all VIP growth rewards (xeapi)
- New module: module/vip_growthpoint_getall.js
- Uses xeapi encryption for direct  endpoint
- No parameters required, claims all completed task rewards at once
- Added TypeScript type declaration in interface.d.ts
- Added API documentation in public/docs/home.md
2026-06-21 05:24:35 +00:00
MoeFurina 1454ee216c fix: 请求异常 (83ee2cda34) 2026-06-20 23:28:08 +08:00
MoeFurina 83ee2cda34 feat: add NCBL encrypted scrobble interface and update related documentation
- Implemented new scrobble endpoint for NCBL encrypted logs in `scrobble_v1.js`.
- Updated `scrobble.js` to use domain from config.
- Enhanced `scrobble.html` with tabbed interface for original and NCBL versions, including new input fields.
- Added cookie handling and quick fill functionality for both versions.
- Updated `config.json` to include new domain configurations.
- Introduced `ncbl.js` for NCBL encryption utilities.
- Improved documentation in `home.md` to reflect changes in API usage.
2026-06-20 22:51:51 +08:00
MoeFurina 3c352b5752 feat: 新增听歌打卡示例页面 2026-06-20 21:16:14 +08:00
109 changed files with 9087 additions and 670 deletions
+5
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
+9 -8
View File
@@ -14,15 +14,16 @@ body:
id: terms
attributes:
label: 确认事项
description: 在提交功能请求前,请确认以下事项
description: |
在提交功能请求前,请确认以下事项
- 我已经搜索了现有的issues,确认这不是重复请求
- 我已经查看了项目文档
- 该项目符合该项目的目标和范围
- 我确认我提供的接口请求是合理且有意义的
options:
- label: 我已经搜索了现有的issues,确认这不是重复请求
required: true
- label: 我已经查看了项目文档
required: true
- label: 该项目符合该项目的目标和范围
required: true
- label: 我确认我提供的接口请求是合理且有意义的
- label: 我已确认以上事项
required: true
+4 -3
View File
@@ -34,10 +34,11 @@ jobs:
with:
node-version: '18'
- name: Setup pnpm
run: npm install -g pnpm@9
- name: Install dependencies
run: |
npm install -g pnpm
pnpm install
run: pnpm install --frozen-lockfile
- name: Build for ${{ matrix.platform }}
run: |
+67
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
+4 -29
View File
@@ -6,8 +6,6 @@ permissions:
on:
issues:
types: [opened, labeled]
issue_comment:
types: [created]
jobs:
welcome-new-issues:
@@ -19,44 +17,21 @@ jobs:
with:
issue-number: ${{ github.event.issue.number }}
body: |
## 快速链接
感谢您在该仓库提出问题!我们非常重视您的反馈,并希望尽快为您提供帮助。
在我们处理您的问题之前,您可以先查看以下资源:
如果您在一周左右的时间里没有收到我们的回复,请在下方留言,以便我们注意到。我们的大部分审核都是志愿者,有时难免会有疏漏。
你可能会想要尝试以下方法来获得更快的帮助:
- 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org)
- 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk)
- 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题
handle-help-wanted:
if: contains(github.event.issue.labels.*.name, 'question')
runs-on: ubuntu-latest
steps:
- name: Add help resources
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
🆘 需要帮助?
这里有一些可能对您有用的资源:
📚 **文档资源**
- [项目文档](https://neteasecloudmusicapienhanced.js.org)
🔍 **常见问题**
- 搜索[已关闭的issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues?q=is%3Aissue+is%3Aclosed) 看看是否有类似的问题已经被解答。
💬 **即时帮助**
- 加入QQ群:https://qm.qq.com/q/TpeP9Uv2yk
如果以上资源无法解决您的问题,请提供更多详细信息,我们会尽快为您解答!
stale-issues:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Mark stale issues
uses: actions/stale@v10
uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: |
+71
View File
@@ -0,0 +1,71 @@
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 != 'yukka-agent[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Fetch config from CF Worker
id: cfg
uses: MoeFurina/pull-oc-config@v1
with:
url: https://bot-storage.interface.focalors.ltd/config
- name: Generate bot token
id: token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ steps.cfg.outputs.app_id }}
private-key: ${{ steps.cfg.outputs.app_private_key }}
owner: ${{ github.repository_owner }}
- name: Checkout
uses: actions/checkout@v6
with:
# 需要持久化凭证: opencode action 内部会 git fetch origin <分支> 拉取 PR 代码
persist-credentials: true
token: ${{ steps.token.outputs.token }}
- name: Configure git identity
run: |
git config --global user.name "yukka-agent[bot]"
git config --global user.email "yukka-agent[bot]@users.noreply.github.com"
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ steps.cfg.outputs.opencode_api_key }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
OPENCODE_PERMISSION: '{"permission":{"*":"allow","question":"deny"}}'
with:
model: opencode/muse-spark-1.3-contributor-free
use_github_token: true
prompt: |
${{ steps.cfg.outputs.opencode_prompt }}
## 用户指令(来自触发评论,请严格按此执行)
${{ github.event.comment.body }}
## 用户要求Review时, 严格执行下面指令
${{ steps.cfg.outputs.pr_review_prompt }}
+2 -1
View File
@@ -9,4 +9,5 @@ bin
anonymous_token
.vercel
.env
IFLOW.md
IFLOW.md
precompiled
+47 -19
View File
@@ -1,23 +1,51 @@
# Agent Instructions for NeteaseCloudMusicApiEnhanced
# NeteaseCloudMusicApiEnhanced Agent 说明
## Quick Start
- **Package Manager**: Use `pnpm` (not npm or yarn).
- **Node Version**: Requires Node.js 18 or later.
## 快速开始
- **包管理器**:开发与 CI 使用 `pnpm`,`Dockerfile` 也用 pnpm(`pnpm@9 --frozen-lockfile`,匹配 `pnpm-lock.yaml`)。仓库没有 `yarn.lock`,不要引入 yarn。
- **Node 版本**:README 推荐 Node 22+;`package.json` 的 `engines` 声明 `>=12`;CI/打包在 Node 18–24 上运行。现代 Node 均可。
- **环境变量**:`server.js` 调用了 `dotenv.config()`,本地 `.env` 会被自动加载;所有支持的变量见 `.env.prod.example`。
## Developer Commands
- **Install dependencies**: `pnpm i`
- **Start server**: `pnpm start` or `node app.js`
- **Start dev server**: `pnpm dev` (uses nodemon)
- **Run tests**: `pnpm test` (uses Mocha)
- **Linting**: `pnpm lint` (check) or `pnpm lint-fix` (auto-fix)
## 常用命令
- 安装依赖:`pnpm i`
- 启动服务:`pnpm start`(等价 `node app.js`);热重载开发:`pnpm dev`(nodemon)
- 跑测试:`pnpm test`(Mocha,超时 60s)
- Lint:`pnpm lint`;自动修复:`pnpm lint-fix`
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`
## Architecture & Entrypoints
- **Executable Server**: `app.js` is the main entrypoint for running the API server.
- **Module Exports**: `main.js` is the entrypoint when the project is imported as a Node.js dependency.
- **API Endpoints**: Located in the `module/` directory. Each file typically corresponds to an API route.
- **Core Utilities**: Request handling, encryption, and core utilities are found in the `util/` directory.
## 目录内非路由文件
- `plugins/`——内部上传辅助(`upload.js`、`songUpload.js`),被 `module/*` require,本身不是路由。
- `module_example/`——接口模板与 `main.js` 库调用示例(含 `test.js` 演示 `login_cellphone`→`song_url` 链路),不是运行时代码。
- `examples/get_static_moddef.js`——用 `getModulesDefinitions(..., false)`(`doRequire=false`)把全部路由导出为 `moddef.json`。改路由注册逻辑时可跑它核对。
- `module_types/` 为空目录;类型声明在根目录 `interface.d.ts`(`types` 字段)。
## Important Gotchas & Quirks
- **Environment Variables**: The server defaults to port 3000 but can be overridden with the `PORT` environment variable.
- **Proxy Variables**: Be very careful with proxy environment variables (`http_proxy`, `https_proxy`, `no_proxy`). The request library (like axios) will automatically pick these up. If they point to an unavailable proxy (especially common in Docker environments), requests will fail silently or throw connection errors.
- **Code Style**: The project uses ESLint and Prettier. Always run `pnpm lint-fix` before committing changes to ensure formatting consistency.
## 架构
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。
- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。
- `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))`。`createOption` 在 `util/option.js`,负责 crypto、cookie(回退到 `NETEASE_COOKIE`)、proxy、realIP/randomCNIP、headers、timeout。
- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。
- `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。
- `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。
- `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。
## 新增/修改接口
- 新建 `module/xxx.js` 会自动挂载路由,无需注册;**文件名即路由**。
- 照抄同目录模块的写法(选对 `crypto`),用 `createOption(query)` 生成请求选项。
- 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。
## 测试
- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测,不需要网络。
- **CI 门禁只看稳定项**:`ci-check.yml` 在 Node 18/22/24 上跑 `pnpm lint` + `main.test.js` + `pnpm docs:check`;完整集成测试(`server.test.js`)是 `continue-on-error`,不阻塞合并。本地跑全量测试失败先怀疑上游风控。
- 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。
- 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "<describe/it 名字>" server.test.js main.test.js --exit`
- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit`
## 坑与注意
- **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。
- **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。
- **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。
- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。
- **`docs:check` 校验的是 `public/docs/home.md`(docsify 站点)**,不是 README;脚本默认只处理该文件,换行会被统一为 `\n`、连续空行压缩到 2 行,PR 改动它后记得跑 `pnpm docs:format`。
- **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`),并 `globalIgnores(['**/public/'])`——`public/` 下的前端页面不参与 lint。
- **`pnpm-workspace.yaml` 是单包工作区**(`packages: ['.']`),并带 `allowBuilds`(core-js/es5-ext)与 `minimumReleaseAgeExclude` 白名单;改依赖安装行为时先看它。
+7 -2
View File
@@ -2,14 +2,19 @@ FROM node:lts-alpine
RUN apk add --no-cache tini
ENV NODE_ENV production
ENV NODE_ENV=production
RUN npm install -g pnpm@9
USER node
WORKDIR /app
COPY --chown=node:node . ./
RUN yarn --network-timeout=100000
# --prod 模式下 husky 不会被安装,prepare 脚本会因找不到 husky 而失败,
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
EXPOSE 3000
+4 -2
View File
@@ -45,14 +45,14 @@
### 环境要求
- Node.js 18 及以上
- Node.js 22 及以上
- 推荐使用 pnpm 进行依赖管理
### 安装
```bash
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git
cd api
cd api-enhanced
pnpm i
```
@@ -225,6 +225,8 @@ pnpm test
[chaunsin/netease-cloud-music](https://github.com/chaunsin/netease-cloud-music)
[folltoshe/netease-report-listen-song](https://github.com/folltoshe/netease-report-listen-song)
### SDK 生态
| 语言 | 作者 | 地址 | 类型 |
+14
View File
@@ -3,6 +3,8 @@ const path = require('path')
const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey')
const { loadNeapiKey } = require('./util/neapiKey')
const registerNeapikey = require('./module/register_neapikey')
const tmpPath = require('os').tmpdir()
async function generateConfig() {
@@ -37,5 +39,17 @@ async function generateConfig() {
} catch (error) {
console.log(error)
}
try {
const current = loadNeapiKey()
await registerNeapikey(
{
version: current ? current.version : 0,
behavior: current ? 'update' : 'open',
},
null,
)
} catch (error) {
console.log(error)
}
}
module.exports = generateConfig
+353
View File
@@ -37,6 +37,41 @@ export const enum SubAction {
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(
params: { nickname: string } & RequestBaseConfig,
): Promise<Response>
@@ -119,6 +154,14 @@ export function album_sublist(
params: MultiPageConfig & RequestBaseConfig,
): Promise<Response>
export function api(
params: {
uri: string
data?: string | Record<string, unknown>
crypto?: string
} & RequestBaseConfig,
): Promise<Response>
export function artist_album(
params: { id: string | number } & MultiPageConfig & RequestBaseConfig,
): Promise<Response>
@@ -271,10 +314,21 @@ export function batch(
params: { [index: string]: unknown } & RequestBaseConfig,
): Promise<Response>
export function captcha_safe_sent(
params: { ctcode?: number | string } & RequestBaseConfig,
): Promise<Response>
export function captcha_sent(
params: { phone: string; ctcode?: number | string } & RequestBaseConfig,
): Promise<Response>
export function captcha_sent_v1(
params: {
phone: number | string
ctcode?: number | string
} & RequestBaseConfig,
): Promise<Response>
export function captcha_verify(
params: {
ctcode?: number | string
@@ -387,6 +441,14 @@ export function comment(
} & RequestBaseConfig,
): Promise<Response>
export function comment_add(
params: {
id: string | number
type: CommentType
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_album(
params: {
id: string | number
@@ -395,6 +457,14 @@ export function comment_album(
RequestBaseConfig,
): Promise<Response>
export function comment_delete(
params: {
id: string | number
type: CommentType
cid: string | number
} & RequestBaseConfig,
): Promise<Response>
export function comment_dj(
params: {
id: string | number
@@ -469,6 +539,15 @@ export function comment_playlist(
RequestBaseConfig,
): Promise<Response>
export function comment_reply(
params: {
id: string | number
type: CommentType
cid: string | number
content: string
} & RequestBaseConfig,
): Promise<Response>
export function comment_video(
params: {
id: string | number
@@ -490,6 +569,25 @@ export function daily_signin(
params: { type?: DailySigninType } & RequestBaseConfig,
): 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 deviceinfo_center_upload(
params: { deviceName: string; name?: string } & RequestBaseConfig,
): Promise<Response>
export function digitalAlbum_ordering(
params: {
payment: string
@@ -618,6 +716,10 @@ export function dj_toplist_popular(
params: { limit?: string | number } & RequestBaseConfig,
): Promise<Response>
export function eapi_decrypt(
params: { hexString: string; isReq?: boolean | string } & RequestBaseConfig,
): Promise<Response>
export function event(
params: { pagesize?: number; lasttime?: number } & RequestBaseConfig,
): Promise<Response>
@@ -626,6 +728,20 @@ export function event_del(
params: { evId: string | number } & RequestBaseConfig,
): 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(
params: {
forwords: string
@@ -665,6 +781,8 @@ export function hot_topic(
params: MultiPageConfig & RequestBaseConfig,
): Promise<Response>
export function inner_version(params: RequestBaseConfig): Promise<Response>
export function like(
params: {
like?: 'true' | 'false' | boolean
@@ -678,6 +796,10 @@ export function likelist(
params: { uid: string | number } & RequestBaseConfig,
): Promise<Response>
export function listentogether_status(
params: RequestBaseConfig,
): Promise<Response>
export function login(
params: { email: string; password: string } & RequestBaseConfig,
): Promise<Response>
@@ -724,6 +846,17 @@ export function lyric_new(
params: { id: string | number } & RequestBaseConfig,
): 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(
params: {
uid: string | number
@@ -946,6 +1079,22 @@ export function register_cellphone(
} & RequestBaseConfig,
): 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_neapikey(
params: { version?: string | number; behavior?: string } & RequestBaseConfig,
): Promise<Response>
export function register_xeapikey(
params: { deviceId?: string; currentKeyVersion?: string } & RequestBaseConfig,
): Promise<Response>
export function related_allvideo(
params: { id: string | number } & RequestBaseConfig,
): Promise<Response>
@@ -954,6 +1103,67 @@ export function related_playlist(
params: { id: string | number } & RequestBaseConfig,
): 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 {
mv = 1,
dj = 4,
@@ -979,6 +1189,22 @@ export function scrobble(
} & RequestBaseConfig,
): 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(
params: {
keywords: string
@@ -1160,6 +1386,7 @@ export const enum SoundQualityType {
jyeffect = 'jyeffect',
jymaster = 'jymaster',
sky = 'sky',
vivid = 'vivid',
}
export function song_url_v1(
@@ -1267,6 +1494,25 @@ export function user_event(
} & RequestBaseConfig,
): 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(
params: {
uid: string | number
@@ -1433,6 +1679,23 @@ export function yunbei_task_finish(
} & RequestBaseConfig,
): 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 hug_comment(
@@ -1474,6 +1737,20 @@ export function artist_new_song(
} & RequestBaseConfig,
): Promise<Response>
export function artist_new_song_playall(
params: RequestBaseConfig,
): Promise<Response>
export function artist_new_song_mv_list_v2(
params: {
startTimestamp?: number | string
before?: number | string
sourceType?: number | string
limit?: number | string
firstRequest?: boolean | string
} & RequestBaseConfig,
): Promise<Response>
export function artist_detail(
params: {
id: number | string
@@ -1594,6 +1871,10 @@ export function vip_growthpoint_get(
} & RequestBaseConfig,
): Promise<Response>
export function vip_growthpoint_getall(
params: RequestBaseConfig,
): Promise<Response>
export function artist_fans(
params: { id: number | string } & MultiPageConfig & RequestBaseConfig,
): Promise<Response>
@@ -2049,6 +2330,13 @@ export function listen_data_report(
} & RequestBaseConfig,
): Promise<Response>
export function listen_data_song_play_rank(
params: {
type?: string
endTime?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function listen_data_today_song(
params: RequestBaseConfig,
): Promise<Response>
@@ -2267,6 +2555,10 @@ export function song_chorus(
} & RequestBaseConfig,
): Promise<Response>
export function song_cloud_download(
params: { id: string | number } & RequestBaseConfig,
): Promise<Response>
export function song_creators(
params: {
id: string | number
@@ -2378,6 +2670,18 @@ export function threshold_detail_get(
params: RequestBaseConfig,
): 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 ugc_album_get(
@@ -2504,6 +2808,14 @@ export function verify_qrcodestatus(
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_tasks_v1(
@@ -2524,6 +2836,22 @@ export function voice_upload(
name: string
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,
): Promise<Response>
@@ -2562,3 +2890,28 @@ export function voicelist_trans(
} & MultiPageConfig &
RequestBaseConfig,
): Promise<Response>
export function fans_group_detail(
params: {
groupId: string
scene?: string
} & RequestBaseConfig,
): Promise<Response>
export function fans_group_feed_recommend(
params: {
fansGroupId: string
cursor?: string
size?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function fans_group_user_groups(
params?: RequestBaseConfig,
): Promise<Response>
export function fans_group_user_group_detail(
params: {
groupId: string | number
} & RequestBaseConfig,
): Promise<Response>
+51
View File
@@ -0,0 +1,51 @@
// 获取广告
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const data = {
type_ids: query.type_ids || '["400002_0"]',
}
const option = createOption(query, 'xeapi', 'v3')
const res = await request(`/api/ad/get`, data, option)
const raw = res.body
// 提取广告中的 req_id
let reqId = ''
try {
if (raw?.ads) {
const ad = Object.values(raw.ads)[0]
// 逆向 v9.5.61:客户端从 ad.adExtMap["req_id"] 取 reqUid
if (ad?.adExtMap) {
if (typeof ad.adExtMap === 'string') {
try {
reqId = JSON.parse(ad.adExtMap).req_id || ''
} catch (_) {}
} else {
reqId = ad.adExtMap.req_id || ''
}
}
// 兜底:adLogId.requestId / ad.reqId / extJson.contextInfo.req_id
if (!reqId && ad?.adLogId?.requestId) reqId = ad.adLogId.requestId
if (!reqId && ad?.reqId) reqId = ad.reqId
if (!reqId && ad?.extJson) {
try {
const ext = JSON.parse(ad.extJson)
reqId = ext?.contextInfo?.req_id || ''
} catch (_) {}
}
}
} catch (_) {}
return {
status: 200,
body: {
code: 200,
ads: raw?.ads || null,
message: raw?.message || null,
extra: { reqId },
},
}
}
+13
View File
@@ -0,0 +1,13 @@
// 获取免费听时长状态
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
entrance: 'FREE_LISTEN_RN',
}
return request(
`/api/ad/homepage/free/tab/extend/v2`,
data,
createOption(query, 'xeapi'),
)
}
+252
View File
@@ -0,0 +1,252 @@
// 看广告免费听歌 - 领取免费听权益
// 请求流程(基于逆向网易云音乐 v9.5.61 原生 Kotlin 源码,classes5/18/19.dex):
// 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid
// 2. 调用本接口传入 reqUid 及相关权益参数,领取权益
// 3. 服务器返回 gainFlag 等标识,用于展示领取结果
//
// 调用链(逆向还原):
// AdDSLIncentiveVideoRightsHelper.requestRightsGainInner
// → AdDSLUtils.requestRightGain(ad, exposeTime, clickTime, creativeType, cb)
// → 构造 ListeningRightRequestParams(21 字段)→ JSON.stringify
// → body = { "reqParam": "..." } → POST /api/ad/listening/rights/gain
// (注意:客户端 AdDSLIncentiveVideoRightsHelper 写死 creativeType=36)
//
// 接口不只领取听歌时长,还包含"看视频得云贝"等权益:
// rightsGainMethod=6 (LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益) 时,
// 弹窗 AdLaxinSegmentedGuideDialog 展示 "获得X云贝"(2000 云贝等),
// 权益数值由广告下发的 AdLaxinSegmentedRightsGuidePopup.rightsValue 决定,
// extraRightsType 作为权益类型随本接口上传。
//
// rightsGainMethod 枚举(h80/a):
// 1=EXPOSE 曝光, 2=EXPOSE_CLICK 曝光+点击, 3=EXPOSE_DOWNLOAD 曝光+下载,
// 4=CLICK_STAY 点击+停留, 5=EXPOSURE_OR_CLICK_STAY 曝光或点击停留,
// 6=LAXIN_EXPOSE_OR_DOWNLOAD 拉新曝光/下载分段权益
const createOption = require('../util/option.js')
const adGet = require('./ad_get.js')
// 安全 JSON 解析(字符串字段可能是 JSON 文本)
function safeParse(str, fallback) {
if (typeof str !== 'string') return fallback
try {
return JSON.parse(str)
} catch (_) {
return fallback
}
}
module.exports = async (query, request) => {
const time = Date.now()
// 从广告对象自动补齐请求字段。
// 实测 v9.5.61 真实 API 返回(/ad/get)确认的字段路径:
// reqUid = ad.extJson.contextInfo.req_id(真实响应中唯一存在)
// contextInfo = ad.extJson.contextInfo(完整对象序列化)
// generalRightsInfo = ad.generalRightsInfo(字符串 JSON)
// creativeType = ad.creativeType
// rightsGainMethod / extraRightsType / rightsGainDuration / rightsGainType /
// extraRightsGainMethod / extraRightsGainDuration / nextRightsGainDuration /
// rightsExtJson / source / rightsUpperLimit / qualified
// = ad.generalRightsInfo(字符串)解析后取值
// (逆向类字段 adExtMap/adLogId/listeningRightHintInfo 真实响应中不存在,仅兜底)
// sniffTime = method==3 或 6 时 currentTimeMillis
let reqUid = query.reqUid || ''
let contextInfo = query.contextInfo
let creativeType = query.creativeType
let generalRightsInfo = query.generalRightsInfo
// 广告 hint 配置:仅当调用方未显式传参时用广告下发值兜底
const hint = {}
if (!reqUid || contextInfo === undefined || creativeType === undefined) {
try {
const adRes = await adGet(
{ ...query, type_ids: query.type_ids || '["400002_0"]' },
request,
)
const ad = Object.values(adRes?.body?.ads || {})[0]
if (!ad) throw new Error('ads 为空(未登录或广告位无广告)')
// reqUid:真实 API 返回中 req_id 在 ad.extJson.contextInfo.req_id(实测 v9.5.61)
// (逆向类字段 adExtMap/adLogId 在真实响应中不存在,仅作兜底)
if (!reqUid) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const extMap =
typeof ad?.adExtMap === 'string'
? safeParse(ad.adExtMap, {})
: ad?.adExtMap
reqUid =
ext?.contextInfo?.req_id ||
extMap?.req_id ||
ad?.adLogId?.requestId ||
ad?.reqId ||
adRes?.body?.extra?.reqId ||
''
}
// contextInfo:真实 API 返回中在 ad.extJson.contextInfo(实测 v9.5.61)
if (contextInfo === undefined) {
const ext =
typeof ad?.extJson === 'string'
? safeParse(ad.extJson, {})
: ad?.extJson || {}
const ci =
ext?.contextInfo || ad?.adLogId?.contextInfo || ad?.showContext
if (ci) {
contextInfo = typeof ci === 'string' ? ci : JSON.stringify(ci)
}
}
if (creativeType === undefined && ad?.creativeType !== undefined) {
creativeType = ad.creativeType
}
if (generalRightsInfo === undefined && ad?.generalRightsInfo) {
generalRightsInfo =
typeof ad.generalRightsInfo === 'string'
? ad.generalRightsInfo
: JSON.stringify(ad.generalRightsInfo)
}
// 缓存广告下发的领取配置(后续用于兜底)
// 实测 v9.5.61:rightsGainMethod/rightsUpperLimit/qualified 等在
// ad.generalRightsInfo(字符串)里,ad.listeningRightHintInfo 真实响应中不存在
const hintCfg =
typeof ad?.listeningRightHintInfo === 'string'
? safeParse(ad.listeningRightHintInfo, {})
: ad?.listeningRightHintInfo || {}
const gri =
typeof ad?.generalRightsInfo === 'string'
? safeParse(ad.generalRightsInfo, {})
: ad?.generalRightsInfo || {}
Object.assign(hint, hintCfg, gri)
console.log(`自动获取 reqUid: ${reqUid}`)
} catch (e) {
// 获取广告失败,后续请求会因缺少 reqUid 被拒绝
}
}
const rightsGainMethod = query.rightsGainMethod
? parseInt(query.rightsGainMethod)
: hint.rightsGainMethod || 2
const rightsParam = {
// 必填: 广告请求 ID,自动从 ad_get 获取
reqUid,
// 曝光时间戳
exposureTime: query.exposureTime ? parseInt(query.exposureTime) : time,
// 当前登录用户 ID(原版客户端从 Profile.getUserId() 获取)
userId: query.uid ? parseInt(query.uid) : undefined,
// 点击时间戳
clickTime: query.clickTime ? parseInt(query.clickTime) : time,
// 额外权益类型(拉新分段权益等,决定发放云贝/时长/下载,来自广告配置)
extraRightsType: query.extraRightsType
? parseInt(query.extraRightsType)
: hint.extraRightsType !== undefined
? parseInt(hint.extraRightsType)
: undefined,
// 是否连续播放(默认 false)
playContinuously: query.playContinuously ? true : false,
// 来源标识(原版从 ad.listeningRightHintInfo.source 读取)
source: query.source
? parseInt(query.source)
: hint.source !== undefined
? parseInt(hint.source)
: undefined,
// 广告创意类型(激励视频场景=36,优先取 query / 广告对象)
creativeType: creativeType !== undefined ? parseInt(creativeType) : 36,
// 权益领取方式(1~6 枚举,见文件头注释)
rightsGainMethod,
// 权益扩展方式与时长
extraRightsGainMethod: query.extraRightsGainMethod
? parseInt(query.extraRightsGainMethod)
: hint.extraRightsGainMethod !== undefined
? parseInt(hint.extraRightsGainMethod)
: undefined,
extraRightsGainDuration: query.extraRightsGainDuration
? parseInt(query.extraRightsGainDuration)
: hint.extraRightsGainDuration !== undefined
? parseInt(hint.extraRightsGainDuration)
: undefined,
nextRightsGainDuration: query.nextRightsGainDuration
? parseInt(query.nextRightsGainDuration)
: hint.nextRightsGainDuration !== undefined
? parseInt(hint.nextRightsGainDuration)
: undefined,
// 权益类型(含 RIGHTS_GAIN_TYPE_CURRENT_DAY 等取值)
rightsGainType: query.rightsGainType
? parseInt(query.rightsGainType)
: hint.rightsGainType !== undefined
? parseInt(hint.rightsGainType)
: undefined,
// 权益时长
rightsGainDuration: query.rightsGainDuration
? parseInt(query.rightsGainDuration)
: hint.rightsGainDuration !== undefined
? parseInt(hint.rightsGainDuration)
: undefined,
// 领取步骤(UNGAIN/GAINING/GAIN_FINISHED 对应的值)
gainMethodStep: query.gainMethodStep
? parseInt(query.gainMethodStep)
: undefined,
// 通用权益信息(ad.generalRightsInfo 序列化 JSON)
generalRightsInfo,
// 权益扩展信息
rightsExtJson: query.rightsExtJson || hint.rightsExtJson || undefined,
// 应用信息(下载类广告)
appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined,
// 广告上下文(ad.adLogId.contextInfo,客户端真实来源)
contextInfo,
// 应用是否已安装(下载类广告)
installed: query.installed ? parseInt(query.installed) : undefined,
// 嗅探时间:逆向确认(classes5.dex AdDSLUtils.requestRightGain):
// rightsGainMethod==3(曝光+下载) 或 6(LAXIN) 时传 currentTimeMillis
sniffTime:
rightsGainMethod === 3 || rightsGainMethod === 6
? query.sniffTime
? parseInt(query.sniffTime)
: time
: undefined,
}
// 清理 undefined 字段
Object.keys(rightsParam).forEach((key) => {
if (rightsParam[key] === undefined) delete rightsParam[key]
})
// 将参数序列化为 reqParam 字符串 (与原生源码一致)
const data = {
reqParam: JSON.stringify(rightsParam),
}
const res = await request(
`/api/ad/listening/rights/gain`,
data,
createOption(query, 'xeapi', 'v3'),
)
return {
status: 200,
body: {
code: 200,
data: res.body,
},
}
}
+16
View File
@@ -0,0 +1,16 @@
// 获取关注歌手的新歌曲和 MV
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
startTimestamp: query.startTimestamp || query.before || Date.now(),
sourceType: query.sourceType || 1,
limit: query.limit || 10,
firstRequest: query.firstRequest ?? true,
}
return request(
`/api/sub/artist/new/works/song-mv/list/v2`,
data,
createOption(query, 'eapi'),
)
}
+10
View File
@@ -0,0 +1,10 @@
// 获取所有关注歌手最近的 50 首新歌
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
`/api/sub/artist/new/works/song/playall`,
{},
createOption(query, 'eapi'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 发送安全验证码
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ctcode: query.ctcode || '86',
}
return request(
`/api/sms/captcha/safe/sent`,
data,
createOption(query, 'eapi'),
)
}
+16
View File
@@ -0,0 +1,16 @@
// 发送验证码
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ctcode: query.ctcode || '86',
secrete: 'music_middleuser_pclogin',
cellphone: query.phone,
scene: '0',
}
return request(
`/api/middle/captcha/sent/v1`,
data,
createOption(query, 'eapi'),
)
}
+5 -1
View File
@@ -6,5 +6,9 @@ module.exports = (query, request) => {
cellphone: query.phone,
countrycode: query.countrycode,
}
return request(`/api/cellphone/existence/check`, data, createOption(query))
return request(
`/api/cellphone/existence/check`,
data,
createOption(query, 'eapi'),
)
}
+1 -1
View File
@@ -25,6 +25,6 @@ module.exports = (query, request) => {
return request(
`/api/resource/comments/${query.t}`,
data,
createOption(query, 'weapi'),
createOption(query, 'eapi', 'v2'),
)
}
+18
View File
@@ -0,0 +1,18 @@
const { resourceTypeMap } = require('../util/config.json')
// 发送评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: resourceTypeMap[query.type] + query.id,
content: query.content,
resourceType: '0',
expressionPicId: '-1',
bubbleId: '-1',
}
return request(
`/api/resource/comments/add`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
+15
View File
@@ -0,0 +1,15 @@
const { resourceTypeMap } = require('../util/config.json')
// 删除评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
commentId: query.cid,
threadId: resourceTypeMap[query.type] + query.id,
}
return request(
`/api/resource/comments/delete`,
data,
createOption(query, 'xeapi'),
)
}
+17
View File
@@ -0,0 +1,17 @@
const { resourceTypeMap } = require('../util/config.json')
// 发送评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: resourceTypeMap[query.type] + query.id,
commentId: query.cid,
content: query.content,
resourceType: '0',
}
return request(
`/api/v1/resource/comments/reply`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
+1 -1
View File
@@ -48,7 +48,7 @@ module.exports = async (query, request) => {
case 'linuxapi': {
if (isReq) {
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))
} else {
result = typeof data === 'string' ? JSON.parse(data) : data
+14
View File
@@ -0,0 +1,14 @@
// 强制下线设备
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
key: query.deviceKey,
captcha: query.captcha || '',
}
return request(
`/api/middle/user/security/device/kickoff`,
data,
createOption(query, 'eapi'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 登录设备列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
excStatus: '9',
}
return request(
`/api/middle/user/device/list`,
data,
createOption(query, 'eapi'),
)
}
+28
View File
@@ -0,0 +1,28 @@
// 上报设备中心设备名称
// 官方 PC 端在登录成功后调用此接口,将当前登录设备在“登录设备管理”中的名称设置为自定义名称
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const deviceName = String(query.deviceName || query.name || '').trim()
if (!deviceName) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: deviceName',
},
})
}
const data = {
deviceName,
}
return request(
`/api/deviceinfo/center/upload`,
data,
createOption(query, 'eapi'),
)
}
+34
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))
}
+15
View File
@@ -0,0 +1,15 @@
// 乐迷团详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
groupId: query.groupId || query.id || '',
scene: query.scene || '',
}
return request(
'/api/social/fansgroup/bff/detail/get',
data,
createOption(query, 'eapi'),
)
}
+17
View File
@@ -0,0 +1,17 @@
// 乐迷团推荐笔记
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
artistSelf: query.artistSelf || '0',
fansGroupId: query.fansGroupId || query.groupId || '',
cursor: query.cursor || '0',
size: String(query.size || 10),
}
return request(
'/api/fans/group/feed/recommend/get',
data,
createOption(query, 'eapi'),
)
}
+14
View File
@@ -0,0 +1,14 @@
// 用户所处乐迷团详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
groupId: query.groupId || query.id || '',
}
return request(
'/api/social/fansgroup/bff/user/group/detail/get',
data,
createOption(query, 'eapi'),
)
}
+11
View File
@@ -0,0 +1,11 @@
// 获取当前登录用户加入的全部歌手乐迷团列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
'/api/social/fansgroup/bff/user/groups/get',
{},
createOption(query, 'eapi'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 红心与取消红心歌曲- v1
const createOption = require('../util/option.js')
module.exports = (query, request) => {
query.like = query.like == 'false' ? false : true
const data = {
alg: 'itembased',
trackId: query.id,
like: query.like,
time: '3',
}
return request(`/api/v1/radio/like`, data, createOption(query, 'xeapi', 'v3'))
}
+12
View File
@@ -0,0 +1,12 @@
// 听歌足迹 - 歌曲播放排行 (Top20)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(
`/api/content/activity/listen/data/song/play/rank`,
{
type: query.type || 'month', //周 week 月 month
endTime: query.endTime, // 不填就是本周/月的
},
createOption(query),
)
}
+1
View File
@@ -14,6 +14,7 @@ module.exports = async (query, request) => {
? query.captcha
: query.md5_password || CryptoJS.MD5(query.password).toString(),
remember: 'true',
secureCaptcha: query.sca || '',
}
let result = await request(
`/api/w/login/cellphone`,
+24
View File
@@ -0,0 +1,24 @@
// 云小编每日抽奖
//
// activityId:
// 默认 6501202
//
// drawCount:
// 默认 1
//
// checkToken:
// 易盾反作弊 Token
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '6501202',
drawCount: query.drawCount || '1',
}
return request(
`/api/middle/play/do/lottery`,
data,
createOption(query, 'eapi', 'v2'),
)
}
@@ -0,0 +1,17 @@
// 云小编抽奖剩余次数查询
//
// activityId:
// 默认 6501202
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '6501202',
}
return request(
`/api/middle/play/lottery/remain/chance`,
data,
createOption(query, 'eapi'),
)
}
-3
View File
@@ -1,8 +1,5 @@
// 私人FM - 模式选择
// aidj, DEFAULT, FAMILIAR, EXPLORE, SCENE_RCMD ( EXERCISE, FOCUS, NIGHT_EMO )
// 来不及解释这几个了
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
+1 -1
View File
@@ -9,6 +9,6 @@ module.exports = (query, request) => {
? { checkToken: query.checkToken || APP_CONF.checkToken }
: {}),
}
query.checkToken = true // 强制开启checkToken
query.checkToken = 'v2' // 强制开启checkToken
return request(`/api/playlist/${path}`, data, createOption(query, 'eapi'))
}
+11
View File
@@ -0,0 +1,11 @@
// 每日风格推荐场景配置列表获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/homepage/daily/song/config/get`,
data,
createOption(query, 'xeapi'),
)
}
+17
View File
@@ -0,0 +1,17 @@
// 每日风格推荐场景配置设置
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
tags: JSON.stringify({
tagIds: String(query.tags).split(',').map(Number),
categoryId: Number(query.category),
}),
}
console.log(data)
return request(
`/api/homepage/daily/song/tag/save`,
data,
createOption(query, 'xeapi'),
)
}
+11
View File
@@ -0,0 +1,11 @@
// 每日推荐风格歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/homepage/category/daily/song/list`,
data,
createOption(query, 'xeapi'),
)
}
+13
View File
@@ -0,0 +1,13 @@
// 每日推荐歌曲 - v1
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
ispush: query.ispush || false,
}
return request(
`/api/v3/discovery/recommend/songs`,
data,
createOption(query, 'xeapi'),
)
}
+158
View File
@@ -0,0 +1,158 @@
// 易盾反作弊 Token 注册端点
// 通过易盾官方 Watchman SDK(Web 版,跑在 jsdom 模拟的浏览器环境里)
// 实时调用 getToken(businessId) 获取反作弊 token,供后续带 checkToken 的请求使用
//
// GET /register/checktoken/v2 → 实时获取新 token(不缓存)
// POST /register/checktoken/v2 → 实时获取新 token
//
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
// 避免反作弊 token 复用触发风控。
//
// 注:jsdom 固定用 v24(engines >=18),其依赖链为纯 CJS,可被 pkg 静态
// 分析自动打包;v30+ 引入纯 ESM 依赖且要求 Node >=22,无法在 CI(18-24) 与
// pkg(node18) 目标下运行。
//
const { JSDOM, VirtualConsole } = require('jsdom')
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const logger = require('../util/logger')
// 网易云音乐在易盾的 productNumber 与 businessId
const PRODUCT_NUMBER = 'YD00000558929251'
const BUSINESS_ID = 'bd5d2f973ef74cd2a61325a412ae54d9'
const TOOL_JS_URL = `${APP_CONF.dunStaticDomain}/tool.min.js`
// 最小 HTML 外壳,模拟网页环境
const HTML =
'<!doctype html><html><head><meta charset="UTF-8"></head><body></body></html>'
let toolJs = ''
let wm = null // Watchman 实例(进程内复用,可反复 getToken)
let dom = null // jsdom 实例(失败时 close 释放活动句柄,避免泄漏)
let initPromise = null
// 获取 tool.min.js(内存缓存,避免每次初始化重复下载)
async function getToolJs() {
if (toolJs) return toolJs
const res = await axios.get(TOOL_JS_URL, { timeout: 10000 })
toolJs = String(res.data)
return toolJs
}
// 初始化 Watchman(进程内只初始化一次,实例可反复 getToken)
async function ensureWatchman() {
if (wm) return wm
if (initPromise) return initPromise
initPromise = (async () => {
const js = await getToolJs()
const virtualConsole = new VirtualConsole()
virtualConsole.on('jsdomError', () => {})
dom = new JSDOM(HTML, {
url: 'https://music.163.com/',
referrer: 'https://music.163.com/',
contentType: 'text/html',
runScripts: 'dangerously',
resources: 'usable', // 允许动态加载 watchman.min.js / JSONP
pretendToBeVisual: true,
virtualConsole,
beforeParse(window) {
// 抹掉 headless 特征,避免易盾风控误判
Object.defineProperty(window.navigator, 'webdriver', {
get: () => undefined,
})
window.chrome = { runtime: {} }
window.navigator.languages = ['zh-CN', 'zh']
window.navigator.plugins = [1, 2, 3, 4, 5]
},
})
const script = dom.window.document.createElement('script')
script.textContent = js
dom.window.document.body.appendChild(script)
return new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
reject(new Error('watchman 初始化超时'))
}, 15000)
dom.window.initWatchman({
auto: true,
productNumber: PRODUCT_NUMBER,
onload(instance) {
if (settled) return
settled = true
clearTimeout(timer)
wm = instance
resolve(instance)
},
onerror(...args) {
if (settled) return
settled = true
clearTimeout(timer)
reject(new Error('watchman 初始化失败'))
},
})
})
})()
try {
return await initPromise
} catch (e) {
// 失败路径:关闭 jsdom 释放 rAF/子资源/定时器等活动句柄,防止每次失败泄漏约 30MB
if (dom) {
dom.window.close()
dom = null
}
initPromise = null
wm = null
throw e
}
}
// 获取新 token
async function fetchToken() {
const instance = await ensureWatchman()
const raw = instance.getInstance()
await new Promise((resolve) => {
const timer = setTimeout(() => resolve(), 15000)
raw.I(() => {
clearTimeout(timer)
resolve()
})
})
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(''), 15000)
instance.getToken(BUSINESS_ID, (tk) => {
clearTimeout(timer)
resolve(typeof tk === 'string' ? tk : '')
})
})
}
// 端点处理:每次实时获取新 token
module.exports = async () => {
let token = ''
try {
token = await fetchToken()
} catch (e) {
logger.warn('[checkToken v2]', e.message)
}
return {
status: 200,
body: { code: 200, token, registered: !!token },
}
}
// 给 request.js 读取用:每次调用实时获取新 token,不缓存
module.exports.getToken = async () => {
try {
return await fetchToken()
} catch (e) {
logger.warn('[checkToken v2]', e.message)
return ''
}
}
+44
View File
@@ -0,0 +1,44 @@
// 易盾反作弊 Token 注册端点
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
//
// GET /register/checktoken/v3 → 实时获取新 token(不缓存)
// POST /register/checktoken/v3 → 实时获取新 token
//
// 注意:每次获取都不缓存,模拟真实客户端每次请求使用新鲜 token,
// 避免反作弊 token 复用触发风控。
//
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251'
async function fetch() {
const res = await axios.get(URL, { timeout: 10000 })
const body = String(res.data)
const m = body.match(/null\(\[(\d+),\d+,\"([^\"]+)\"\]\)/)
if (m && m[1] === '200') return m[2]
throw new Error('易盾返回异常: ' + body.substring(0, 100))
}
// 端点处理:每次实时获取新 token
module.exports = async () => {
let token = ''
try {
token = await fetch()
} catch (e) {
// token 获取失败时返回空,由调用方决定是否重试
}
return {
status: 200,
body: { code: 200, token, registered: !!token },
}
}
// 给 request.js 读取用:每次调用实时获取新 token,不缓存
module.exports.getToken = async () => {
try {
return await fetch()
} catch (e) {
return ''
}
}
+48
View File
@@ -0,0 +1,48 @@
const { default: axios } = require('axios')
const { APP_CONF } = require('../util/config.json')
const { saveNeapiKey } = require('../util/neapiKey')
const { decodeConfig } = require('../util/neapiConfig')
const USER_AGENT =
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)'
module.exports = async (query, request) => {
const version = Number(query.version) || 0
const behavior = query.behavior === 'update' ? 'update' : 'open'
const res = await axios({
method: 'GET',
url: APP_CONF.apiDomain + '/api/gorilla/algorithm/record',
params: { version, behavior },
headers: { 'User-Agent': USER_AGENT },
proxy: false,
})
if (!res.data || res.data.code !== 200 || !res.data.data) {
throw new Error('neapi config request failed')
}
// file 为空表示本地已是最新,无需更新
const { file, circleTime } = res.data.data
const config = file
? saveNeapiKey(decodeConfig(Buffer.from(file, 'base64')), circleTime)
: null
// 只回版本与密钥偏移,密钥本身留在本地缓存
const keyOffsets = {}
if (config) {
keyOffsets.encrypt = config.keyBlob.indexOf(config.encryptKey)
keyOffsets.decrypt = config.keyBlob.indexOf(config.decryptKey)
}
return {
status: 200,
body: {
version: config ? config.version : res.data.data.version,
updated: Boolean(config),
circleTime,
keyOffsets,
},
cookie: [],
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ module.exports = async (query, request) => {
const currentKeyVersion = query.currentKeyVersion || ''
const data = {
appVersion: '9.1.65',
appVersion: '9.5.61',
currentKeyVersion,
deviceId,
nonce,
@@ -32,10 +32,10 @@ module.exports = async (query, request) => {
const res = await axios({
method: 'POST',
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
url: APP_CONF.apiDomain + '/api/bsr/sk/get',
headers: {
'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)}` : '',
},
data: new URLSearchParams(data).toString(),
+17
View File
@@ -0,0 +1,17 @@
// 云小编领取任务积分
//
// activityId:
// 调用 rep/ugc/activity/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '5001',
}
return request(
`/api/rep/ugc/activity/collect`,
data,
createOption(query, 'eapi'),
)
}
+7
View File
@@ -0,0 +1,7 @@
// 云小编活动信息
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/activity/get`, {}, createOption(query, 'eapi'))
}
+28
View File
@@ -0,0 +1,28 @@
// 云小编考试状态
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
}
return request(
'/api/rep/ugc/exam/info/get',
data,
createOption(query, 'eapi'),
)
}
@@ -0,0 +1,32 @@
// 云小编考试取题
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
}
return request(
'/api/rep/ugc/exam/question/single/get',
data,
createOption(query, 'eapi'),
)
}
+32
View File
@@ -0,0 +1,32 @@
// 云小编考试结果
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
}
return request(
'/api/rep/ugc/exam/result/get',
data,
createOption(query, 'eapi'),
)
}
+24
View File
@@ -0,0 +1,24 @@
// 云小编考试开始
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
}
return request('/api/rep/ugc/exam/start', data, createOption(query, 'eapi'))
}
+36
View File
@@ -0,0 +1,36 @@
// 云小编考试提交
//
// examType:
// 1. 歌曲曲风审核: musicalStyleEnter
// 2. 歌曲语种审核: languageEnter
// 3. 歌曲原唱审核: oriSingerEnter
// 4. 情绪标签审核: emotionEnter
//
// taskId:
// 首次调用 rep/ugc/exam/start 获取,之后调用 rep/ugc/exam/info/get 获取
//
// questionId:
// 调用 rep/ugc/exam/question/single/get 获取
//
// answer:
// A 对, B 错
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.examType || !query.taskId || !query.questionId || !query.answer)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
examType: query.examType,
taskId: query.taskId,
questionId: query.questionId,
answer: query.answer,
}
return request('/api/rep/ugc/exam/submit', data, createOption(query, 'eapi'))
}
+16
View File
@@ -0,0 +1,16 @@
// 云小编领取一日会员
//
// 注:前提条件见 rep/ugc/user/vip
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
activityId: query.activityId || '5001',
}
return request(
`/api/rep/ugc/user/collect-vip`,
data,
createOption(query, 'eapi'),
)
}
+7
View File
@@ -0,0 +1,7 @@
// 云小编获取用户详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/get`, {}, createOption(query, 'eapi'))
}
+7
View File
@@ -0,0 +1,7 @@
// 云小编每日签到
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/sign`, {}, createOption(query, 'eapi'))
}
+12
View File
@@ -0,0 +1,12 @@
// 云小编查询会员任务状态
//
// 状态 (data.status)
// 10: 用户积分达50,可免费领取1日黑胶会员
// 20: 用户积分已达50,可免费领取1日黑胶会员
// 30: 已领取1日黑胶会员,明天再来吧~
const createOption = require('../util/option.js')
module.exports = (query, request) => {
return request(`/api/rep/ugc/user/vip`, {}, createOption(query, 'eapi'))
}
+3 -1
View File
@@ -1,6 +1,8 @@
// 听歌打卡
const createOption = require('../util/option.js')
const { APP_CONF } = require('../util/config.json')
const DOMAIN = APP_CONF.clDomian
module.exports = async (query, request) => {
// 注入 os=osx 的 cookie
let cookie = query.cookie || ''
@@ -56,7 +58,7 @@ module.exports = async (query, request) => {
}
const option = createOption(query, 'eapi')
option.domain = 'https://clientlog.music.163.com'
option.domain = DOMAIN
// 发送两次请求
const res1 = await request(`/api/feedback/weblog`, startplayData, option)
+128
View File
@@ -0,0 +1,128 @@
// 听歌打卡 - NCBL 加密版 (仿桌面客户端 PLV/PLD 上报)
// 复制自 https://github.com/folltoshe/netease-report-listen-song 的 PC 端日志上报方式
//
// PLV 和 PLD 分两次独立上传
const {
buildPlv,
buildPld,
buildRecords,
extractContext,
parseCookie,
buildCookieStr,
buildMetaJson,
doUpload,
} = require('../util/ncbl')
module.exports = async (query, request) => {
// --- 参数校验 ---
const songId = Number(query.id)
if (!songId || isNaN(songId)) {
return { status: 400, body: { code: 400, msg: '缺少有效的 id (歌曲ID)' } }
}
const playTime = Number(query.time)
if (isNaN(playTime) || playTime <= 0) {
return {
status: 400,
body: { code: 400, msg: '缺少有效的 time (播放时长)' },
}
}
const totalTime = Number(query.total) || playTime
const sourceId = String(query.sourceid || query.sourceId || '')
const sourceName = query.source || 'list'
// --- 解析认证上下文 ---
const rawCookie = query.cookie || ''
const cookieObj = parseCookie(rawCookie)
cookieObj.os = 'pc'
const ctx = extractContext(cookieObj)
// 兜底取 token
if (!ctx.auth.token && rawCookie) {
const parsed =
typeof rawCookie === 'string' ? parseCookie(rawCookie) : rawCookie
ctx.auth.token = parsed.MUSIC_U || ''
}
if (!ctx.auth.token) {
return { status: 401, body: { code: 401, msg: '缺少 MUSIC_U 鉴权令牌' } }
}
// --- 构建歌曲和来源 ---
const song = {
id: songId,
name: query.name || '',
artist: query.artist || '',
bitrate: Number(query.bitrate) || 320,
level: query.level || 'exhigh',
vip: query.vip === 'true' || query.vip === true,
time: totalTime,
}
const source = {
id: sourceId || String(songId),
type: 'track',
name: sourceName,
}
const metaJson = buildMetaJson(ctx)
const cookieStr = buildCookieStr(ctx)
const ts = Math.floor(Date.now() / 1000)
const played = Math.min(playTime, totalTime)
const plvBody = buildRecords([
{ time: ts, action: '_plv', data: buildPlv(ctx, song, source) },
])
const pldBody = buildRecords([
{ time: ts, action: '_pld', data: buildPld(ctx, song, source, played) },
])
try {
// 1) 上传 PLV
const plv = await doUpload(ctx, metaJson, plvBody, cookieStr, 'PLV')
if (!plv.success) {
const rateMsg =
plv.respBody?.data?.rate != null
? ` (rate=${plv.respBody.data.rate})`
: ''
return {
status: 200,
body: {
code: plv.respBody?.code || -1,
msg: `PLV 上报失败${rateMsg}`,
details: plv.respBody,
},
}
}
// 2) 上传 PLD
const pld = await doUpload(ctx, metaJson, pldBody, cookieStr, 'PLD')
if (!pld.success) {
return {
status: 200,
body: {
code: pld.respBody?.code || -1,
msg: 'PLV 成功但 PLD 失败',
details: { plv: plv.respBody, pld: pld.respBody },
},
}
}
return {
status: 200,
body: {
code: 200,
data: 'scrobble_v1 上报成功',
details: {
plv: { fileName: plv.fileName, payloadSize: plv.payload.length },
pld: { fileName: pld.fileName, payloadSize: pld.payload.length },
},
},
}
} catch (err) {
return {
status: 502,
body: { code: 502, msg: `请求异常: ${err.message || err}` },
}
}
}
+5 -1
View File
@@ -7,5 +7,9 @@ module.exports = (query, request) => {
msg: query.msg || '',
id: query.id || '',
}
return request(`/api/share/friends/resource`, data, createOption(query))
return request(
`/api/share/friends/resource`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
+12 -2
View File
@@ -1,13 +1,23 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = (query, request) => {
const data = {
id: query.id,
immerseType: 'c51',
level: query.level,
}
return request(`/api/song/enhance/download/url/v1`, data, createOption(query))
const options = createOption(query)
if (query.level === 'vivid') {
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/download/url/v1`, data, options)
}
+15
View File
@@ -0,0 +1,15 @@
// 插播相似歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
positionCode: 'toolBarRcmdSong',
resourceId: query.id,
resourceType: 'song',
}
return request(
`/api/link/position/show/resource`,
data,
createOption(query, 'eapi'),
)
}
+15 -7
View File
@@ -1,10 +1,12 @@
// 歌曲链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c512(新版c51类型)、ste2(新版环绕立体声类型)、aac2(新版aac类型)、c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = async (query, request) => {
const {
matchID,
@@ -15,6 +17,7 @@ module.exports = async (query, request) => {
level: query.level,
encodeType: 'flac',
}
const options = createOption(query, 'xeapi')
if (query.unblock === 'true') {
try {
const result = await matchID(query.id, query.source)
@@ -51,11 +54,16 @@ module.exports = async (query, request) => {
}
}
if (data.level == 'sky') {
data.immerseType = 'c51'
data.immerseType = query.immerseType || 'c51'
}
return request(
`/api/song/enhance/player/url/v1`,
data,
createOption(query, 'xeapi'),
)
if (data.level == 'vivid') {
data.encodeType = 'mp3'
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/player/url/v1`, data, options)
}
+17 -4
View File
@@ -1,20 +1,30 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清臻音), vivid(臻音全景声), jymaster(超清母带), sky(沉浸环绕声) 进行音质判断
const createOption = require('../util/option.js')
const { cookieToJson } = require('../util/index.js')
module.exports = async (query, request) => {
const data = {
id: query.id,
immerseType: 'c51',
level: query.level,
}
const options = createOption(query)
if (query.level === 'vivid') {
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
const response = await request(
`/api/song/enhance/download/url/v1`,
data,
createOption(query),
options,
)
let url = response?.body?.data?.[0]?.url
let url = response?.body?.data?.url || response?.body?.data?.[0]?.url
if (!url) {
const fallbackData = {
@@ -25,10 +35,13 @@ module.exports = async (query, request) => {
if (query.level === 'sky') {
fallbackData.immerseType = 'c51'
}
if (query.level === 'vivid') {
fallbackData.encodeType = 'mp3'
}
const fallback = await request(
`/api/song/enhance/player/url/v1`,
fallbackData,
createOption(query),
options,
)
url = fallback?.body?.data?.[0]?.url
+22
View File
@@ -0,0 +1,22 @@
// 歌曲百科
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const extJson = {
states: {
playingResource: {
current: query.id,
scene: 'songWiki',
},
},
}
const data = {
extJson: JSON.stringify(extJson),
positionCode: 'songWikiMainPosition',
}
return request(
`/api/link/page/parent/relation/construct/info`,
data,
createOption(query, 'eapi'),
)
}
+20
View File
@@ -0,0 +1,20 @@
// 云小编获取任务
//
// type:
// 1: 歌曲曲风审核 musicalStyleEnter
// 2: 歌曲语种审核 languageEnter
// 3: 歌曲原唱审核 oriSingerEnter
// 4: 情绪标签审核 emotionEnter
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
type: query.type || '4',
}
return request(
`/api/thinktank/audit/resource/detail`,
data,
createOption(query, 'eapi'),
)
}
+36
View File
@@ -0,0 +1,36 @@
// 云小编提交任务
//
// type:
// 1: 歌曲曲风审核 musicalStyleEnter
// 2: 歌曲语种审核 languageEnter
// 3: 歌曲原唱审核 oriSingerEnter
// 4: 情绪标签审核 emotionEnter
//
// taskId:
// 调用 thinktank/audit/resource/detail 获取
//
// judgement:
// 1: 同意, 2: 否决, 3: 跳过 (不算次数)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
if (!query.taskId || !query.judgement)
return Promise.reject({
status: 400,
body: {
code: 400,
message: '参数不足',
},
})
const data = {
type: query.type || '4',
taskId: query.taskId,
judgement: query.judgement,
}
return request(
`/api/thinktank/audit/resource/update`,
data,
createOption(query, 'eapi'),
)
}
+3 -2
View File
@@ -4,9 +4,10 @@ const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
getcounts: true,
time: query.lasttime || -1,
limit: query.limit || 30,
time: query.lasttime ?? -1,
limit: query.limit ?? 30,
total: false,
fromRN: 'true',
}
return request(`/api/event/get/${query.uid}`, data, createOption(query))
}
+135
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,
}
}
+11
View File
@@ -0,0 +1,11 @@
// 一键领取所有会员成长值
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/vipnewcenter/app/level/task/reward/getall`,
data,
createOption(query, 'xeapi'),
)
}
+34 -5
View File
@@ -1,11 +1,40 @@
// 黑胶乐签打卡
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/vip-center-bff/task/sign`,
data,
module.exports = async (query, request) => {
const results = {}
const taskSign = await request(
'/api/vip-center-bff/task/sign',
{},
createOption(query, 'weapi'),
)
results.taskSign = taskSign.body
const checkinDetail = await request(
'/api/vipnewcenter/app/level/user/checkin/history/detail',
{
signDayTime: Date.now(),
type: 1,
},
createOption(query, 'eapi'),
)
results.checkinDetail = checkinDetail.body
// 两个接口都返回 code=200 即视为打卡成功
const signed =
Number(results.taskSign?.code) === 200 &&
Number(results.checkinDetail?.code) === 200
return {
body: {
code: 200,
...results,
signed,
message: signed ? '黑胶乐签打卡成功' : '黑胶乐签打卡失败',
},
cookie: taskSign.cookie,
status: 200,
}
}
+4 -3
View File
@@ -1,13 +1,14 @@
// 黑胶乐签打卡历史
// 黑胶乐签打卡历史 / 状态查询
// 支持传入 type=0(用户信息栏)或 type=1(黑胶乐签)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
type: '0',
type: query.type || '0',
}
return request(
`/api/vipnewcenter/app/minidesk/music/sign/pc`,
data,
createOption(query, 'xeapi'),
createOption(query, 'eapi'),
)
}
+38 -50
View File
@@ -2,6 +2,7 @@ const { default: axios } = require('axios')
const fs = require('fs')
var xml2js = require('xml2js')
const uploadPlugin = require('../plugins/upload')
const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper')
@@ -19,15 +20,7 @@ function createDupkey() {
return s.join('')
}
module.exports = async (query, request) => {
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
module.exports = async (query, request, dependencies = {}) => {
if (!query.songFile) {
return Promise.reject({
status: 500,
@@ -38,6 +31,19 @@ module.exports = async (query, request) => {
})
}
const axiosRequest = dependencies.axios || axios
const uploadImage = dependencies.uploadPlugin || uploadPlugin
const ext = getFileExtension(query.songFile.name)
const filename =
query.songName ||
query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const coverImgId = query.imgFile
? (await uploadImage(query, request)).imgId
: query.coverImgId
const tokenRes = await request(
`/api/nos/token/alloc`,
{
@@ -53,7 +59,7 @@ module.exports = async (query, request) => {
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
const docId = tokenRes.body.result.docId
const res = await axios({
const res = await axiosRequest({
method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: {
@@ -94,7 +100,7 @@ module.exports = async (query, request) => {
)
}
const res3 = await axios({
const res3 = await axiosRequest({
method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: {
@@ -117,7 +123,7 @@ module.exports = async (query, request) => {
}
completeStr += '</CompleteMultipartUpload>'
await axios({
await axiosRequest({
method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: {
@@ -128,29 +134,29 @@ module.exports = async (query, request) => {
data: completeStr,
})
const voiceData = JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
])
await request(
`/api/voice/workbench/voice/batch/upload/preCheck`,
{
dupkey: createDupkey(),
voiceData: JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
voiceData,
},
{
...createOption(query),
@@ -163,25 +169,7 @@ module.exports = async (query, request) => {
`/api/voice/workbench/voice/batch/upload/v2`,
{
dupkey: createDupkey(),
voiceData: JSON.stringify([
{
name: filename,
autoPublish: query.autoPublish == 1 ? true : false,
autoPublishText: query.autoPublishText || '',
description: query.description,
voiceListId: query.voiceListId,
coverImgId: query.coverImgId,
dfsId: docId,
categoryId: query.categoryId,
secondCategoryId: query.secondCategoryId,
composedSongs: query.composedSongs
? query.composedSongs.split(',')
: [],
privacy: query.privacy == 1 ? true : false,
publishTime: query.publishTime || 0,
orderNo: query.orderNo || 1,
},
]),
voiceData,
},
{
...createOption(query),
+5 -1
View File
@@ -1,5 +1,9 @@
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(`/api/pointmall/user/sign`, data, createOption(query, 'weapi'))
return request(
`/api/pointmall/user/sign`,
data,
createOption(query, 'xeapi', 'v3'),
)
}
+26
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'),
)
}
+16
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'),
)
}
+20
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'),
)
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { banner, lyric } from '@neteasecloudmusicapienhanced/api'
import { logger } from '../util/logger.js'
banner({ type: 0 }).then((res) => {
import type { Response } from '@neteasecloudmusicapienhanced/api'
import logger from '../util/logger.js'
banner({ type: 0 }).then((res: Response) => {
logger.info(res)
})
lyric({
id: '33894312',
}).then((res) => {
}).then((res: Response) => {
logger.info(res)
})
+48
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()
+20 -18
View File
@@ -1,7 +1,7 @@
{
"name": "@neteasecloudmusicapienhanced/api",
"version": "4.35.2",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"version": "4.41.1",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced)",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js",
@@ -11,9 +11,9 @@
"prepare": "husky install",
"docs:format": "node scripts/format-docs.js",
"docs:check": "node scripts/format-docs.js --check",
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
"pkgwin": "node scripts/build-pkg.js . -t node18-win-x64 -C GZip -o precompiled/app",
"pkglinux": "node scripts/build-pkg.js . -t node18-linux-x64 -C GZip -o precompiled/app",
"pkgmacos": "node scripts/build-pkg.js . -t node18-macos-x64 -C GZip -o precompiled/app"
},
"bin": "./app.js",
"pkg": {
@@ -75,44 +75,46 @@
"data"
],
"dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.2",
"axios": "^1.17.0",
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.4.5",
"axios": "^1.20.0",
"crypto-js": "^4.2.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"fzstd": "^0.1.1",
"gzip": "^0.1.0",
"music-metadata": "^11.13.0",
"jsdom": "^24.1.3",
"music-metadata": "^11.16.1",
"node-forge": "^1.4.0",
"pac-proxy-agent": "^7.2.0",
"qrcode": "^1.5.4",
"safe-decode-uri-component": "^1.2.1",
"tunnel": "^0.0.6",
"xml2js": "^0.6.2",
"yargs": "^18.0.0"
"yargs": "^18.2.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^9.39.5",
"@types/express": "^5.0.6",
"@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10",
"@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^9.39.4",
"@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.71.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-html": "^8.1.4",
"eslint-plugin-html": "^8.2.1",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.6.0",
"globals": "^17.13.0",
"husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0",
"mocha": "^11.7.6",
"mocha": "^11.8.0",
"nodemon": "^3.1.14",
"pkg": "^5.8.1",
"power-assert": "^1.6.1",
"prettier": "^3.8.3",
"prettier": "^3.9.9",
"typescript": "^5.9.3"
},
"publishConfig": {
+687 -346
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
packages:
- '.'
allowBuilds:
core-js: true
es5-ext: true
minimumReleaseAgeExclude:
- '@neteasecloudmusicapienhanced/[email protected]'
+495 -44
View File
@@ -115,6 +115,182 @@
textarea:focus {
border-color: #333;
}
/* ===== 自定义选项区域 ===== */
.section-container {
margin-top: 20px;
padding: 16px 20px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 8px;
}
.section-container + .section-container {
margin-top: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #555;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title .count-badge {
font-size: 11px;
background: #ddd;
color: #666;
padding: 1px 8px;
border-radius: 10px;
font-weight: 400;
}
.dynamic-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.dynamic-row .key-input {
flex: 0 0 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
outline: none;
font-family: 'Courier New', monospace;
}
.dynamic-row .value-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
outline: none;
}
.dynamic-row .key-input:focus,
.dynamic-row .value-input:focus {
border-color: #333;
}
.remove-btn {
width: 28px;
height: 28px;
padding: 0;
background: #fff;
color: #999;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-btn:hover {
background: #fee;
color: #e33;
border-color: #fcc;
}
.add-btn {
background: #fff;
color: #333;
border: 1px dashed #ccc;
padding: 6px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
margin-top: 4px;
}
.add-btn:hover {
border-color: #999;
background: #f5f5f5;
}
/* ===== 参数编辑区 Tab ===== */
.param-container {
flex: 1;
display: flex;
flex-direction: column;
}
.param-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.param-header-title {
font-size: 14px;
font-weight: 500;
color: #555;
}
.param-tabs {
display: flex;
gap: 0;
background: #f0f0f0;
border-radius: 6px;
padding: 2px;
}
.tab-btn {
padding: 4px 14px;
border: none;
background: transparent;
color: #888;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.tab-btn:hover {
color: #555;
}
.tab-btn.active {
background: #fff;
color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.param-panel {
flex: 1;
display: flex;
flex-direction: column;
}
.param-panel textarea {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
resize: vertical;
min-height: 350px;
outline: none;
}
.param-panel textarea:focus {
border-color: #333;
}
</style>
</head>
<body>
@@ -125,6 +301,16 @@
<label for="uri">URI</label>
<input type="text" id="uri" name="uri" value="/api/song/lyric/v1">
</div>
<div class="form-row">
<label for="method">请求方法</label>
<select id="method" name="method">
<option value="POST" selected>POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div class="form-row">
<label for="crypto">加密方式</label>
<select id="crypto" name="crypto">
@@ -133,22 +319,45 @@
<option value="api">api</option>
<option value="linuxapi">linuxapi</option>
<option value="xeapi">xeapi</option>
<option value="neapi">neapi</option>
<option value="" selected>(默认)</option>
</select>
</div>
<div class="form-row">
<label for="timeout">超时(ms)</label>
<input type="number" id="timeout" name="timeout" value="30000" min="0" step="1000" placeholder="0 表示无超时">
</div>
<div class="form-row">
<label></label>
<button type="submit">发送请求</button>
</div>
</form>
<!-- ===== 自定义请求头 ===== -->
<div class="section-container" id="headersSection">
<div class="section-title">
请求头 (Headers)
<span class="count-badge" id="headerCount">4</span>
</div>
<div id="headersList"></div>
<button class="add-btn" onclick="addHeaderRow('', '')">+ 添加请求头</button>
</div>
<div class="data-result">
<div>
<label for="result">响应结果</label>
<textarea id="result" name="result" readonly></textarea>
</div>
<div>
<label for="data">请求数据</label>
<textarea id="data" name="data">
<div class="param-container">
<div class="param-header">
<span class="param-header-title">请求参数</span>
<div class="param-tabs">
<button class="tab-btn active" data-mode="json" onclick="switchParamTab('json')">JSON</button>
<button class="tab-btn" data-mode="kv" onclick="switchParamTab('kv')">键值对</button>
</div>
</div>
<div class="param-panel" id="jsonPanel">
<textarea id="data" name="data">
{
"cp": false,
"id": "2058263032",
@@ -160,64 +369,306 @@
"ytv": 0,
"yv": 0
}</textarea>
</div>
<div class="param-panel" id="kvPanel" style="display:none">
<div id="kvList"></div>
<button class="add-btn" onclick="addKvRow('', '')">+ 添加参数</button>
</div>
</div>
</div>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
async function sendRequest() {
document.getElementById('result').value = ""
// ===== 动态行管理 =====
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const uri = document.getElementById('uri').value;
const crypto = document.getElementById('crypto').value;
const data = document.getElementById('data').value;
function addHeaderRow(key, value) {
const list = document.getElementById('headersList');
const row = document.createElement('div');
row.className = 'dynamic-row';
row.innerHTML =
'<input type="text" class="key-input" placeholder="Header 名称" value="' +
escapeHtml(key) +
'">' +
'<input type="text" class="value-input" placeholder="值" value="' +
escapeHtml(value) +
'">' +
'<button class="remove-btn" onclick="removeRow(this)" title="删除">✕</button>';
list.appendChild(row);
updateCount();
}
function addKvRow(key, value) {
const list = document.getElementById('kvList');
const row = document.createElement('div');
row.className = 'dynamic-row';
row.innerHTML =
'<input type="text" class="key-input" placeholder="参数名" value="' +
escapeHtml(key) +
'">' +
'<input type="text" class="value-input" placeholder="值" value="' +
escapeHtml(value) +
'">' +
'<button class="remove-btn" onclick="removeRow(this)" title="删除">✕</button>';
list.appendChild(row);
}
function removeRow(btn) {
btn.parentElement.remove();
}
function collectHeaders() {
const headers = {};
document.querySelectorAll('#headersList .dynamic-row').forEach(function (row) {
var key = row.querySelector('.key-input').value.trim();
var value = row.querySelector('.value-input').value;
if (key) headers[key] = value;
});
return headers;
}
function collectKvData() {
var obj = {};
document.querySelectorAll('#kvList .dynamic-row').forEach(function (row) {
var key = row.querySelector('.key-input').value.trim();
var val = row.querySelector('.value-input').value;
if (!key) return;
// 自动类型转换
if (val === 'true') obj[key] = true;
else if (val === 'false') obj[key] = false;
else if (val === 'null') obj[key] = null;
else if (/^-?\d+(\.\d+)?$/.test(val) && !isNaN(Number(val))) obj[key] = Number(val);
else obj[key] = val;
});
return obj;
}
// ===== Tab 切换 =====
function syncJsonToKv() {
try {
const res = await axios({
url: `/api?timestamp=${Date.now()}`,
method: 'post',
data: {
uri: uri,
data: JSON.parse(data),
crypto: crypto,
},
var data = JSON.parse(document.getElementById('data').value);
var list = document.getElementById('kvList');
list.innerHTML = '';
Object.keys(data).forEach(function (k) {
addKvRow(k, String(data[k]));
});
document.getElementById('result').value = JSON.stringify(res.data, null, 2);
} catch (error) {
document.getElementById('result').value = 'Request failed: ' + error.message;
return true;
} catch (e) {
return false;
}
}
(function fillFromQuery() {
const params = new URLSearchParams(window.location.search);
if (!params.toString()) return;
const uri = params.get('uri');
const crypto = params.get('crypto');
const dataParam = params.get('data');
if (uri) {
document.getElementById('uri').value = uri;
function syncKvToJson() {
var obj = collectKvData();
document.getElementById('data').value = JSON.stringify(obj, null, 2);
}
function switchParamTab(mode) {
var jsonPanel = document.getElementById('jsonPanel');
var kvPanel = document.getElementById('kvPanel');
if (mode === 'kv') {
if (!syncJsonToKv()) {
alert('JSON 格式有误,无法切换到键值对模式,请先修正 JSON');
return;
}
jsonPanel.style.display = 'none';
kvPanel.style.display = 'block';
} else {
syncKvToJson();
kvPanel.style.display = 'none';
jsonPanel.style.display = 'block';
}
if (crypto) {
const cryptoSelect = document.getElementById('crypto');
if ([...cryptoSelect.options].some((opt) => opt.value === crypto)) {
cryptoSelect.value = crypto;
}
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
}
// ===== 默认请求头(直接套用后端预设值)=====
var defaultHeaders = [
{ key: 'Cookie', value: '' },
{
key: 'User-Agent',
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
},
{ key: 'Referer', value: 'https://music.163.com' },
{ key: 'Content-Type', value: 'application/json' },
];
// ===== 发送请求 =====
async function sendRequest() {
document.getElementById('result').value = '';
var uri = document.getElementById('uri').value;
var method = document.getElementById('method').value;
var crypto = document.getElementById('crypto').value;
var timeout = parseInt(document.getElementById('timeout').value, 10) || 0;
var headers = collectHeaders();
// 根据当前激活的 tab 读取数据
var parsedData;
var activeTab = document.querySelector('.tab-btn.active');
if (activeTab && activeTab.dataset.mode === 'kv') {
parsedData = collectKvData();
} else {
var rawData = document.getElementById('data').value;
try {
parsedData = rawData.trim() ? JSON.parse(rawData) : {};
} catch (e) {
document.getElementById('result').value =
'请求数据格式错误:' + e.message;
return;
}
}
if (dataParam) {
const decoded = dataParam;
try {
document.getElementById('data').value = JSON.stringify(
JSON.parse(decoded),
null,
2,
var axiosConfig = {
url: '/api?timestamp=' + Date.now(),
method: method,
};
if (timeout > 0) {
axiosConfig.timeout = timeout;
}
if (method === 'GET') {
axiosConfig.params = {
uri: uri,
crypto: crypto,
data: JSON.stringify(parsedData),
headers: Object.keys(headers).length ? JSON.stringify(headers) : undefined,
};
} else {
axiosConfig.data = {
uri: uri,
crypto: crypto,
data: parsedData,
headers: Object.keys(headers).length ? headers : undefined,
};
}
try {
var res = await axios(axiosConfig);
document.getElementById('result').value = JSON.stringify(
res.data,
null,
2,
);
} catch (error) {
document.getElementById('data').value = decoded;
}
} catch (error) {
document.getElementById('result').value =
'Request failed: ' + error.message;
}
})();
}
// ===== 从 URL 查询参数恢复 =====
(function fillFromQuery() {
var params = new URLSearchParams(window.location.search);
if (!params.toString()) {
// 没有查询参数,用默认 header
defaultHeaders.forEach(function (h) {
addHeaderRow(h.key, h.value);
});
return;
}
var uri = params.get('uri');
var crypto = params.get('crypto');
var dataParam = params.get('data');
var method = params.get('method');
var timeout = params.get('timeout');
var headersParam = params.get('headers');
var kvParams = params.get('kvParams');
var paramMode = params.get('paramMode');
if (uri) document.getElementById('uri').value = uri;
if (method) {
var methodSelect = document.getElementById('method');
if (
[].slice.call(methodSelect.options).some(function (opt) {
return opt.value === method;
})
) {
methodSelect.value = method;
}
}
if (timeout) document.getElementById('timeout').value = timeout;
if (crypto) {
var cryptoSelect = document.getElementById('crypto');
if (
[].slice.call(cryptoSelect.options).some(function (opt) {
return opt.value === crypto;
})
) {
cryptoSelect.value = crypto;
}
}
if (dataParam) {
try {
document.getElementById('data').value = JSON.stringify(
JSON.parse(dataParam),
null,
2,
);
} catch (_) {
document.getElementById('data').value = dataParam;
}
}
if (headersParam) {
try {
var parsed = JSON.parse(headersParam);
Object.keys(parsed).forEach(function (k) {
addHeaderRow(k, parsed[k]);
});
} catch (_) {
// fallback: 用默认 header
defaultHeaders.forEach(function (h) {
addHeaderRow(h.key, h.value);
});
}
} else {
defaultHeaders.forEach(function (h) {
addHeaderRow(h.key, h.value);
});
}
if (kvParams) {
try {
var parsed = JSON.parse(kvParams);
// 先填充 kv 列表,再切换到 kv 模式
Object.keys(parsed).forEach(function (k) {
addKvRow(k, String(parsed[k]));
});
if (paramMode === 'kv') {
switchParamTab('kv');
}
} catch (_) {
/* ignore */
}
} else if (paramMode === 'kv' && dataParam) {
// 从 data 参数同步到键值对模式
try {
var data = JSON.parse(dataParam);
Object.keys(data).forEach(function (k) {
addKvRow(k, String(data[k]));
});
switchParamTab('kv');
} catch (_) {
/* ignore */
}
}
})();
</script>
</body>
</html>
+7 -3
View File
@@ -255,9 +255,13 @@
}
logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`)
for (var song of resp.data.result) {
logs.write(
`[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>`
)
var a = document.createElement('a');
a.href = 'https://music.163.com/song?id=' + encodeURIComponent(song.song.id);
a.target = '_blank';
a.textContent = song.song.name + ' - ' + song.song.album.name + ' (' + (song.startTime / 1000) + 's)';
logs.appendChild(document.createTextNode('[result] '));
logs.appendChild(a);
logs.appendChild(document.createElement('br'));
}
})
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+574 -22
View File
@@ -216,7 +216,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
- 大部分请求参数或返回内容可在 `/api_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数)
- 目前支持算法 有 `weapi`, `eapi`, `linuxapi`, `xeapi` 和 `neapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数; neapi 走独立的压缩加密通道, 配置由 `/register/neapikey` 自动获取与刷新)
## 接口文档
@@ -283,7 +283,9 @@ AI 生成的图,仅供娱乐()
`md5_password`: md5 加密后的密码,传入后 `password` 参数将失效
`captcha`: 验证码,使用 [`/captcha/sent`](#发送验证码)接口传入手机号获取验证码,调用此接口传入验证码,可使用验证码登录,传入后 `password` 参数将失效
`captcha`: 验证码,使用 `/captcha/sent` 或 `/captcha/sent/v1`接口传入手机号获取验证码,调用此接口传入验证码,可使用验证码登录,传入后 `password` 参数将失效
`sca`: 网易易盾滑块验证token, 获取方式未知
**接口地址 :** `/login/cellphone`
@@ -397,6 +399,19 @@ body {
**调用例子 :** `/captcha/sent?phone=13xxx`
### 新版发送验证码
说明 : 调用此接口 ,传入手机号码, 可发送验证码
**必选参数 :** `phone`: 手机号码
**可选参数 :**
`ctcode`: 国家区号,默认 86 即中国
**接口地址 :** `/captcha/sent/v1`
**调用例子 :** `/captcha/sent/v1?phone=13xxx`
### 验证验证码
说明 : 调用此接口 ,传入手机号码和验证码, 可校验验证码是否正确
@@ -811,6 +826,8 @@ tags: 歌单标签
`lasttime` : 返回数据的 `lasttime` ,默认-1,传入上一次返回结果的 lasttime,将会返回下一页的数据
接口会传入官方客户端使用的 `fromRN=true`,登录用户读取自己的动态时可获得网易云允许本人查看的非公开动态。返回的 `size` 是网易云账户侧统计值,不保证等于分页后实际可获取的动态数量。
**接口地址 :** `/user/event`
**调用例子 :** `/user/event?uid=32953014` `/user/event?uid=32953014&limit=1&lasttime=1558011138743`
@@ -828,6 +845,38 @@ tags: 歌单标签
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`
### 转发用户动态
说明 : 登录后调用此接口 ,可以转发用户动态
@@ -1215,7 +1264,6 @@ tags: 歌单标签
> 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲
### 歌单详情动态
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
@@ -1256,12 +1304,14 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为`c512` => `新版c51类型`, `ste2` => `新版环绕立体声类型`, `aac2` => `新版aac类型`, `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
**接口地址 :** `/song/url/v1`
**调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless`
**调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless` `/song/url/v1?id=1969519579&level=sky&immerseType=ste` `/song/url/v1?id=1969519579&level=vivid`
说明:`杜比全景声`音质需要设备支持,不同的设备可能会返回不同码率的 url。cookie 需要传入`os=pc`保证返回正常码率的 url。
@@ -1271,7 +1321,7 @@ tags: 歌单标签
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
**接口地址 :** `/song/url/v1/302`
@@ -1395,8 +1445,6 @@ tags: 歌单标签
说明 : 调用此接口 , 传入类型和歌单 id 可收藏歌单或者取消收藏歌单
!> 警告: 在`v4.29.7`版本后, 在网易云登陆后请求要带上`timestamp`字段, 否则会导致请求不合法
**必选参数 :**
`t` : 类型,1:收藏,2:取消收藏
@@ -2343,6 +2391,16 @@ privilege:权限相关信息
**调用例子 :** `/recommend/songs`
### 获取每日推荐歌曲 - v1
说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 )
**可选参数 :** `ispush`: 是否刷新日推 , 默认为 false
**接口地址 :** `/recommend/songs/v1`
**调用例子 :** `/recommend/songs/v1`
### 每日推荐歌曲-不感兴趣
说明 : 日推歌曲标记为不感兴趣( 同时会返回一个新推荐歌曲, 需要登录 )
@@ -2425,6 +2483,18 @@ privilege:权限相关信息
喜欢成功则返回数据的 code 为 200, 其余为失败
### 喜欢音乐 - 新版
说明: 调用此接口 , 传入音乐 id, 可喜欢该音乐
**必选参数 :** `id`: 歌曲 id
**可选参数 :** `like`: 布尔值 , 默认为 true 即喜欢 , 若传 false, 则取消喜欢
**接口地址 :** `/like/v1`
**调用例子 :** `/like/v1?id=347230`
### 喜欢音乐列表
说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐 id 列表(id 数组)
@@ -2500,6 +2570,18 @@ privilege:权限相关信息
**调用例子 :** `/scrobble?id=518066366&sourceid=36780169&time=291`
#### 听歌打卡 V2 (NCBL 加密版)
说明 : 调用此接口,使用桌面客户端 NCBL 加密日志格式上报听歌记录
**必选参数 :** `id`: 歌曲 id, `time`: 播放时长(秒)
**可选参数 :** `sourceid`: 来源列表 id, `source`: 来源名称(默认 list), `name`: 歌曲名, `artist`: 艺术家, `bitrate`: 码率(默认 320), `level`: 音质等级(默认 exhigh), `total`: 歌曲总时长(秒)
**接口地址 :** `/scrobble/v1`
**调用例子 :** `/scrobble/v1?id=518066366&sourceid=36780169&time=291`
### 提交歌曲播放状态
说明 : 调用此接口可提交歌曲播放状态,支持会话追踪和播放模式记录,未传入 `sessionId` 时后端会自动生成
@@ -3565,6 +3647,36 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子 :** `/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`
### 云贝收入
说明 :登录后调用此接口可获取云贝收入
@@ -3600,6 +3712,32 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子 :** `/artist/new/song?limit=1` `/artist/new/song?limit=1&before=1602777625000`
### 关注歌手最近新歌 - 播放全部
说明 : 登录后调用此接口可获取所有关注歌手最近的 50 首新歌
**接口地址 :** `/artist/new/song/playall`
**调用例子 :** `/artist/new/song/playall`
### 关注歌手新作品(歌曲/MV)
说明 : 登录后调用此接口可分页获取关注歌手的新歌曲和 MV
**可选参数 :** `limit`: 返回数量,默认为 10
`startTimestamp`: 时间游标,首次请求默认为当前时间,后续请求传入上一页返回的时间游标
`before`: `startTimestamp` 的别名
`sourceType`: 作品来源类型,已确认 `1` 表示新歌,其他取值待确认,默认为 `1`
`firstRequest`: 是否首次请求,默认为 `true`,翻页时传 `false`
**接口地址 :** `/artist/new/song/mv/list/v2`
**调用例子 :** `/artist/new/song/mv/list/v2` `/artist/new/song/mv/list/v2?startTimestamp=1783680576099&sourceType=1&limit=10&firstRequest=false`
### 关注歌手新 MV
说明 :登录后调用此接口可获取关注歌手新 MV
@@ -3730,6 +3868,14 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子 :** `/vip/growthpoint/get?ids=7043206830_7` `/vip/growthpoint/get?ids=8613118351_1,8607552957_1`
### 一键领取所有 vip 成长值
说明 : 登录后调用此接口 , 一键领取所有已完成的会员任务成长值奖励
**接口地址 :** `/vip/growthpoint/getall`
**调用例子 :** `/vip/growthpoint/getall`
### 歌手粉丝
说明 : 调用此接口 , 传入歌手 id, 可获取歌手粉丝
@@ -4014,6 +4160,16 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
**调用例子:** `/song/wiki/summary?id=1958384591`
### 音乐百科
说明: 调用此接口可以获取歌曲的音乐百科信息
**接口地址:** `/song/wiki/info`
**必选参数:** `id`: 歌曲 ID
**调用例子:** `/song/wiki/info?id=1958384591`
### 乐谱列表
说明: 调用此接口可以获取歌曲的乐谱列表
@@ -4276,27 +4432,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`
**必选参数:**
`voiceListId`: 播客 id
`coverImgId`: 播客封面
`songFile`: 声音文件
`voiceListId`: 播客 id
`categoryId`: 分类 id
`secondCategoryId`:次级分类 id
`secondCategoryId`: 次级分类 id
`description`: 声音介绍
**可选参数:**
`imgFile`: 声音封面图片文件,上传后会自动生成图片 id。与`coverImgId`同时传入时,优先使用`imgFile`
`coverImgId`: 已上传的声音封面图片 id,未传入`imgFile`时使用该值
`songName`: 声音名称
`privacy`: 设为隐私声音,播客如果是隐私博客,则必须设为 1
`privacy`: 设为隐私声音,播客如果是隐私播客,则必须设为 1
`publishTime`:默认立即发布,定时发布的话需传入时间戳
`publishTime`: 默认立即发布,定时发布的话需传入时间戳
`autoPublish`: 是否发布动态,是则传入 1
@@ -4523,7 +4685,7 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
### 歌曲音质详情
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出 `高清环绕声`、`沉浸环绕声`、`超清母带`等音质的信息
说明: 调用此接口获取歌曲各个音质的文件信息,与 `获取歌曲详情` 接口相比,多出`高清臻音`、`杜比全景声`、`臻音全景声`、`超清母带`、`沉浸环绕声`等音质的信息
**必选参数:**
@@ -4560,11 +4722,11 @@ qrCodeStatus:20,detailReason:0 验证成功 qrCodeStatus:21,detailReason:0 二
**必选参数:**
`mode`: 模式 (aidj, DEFAULT, FAMILIAR, EXPLORE, SCENE_RCMD)
`mode`: 模式 (`DEFAULT`: 默认模式,`FAMILIAR`: 熟悉模式,`EXPLORE`: 探索模式,`SCENE_RCMD`: 场景模式, 需要传入`submode`参数,`PUZZLE_MODE_RCMD`: 拼图模式)
**可选参数:**
`submode`: 当 mode 为 SCENE_RCMD 是可为 ( EXERCISE, FOCUS, NIGHT_EMO )
`submode`: 当 mode 为 SCENE_RCMD 时传入 ( `EXERCISE`: 运动, `FOCUS`: 专注, `NIGHT_EMO`: 伤感, `SLEEP_HELP`: 助眠, `RELAX`: 放松, `CHEERFUL`: 欢快, `LYRICAL`: 抒情, `CURE`: 治愈, `SWEET`: 情歌, `RHYTHM_BLUES`: R&B, `RAINY`: 雨天, `GAMES`: 游戏, `RAP`: 说唱, `K_POP`: K-Pop, `ORIGINAL_MUSICIAL`: 宝藏原创, `ELECTRONIC`: 电音, `COMMUTE`: 出行, `TAKE_SHOWER`: 洗澡, `COFFEE_SHOP`: 咖啡馆, `ROCK`: 摇滚, `INSPIRATIONAL`: 励志, `CHINESE`: 华语, `ENGLISH`: 欧美, `YUEYU`: 粤语, `MANYAO`: 慢摇DJ, `JINGDIAN`: 经典, `LIGHT`: 轻音乐, `GUOFENG`: 国风, `FOLK`: 民谣, `ACG`: 二次元, `GUDIAN`: 古典, `JAZZ`: 爵士, `JAPANESE`: 日语, `GLOBAL`: 全球, `FRANCH`: 法语, `BLUE`: 蓝调, `DANCE`: 舞蹈, `LATIN`: 拉丁, `PUNK`: 放克, `COUNTRY`: 乡村乐, `MUSICAL`: 音乐剧, `YINGSHI`: 影视)
**接口地址:** `/personal/fm/mode`
@@ -4644,7 +4806,7 @@ bitrate = Math.floor(br / 1000)
**必选参数 :** `id` : 音乐 id
`level`: 播放音质等级, 分为 `standard` => `标准`,`higher` => `较高`, `exhigh`=>`极高`,
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清臻音`, `dolby` => `杜比全景声`, `vivid` => `臻音全景声`, `jymaster` => `超清母带`, `sky` => `沉浸环绕声`
**接口地址 :** `/song/download/url/v1`
@@ -4798,6 +4960,23 @@ bitrate = Math.floor(br / 1000)
**接口地址 :** `/listen/data/today/song`
### 听歌足迹 - 歌曲播放排行
说明 : 登录后调用此接口, 获取歌曲播放排行, 返回每首歌的播放次数 (playCount); 默认返回前 20 名 (Top20), 不足则返回全部
**必选参数 :**
`type`: 维度类型 周 week 月 month
**可选参数 :**
`endTime` : 周: 每周周六 0 点的时间戳 月: 每月最后一天 0 点的时间戳
不填就是本周/月的
**接口地址 :** `/listen/data/song/play/rank`
**调用例子 :** `/listen/data/song/play/rank?type=month`
### 听歌足迹 - 总收听时长
说明 : 登录后调用此接口, 获取总收听时长; 相关接口可能需要 vip 权限
@@ -5076,7 +5255,6 @@ let data = encodeURIComponent(
**调用例子:** `/broadcast/sub?id=5&t=1`
### 用户的创建歌单列表
说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表
@@ -5151,7 +5329,6 @@ let data = encodeURIComponent(
**调用例子 :** `/voicelist/my/created`
### DIFM电台 - 分类
说明: 调用此接口, 获取DIFM电台分类
@@ -5330,7 +5507,7 @@ let data = encodeURIComponent(
**接口地址 :** `/comment/report`
**调用例子 :* `/comment/report?id=2058263032&cid=123456789&reason=人身攻击`
*_调用例子 :_ `/comment/report?id=2058263032&cid=123456789&reason=人身攻击`
### 多级行政区划数据
@@ -5410,7 +5587,382 @@ let data = encodeURIComponent(
**接口地址 :** `/song/cloud/download`
**调用例子 :** `/song/cloud/download?id=123456789`
**调用例子 :** `/song/cloud/download?id=123456789`'
### 获取广告
说明 : 调用此接口, 可获取广告
**接口地址 :** `/ad/get`
**调用例子 :** `/ad/get`
### 看广告领取权益(免费听歌时长 / 云贝等)
说明 : 登录后调用此接口, 领取广告权益。权益类型由广告平台下发的配置决定, 不仅限于 30 分钟免费听歌时长, 还包括"看视频获得最高 2000 云贝"等拉新分段权益(`rightsGainMethod=6`)。除下方常用参数外, 权益类型/时长/扩展权益等其余字段会自动从广告下发配置补齐, 无需手动传入。
!> 警告: 通过调取接口出现的任何问题由调用者自行承担
**可选参数 :**
`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`
### 获取免费听时长状态
说明 : 登录后调用此接口, 获取免费听剩余时长
**接口地址 :** `/ad/listening/rights`
**调用例子 :** `/ad/listening/rights`
### 云小编 - 获取用户详情
说明: 登录后调用此接口, 获取云小编用户详情
**接口地址:** `/rep/ugc/user/get`
**调用例子:** `/rep/ugc/user/get`
### 云小编 - 每日签到
说明: 登录后调用此接口, 进行云小编签到, 领取 5 积分, 签到后 `/rep/ugc/user/get` 返回 `data.signed = 1`
**接口地址:** `/rep/ugc/user/sign`
**调用例子:** `/rep/ugc/user/sign`
### 云小编 - 查询会员任务状态
说明: 登录后调用此接口, 查询云小编会员任务状态, 当 `data.status = 20` 时, 可调用 `/rep/ugc/user/collect-vip` 领取会员
**接口地址:** `/rep/ugc/user/vip`
**调用例子:** `/rep/ugc/user/vip`
### 云小编 - 活动信息
说明: 登录后调用此接口, 查询云小编会员活动信息
**接口地址:** `/rep/ugc/activity/get`
**调用例子:** `/rep/ugc/activity/get`
### 云小编 - 获取任务
> 注意: 调用前请先使用官方客户端完成云小编“情绪标签审核”入站考试
**可选参数:**
`type`: 任务类型, 1: 歌曲曲风审核, 2: 歌曲语种审核, 3: 歌曲原唱审核, 4: 情绪标签审核, 默认 `4`
**接口地址:** `/thinktank/audit/resource/detail`
**调用例子:** `/thinktank/audit/resource/detail?type=4`
### 云小编 - 提交任务
> 注意: 投票结果会在后台审核, 一致 +3 积分, 不一致 -2 积分
**可选参数:**
`type`: 任务类型, 1: 歌曲曲风审核, 2: 歌曲语种审核, 3: 歌曲原唱审核, 4: 情绪标签审核, 默认 `4`
**必选参数:**
`taskId`: 任务 ID, 调用 `/thinktank/audit/resource/detail` 获取 `data.taskId`
`judgement`: 审核结果, 1: 同意, 2: 否决, 3: 跳过 (不算次数)
**接口地址:** `/thinktank/audit/resource/update`
**调用例子:** `/thinktank/audit/resource/update?type=4&taskId=123456&judgement=1`
### 云小编 - 领取任务积分
说明: 完成任务后调用此接口, 领取云小编任务积分
**可选参数:**
`activityId`: 活动 ID, 调用 `/rep/ugc/activity/get` 获取, 默认 `5001`
**接口地址:** `/rep/ugc/activity/collect`
**调用例子:** `/rep/ugc/activity/collect?activityId=5001`
### 云小编 - 领取一日会员
说明: 达成领取条件 (`/rep/ugc/user/vip`) 后调用此接口, 领取一日会员
**可选参数:**
`activityId`: 活动 ID, 调用 `/rep/ugc/activity/get` 获取, 默认 `5001`
**接口地址:** `/rep/ugc/user/collect-vip`
**调用例子:** `/rep/ugc/user/collect-vip?activityId=5001`
### 云小编 - 剩余抽奖次数
说明: 登录后调用此接口, 获取今日云小编抽奖剩余次数
**可选参数:**
`activityId`: 活动 ID, 默认 `6501202`
**接口地址:** `/middle/play/lottery/remain/chance`
**调用例子:** `/middle/play/lottery/remain/chance?activityId=6501202`
### 云小编 - 每日抽奖
说明: 登录后调用此接口, 消耗 200 积分进行抽奖, 每日最多抽 3 次
> 注意: 抽奖失败也消耗每日次数, 请先调用 `/rep/ugc/user/get` 查询可用积分
**可选参数:**
`activityId`: 活动 ID, 默认 `6501202`
`drawCount`: 未知, 默认 `1`
`checkToken`: 易盾反作弊 Token, 默认自动获取
**接口地址:** `/middle/play/do/lottery`
**调用例子:** `/middle/play/do/lottery?activityId=6501202&drawCount=1`
### 发送/删除评论
说明 : 调用此接口,可发送评论或者删除评论
1. 发送评论
**必选参数**
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
```
0: 歌曲
1: mv
2: 歌单
3: 专辑
4: 电台
5: 视频
6: 动态
```
`id`: 对应资源 id
`content`: 要发送的内容
**调用例子** : `/comment/add?type=1&id=5436712&content=test` (往广岛之恋 mv 发送评论: test)
2. 回复评论
**必选参数**
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
```
0: 歌曲
1: mv
2: 歌单
3: 专辑
4: 电台
5: 视频
6: 动态
```
`id`: 对应资源 id
`cid`: 评论 id
`content`: 要发送的内容
**调用例子** : `/comment/add?type=1&id=5436712&cid=1535550516319&content=test` (往广岛之恋 mv 回复test评论: test)
3. 删除评论
**必选参数**
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
```
0: 歌曲
1: mv
2: 歌单
3: 专辑
4: 电台节目
5: 视频
6: 动态
7: 电台
```
`id`: 对应资源 id
`cid`: 评论 id
**调用例子** : `/comment?type=1&id=5436712&cid=1535550516319` (在广岛之恋 mv 删除评论)
### 获取在线设备列表
说明: 登录后调用此接口, 获取在线设备列表
**接口地址:** `/device/list`
**调用例子:** `/device/list`
### 发送安全验证码
说明: 登录后调用此接口, 传入手机号, 可发送安全验证码
**必选参数 :** `phone`: 手机号
**接口地址 :** `/captcha/safe/sent`
**调用例子 :** `/captcha/safe/sent?phone=13XXXXXXXXX`
### 强制下线设备
说明: 登录后调用此接口, 传入设备 id, 可强制下线设备的登录会话
**必选参数 :** `key`: 设备的 `deviceKey`, 可通过 `/device/list` 获取
`captcha`: 安全验证码, 可通过 `/captcha/safe/sent` 获取
**接口地址 :** `/device/kickoff`
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
### 上报设备中心设备名称
说明: 登录后调用此接口, 传入 `deviceName`, 可修改当前设备在网易云登录设备管理列表里显示的名称
**必选参数 :** `deviceName`: 期望展示的设备名称
`name`: `deviceName` 的兼容别名, 二选一即可
**接口地址 :** `/deviceinfo/center/upload`
**调用例子 :** `/deviceinfo/center/upload?deviceName=XXXXX`
### 插播相似歌曲
说明: 调用此接口, 传入歌曲 id, 可获取插播相似歌曲
**必选参数 :** `id`: 歌曲 id
**接口地址 :** `/song/simi/get`
**调用例子 :** `/song/simi/get?id=39227633`
### 获取用户乐迷团列表
说明 : 登录后调用此接口 , 可获取当前登录用户已加入的全部歌手乐迷团列表
**接口地址 :** `/fans/group/user/groups`
**调用例子 :** `/fans/group/user/groups`
### 获取乐迷团详情
说明 : 调用此接口 , 可获取乐迷团详情
**必选参数 :** `groupId` : 乐迷团 ID
**可选参数 :** `scene` : 场景标识
**接口地址 :** `/fans/group/detail`
**调用例子 :** `/fans/group/detail?groupId=1872529203038486609`
### 获取用户在乐迷团的详情
说明 : 登录后调用此接口 , 可获取当前用户在指定乐迷团的详细成员信息及铭牌等级
**必选参数 :** `groupId` : 乐迷团 ID
**接口地址 :** `/fans/group/user/group/detail`
**调用例子 :** `/fans/group/user/group/detail?groupId=1755777360858267711`
### 获取乐迷团推荐笔记
说明 : 调用此接口 , 可获取指定乐迷团的推荐笔记
**必选参数 :** `fansGroupId` : 乐迷团 ID
**可选参数 :** `cursor` : 游标 , 默认为 0
`size` : 返回数量 , 默认为 10
**接口地址 :** `/fans/group/feed/recommend`
**调用例子 :** `/fans/group/feed/recommend?fansGroupId=1872529203038486609&size=10&cursor=0`
### 每日指定风格歌曲推荐
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐
**接口地址 :** `/recommend/category/songs`
**调用例子 :** `/recommend/category/songs`
### 获取每日指定风格歌曲推荐的风格列表
说明 : 调用此接口 , 可获取每日指定风格歌曲推荐的风格列表
**接口地址 :** `/recommend/category/configs`
**调用例子 :** `/recommend/category/configs`
### 设置每日指定风格歌曲推荐的风格
说明 : 调用此接口 , 可设置每日指定风格歌曲推荐的风格
**必选参数 :**
`tags`: 风格标签, 可通过 `/recommend/category/configs` 获取
`category`: 风格标签的父ID, 可通过 `/recommend/category/configs` 获取
!> 只能设置当前CategoryId下的tags, 不能设置其他CategoryId下的tags, 否则会报错
**接口地址 :** `/recommend/category/config`
**调用例子 :** `/recommend/category/config?tags=10004,10015&categoryId=1000`
## 离线访问此文档
+496
View File
@@ -0,0 +1,496 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>免费听 — 网易云音乐 API Enhanced</title>
<style>
:root {
--fg: #333;
--muted: #666;
--border: #ddd;
--bg: #f5f5f5;
--panel: #ffffff;
--accent: #333;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
.container { max-width: 800px; margin: 40px auto; padding: 0 20px; }
@media (max-width: 480px) {
.container { margin: 20px auto; padding: 0 16px; }
header.site-header h1 { font-size: 22px; }
.block { padding: 16px; }
}
header.site-header { margin-bottom: 24px; }
header.site-header h1 { font-size: 24px; font-weight: 600; margin: 0; }
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
.block h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
.block .desc { margin: 0 0 12px; color: var(--muted); font-size: 13px; }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 200px; overflow: auto; }
pre.data { max-height: 360px; }
.row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
button:hover { background: #eee; }
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
button.primary:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
button.danger { background: #a11; color: #fff; border-color: #a11; }
button.danger:hover { opacity: .85; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); }
.tag.ok { background: #e6f7e6; color: #1a7a1a; border-color: #b7e6b7; }
.tag.no { background: #ffe6e6; color: #a11; border-color: #f5c6c6; }
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
.flex { display: flex; align-items: center; gap: 10px; }
.flex.sb { justify-content: space-between; }
.step { display: flex; gap: 12px; align-items: flex-start; }
.step .num { width: 26px; height: 26px; border-radius: 50%; background: var(--fg); color: var(--panel); display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; flex-shrink: 0; margin-top: 1px; }
.step .num.done { background: #1a7a1a; }
.step .body { flex: 1; min-width: 0; }
input[type=text] { width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); font-size: 14px; font-family: inherit; margin-top: 6px; }
input[type=text]:focus { outline: 2px solid var(--fg); outline-offset: -1px; border-color: transparent; }
footer.site-footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
footer.site-footer a { color: var(--fg); text-decoration: none; }
footer.site-footer a:hover { border-bottom: 1px dotted var(--fg); }
.info-grid { display: grid; grid-template-columns: 80px 1fr; gap: 4px 12px; font-size: 13px; margin-top: 8px; }
.info-grid .lbl { color: var(--muted); }
/* ---- 剩余时长计时器 ---- */
.timer-wrap { text-align: center; padding: 12px 0 4px; }
.timer-row { display: flex; align-items: baseline; justify-content: center; gap: 2px; font-variant-numeric: tabular-nums; }
.timer-num { font-size: 36px; font-weight: 700; letter-spacing: 1px; line-height: 1.2; font-family: 'SF Mono', 'Courier New', monospace; min-width: 2ch; text-align: center; }
.timer-label { font-size: 13px; color: var(--muted); margin-right: 12px; }
.timer-label:last-child { margin-right: 0; }
.timer-sep { font-size: 28px; font-weight: 300; color: #bbb; margin: 0 2px; }
.timer-unit { display: flex; flex-direction: column; align-items: center; }
</style>
</head>
<body>
<main class="container">
<header class="site-header">
<h1>🎧 免费听 · 看广告获取时长</h1>
<p class="sub">模拟 App 内看激励视频广告 → 领取免费听权益的完整流程</p>
</header>
<!-- 步骤 1: 注册 Token -->
<div class="block">
<div class="step">
<div class="num" id="s1num">1</div>
<div class="body">
<div class="flex sb">
<h3>注册反作弊 Token</h3>
<span id="s1tag" class="tag no">未开始</span>
</div>
<p class="desc">调用易盾接口获取实时 token,广告接口需要此 token 才能正常响应</p>
<pre id="s1pre">(尚未执行)</pre>
<div class="row">
<button id="step1" class="primary">① 注册 Token</button>
</div>
</div>
</div>
</div>
<!-- 步骤 2: 获取广告 -->
<div class="block">
<div class="step">
<div class="num" id="s2num">2</div>
<div class="body">
<div class="flex sb">
<h3>获取广告 &amp; 提取 reqId</h3>
<span id="s2tag" class="tag no">未开始</span>
</div>
<p class="desc">请求广告位,从返回的 extJson.contextInfo.req_id 提取用于领取权益的标识</p>
<pre id="s2pre" class="data">(尚未执行)</pre>
<div class="row">
<button id="step2" class="primary" disabled>② 获取广告</button>
<button id="step2b" disabled>换一个广告位</button>
</div>
<div class="info-grid" id="s2info" style="display:none">
<div class="lbl">广告 ID</div><div id="s2adId">-</div>
<div class="lbl">reqId</div><div id="s2reqId">-</div>
<div class="lbl">文案</div><div id="s2text">-</div>
</div>
</div>
</div>
</div>
<!-- 步骤 3: 领取权益 -->
<div class="block">
<div class="step">
<div class="num" id="s3num">3</div>
<div class="body">
<div class="flex sb">
<h3>领取免费听权益</h3>
<span id="s3tag" class="tag no">未开始</span>
</div>
<p class="desc">传入 reqId 及相关参数,领取免费听权益(模拟点击广告后的领取请求)</p>
<pre id="s3pre" class="data">(尚未执行)</pre>
<div class="row">
<button id="step3" class="primary" disabled>③ 领取权益</button>
</div>
<div class="info-grid" id="s3info" style="display:none">
<div class="lbl">结果</div><div id="s3result">-</div>
</div>
</div>
</div>
</div>
<!-- 一键全流程 -->
<div class="block">
<div class="flex sb">
<h3>一键运行</h3>
<span id="allTag" class="tag no">就绪</span>
</div>
<p class="desc" style="margin-top:4px">按顺序自动执行以上三个步骤</p>
<div class="row">
<button id="runAll" class="primary">▶ 一键获取免费听时长</button>
<button id="clearAll" class="danger">✕ 清空</button>
</div>
</div>
<!-- 剩余时长计时器 -->
<div class="block" id="rightsBlock">
<div class="flex sb">
<h3 id="rightsTitleDisplay">🕐 免费听时长剩余</h3>
<span id="rightsTag" class="tag no">未查询</span>
</div>
<p class="desc" id="rightsSubDesc">查询你的免费听权益剩余时长</p>
<div class="timer-wrap">
<div class="timer-row" id="timerRow">
<div class="timer-unit">
<span class="timer-num" id="timerDays">--</span>
<span class="timer-label">天</span>
</div>
<span class="timer-sep">:</span>
<div class="timer-unit">
<span class="timer-num" id="timerHours">--</span>
<span class="timer-label">时</span>
</div>
<span class="timer-sep">:</span>
<div class="timer-unit">
<span class="timer-num" id="timerMinutes">--</span>
<span class="timer-label">分</span>
</div>
<span class="timer-sep">:</span>
<div class="timer-unit">
<span class="timer-num" id="timerSeconds">--</span>
<span class="timer-label">秒</span>
</div>
</div>
</div>
<div class="row" style="justify-content:center">
<button id="queryRightsBtn" class="primary">⟳ 查询剩余时长</button>
<button id="claim30Btn">领取30分钟</button>
</div>
<div class="info-grid" id="rightsInfo" style="display:none">
<div class="lbl">状态</div><div id="rightsStatus">-</div>
<div class="lbl">结束时间</div><div id="rightsEndTimeDisplay">-</div>
<div class="lbl">今日已覆盖</div><div id="rightsCoverToday">-</div>
</div>
</div>
<footer class="site-footer">
<a href="/">← 返回首页</a>
</footer>
</main>
<script>
const $ = (id) => document.getElementById(id)
const base = location.origin
function setTag(id, type, text) {
const el = $(id)
el.className = 'tag ' + type
el.textContent = text
}
function setNum(id, done) {
$(id).className = 'num' + (done ? ' done' : '')
}
function nocache(url) {
const sep = url.indexOf('?') > -1 ? '&' : '?'
return url + sep + '_t=' + Date.now()
}
// ===== 步骤 1: 注册 Token =====
async function step1() {
const btn = $('step1')
btn.disabled = true
setTag('s1tag', 'busy', '注册中…')
setNum('s1num', false)
try {
const res = await fetch(nocache(base + '/register/checktoken/v3?refresh=1'))
const data = await res.json()
$('s1pre').textContent = JSON.stringify(data, null, 2)
if (data.token) {
setTag('s1tag', 'ok', '已注册')
setNum('s1num', true)
$('step2').disabled = false
$('step2b').disabled = false
return data.token
}
setTag('s1tag', 'no', '失败')
return null
} catch (e) {
$('s1pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s1tag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
// ===== 步骤 2: 获取广告 =====
async function step2(typeId) {
const btn = $('step2')
const btnB = $('step2b')
btn.disabled = btnB.disabled = true
setTag('s2tag', 'busy', '获取中…')
setNum('s2num', false)
$('s2info').style.display = 'none'
try {
let url = base + '/ad/get' + (typeId ? '?type_ids=' + encodeURIComponent(typeId) : '')
const res = await fetch(nocache(url))
const data = await res.json()
$('s2pre').textContent = JSON.stringify(data, null, 2)
if (data.code === 200 && data.extra && data.extra.reqId) {
setTag('s2tag', 'ok', '已获取')
setNum('s2num', true)
$('s2info').style.display = 'grid'
const adKeys = data.ads ? Object.keys(data.ads) : []
let adId = '-', text = '-'
if (adKeys.length > 0) {
const ad = data.ads[adKeys[0]]
adId = ad.ad_id || '-'
text = ad.text || '-'
}
$('s2adId').textContent = adId
$('s2reqId').textContent = data.extra.reqId
$('s2text').textContent = text
$('step3').disabled = false
return data.extra.reqId
}
setTag('s2tag', 'no', '未获取到 reqId')
return null
} catch (e) {
$('s2pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s2tag', 'no', e.message)
return null
} finally {
btn.disabled = false
btnB.disabled = false
}
}
$('step2').onclick = () => step2()
$('step2b').onclick = () => step2('["400002_0"]')
// ===== 步骤 3: 领取权益 =====
async function step3() {
const btn = $('step3')
btn.disabled = true
setTag('s3tag', 'busy', '领取中…')
setNum('s3num', false)
$('s3info').style.display = 'none'
try {
const res = await fetch(nocache(base + '/ad/listening/rights/gain'))
const data = await res.json()
$('s3pre').textContent = JSON.stringify(data, null, 2)
if (data.code === 200) {
setTag('s3tag', 'ok', '已领取')
setNum('s3num', true)
$('s3info').style.display = 'grid'
const body = data.data || {}
$('s3result').textContent = JSON.stringify(body).substring(0, 200)
// 领取成功后自动刷新剩余时长
queryRights()
return true
}
setTag('s3tag', 'no', '领取失败')
return false
} catch (e) {
$('s3pre').textContent = JSON.stringify({ error: e.message }, null, 2)
setTag('s3tag', 'no', e.message)
return false
} finally {
btn.disabled = false
}
}
$('step3').onclick = step3
// ===== 一键运行 =====
$('runAll').onclick = async function () {
const btn = this
btn.disabled = true
setTag('allTag', 'busy', '运行中…')
await step1()
await step2()
await step3()
setTag('allTag', 'ok', '完成')
btn.disabled = false
}
// ===== 清空 =====
$('clearAll').onclick = function () {
const steps = ['s1', 's2', 's3']
const tags = { s1: '未开始', s2: '未开始', s3: '未开始' }
steps.forEach(s => {
setTag(s + 'tag', 'no', tags[s])
setNum(s + 'num', false)
$(s + 'pre').textContent = '(尚未执行)'
const info = $(s + 'info')
if (info) info.style.display = 'none'
})
$('step1').disabled = false
$('step2').disabled = true
$('step2b').disabled = true
$('step3').disabled = true
setTag('allTag', 'no', '就绪')
$('runAll').disabled = false
// 重置计时器
stopCountdown()
$('timerDays').textContent = '--'
$('timerHours').textContent = '--'
$('timerMinutes').textContent = '--'
$('timerSeconds').textContent = '--'
setTag('rightsTag', 'no', '未查询')
$('rightsInfo').style.display = 'none'
$('rightsTitleDisplay').textContent = '🕐 免费听时长剩余'
$('rightsSubDesc').textContent = '查询你的免费听权益剩余时长'
$('runAll').disabled = true
}
// ===== 剩余时长计时器 =====
let countdownTimer = null
let remainingMs = 0
/** 毫秒 → { d, h, m, s } */
function parseMs(ms) {
if (ms <= 0) return { d: 0, h: 0, m: 0, s: 0 }
const totalSec = Math.floor(ms / 1000)
return {
d: Math.floor(totalSec / 86400),
h: Math.floor((totalSec % 86400) / 3600),
m: Math.floor((totalSec % 3600) / 60),
s: totalSec % 60
}
}
function renderTimer(ms) {
const t = parseMs(ms)
$('timerDays').textContent = String(t.d).padStart(2, '0')
$('timerHours').textContent = String(t.h).padStart(2, '0')
$('timerMinutes').textContent = String(t.m).padStart(2, '0')
$('timerSeconds').textContent = String(t.s).padStart(2, '0')
}
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startCountdown(ms) {
stopCountdown()
remainingMs = Math.max(0, ms)
renderTimer(remainingMs)
if (remainingMs <= 0) {
setTag('rightsTag', 'no', '已用完')
return
}
countdownTimer = setInterval(() => {
remainingMs = Math.max(0, remainingMs - 1000)
renderTimer(remainingMs)
if (remainingMs <= 0) {
stopCountdown()
setTag('rightsTag', 'no', '已过期')
}
}, 1000)
}
async function queryRights() {
const btn = $('queryRightsBtn')
btn.disabled = true
setTag('rightsTag', 'busy', '查询中…')
try {
const res = await fetch(nocache(base + '/ad/listening/rights'))
const data = await res.json()
if (data.code === 200 && data.data) {
const d = data.data
$('rightsTitleDisplay').textContent = '🕐 ' + (d.title || '免费听时长剩余')
$('rightsSubDesc').textContent = d.vipInfoContent || '查询你的免费听权益剩余时长'
$('rightsInfo').style.display = 'grid'
// 状态
const status = d.status || ''
$('rightsStatus').textContent = status
// 结束时间
if (d.rightsEndTime) {
const endDate = new Date(d.rightsEndTime)
$('rightsEndTimeDisplay').textContent = endDate.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
}
// 今日已覆盖
$('rightsCoverToday').textContent = d.rightsCoverToday ? '是 ✓' : '否'
// 倒计时
if (d.rightsRemainingTime > 0) {
startCountdown(d.rightsRemainingTime)
setTag('rightsTag', 'ok', '已解锁 ✓')
} else {
renderTimer(0)
setTag('rightsTag', 'no', '已用完')
}
// 领取按钮(仅当 step3 可用时启用)
if (d.cardContent && d.cardContent.actionTitle && $('step3').disabled === false) {
$('claim30Btn').disabled = false
$('claim30Btn').textContent = d.cardContent.actionTitle
} else {
$('claim30Btn').disabled = true
}
if (d.rightsUpperLimit) {
$('rightsStatus').textContent += ' (已达上限)'
}
return data.data
}
setTag('rightsTag', 'no', '查询失败')
return null
} catch (e) {
setTag('rightsTag', 'no', e.message)
return null
} finally {
btn.disabled = false
}
}
$('queryRightsBtn').onclick = queryRights
$('claim30Btn').onclick = async function () {
const ok = await step3()
if (ok) await queryRights()
}
// 页面加载后自动查询一次
document.addEventListener('DOMContentLoaded', () => {
setTimeout(queryRights, 300)
})
</script>
</body>
</html>
+7 -1
View File
@@ -95,7 +95,13 @@ curl -s {origin}/search?keywords=网易云</code></pre>
<a href="/api_decrypt.html">API 解密</a> ·
<a href="/listen_together_host.html">一起听示例</a> ·
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
<a href="/avatar_update.html">头像更新示例</a>
<a href="/avatar_update.html">头像更新示例</a> ·
<a href="/scrobble.html">听歌打卡示例</a> ·
<a href="/yidun.html">获取 CheckToken</a> ·
<a href="/free_listen.html">免费听权益</a> ·
<a href="/yunbei_task.html">云贝广告任务</a> ·
<a href="/ugc.html">云小编任务中心</a> ·
<a href="/ugc_lottery.html">云小编每日抽奖</a>
</div>
</section>
+480
View File
@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>听歌打卡 - 网易云音乐 API Enhanced</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 520px;
margin: 40px auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 24px;
text-align: center;
}
.login-link {
display: block;
margin-bottom: 20px;
color: #666;
font-size: 14px;
text-decoration: none;
text-align: center;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
/* ---- Tabs ---- */
.tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
border-bottom: 2px solid #eee;
}
.tab-btn {
flex: 1;
padding: 10px 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-size: 14px;
font-weight: 500;
color: #999;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn:hover {
color: #555;
}
.tab-btn.active {
color: #333;
border-bottom-color: #333;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* ---- Form ---- */
.form-group {
margin-bottom: 16px;
text-align: left;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: #555;
margin-bottom: 6px;
}
label .tag {
display: inline-block;
font-size: 11px;
font-weight: 400;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
label .tag.r {
background: #fee2e2;
color: #991b1b;
}
label .tag.o {
background: #fef3c7;
color: #92400e;
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus {
border-color: #333;
}
textarea {
resize: vertical;
}
.row {
display: flex;
gap: 12px;
}
.row .form-group {
flex: 1;
}
.quick-fill {
margin-top: 5px;
font-size: 12px;
color: #666;
}
.quick-fill span {
color: #0066cc;
cursor: pointer;
margin-right: 8px;
}
.quick-fill span:hover {
text-decoration: underline;
}
.btn {
display: block;
width: 100%;
padding: 12px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
text-align: center;
margin-top: 20px;
}
.btn:hover {
background: #555;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.result {
margin-top: 16px;
padding: 12px 16px;
border-radius: 6px;
font-size: 14px;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
}
.result.success {
background: #d1fae5;
color: #065f46;
}
.result.error {
background: #fee2e2;
color: #991b1b;
}
.result.info {
background: #e0f2fe;
color: #0369a1;
}
.footer-link {
text-align: center;
margin-top: 24px;
font-size: 13px;
}
.footer-link a {
color: #666;
text-decoration: none;
}
.footer-link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>听歌打卡</h1>
<p class="subtitle">同步听歌记录至网易云音乐,增加听歌排行计数</p>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<!-- Cookie (共享) -->
<div class="form-group">
<label for="cookie">Cookie <span class="tag o">可选</span></label>
<textarea id="cookie" placeholder="留空则默认读取本地存储的登录态" rows="2"></textarea>
<div class="quick-fill">
<span onclick="loadLocalCookie()">读取本地 Cookie</span>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="v0" onclick="switchTab('v0')">原版</button>
<button class="tab-btn" data-tab="v1" onclick="switchTab('v1')">V2 (NCBL 加密版)</button>
</div>
<!-- ============ Tab: v0 (原版 /scrobble) ============ -->
<div class="tab-content active" id="tab-v0">
<div class="form-group">
<label>歌曲 ID <span class="tag r">必填</span></label>
<input type="text" id="v0-songId" placeholder="例如: 2756058128" value="2756058128" />
<div class="quick-fill">
示例:
<span onclick="fillV0('2756058128', '288651229')">妖精小姐的魔法邀约</span>
<span onclick="fillV0('2637402867', '251025018')">Echoes of Memoria</span>
<span onclick="fillV0('36307815', '3394198')">Lose Control</span>
</div>
</div>
<div class="form-group">
<label>来源 ID (歌单或专辑 ID) <span class="tag r">必填</span></label>
<input type="text" id="v0-sourceId" placeholder="例如: 2756058128" value="2756058128" />
</div>
<div class="form-group">
<label>播放时间 (秒) <span class="tag o">可选</span></label>
<input type="number" id="v0-time" placeholder="300" value="300" />
</div>
<button class="btn" onclick="submitV0()">立即打卡 (原版)</button>
</div>
<!-- ============ Tab: v1 (NCBL 加密版 /scrobble/v1) ============ -->
<div class="tab-content" id="tab-v1">
<div class="form-group">
<label>歌曲 ID <span class="tag r">必填</span></label>
<input type="text" id="v1-songId" placeholder="例如: 518066366" value="518066366" />
<div class="quick-fill">
示例:
<span onclick="fillV1('518066366', '36780169', '291')">默认示例</span>
<span onclick="fillV1('2756058128', '288651229', '300')">妖精小姐的魔法邀约</span>
<span onclick="fillV1('2637402867', '251025018', '300')">Echoes of Memoria</span>
</div>
</div>
<div class="form-group">
<label>播放时间 (秒) <span class="tag r">必填</span></label>
<input type="number" id="v1-time" placeholder="例如: 291" value="291" />
</div>
<div class="form-group">
<label>来源 ID <span class="tag o">可选</span></label>
<input type="text" id="v1-sourceId" placeholder="歌单或专辑 ID" value="36780169" />
</div>
<div class="row">
<div class="form-group">
<label>歌曲名 <span class="tag o">可选</span></label>
<input type="text" id="v1-name" placeholder="歌曲名称" />
</div>
<div class="form-group">
<label>艺术家 <span class="tag o">可选</span></label>
<input type="text" id="v1-artist" placeholder="歌手名" />
</div>
</div>
<div class="row">
<div class="form-group">
<label>码率 <span class="tag o">可选</span></label>
<input type="number" id="v1-bitrate" placeholder="默认 320" value="320" />
</div>
<div class="form-group">
<label>音质等级 <span class="tag o">可选</span></label>
<input type="text" id="v1-level" placeholder="默认 exhigh" value="exhigh" />
</div>
</div>
<div class="form-group">
<label>歌曲总时长 (秒) <span class="tag o">可选</span></label>
<input type="number" id="v1-total" placeholder="留空则与播放时间相同" />
</div>
<button class="btn" onclick="submitV1()">立即打卡 (NCBL 加密版)</button>
</div>
<!-- Result -->
<div id="result" class="result" style="display: none;"></div>
<div class="footer-link">
<a href="/">返回首页</a>
</div>
</div>
<script src="https://fastly.jsdelivr.net/npm/[email protected]/dist/axios.min.js"></script>
<script>
/* ---- DOM refs ---- */
const cookieInput = document.getElementById('cookie')
const resultDiv = document.getElementById('result')
/* ---- init ---- */
if (localStorage.getItem('cookie')) {
cookieInput.value = localStorage.getItem('cookie')
}
/* ---- Tab switching ---- */
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name))
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name))
hideResult()
}
/* ---- Cookie ---- */
function loadLocalCookie() {
const local = localStorage.getItem('cookie')
if (local) {
cookieInput.value = local
showResult('已成功读取本地 Cookie', 'success')
} else {
showResult('未在本地发现登录 Cookie,请先登录或手动粘贴', 'error')
}
}
/* ---- Quick fill ---- */
function fillV0(id, sourceId) {
document.getElementById('v0-songId').value = id
document.getElementById('v0-sourceId').value = sourceId
}
function fillV1(id, sourceId, time) {
document.getElementById('v1-songId').value = id
document.getElementById('v1-sourceId').value = sourceId
document.getElementById('v1-time').value = time
}
/* ---- Result ---- */
function hideResult() {
resultDiv.style.display = 'none'
}
function showResult(message, type) {
resultDiv.textContent = message
resultDiv.className = 'result ' + type
resultDiv.style.display = 'block'
}
function disableAll(disabled) {
document.querySelectorAll('.btn').forEach(b => b.disabled = disabled)
}
/* ---- Submit: 原版 /scrobble ---- */
async function submitV0() {
const id = document.getElementById('v0-songId').value.trim()
const sourceid = document.getElementById('v0-sourceId').value.trim()
const time = document.getElementById('v0-time').value.trim() || '300'
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
if (!sourceid) { showResult('请输入来源 ID !', 'error'); return }
disableAll(true)
showResult('打卡中,请稍候...', 'info')
try {
const params = { id, sourceid: sourceid || id, time, timestamp: Date.now() }
const cookie = cookieInput.value.trim()
if (cookie) params.cookie = cookie
const res = await axios({ method: 'get', url: '/scrobble', params })
if (res.data.code === 200) {
showResult('✅ 打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
} else {
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
}
} catch (error) {
const errData = error.response ? error.response.data : { error: error.message }
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
} finally {
disableAll(false)
}
}
/* ---- Submit: NCBL 加密版 /scrobble/v1 ---- */
async function submitV1() {
const id = document.getElementById('v1-songId').value.trim()
const time = document.getElementById('v1-time').value.trim()
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
if (!time || isNaN(+time) || +time <= 0) { showResult('请输入有效的播放时长(秒)!', 'error'); return }
disableAll(true)
showResult('打卡中(NCBL 加密),请稍候...', 'info')
try {
const params = {
id,
time,
sourceid: document.getElementById('v1-sourceId').value.trim() || undefined,
name: document.getElementById('v1-name').value.trim() || undefined,
artist: document.getElementById('v1-artist').value.trim() || undefined,
bitrate: document.getElementById('v1-bitrate').value.trim() || undefined,
level: document.getElementById('v1-level').value.trim() || undefined,
total: document.getElementById('v1-total').value.trim() || undefined,
timestamp: Date.now(),
}
const cookie = cookieInput.value.trim()
if (cookie) params.cookie = cookie
// 清理 undefined 参数
Object.keys(params).forEach(k => params[k] === undefined && delete params[k])
const res = await axios({ method: 'get', url: '/scrobble/v1', params })
if (res.data.code === 200) {
showResult('✅ NCBL 加密打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
} else {
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
}
} catch (error) {
const errData = error.response ? error.response.data : { error: error.message }
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
} finally {
disableAll(false)
}
}
</script>
</body>
</html>
+882
View File
@@ -0,0 +1,882 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="docs/netease.png" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="docs/netease.png" />
<title>云小编任务中心 - 网易云音乐 API Enhanced</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 600px;
margin: 40px auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 24px;
text-align: center;
}
.login-link {
display: block;
margin-bottom: 20px;
color: #666;
font-size: 14px;
text-decoration: none;
text-align: center;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
/* ---- Section ---- */
.section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.section:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
/* ---- Form ---- */
.form-group {
margin-bottom: 16px;
text-align: left;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: #555;
margin-bottom: 6px;
}
label .tag {
display: inline-block;
font-size: 11px;
font-weight: 400;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
label .tag.r {
background: #fee2e2;
color: #991b1b;
}
label .tag.o {
background: #fef3c7;
color: #92400e;
}
label .tag.g {
background: #d1fae5;
color: #065f46;
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus {
border-color: #333;
}
textarea {
resize: vertical;
}
.row {
display: flex;
gap: 12px;
}
.row .form-group {
flex: 1;
}
.quick-fill {
margin-top: 5px;
font-size: 12px;
color: #666;
}
.quick-fill span {
color: #0066cc;
cursor: pointer;
margin-right: 8px;
}
.quick-fill span:hover {
text-decoration: underline;
}
.btn {
display: block;
width: 100%;
padding: 12px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
text-align: center;
margin-top: 20px;
}
.btn:hover {
background: #555;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-sm {
display: inline-block;
width: auto;
padding: 6px 16px;
background: #333;
color: white;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
text-align: center;
margin-top: 0;
}
.btn-sm:hover {
background: #555;
}
.btn-sm:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-sm.green {
background: #059669;
}
.btn-sm.green:hover {
background: #047857;
}
.btn-sm.green:disabled {
background: #a7f3d0;
}
.btn-sm.red {
background: #dc2626;
}
.btn-sm.red:hover {
background: #b91c1c;
}
.btn-sm.red:disabled {
background: #fca5a5;
}
.btn-sm.gray {
background: #6b7280;
}
.btn-sm.gray:hover {
background: #4b5563;
}
.btn-sm.gray:disabled {
background: #d1d5db;
}
.result {
margin-top: 16px;
padding: 12px 16px;
border-radius: 6px;
font-size: 14px;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
}
.result.success {
background: #d1fae5;
color: #065f46;
}
.result.error {
background: #fee2e2;
color: #991b1b;
}
.result.info {
background: #e0f2fe;
color: #0369a1;
}
.footer-link {
text-align: center;
margin-top: 24px;
font-size: 13px;
}
.footer-link a {
color: #666;
text-decoration: none;
}
.footer-link a:hover {
text-decoration: underline;
}
/* ---- Info Card ---- */
.info-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #eee;
}
.info-card .avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
background: #e5e7eb;
}
.info-card .info-text {
flex: 1;
font-size: 14px;
color: #333;
}
.info-card .btn-sm {
flex-shrink: 0;
}
#userPoint {
font-size: 14px;
}
/* ---- Song Review ---- */
.song-review {
margin-top: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #eee;
}
.song-review .cover {
width: 100px;
height: 100px;
border-radius: 6px;
object-fit: cover;
background: #e5e7eb;
display: block;
margin-bottom: 12px;
}
.song-review .song-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.song-review audio {
width: 100%;
margin-bottom: 8px;
}
.song-review .song-tag {
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.song-review .btn-group {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.song-review .lyric {
font-size: 13px;
color: #555;
background: #fff;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #e5e7eb;
white-space: pre-wrap;
max-height: 160px;
overflow-y: auto;
font-family: monospace;
line-height: 1.6;
}
.status-text {
font-size: 14px;
color: #888;
}
</style>
</head>
<body>
<div class="container">
<h1>云小编任务中心</h1>
<p class="subtitle">管理云音乐审核任务、签到与会员权益领取</p>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<!-- Cookie -->
<div class="form-group">
<label for="cookie">Cookie <span class="tag o">可选</span></label>
<textarea id="cookie" placeholder="留空则默认读取本地存储的登录态" rows="2"></textarea>
<div class="quick-fill">
<span onclick="loadLocalCookie()">读取本地 Cookie</span>
<span onclick="clearCookie()">清除 Cookie</span>
<span onclick="saveCookie()">保存 Cookie</span>
<span onclick="loadAll()">一键载入</span>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="section-title">用户信息</span>
<button class="btn-sm" id="userGet">获取用户详情</button>
</div>
<div class="info-card" id="user">
<img src="docs/netease.png" alt="头像" class="avatar" id="userAvatar" />
<span class="info-text" id="userName">未登录</span>
<span id="userPoint">0</span>
<button class="btn-sm green" id="userSign" disabled>签到</button>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="section-title">会员领取</span>
<button class="btn-sm" id="vipGet">获取会员详情</button>
</div>
<div class="info-card" id="vip">
<span class="info-text" id="vipStatus">未载入</span>
<button class="btn-sm" id="vipCollect" disabled>领取</button>
</div>
</div>
<div class="section">
<div class="section-header">
<span class="section-title">审核任务</span>
<button class="btn-sm" id="taskGet">获取任务详情</button>
</div>
<div class="info-card" id="task">
<span class="info-text" id="taskInfo">[<span id="taskCount">0</span>/<span id="taskGoal">0</span>] 审核歌曲赚贡献分 (+<span id="taskPoint">0</span>)</span>
<button class="btn-sm" id="taskStart" disabled>去完成</button>
</div>
<!-- Song Review -->
<div class="song-review" id="song" style="display: none;">
<img alt="封面" class="cover" id="songCover" />
<div class="song-title" id="songTitle">未载入</div>
<audio controls id="songAudio"></audio>
<div class="song-tag" id="songTag"></div>
<div class="btn-group">
<button class="btn-sm green" id="songApprove" disabled>同意</button>
<button class="btn-sm red" id="songReject" disabled>否决</button>
<button class="btn-sm gray" id="songSkip" disabled>跳过 (不计次数)</button>
</div>
<div class="lyric" id="songLyric">[00:00.00] 暂无歌词</div>
</div>
</div>
<div id="result" class="result" style="display: none;"></div>
<div class="footer-link">
<a href="/">返回首页</a>
</div>
</div>
<script>
(function () {
var k_cookie = 'ncm_cookie';
var cookie = localStorage.getItem(k_cookie) || '';
/* ---- DOM refs ---- */
var cookieInput = document.getElementById('cookie');
var resultDiv = document.getElementById('result');
/* ---- Init ---- */
if (cookie) {
cookieInput.value = cookie;
}
/* ---- Cookie ---- */
window.loadLocalCookie = function () {
var local = localStorage.getItem(k_cookie);
if (local) {
cookie = local;
cookieInput.value = local;
showResult('已成功读取本地 Cookie', 'success');
} else {
showResult('未在本地发现登录 Cookie,请先登录或手动粘贴', 'error');
}
};
window.clearCookie = function () {
cookie = '';
cookieInput.value = '';
localStorage.removeItem(k_cookie);
showResult('已清除本地 Cookie', 'info');
};
window.saveCookie = function () {
var ck = getCookie()
if (ck) localStorage.setItem(k_cookie, ck)
showResult('已保存本地 Cookie', 'info');
}
/* ---- Result ---- */
function hideResult() {
resultDiv.style.display = 'none';
}
function showResult(message, type) {
resultDiv.textContent = message;
resultDiv.className = 'result ' + type;
resultDiv.style.display = 'block';
}
function getCookie() {
var ck = cookieInput.value.trim();
if (ck) cookie = ck;
return ck || cookie || '';
}
/**
* 发起请求 (兼容旧版)
* @param {string} url
* @param {Record<string,string>} params
* @param {(resp:Record<string,any>)=>void} done
*/
function request(url, params, done) {
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 1e4;
xhr.onload = function () {
var r = xhr.response;
if (r.code !== 200) {
showResult('错误 ' + r.code + ': ' + (r.message || '未知错误'), 'error');
} else {
done(r);
}
};
xhr.onerror = function () {
showResult('网络请求失败: ' + xhr.statusText, 'error');
};
var ck = getCookie();
if (ck) params.cookie = ck;
var s = '?_=' + Date.now();
for (var k in params) {
s += '&' + k + '=' + encodeURIComponent(params[k]);
}
xhr.open('GET', url + s);
xhr.send();
}
/// 用户信息 ///
var userAvatar = document.getElementById('userAvatar');
var userName = document.getElementById('userName');
var userPoint = document.getElementById('userPoint');
var userSign = document.getElementById('userSign');
var userGet = document.getElementById('userGet');
userGet.onclick = function () {
request('rep/ugc/user/get', {}, function (res) {
var d = res.data;
if (d.imgUrl) userAvatar.src = d.imgUrl + '?param=50y50';
userName.textContent = d.name || '游客' + d.id;
userPoint.textContent = d.availablePoints;
userSign.disabled = d.signed;
showResult('用户信息加载成功', 'success');
});
};
userSign.onclick = function () {
if (!userSign.disabled) {
userSign.disabled = true;
request('rep/ugc/user/sign', {}, function (res) {
var d = res.data;
userSign.disabled = d;
showResult('签到' + (d ? '成功 ✓' : '失败 ✗'), d ? 'success' : 'error');
if (d) userGet.click();
});
}
};
/// 会员领取 ///
var vipStatus = document.getElementById('vipStatus');
var vipCollect = document.getElementById('vipCollect');
var vipGet = document.getElementById('vipGet');
vipGet.onclick = function () {
request('rep/ugc/user/vip', {}, function (res) {
var d = res.data;
vipStatus.textContent = d.status + ': ' + d.title;
vipCollect.disabled = d.status !== 20;
showResult('会员详情加载成功', 'success');
});
};
vipCollect.onclick = function () {
if (!vipCollect.disabled) {
vipCollect.disabled = true;
request('rep/ugc/user/collect-vip', {}, function (res) {
var d = res.data;
vipCollect.disabled = d;
showResult('领取' + (d ? '成功 ✓' : '失败 ✗'), d ? 'success' : 'error');
});
}
};
/// 审核任务 ///
var taskCount = document.getElementById('taskCount');
var taskGoal = document.getElementById('taskGoal');
var taskPoint = document.getElementById('taskPoint');
var taskStartBtn = document.getElementById('taskStart');
var taskGetBtn = document.getElementById('taskGet');
taskGetBtn.onclick = function () {
request('rep/ugc/activity/get', {}, function (res) {
var d = res.data;
taskCount.textContent = d.count;
taskGoal.textContent = d.goalCount;
taskPoint.textContent = d.points;
// 注:超过 100 可能有 bug,导致 thinktank/audit/resource/detail 获取的任务 ID 一直是 5002,目标数量也不对,暂时限制前两个任务
taskStartBtn.disabled = d.activityId !== 5001 && d.activityId !== 5002; // 5001: 1-50, 5002: 51-100
showResult('任务详情加载成功', 'success');
// 任务完成,领取积分
if (d.count >= d.goalCount) {
request('rep/ugc/activity/collect', { activityId: d.activityId }, function (res) {
var d = res.data;
showResult('积分领取' + (d ? '成功,可以兑换会员了!' : '失败'), d ? 'success' : 'error');
// 刷新任务状态
if (d) loadAll();
});
}
});
};
taskStartBtn.onclick = function () {
if (!taskStartBtn.disabled) {
songAudio.pause();
examInit();
}
};
/// 歌曲审核区域 ///
var song = document.getElementById('song');
var songCover = document.getElementById('songCover');
var songTitle = document.getElementById('songTitle');
var songAudio = document.getElementById('songAudio');
var songTag = document.getElementById('songTag');
var songApprove = document.getElementById('songApprove');
var songReject = document.getElementById('songReject');
var songSkip = document.getElementById('songSkip');
var songLyric = document.getElementById('songLyric');
var examType = 'emotionEnter';
// 考试初始化、检查状态
function examInit() {
request('rep/ugc/exam/info/get', { examType: examType }, function (res) {
var d = res.data;
switch (d.process) {
case 'whole_exam_end':
// 通过考试
if (d.hasPassExamination) {
taskStart();
return;
}
request('rep/ugc/exam/result/get', { examType: examType, taskId: d.taskId }, function (res) {
var d = res.data;
showResult('考试未通过:正确数:' + d.wrightNum + ',正确率:' + d.wrightRate, 'error');
});
case 'no':
// 未通过或未开始
request('rep/ugc/exam/start', { examType: examType }, function (res) {
examStart(res.data.taskId);
});
break;
case 'process':
// 正在考试
examStart(d.taskId);
break;
default:
showResult('未知考试进度:' + d.process, 'error');
return;
}
// 在上个信息不为异常时显示考试中提示
if (d.process !== 'whole_exam_end' && !resultDiv.className.endsWith('error')) showResult('正在考试,请认真审题!', 'info');
});
}
// 考试取题开始
function examStart(taskId) {
request('rep/ugc/exam/question/single/get', { examType: examType, taskId: taskId }, function (res) {
var d = res.data;
songCover.src = d.coverUrl + '?param=100y100';
songTitle.textContent = '[' + d.resId + '] ' + d.resName + ' - ' + d.artists;
songTag.textContent = '[' + (d.auditCount + 1) + '/' + d.auditTaskCount + '] 当前结果: ' + d.questionContent + '?';
songLyric.textContent = d.lyric ? d.transLyric ? lrctran(d.lyric, d.transLyric) : d.lyric : '[00:00.00] 暂无歌词';
songLyric.scrollTop = 0;
// 有时候就是没有播放链接,只能根据歌词硬审
if (d.songUrl) {
songAudio.src = d.songUrl;
songAudio.play().catch(function () { /* 自动播放被浏览器阻止 */ });
} else {
songAudio.src = ''; // 清理播放器
}
songApprove.onclick = function () { if (!songApprove.disabled) examSubmit(taskId, d.questionId, 'A') };
songReject.onclick = function () { if (!songReject.disabled) examSubmit(taskId, d.questionId, 'B') };
songSkip.onclick = null;
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
song.style.display = 'block';
setTimeout(function () {
songApprove.disabled = songReject.disabled = false;
}, 3e3);
});
}
// 考试提交
function examSubmit(taskId, questionId, answer) {
songAudio.pause();
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
request(
'rep/ugc/exam/submit',
{ examType: examType, taskId: taskId, questionId: questionId, answer: answer },
function (res) {
var d = res.data;
if (d.result) {
showResult('回答正确,加载下一首...', 'success');
} else {
showResult('回答错误:' + d.analysis, 'error');
}
examInit();
},
);
}
/**
* 获取任务
*/
function taskStart() {
request('thinktank/audit/resource/detail', { type: '4' }, function (res) {
var d = res.data;
if (d.auditCount >= 50) {
request('rep/ugc/activity/collect', { activityId: '5001' }, function (res) {
var d = res.data;
showResult('积分领取' + (d ? '成功,可以兑换会员了!' : '失败'), d ? 'success' : 'error');
// 刷新任务状态
if (d) {
vipGet.click();
taskGetBtn.click();
}
});
songAudio.pause()
return;
}
songCover.src = d.coverUrl + '?param=100y100';
songTitle.textContent = '[' + d.resId + '] ' + d.resName + ' - ' + d.artists;
songTag.textContent = '[' + (d.auditCount + 1) + '/' + d.auditTaskCount + '] 当前结果: ' + d.initResult + '?';
songLyric.textContent = d.lyric ? d.transLyric ? lrctran(d.lyric, d.transLyric) : d.lyric : '[00:00.00] 暂无歌词';
songLyric.scrollTop = 0;
songAudio.src = d.songUrl;
songAudio.play().catch(function () { /* 自动播放被浏览器阻止 */ });
songApprove.onclick = function () { if (!songApprove.disabled) taskSubmit(d.taskId, '1'); };
songReject.onclick = function () { if (!songReject.disabled) taskSubmit(d.taskId, '2'); };
songSkip.onclick = function () { if (!songSkip.disabled) taskSubmit(d.taskId, '3'); };
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
song.style.display = 'block';
setTimeout(function () {
songApprove.disabled = songReject.disabled = songSkip.disabled = false;
}, 3e3);
});
}
/**
* 提交任务
* @param {string} taskId
* @param {'1'|'2'|'3'} judgement
*/
function taskSubmit(taskId, judgement) {
songAudio.pause();
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
request(
'thinktank/audit/resource/update',
{ type: '4', taskId: taskId, judgement: judgement },
function (res) {
var d = res.data;
if (d.result) {
showResult((d.test ? '抽查' : '审核') + '提交成功,加载下一首...', 'success');
taskStart();
} else if (d.test) {
showResult('抽查失败: [' + d.dayErrorNum + '/' + d.dayErrorNumStandard + '] ' + d.errorAnalysis, 'error');
taskStart();
} else {
showResult('未知状态: ' + JSON.stringify(d), 'error');
}
},
);
}
/**解析歌词时间戳和文本 */
function lrctrim(lyrics) {
var lines = lyrics.split('\n');
var data = [];
lines.forEach(function (line, index) {
var matches = line.match(/\[(\d{2}):(\d{2}[\.:]?\d*)\]/);
if (matches) {
var minutes = parseInt(matches[1], 10);
var seconds = parseFloat(matches[2].replace('.', ':')) || 0;
var timestamp = minutes * 6e4 + seconds * 1e3;
var text = line.replace(/\[\d{2}:\d{2}[\.:]?\d*\]/g, '').trim();
text = text.replace(/\s\s+/g, ' '); // Replace multiple spaces with a single space
data.push([timestamp, index, text]);
}
});
data.sort(function (a, b) {
return a[0] - b[0];
});
return data;
}
/**合并原文歌词和翻译歌词 */
function lrctran(lyric, tlyric) {
lyric = lrctrim(lyric);
tlyric = lrctrim(tlyric);
var len1 = lyric.length;
var len2 = tlyric.length;
var result = '';
for (var i = 0, j = 0; i < len1 && j < len2; i++) {
while (lyric[i][0] > tlyric[j][0] && j + 1 < len2) {
j++;
}
if (lyric[i][0] === tlyric[j][0]) {
tlyric[j][2] = tlyric[j][2].replace('/', '');
if (tlyric[j][2]) {
lyric[i][2] += ' (' + tlyric[j][2] + ')';
}
j++;
}
}
for (var k = 0; k < len1; k++) {
var t = lyric[k][0];
result += '['.concat(
String(Math.floor(t / 6e4)).padStart(2, '0'),
':',
String(Math.floor((t % 6e4) / 1e3)).padStart(2, '0'),
'.',
String(t % 1e3).padStart(3, '0'),
']',
lyric[k][2],
'\n',
);
}
return result;
}
/// 一键载入 ///
window.loadAll = function () {
userGet.click();
vipGet.click();
taskGetBtn.click();
showResult('正在载入所有数据...', 'info');
};
window.loadLocalCookie();
})();
</script>
</body>
</html>
+670
View File
@@ -0,0 +1,670 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="docs/netease.png" />
<title>云小编每日抽奖 - 网易云音乐 API Enhanced</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 520px;
margin: 40px auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 24px;
text-align: center;
}
.subtitle b {
color: #d63031;
}
/* ---- Section ---- */
.section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.section:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
/* ---- Form Elements ---- */
.form-group {
margin-bottom: 16px;
text-align: left;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: #555;
margin-bottom: 6px;
}
label .tag {
display: inline-block;
font-size: 11px;
font-weight: 400;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
label .tag.o {
background: #fef3c7;
color: #92400e;
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus {
border-color: #333;
}
textarea {
resize: vertical;
}
.btn {
display: block;
width: 100%;
padding: 12px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
text-align: center;
margin-top: 16px;
}
.btn:hover {
background: #555;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-sm {
display: inline-block;
width: auto;
padding: 6px 16px;
background: #333;
color: white;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
text-align: center;
margin-top: 0;
}
.btn-sm:hover {
background: #555;
}
.btn-sm:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-sm.gray {
background: #6b7280;
}
.btn-sm.gray:hover {
background: #4b5563;
}
.btn-sm.gray:disabled {
background: #d1d5db;
}
.btn-sm.green {
background: #059669;
}
.btn-sm.green:hover {
background: #047857;
}
.btn-sm.green:disabled {
background: #a7f3d0;
}
.quick-fill {
margin-top: 5px;
font-size: 12px;
color: #666;
}
.quick-fill span {
color: #0066cc;
cursor: pointer;
margin-right: 8px;
}
.quick-fill span:hover {
text-decoration: underline;
}
/* ---- Lottery Display ---- */
.lottery-display {
text-align: center;
padding: 24px 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #eee;
margin-bottom: 16px;
}
.rolling-display {
font-size: 2.4rem;
font-weight: 800;
color: #333;
min-height: 4rem;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 1px;
word-break: break-all;
}
.rolling-display.idle {
color: #999;
font-size: 1.2rem;
font-weight: 500;
}
/* ---- Winner Result ---- */
.winner-display {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
padding: 20px 16px;
margin-top: 16px;
text-align: center;
}
.winner-label {
font-size: 14px;
font-weight: 600;
color: #065f46;
margin-bottom: 8px;
}
.winner-name {
font-size: 2rem;
font-weight: 800;
color: #dc2626;
letter-spacing: 1px;
word-break: break-all;
}
.confetti-row {
margin-top: 8px;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.confetti-emoji {
font-size: 1.6rem;
animation: bounce 0.6s ease infinite alternate;
}
.confetti-emoji:nth-child(2) {
animation-delay: 0.15s;
}
.confetti-emoji:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes bounce {
from {
transform: translateY(0);
}
to {
transform: translateY(-8px);
}
}
/* ---- Status ---- */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 6px;
margin-top: 16px;
font-size: 13px;
color: #666;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #059669;
flex-shrink: 0;
animation: pulse 1.5s ease infinite;
}
.status-dot.error {
background: #dc2626;
}
.status-dot.idle {
background: #9ca3af;
animation: none;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.status-text {
flex: 1;
}
/* ---- Footer ---- */
.footer-link {
text-align: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #eee;
font-size: 13px;
}
.footer-link a {
color: #666;
text-decoration: none;
}
.footer-link a:hover {
text-decoration: underline;
}
/* ---- Responsive ---- */
@media (max-width: 500px) {
.container {
padding: 20px 16px;
margin: 20px auto;
}
h1 {
font-size: 20px;
}
.rolling-display {
font-size: 1.8rem;
min-height: 3rem;
}
.winner-name {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>云小编每日抽奖</h1>
<p class="subtitle">每次抽奖消耗 <b>200 个曲库编辑部积分</b>,每日最多可抽奖 3 次</p>
<!-- Cookie 管理 -->
<div class="form-group">
<label for="cookieInput">令牌 <span class="tag o">可选</span></label>
<input type="text" id="cookieInput" placeholder="留空则默认读取本地存储的令牌" autocomplete="off" />
<div class="quick-fill">
<span onclick="readLocalCookie()">读取本地令牌</span>
<span onclick="clearCookie()">清除令牌</span>
<span onclick="saveCookie()">保存令牌</span>
</div>
</div>
<!-- 抽奖区域 -->
<div class="section">
<div class="section-header">
<span class="section-title">每日抽奖</span>
</div>
<div class="lottery-display">
<div class="rolling-display idle" id="rollingDisplay">等待抽奖</div>
<div id="resultContainer"></div>
</div>
<button class="btn" id="lotteryBtn">
🎲 开始抽奖
</button>
<div class="status-bar" id="statusBar">
<span class="status-dot idle" id="statusDot"></span>
<span class="status-text" id="statusText">就绪,点击按钮开始抽奖</span>
</div>
</div>
<!-- 页脚 -->
<div class="footer-link">
<a href="/">← 返回首页</a>
<span style="margin: 0 8px; color: #ddd;">·</span>
<a href="/ugc.html">云小编任务中心</a>
</div>
</div>
<script>
(function () {
// ============ DOM 元素 ============
const cookieInput = document.getElementById('cookieInput');
const rollingDisplay = document.getElementById('rollingDisplay');
const resultContainer = document.getElementById('resultContainer');
const lotteryBtn = document.getElementById('lotteryBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
// ============ 状态 ============
let isDrawing = false;
let rollingTimer = null;
// ============ 状态栏更新 ============
function setStatus(type, message) {
statusText.textContent = message;
statusDot.className = 'status-dot';
if (type === 'error') {
statusDot.classList.add('error');
} else if (type === 'info') {
statusDot.style.background = '#059669';
} else {
statusDot.classList.add('idle');
}
}
// ============ 重置结果 ============
function resetResult() {
resultContainer.innerHTML = '';
rollingDisplay.textContent = '等待抽奖';
rollingDisplay.className = 'rolling-display idle';
setStatus('idle', '就绪,点击按钮开始抽奖');
}
// ============ 本地令牌管理 ============
const cookieKey = 'ncm_cookie';
let cookie = localStorage.getItem(cookieKey) || '';
if (cookie) {
cookieInput.value = cookie;
}
window.readLocalCookie = function () {
const local = localStorage.getItem(cookieKey);
if (local) {
cookie = local;
cookieInput.value = local;
setStatus('info', '已读取本地令牌');
} else {
setStatus('error', '未在本地发现令牌,请手动输入');
}
};
window.clearCookie = function () {
cookie = '';
cookieInput.value = '';
localStorage.removeItem(cookieKey);
setStatus('info', '已清除本地令牌');
};
window.saveCookie = function () {
const ck = cookieInput.value.trim();
if (ck) {
cookie = ck;
localStorage.setItem(cookieKey, ck);
setStatus('info', '已保存本地令牌');
} else {
setStatus('error', '令牌内容为空,未保存');
}
};
function getCookie() {
const ck = cookieInput.value.trim();
if (ck) cookie = ck;
return ck || cookie || '';
}
// ============ 调用后端接口 ============
async function request(url, params) {
try {
url += '?timestamp=' + Date.now() + params;
const ck = getCookie();
if (ck) url += '&cookie=' + encodeURIComponent(ck);
const response = await fetch(url);
let res;
try {
res = await response.json();
} catch (error) {
console.error('数据解析错误:', error);
throw new Error('接口请求错误: ' + response.status + ': ' + response.statusText);
}
if (res.code !== 200) {
throw new Error('接口返回错误: ' + res.code + ': ' + (res.message || '未知错误'));
}
return res;
} catch (error) {
console.error('接口请求失败:', error);
throw error;
}
}
async function fetchLotteryResult() {
const user = await request('rep/ugc/user/get', '');
if ((user.data?.availablePoints ?? 0) < 200) {
throw new Error('用户剩余积分不足,请先完成任务获取积分');
}
const chance = await request('middle/play/lottery/remain/chance', '&activityId=6501202');
if (chance.data === 0) {
throw new Error('今日机会已用完,明天再来吧');
}
const res = await request('middle/play/do/lottery', '&activityId=6501202&drawCount=1');
const winner = res.data?.prizeDetailInfoMap?.['8186002']?.prizeName;
if (!winner) {
throw new Error('无法解析响应数据');
}
return [winner, res.data.restChance];
}
// ============ 滚动动画 ============
function startRollingAnimation(finalWinner) {
const placeholderNames = ['🎰', '🎲', '🎯', '✨', '💫', '🌟', '🎪', '🎭'];
const totalDuration = 2800;
const startInterval = 50;
const endInterval = 280;
const startTime = Date.now();
rollingDisplay.className = 'rolling-display';
resultContainer.innerHTML = '';
function roll() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / totalDuration, 1.0);
const easeOutProgress = 1 - Math.pow(1 - progress, 3);
const currentInterval = startInterval + (endInterval - startInterval) * easeOutProgress;
const randomPlaceholder = placeholderNames[Math.floor(Math.random() * placeholderNames.length)];
rollingDisplay.textContent = randomPlaceholder;
if (progress >= 1.0) {
clearTimeout(rollingTimer);
rollingDisplay.textContent = finalWinner;
showWinnerResult(finalWinner);
finishDrawing();
return;
}
rollingTimer = setTimeout(roll, currentInterval);
}
roll();
}
function showWinnerResult(winner) {
resultContainer.innerHTML =
'<div class="winner-display">' +
'<div class="winner-label">🏆 恭喜中奖</div>' +
'<div class="winner-name">' + escapeHTML(winner) + '</div>' +
'<div class="confetti-row">' +
'<span class="confetti-emoji">🎉</span>' +
'<span class="confetti-emoji">🎊</span>' +
'<span class="confetti-emoji">✨</span>' +
'</div>' +
'</div>';
}
function escapeHTML(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function finishDrawing() {
isDrawing = false;
lotteryBtn.disabled = false;
}
// ============ 主抽奖流程 ============
async function startLottery() {
if (isDrawing) return;
isDrawing = true;
lotteryBtn.disabled = true;
resultContainer.innerHTML = '';
rollingDisplay.className = 'rolling-display';
rollingDisplay.textContent = '🎰';
setStatus('info', '正在获取抽奖结果...');
try {
const res = await fetchLotteryResult();
setStatus('info', '抽奖成功,剩余次数 ' + res[1]);
startRollingAnimation(res[0]);
} catch (error) {
rollingDisplay.textContent = '抽奖失败';
rollingDisplay.className = 'rolling-display idle';
setStatus('error', '抽奖失败: ' + error.message);
isDrawing = false;
lotteryBtn.disabled = false;
}
}
// ============ 事件绑定 ============
lotteryBtn.addEventListener('click', startLottery);
document.addEventListener('keydown', function (e) {
if (e.key === ' ' && document.activeElement !== cookieInput) {
e.preventDefault();
if (!isDrawing && !lotteryBtn.disabled) {
startLottery();
}
}
});
// 回车键在输入框中触发保存
cookieInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
saveCookie();
}
});
// ============ 初始化 ============
resetResult();
})();
</script>
</body>
</html>
+28 -13
View File
@@ -217,8 +217,13 @@
</div>
<div class="form-group">
<label>选择文件</label>
<input type="file" name="songFile" accept="audio/*" />
<label for="songFile">选择声音文件</label>
<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>
<button class="btn" @click="submit">上传</button>
@@ -251,29 +256,36 @@
methods: {
submit() {
console.info('submit')
const file = document.querySelector('input[type=file]').files[0]
if (!file) {
alert('请选择文件')
const songFile = document.querySelector('input[name=songFile]').files[0]
const imgFile = document.querySelector('input[name=imgFile]').files[0]
if (!songFile) {
alert('请选择声音文件')
return
}
this.upload(file)
this.upload(songFile, imgFile)
},
async getData() {
this.loading = true
try {
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)
this.voicelist = res.data.data.list || []
this.voicelist = res.data.data.data || []
this.voicelist.forEach(async (i) => {
try {
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)
} catch (err) {
console.error('获取播客详情失败:', err)
@@ -286,14 +298,17 @@
}
},
upload(file) {
upload(songFile, imgFile) {
if (!this.currentVoice) {
alert('请先选择播客列表')
return
}
var formData = new FormData()
formData.append('songFile', file)
formData.append('songFile', songFile)
if (imgFile) {
formData.append('imgFile', imgFile)
}
axios({
method: 'post',
@@ -312,7 +327,7 @@
data: formData,
})
.then((res) => {
alert(`${file.name} 上传成功`)
alert(`${songFile.name} 上传成功`)
})
.catch((err) => {
console.error('上传失败:', err)
+231
View File
@@ -0,0 +1,231 @@
<!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>易盾 Watchman 调试器 — 网易云音乐 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: 760px; 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 12px; font-size: 15px; font-weight: 600; }
.field { margin-bottom: 10px; }
.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:hover { background: #eee; }
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
button.primary:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
.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; }
.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; }
</style>
</head>
<body>
<main class="container">
<header class="site-header">
<h1>🛡️ 易盾 Watchman 调试器</h1>
<p class="sub">
直接调用易盾官方 Web SDK(tool.min.js)获取反作弊 token。
与 <code>module/register_checktoken_v2.js</code> 内部(jsdom 模拟同一套 SDK)逻辑一致,可用于对照验证。
</p>
</header>
<!-- Watchman 初始化 -->
<div class="block">
<div class="flex sb">
<h3>① 初始化 Watchman</h3>
<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 class="row">
<button id="btnInit" class="primary">初始化</button>
<button id="btnReset">重置</button>
</div>
</div>
<!-- 获取 Token -->
<div class="block">
<div class="flex sb">
<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>
<footer class="site-footer">
<a href="/">← 返回首页</a>
</footer>
</main>
<script src="https://acstatic-dun.126.net/tool.min.js"></script>
<script>
const $ = (id) => document.getElementById(id)
const DEFAULTS = {
pn: 'YD00000558929251',
bid: 'bd5d2f973ef74cd2a61325a412ae54d9',
}
let wm = null // Watchman 实例
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>
</body>
</html>
+622
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>
+51
View File
@@ -0,0 +1,51 @@
/**
* Invoke the pkg packager (cross-platform, independent of node_modules layout).
*
* Background: on Windows, pnpm links node_modules/pkg to
* node_modules/.pnpm/pkg@<ver>/node_modules/pkg via a junction. Some CI
* environments (pnpm 11 + Windows runner) may fail to create that link,
* so executing node_modules/pkg/lib-es5/bin.js directly throws
* MODULE_NOT_FOUND.
*
* This script first resolves pkg via require.resolve and falls back to
* scanning the .pnpm physical directory, so pkg is found whether the
* junction exists or not.
*/
const { spawnSync } = require('child_process')
const fs = require('fs')
const path = require('path')
function resolvePkgBin() {
try {
return require.resolve('pkg/lib-es5/bin.js')
} catch (err) {
// require.resolve failed (e.g. missing node_modules/pkg link): scan .pnpm
const pnpmDir = path.join(process.cwd(), 'node_modules', '.pnpm')
if (fs.existsSync(pnpmDir)) {
const candidates = fs
.readdirSync(pnpmDir)
.filter((dir) => dir.startsWith('pkg@'))
.map((dir) =>
path.join(pnpmDir, dir, 'node_modules', 'pkg', 'lib-es5', 'bin.js'),
)
.filter((file) => fs.existsSync(file))
if (candidates.length) {
return candidates[0]
}
}
return null
}
}
const bin = resolvePkgBin()
if (!bin) {
console.error(
'[build-pkg] pkg executable not found; run `pnpm install --frozen-lockfile` first',
)
process.exit(1)
}
const result = spawnSync(process.execPath, [bin, ...process.argv.slice(2)], {
stdio: 'inherit',
})
process.exit(result.status === null ? 1 : result.status)
+34
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
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
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')
})
})
})

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