Compare commits

..
160 Commits
Author SHA1 Message Date
Takanashi Hoshino 1bdc85998b bump 5.0.0 2026-10-17 20:21:42 +08:00
Takanashi Hoshino 49c7800afe build(package): 更换pkg依赖并提升node要求至22 2026-10-17 19:48:29 +08:00
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] <support@github.com>
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] <support@github.com>
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
MoeFurina 60ee927e32 fix: 修复听歌打卡接口 (#167) 2026-06-19 15:11:31 +08:00
MoeFurina eb07a525eb feat: 支持显示加密方式 2026-06-18 23:46:11 +08:00
MoeFurina 6fc4231142 feat(docs-format): 增加文档格式化工具 2026-06-18 23:05:44 +08:00
MoeFurina fa69736300 revert: some changes
This reverts commit c959866436.
2026-06-18 22:52:35 +08:00
MoeFurina 73a5e9d788 Revert "feat: 增加文档格式化工具"
This reverts commit 6550c7c827.
2026-06-18 22:51:34 +08:00
MoeFurina dc6f56e8a8 Revert "fix(deploy): 修复部分启动失败 (#196)"
This reverts commit 395a80e74b.
2026-06-18 22:50:53 +08:00
MoeFurina 2c9da8507d Revert "refactor: 优化serverless入口文件"
This reverts commit 1db354464f.
2026-06-18 22:50:51 +08:00
MoeFurina 1db354464f refactor: 优化serverless入口文件 2026-06-18 22:29:24 +08:00
MoeFurina 395a80e74b fix(deploy): 修复部分启动失败 (#196) 2026-06-18 21:00:06 +08:00
MoeFurina 6550c7c827 feat: 增加文档格式化工具 2026-06-13 23:49:30 +08:00
MoeFurina c959866436 feat: 增强配置生成逻辑,添加重试机制和日志记录 2026-06-13 21:21:33 +08:00
MoeFurina e3a1c041b6 fix: 修复游客登陆接口 2026-06-09 00:09:48 +08:00
MoeFurina e2324e26da Merge remote-tracking branch 'origin/dependabot/github_actions/docker/login-action-4' 2026-06-09 00:09:09 +08:00
MoeFurina f01e770c35 Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/types/node-25.9.1' 2026-06-09 00:08:41 +08:00
MoeFurina abb56ec014 Merge remote-tracking branch 'origin/dependabot/github_actions/aormsby/Fork-Sync-With-Upstream-action-3.4.3' 2026-06-09 00:08:23 +08:00
dependabot[bot] 0300c1b51e build(packages): bump @types/node from 25.5.0 to 25.9.2
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.0 to 25.9.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:42:44 +00:00
MoeFurina 9896242333 bump 4.35.0 2026-06-06 15:40:08 +08:00
MoEFusion e3812be97b Merge pull request #182 from zhuixingzhe-baisheng/feat/add-relay-play-state-submit
feat: add relay_play_state_submit API for song play state tracking
2026-06-06 15:36:27 +08:00
MoeFurina e7ff0da4ba refactor: 自动生成sessionID 2026-06-06 15:33:15 +08:00
MoeFurina 65261fa658 Merge branch 'main' into pr/182 2026-06-06 15:23:37 +08:00
MoEFusionandgemini-code-assist[bot] cb5b2e7d0f Apply suggestions from code review
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-06 15:22:25 +08:00
MoeFurina f58fd4b50d feat: 新增云盘歌曲下载链接接口 (1a5f91a192) 2026-06-06 15:20:06 +08:00
MoeFurina 1a5f91a192 feat: 新增从云盘获取歌曲下载链接接口 2026-06-06 15:17:28 +08:00
MoeFurina 50cc26f297 feat: 为xeapi重构相关接口以优化密钥获取流程 2026-06-06 14:52:33 +08:00
MoeFurina c92613b19e fix: 更新GitHub Actions和Dockerfile中的依赖版本 2026-05-30 20:58:03 +08:00
MoeFurina 7aacc8990b feat: 新增和改进黑胶乐签相关接口 2026-05-30 20:45:24 +08:00
MoeFurina 8e934789a1 ci: 修复Dockerfile的错误配置 2026-05-30 20:17:22 +08:00
MoeFurina d26e6b0615 feat: 新增新版会员任务接口 (#173) 2026-05-30 20:02:00 +08:00
dependabot[bot] a9c33de182 chore(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 10:24:02 +00:00
MoeFurina d0935f6e42 feat: add xeapi support and refactor eapi_decrypt to api_decrypt
- Added xeapi option to the API selection dropdown in api.html.
- Removed eapi_decrypt.html and created a new api_decrypt.html for unified API decryption.
- Implemented multi-crypto support in api_decrypt.html with corresponding UI changes.
- Created decrypt.js module to handle decryption logic for different crypto types including xeapi.
- Updated request.js to remove unnecessary debug logs and improve code clarity.
- Updated index.html to link to the new api_decrypt.html instead of the removed eapi_decrypt.html.
2026-05-30 00:17:24 +08:00
MoeFurina 3e0337ab94 Merge branch 'pr/186' 2026-05-30 00:14:39 +08:00
LuoRain 5cf43c48ef fix: wrong unit for yrc 2026-05-30 00:03:27 +08:00
MoeFurina 68567f4a14 perf: 更新Bug报告模板和配置 2026-05-29 23:24:24 +08:00
MoEFusion d0d71bb4f9 Merge pull request #183 from 1254qwer/feat/xeapi-crypto
feat: 新增 xeapi 算法支持
2026-05-29 22:49:37 +08:00
MoeFurina ec7eff1697 Merge branch 'pr/183' 2026-05-29 22:46:26 +08:00
MoeFurina 1882c3c31e fix workflows 2026-05-29 22:17:16 +08:00
1254qwer f21f5677e8 feat: Add xeapi crypto support 2026-05-29 21:56:27 +08:00
Developer 39e0e0718d feat: add relay_play_state_submit API for song play state tracking
- Add new interface /api/relay/play/state/submit
- Support sessionId tracking for play sessions
- Support playMode parameter (list_loop, single_loop, random, single)
- Add parameter validation
- Include integration documentation
2026-05-28 13:00:21 +00:00
dependabot[bot] 6175d55f89 chore(deps): bump aormsby/Fork-Sync-With-Upstream-action
Bumps [aormsby/Fork-Sync-With-Upstream-action](https://github.com/aormsby/fork-sync-with-upstream-action) from 3.4.2 to 3.4.3.
- [Release notes](https://github.com/aormsby/fork-sync-with-upstream-action/releases)
- [Commits](https://github.com/aormsby/fork-sync-with-upstream-action/compare/v3.4.2...v3.4.3)

---
updated-dependencies:
- dependency-name: aormsby/Fork-Sync-With-Upstream-action
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 14:54:43 +00:00
136 changed files with 12629 additions and 2759 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
+14 -29
View File
@@ -14,17 +14,17 @@ body:
id: terms
attributes:
label: 确认事项
description: 在提交Bug报告前,请确认以下事项
description: |
在提交Bug报告前,请确认以下事项:
- 我已经搜索了现有的issues,确认这不是重复问题
- 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
- 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
- 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
- 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
options:
- label: 我已经搜索了现有的issues,确认这不是重复问题
required: true
- label: 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
required: true
- label: 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
required: true
- label: 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
required: true
- label: 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
- label: 我已确认以上事项
required: true
- type: input
@@ -36,29 +36,14 @@ body:
validations:
required: true
- type: dropdown
id: os
attributes:
label: 操作系统或平台
description: 您在哪个操作系统上遇到了这个问题?
options:
- Windows 10
- Windows 11
- Ubuntu 20.04
- Ubuntu 22.04
- macOS
- 其他 Linux 发行版
- 使用部署平台或其他 (请在描述中说明)
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署平台
description:
label: 部署方式
description: 此项目支持本地部署和云平台部署,您使用的是哪种方式?
options:
- 我使用的自己的服务器部署
- 本机直接运行 (node / pm2)
- 本机 Docker 部署
- Vercel
- Heroku
- Railway
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: ↑请尽量使用议题模板创建议题↑
url: https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/issues/new/choose
about: 选择适合的议题模板可以帮助我们更快地定位和解决问题
- name: 提问的艺术
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
about: 默认所有 Issues 发起者均已了解此处的内容
+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
+8 -7
View File
@@ -14,15 +14,15 @@ jobs:
include:
- os: ubuntu-latest
platform: linux
target: node18-linux-x64
target: node22-linux-x64
output: precompiled/app
- os: windows-latest
platform: win
target: node18-win-x64
target: node22-win-x64
output: precompiled/app.exe
- os: macos-latest
platform: macos
target: node18-macos-x64
target: node22-macos-x64
output: precompiled/app
steps:
@@ -32,12 +32,13 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
- 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: [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 }}
+11 -12
View File
@@ -78,17 +78,17 @@ jobs:
include:
- os: ubuntu-latest
platform: linux
target: node18-linux-x64
target: node22-linux-x64
output: ncm-api-linux-x64
- os: windows-latest
platform: windows
target: node18-win-x64
target: node22-win-x64
output: ncm-api-win-x64.exe
- os: macos-latest
platform: macos
target: node18-macos-x64
target: node22-macos-x64
output: ncm-api-macos-x64
steps:
@@ -136,7 +136,7 @@ jobs:
esac
- name: Upload build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.platform }}-binary
path: release-artifacts/*
@@ -165,17 +165,16 @@ jobs:
fetch-tags: true
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: release-artifacts
merge-multiple: true
- name: Flatten artifacts
- name: List flattened artifacts
shell: bash
run: |
mkdir -p final-artifacts
find release-artifacts -type f -exec cp {} final-artifacts/ \;
cp release-artifacts/* final-artifacts/
ls -lah final-artifacts
- name: Generate release notes
@@ -261,13 +260,13 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -313,4 +312,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm publish --access public
- run: pnpm publish --access public --provenance
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
# Step 2: run the sync action
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.3
with:
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
upstream_sync_branch: main
+2 -1
View File
@@ -9,4 +9,5 @@ bin
anonymous_token
.vercel
.env
IFLOW.md
IFLOW.md
precompiled
+51
View File
@@ -0,0 +1,51 @@
# NeteaseCloudMusicApiEnhanced Agent 说明
## 快速开始
- **包管理器**:开发与 CI 使用 `pnpm`,`Dockerfile` 也用 pnpm(`pnpm@9 --frozen-lockfile`,匹配 `pnpm-lock.yaml`)。仓库没有 `yarn.lock`,不要引入 yarn。
- **Node 版本**:`package.json` 的 `engines` 声明 `>=22`;CI/打包在 Node 22–24 上运行。现代 Node 均可。
- **环境变量**:`server.js` 调用了 `dotenv.config()`,本地 `.env` 会被自动加载;所有支持的变量见 `.env.prod.example`。
## 常用命令
- 安装依赖:`pnpm i`
- 启动服务:`pnpm start`(等价 `node app.js`);热重载开发:`pnpm dev`(nodemon)
- 跑测试:`pnpm test`(Mocha,超时 60s)
- Lint:`pnpm lint`;自动修复:`pnpm lint-fix`
- 文档格式化检查/修复:`pnpm docs:check` / `pnpm docs:format`
- 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos`(@yao-pkg/pkg,node22 目标,`--no-bytecode` + `--public` 规避 node22 bytecode 回归)
## 目录内非路由文件
- `plugins/`——内部上传辅助(`upload.js`、`songUpload.js`),被 `module/*` require,本身不是路由。
- `module_example/`——接口模板与 `main.js` 库调用示例(含 `test.js` 演示 `login_cellphone`→`song_url` 链路),不是运行时代码。
- `examples/get_static_moddef.js`——用 `getModulesDefinitions(..., false)`(`doRequire=false`)把全部路由导出为 `moddef.json`。改路由注册逻辑时可跑它核对。
- `module_types/` 为空目录;类型声明在根目录 `interface.d.ts`(`types` 字段)。
## 架构
- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。
- `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` 白名单;改依赖安装行为时先看它。
-1418
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -2,15 +2,20 @@ FROM node:lts-alpine
RUN apk add --no-cache tini
ENV NODE_ENV production
ENV NODE_ENV=production
RUN npm install -g pnpm@9
USER node
WORKDIR /app
COPY --chown=node:node . ./
RUN yarn --network-timeout=100000
# --prod 模式下 husky 不会被安装,prepare 脚本会因找不到 husky 而失败,
# 故显式跳过生命周期脚本;本项目生产运行也不依赖任何 postinstall 步骤。
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
EXPOSE 3000
CMD [ "/sbin/tini", "--", "node", "app.js" ]
CMD [ "/sbin/tini", "--", "node", "app.js" ]
+9 -5
View File
@@ -30,7 +30,7 @@
## 项目简介
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署(如果原版诈尸, 我会及时同步 or 归档)。
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署
> [!IMPORTANT]
>
@@ -45,14 +45,14 @@
### 环境要求
- Node.js 18 及以上
- Node.js 22 及以上
- 推荐使用 pnpm 进行依赖管理
### 安装
```bash
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git
cd api
cd api-enhanced
pnpm i
```
@@ -163,7 +163,7 @@ docker run -d -p 3000:3000 moefurina/ncm-api:latest
```bash
#!/bin/bash
export PORT=9000
/var/lang/node16/bin/node app.js
/var/lang/node22/bin/node app.js
```
4. 完成部署后,访问 API 网关的 URL 即可
@@ -215,7 +215,7 @@ pnpm test
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
感谢大佬们为逆向eapi, weapi等加密算法所做的贡献
感谢大佬们为逆向eapi, weapi, xeapi等加密算法所做的贡献
项目参考:
@@ -225,6 +225,8 @@ pnpm test
[chaunsin/netease-cloud-music](https://github.com/chaunsin/netease-cloud-music)
[folltoshe/netease-report-listen-song](https://github.com/folltoshe/netease-report-listen-song)
### SDK 生态
| 语言 | 作者 | 地址 | 类型 |
@@ -244,6 +246,8 @@ pnpm test
- [Yueby/music-together](https://github.com/Yueby/music-together)
- [chthollyphlie/folia-major](https://github.com/chthollyphile/folia-major)
## License
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE)
+11
View File
@@ -3,6 +3,17 @@ const fs = require('fs')
const path = require('path')
const tmpPath = require('os').tmpdir()
// engines 字段只在 npm/pnpm 安装时生效,直接 node app.js 不会被拦截,
// 这里给出友好的最低版本检查(>=22,与 package.json engines 保持一致)
const [major] = process.versions.node.split('.').map(Number)
if (major < 22) {
console.error(
`[${new Date().toLocaleString()}] Node.js >= 22 is required (current: ${process.versions.node}). ` +
'请升级 Node.js 后重试 / Please upgrade Node.js to v22 or newer.',
)
process.exit(1)
}
async function start() {
// 检测是否存在 anonymous_token 文件,没有则生成
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
+31
View File
@@ -2,6 +2,9 @@ const fs = require('fs')
const path = require('path')
const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey')
const { loadNeapiKey } = require('./util/neapiKey')
const registerNeapikey = require('./module/register_neapikey')
const tmpPath = require('os').tmpdir()
async function generateConfig() {
@@ -20,5 +23,33 @@ async function generateConfig() {
} catch (error) {
console.log(error)
}
try {
let currentPublicKey = {}
try {
currentPublicKey = JSON.parse(
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
)
} catch (_) {}
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
fs.writeFileSync(
path.resolve(tmpPath, 'xeapi_public_key'),
JSON.stringify(publicKey),
'utf-8',
)
} catch (error) {
console.log(error)
}
try {
const current = loadNeapiKey()
await registerNeapikey(
{
version: current ? current.version : 0,
behavior: current ? 'update' : 'open',
},
null,
)
} catch (error) {
console.log(error)
}
}
module.exports = generateConfig
+1074
View File
File diff suppressed because it is too large Load Diff
+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'),
)
}
+97
View File
@@ -0,0 +1,97 @@
const {
eapiResDecrypt,
eapiReqDecrypt,
aesDecrypt,
xeapiResDecrypt,
} = require('../util/crypto')
const CryptoJS = require('crypto-js')
const linuxapiKey = 'rFgB&h#%2?^eDg:Q'
module.exports = async (query, request) => {
const crypto = query.crypto || 'eapi'
const data = query.data || query.hexString || ''
const isReq = query.isReq !== 'false'
if (!data) {
return {
status: 400,
body: { code: 400, message: 'data is required' },
}
}
try {
let result
switch (crypto) {
case 'eapi': {
const pureHex = data.replace(/\s/g, '')
result = isReq ? eapiReqDecrypt(pureHex) : eapiResDecrypt(pureHex)
break
}
case 'weapi': {
if (isReq) {
return {
status: 400,
body: {
code: 400,
message:
'weapi 请求解密需要 RSA 私钥,暂不支持;仅支持 weapi 返回数据解密(e_r=true 时与 eapi 相同)',
},
}
}
const pureHex = data.replace(/\s/g, '')
result = eapiResDecrypt(pureHex)
break
}
case 'linuxapi': {
if (isReq) {
const pureHex = data.replace(/\s/g, '')
const decrypted = aesDecrypt(pureHex, 'ecb', linuxapiKey, '', 'hex')
result = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8))
} else {
result = typeof data === 'string' ? JSON.parse(data) : data
}
break
}
case 'xeapi': {
if (isReq) {
return {
status: 400,
body: {
code: 400,
message:
'xeapi 请求解密涉及 X25519 ECDH 密钥交换,流程复杂,暂不支持;仅支持 xeapi 返回数据解密',
},
}
}
const buf = Buffer.from(data, 'base64')
result = xeapiResDecrypt(buf)
break
}
case 'api': {
result = typeof data === 'string' ? JSON.parse(data) : data
break
}
default:
return {
status: 400,
body: { code: 400, message: `未知加密方式: ${crypto}` },
}
}
return {
status: 200,
body: { code: 200, data: result },
}
} catch (error) {
return {
status: 400,
body: { code: 400, message: `解密失败: ${error.message}` },
}
}
}
+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
View File
@@ -1,4 +1,5 @@
// 私信和通知接口
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'),
)
}
+3 -1
View File
@@ -1,3 +1,5 @@
// 获取游客cookie
const CryptoJS = require('crypto-js')
const path = require('path')
const fs = require('fs')
@@ -37,7 +39,7 @@ module.exports = async (query, request) => {
let result = await request(
`/api/register/anonimous`,
data,
createOption(query, 'weapi'),
createOption(query, 'xeapi'),
)
if (result.body.code === 200) {
result = {
+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: [],
}
}
+74
View File
@@ -0,0 +1,74 @@
const { default: axios } = require('axios')
const encrypt = require('../util/crypto')
const { APP_CONF } = require('../util/config.json')
const generateNonce = () => {
let nonce = ''
for (let i = 0; i < 16; i++) {
nonce += Math.floor(Math.random() * 10).toString()
}
return nonce
}
module.exports = async (query, request) => {
const nonce = generateNonce()
const timestamp = String(Date.now())
const deviceId = query.deviceId || global.deviceId || ''
const currentKeyVersion = query.currentKeyVersion || ''
const data = {
appVersion: '9.5.61',
currentKeyVersion,
deviceId,
nonce,
os: 'android',
requestType: 'active',
signature: encrypt.xeapiSign(timestamp, nonce),
t1: '',
t2: '',
timestamp,
uid: '',
}
const res = await axios({
method: 'POST',
url: APP_CONF.apiDomain + '/api/bsr/sk/get',
headers: {
'User-Agent':
'NeteaseMusic/9.5.61.260802021928(9005061);Dalvik/2.1.0 (Linux; U; Android 12; HBN-AL00 Build/cd737a2.0)',
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
},
data: new URLSearchParams(data).toString(),
proxy: false,
})
if (
!res.data ||
res.data.code !== 200 ||
!res.data.data ||
!res.data.data.encryptedData
) {
throw new Error('xeapi public key request failed')
}
if (
!res.data.data.signature ||
encrypt.xeapiSign(res.data.data.timestamp, nonce) !==
res.data.data.signature
) {
throw new Error('xeapi public key response signature mismatch')
}
const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData)
if (!publicKey.sk) {
throw new Error('xeapi public key response missing sk')
}
return {
status: 200,
body: {
...publicKey,
deviceId,
},
cookie: [],
}
}
+49
View File
@@ -0,0 +1,49 @@
// 提交歌曲播放状态
const createOption = require('../util/option.js')
const generateSessionId = () =>
Array.from(
{ length: 12 },
() =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[Math.floor(Math.random() * 36)],
).join('')
module.exports = (query, request) => {
const {
id,
sessionId,
progress = 0,
playMode = 'list_loop',
type = 'song',
} = query
if (!id) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数:id',
},
})
}
const playStateSubmitReq = JSON.stringify({
resource: {
id: String(id),
type: type,
},
progress: Number(progress) || 0,
sessionId: sessionId || generateSessionId(),
playMode: playMode,
})
const data = {
playStateSubmitReq: playStateSubmitReq,
}
return request(
'/api/relay/play/state/submit',
data,
createOption(query, 'weapi'),
)
}
+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'))
}
+57 -5
View File
@@ -1,8 +1,42 @@
// 听歌打卡
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
const { APP_CONF } = require('../util/config.json')
const DOMAIN = APP_CONF.clDomian
module.exports = async (query, request) => {
// 注入 os=osx 的 cookie
let cookie = query.cookie || ''
if (typeof cookie === 'object') {
cookie = Object.assign({ os: 'osx' }, cookie)
} else if (typeof cookie === 'string') {
if (cookie.indexOf('os=') > -1) {
cookie = cookie.replace(/os=[^;]+/g, 'os=osx')
} else {
cookie = cookie + '; os=osx'
}
} else {
cookie = 'os=osx'
}
query.cookie = cookie
// 1) startplay → 进「最近播放」
const startplayData = {
logs: JSON.stringify([
{
action: 'startplay',
json: {
id: query.id,
type: 'song',
mainsite: '1',
mainsiteWeb: '1',
content: `id=${query.sourceid}`,
},
},
]),
}
// 2) play → 涨「听歌排行」计数
const playData = {
logs: JSON.stringify([
{
action: 'play',
@@ -15,12 +49,30 @@ module.exports = (query, request) => {
type: 'song',
wifi: 0,
source: 'list',
mainsite: 1,
content: '',
mainsite: '1',
mainsiteWeb: '1',
content: `id=${query.sourceid}`,
},
},
]),
}
return request(`/api/feedback/weblog`, data, createOption(query, 'weapi'))
const option = createOption(query, 'eapi')
option.domain = DOMAIN
// 发送两次请求
const res1 = await request(`/api/feedback/weblog`, startplayData, option)
const res2 = await request(`/api/feedback/weblog`, playData, option)
return {
status: 200,
body: {
code: 200,
data: 'success',
details: {
startplay: res1.body,
play: res2.body,
},
},
}
}
+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'),
)
}
+9
View File
@@ -0,0 +1,9 @@
// 从云盘获取歌曲下载链接
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
songId: query.id,
}
return request(`/api/cloud/dowonload`, data, createOption(query, 'eapi'))
}
+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 -3
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,7 +54,16 @@ module.exports = async (query, request) => {
}
}
if (data.level == 'sky') {
data.immerseType = 'c51'
data.immerseType = query.immerseType || 'c51'
}
return request(`/api/song/enhance/player/url/v1`, data, createOption(query))
if (data.level == 'vivid') {
data.encodeType = 'mp3'
const cookie = options.cookie
options.cookie = {
...(typeof cookie === 'string' ? cookieToJson(cookie) : cookie),
os: 'android',
appver: '9.5.61',
}
}
return request(`/api/song/enhance/player/url/v1`, data, options)
}
+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'),
)
}
+36 -3
View File
@@ -1,7 +1,40 @@
// 黑胶乐签打卡
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(`/api/vip-center-bff/task/sign`, data, createOption(query))
module.exports = async (query, request) => {
const results = {}
const taskSign = await request(
'/api/vip-center-bff/task/sign',
{},
createOption(query, 'weapi'),
)
results.taskSign = taskSign.body
const checkinDetail = await request(
'/api/vipnewcenter/app/level/user/checkin/history/detail',
{
signDayTime: Date.now(),
type: 1,
},
createOption(query, 'eapi'),
)
results.checkinDetail = checkinDetail.body
// 两个接口都返回 code=200 即视为打卡成功
const signed =
Number(results.taskSign?.code) === 200 &&
Number(results.checkinDetail?.code) === 200
return {
body: {
code: 200,
...results,
signed,
message: signed ? '黑胶乐签打卡成功' : '黑胶乐签打卡失败',
},
cookie: taskSign.cookie,
status: 200,
}
}
+14
View File
@@ -0,0 +1,14 @@
// 黑胶乐签打卡详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
signDayTime: query.timestamp,
type: '1',
}
return request(
`/api/vipnewcenter/app/level/user/checkin/history/detail`,
data,
createOption(query, 'eapi'),
)
}
+14
View File
@@ -0,0 +1,14 @@
// 黑胶乐签打卡历史 / 状态查询
// 支持传入 type=0(用户信息栏)或 type=1(黑胶乐签)
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
type: query.type || '0',
}
return request(
`/api/vipnewcenter/app/minidesk/music/sign/pc`,
data,
createOption(query, 'eapi'),
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// 黑胶乐签签到信息
// 黑胶乐签未来签到信息
const createOption = require('../util/option.js')
module.exports = (query, request) => {
+14
View File
@@ -0,0 +1,14 @@
// 会员任务 - 新版
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
taskType: 'app_vip_task_center',
userId: query.id,
}
return request(
`/api/middle/vip/mission/user/progress/list`,
data,
createOption(query, 'xeapi'),
)
}
+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()
+38 -22
View File
@@ -1,7 +1,7 @@
{
"name": "@neteasecloudmusicapienhanced/api",
"version": "4.33.1",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"version": "5.0.0",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced)",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js",
@@ -9,9 +9,11 @@
"lint": "eslint \"**/*.{js,ts}\"",
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
"prepare": "husky install",
"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"
"docs:format": "node scripts/format-docs.js",
"docs:check": "node scripts/format-docs.js --check",
"pkgwin": "node scripts/build-pkg.js . -t node22-win-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public",
"pkglinux": "node scripts/build-pkg.js . -t node22-linux-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public",
"pkgmacos": "node scripts/build-pkg.js . -t node22-macos-x64 -C GZip -o precompiled/app --no-bytecode --public-packages \"*\" --public"
},
"bin": "./app.js",
"pkg": {
@@ -41,8 +43,16 @@
],
"main": "main.js",
"types": "./interface.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git"
},
"bugs": {
"url": "https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/issues"
},
"homepage": "https://neteasecloudmusicapienhanced.js.org/",
"engines": {
"node": ">=12"
"node": ">=22"
},
"lint-staged": {
"*.js": [
@@ -65,44 +75,50 @@
"data"
],
"dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.1",
"axios": "^1.16.1",
"@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.12.3",
"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.5.0",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^9.39.4",
"@types/node": "25.9.2",
"@typescript-eslint/eslint-plugin": "^8.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-prettier": "^5.5.5",
"globals": "^17.6.0",
"eslint-plugin-html": "^8.2.1",
"eslint-plugin-prettier": "^5.5.6",
"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",
"@yao-pkg/pkg": "^6.23.0",
"power-assert": "^1.6.1",
"prettier": "^3.8.3",
"prettier": "^3.9.9",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
+1395 -667
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/unblockmusic-utils@0.4.2'
+496 -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">
@@ -132,22 +318,46 @@
<option value="eapi">eapi</option>
<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",
@@ -159,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>
+695
View File
@@ -0,0 +1,695 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API 参数和返回内容解析</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
resize: vertical;
min-height: 200px;
outline: none;
}
textarea:focus {
border-color: #333;
}
.radio-group {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.radio-item {
display: flex;
align-items: center;
gap: 8px;
}
.radio-item input[type="radio"] {
cursor: pointer;
}
.radio-item label {
margin: 0;
cursor: pointer;
font-size: 14px;
}
button {
background: #333;
color: white;
padding: 12px 28px;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
button + button {
margin-left: 12px;
}
button:hover {
background: #555;
}
.result-section {
margin-top: 24px;
}
.result-section label {
margin-bottom: 12px;
}
.decode-result {
white-space: pre-wrap;
word-break: break-all;
background: #f9f9f9;
padding: 16px;
border-radius: 6px;
border: 1px solid #eee;
min-height: 200px;
max-height: 400px;
overflow: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.decode-result .json-key { color: #881391; }
.decode-result .json-string { color: #1a7f37; }
.decode-result .json-number { color: #0550ae; }
.decode-result .json-boolean { color: #cf222e; }
.decode-result .json-null { color: #656d76; }
.example-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #eee;
}
.example-section h2 {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.example-section img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin-bottom: 16px;
border: 1px solid #eee;
}
/* new elements for multi-crypto support */
.crypto-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 20px;
padding: 12px 16px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #eee;
}
.crypto-bar .bar-label {
font-size: 13px;
font-weight: 500;
color: #555;
margin-right: 8px;
}
.crypto-btn {
padding: 5px 14px;
border-radius: 4px;
border: 1px solid #ddd;
background: #fff;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #666;
transition: all 0.15s ease;
}
.crypto-btn:hover {
border-color: #333;
color: #333;
background: #f5f5f5;
}
.crypto-btn.active {
border-color: #333;
background: #333;
color: #fff;
}
.crypto-btn .badge {
display: inline-block;
font-size: 10px;
background: rgba(0,0,0,0.08);
border-radius: 4px;
padding: 0 5px;
margin-left: 3px;
}
.crypto-btn.active .badge {
background: rgba(255,255,255,0.2);
}
.mode-row {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.mode-row .mode-label {
font-size: 13px;
font-weight: 500;
color: #555;
}
.mode-row .radio-group {
margin-bottom: 0;
}
.info-hint {
font-size: 12px;
color: #888;
margin-top: 6px;
padding: 6px 10px;
background: #fafafa;
border-radius: 4px;
border-left: 3px solid #999;
}
.info-hint.warn {
border-left-color: #e67e22;
background: #fef9f0;
color: #d35400;
}
.action-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.action-row button + button {
margin-left: 0;
}
button.btn-secondary {
background: #e8e8e8;
color: #444;
}
button.btn-secondary:hover {
background: #d5d5d5;
}
button.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
button.btn-success {
background: #4caf50;
}
button.btn-success:hover {
background: #43a047;
}
button.btn-success:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.result-section .section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.result-section .section-header label {
margin: 0;
}
.copy-btn {
font-size: 12px;
padding: 3px 12px;
border-radius: 4px;
border: 1px solid #ddd;
background: #fff;
cursor: pointer;
color: #666;
transition: all 0.15s;
}
.copy-btn:hover {
background: #333;
color: #fff;
border-color: #333;
}
.example-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.example-card {
background: #fafafa;
border-radius: 6px;
padding: 16px;
border: 1px solid #eee;
}
.example-card h3 {
font-size: 14px;
font-weight: 600;
color: #555;
margin-bottom: 8px;
}
.example-card pre {
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
background: #fff;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
border: 1px solid #e8e8e8;
max-height: 120px;
overflow-y: auto;
margin-bottom: 8px;
}
.example-card .tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
margin-bottom: 6px;
}
.tag-eapi { background: #e3f2fd; color: #1565c0; }
.tag-weapi { background: #fce4ec; color: #c62828; }
.tag-linuxapi { background: #e8f5e9; color: #2e7d32; }
.tag-xeapi { background: #f3e5f5; color: #7b1fa2; }
.tag-api { background: #fff3e0; color: #e65100; }
@media (max-width: 640px) {
.example-grid { grid-template-columns: 1fr; }
.crypto-bar { gap: 4px; }
.crypto-btn { padding: 4px 10px; font-size: 12px; }
}
</style>
</head>
<body>
<div id="app" class="container">
<h1>API 参数和返回内容解析</h1>
<!-- 加密方式选择 -->
<div class="crypto-bar">
<span class="bar-label">加密方式</span>
<button
v-for="c in cryptoList"
:key="c.value"
class="crypto-btn"
:class="{ active: crypto === c.value }"
@click="selectCrypto(c.value)"
>
{{ c.label }}
<span class="badge">{{ c.badge }}</span>
</button>
</div>
<div class="mode-row">
<span class="mode-label">数据类型</span>
<div class="radio-group">
<div class="radio-item">
<input
type="radio"
id="mode-req"
:value="true"
v-model="isReq"
:disabled="cryptoInfo.reqDisabled"
/>
<label for="mode-req">请求数据 request</label>
</div>
<div class="radio-item">
<input
type="radio"
id="mode-resp"
:value="false"
v-model="isReq"
:disabled="cryptoInfo.respDisabled"
/>
<label for="mode-resp">返回数据 response</label>
</div>
</div>
</div>
<div class="form-group">
<label for="dataInput">{{ cryptoInfo.inputLabel }}</label>
<textarea
id="dataInput"
v-model="encryptedData"
:rows="crypto === 'xeapi' ? 8 : 10"
:placeholder="cryptoInfo.placeholder"
></textarea>
<div class="info-hint" :class="{ warn: cryptoInfo.warning }">
{{ cryptoInfo.hint }}
</div>
</div>
<div class="action-row">
<button @click="decrypt">解密</button>
<button class="btn-success" @click="sendToApi" :disabled="!canSend">
填入 API 调试
</button>
<button class="btn-secondary" @click="loadExample">加载示例</button>
<button class="btn-secondary" @click="clearAll">清空</button>
</div>
<div class="result-section">
<div class="section-header">
<label>解密结果:</label>
<button
v-if="result && result !== '{}' && result !== 'null'"
class="copy-btn"
@click="copyResult"
>
复制
</button>
</div>
<pre class="decode-result" v-html="highlightedResult"></pre>
</div>
<div class="example-section">
<h2>使用示例</h2>
<img src="/static/eapi_params.png" alt="请求示例" />
<img src="/static/eapi_response.png" alt="响应示例" />
<div class="example-grid">
<div class="example-card">
<span class="tag tag-eapi">eapi</span>
<h3>请求参数解密</h3>
<pre>输入 hex 字符串,解密后得到请求 URL 和参数</pre>
<button class="copy-btn" @click="loadExampleData('eapi_req')">加载示例</button>
</div>
<div class="example-card">
<span class="tag tag-linuxapi">linuxapi</span>
<h3>请求 eparams 解密</h3>
<pre>AES-ECB 解密 linuxapi 的 eparams 参数(hex)</pre>
<button class="copy-btn" @click="loadExampleData('linuxapi_req')">加载示例</button>
</div>
</div>
</div>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios"></script>
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
<script>
const cryptoInfos = {
eapi: {
label: 'eapi',
badge: 'AES-ECB',
inputLabel: '十六进制字符串(Hex)',
placeholder: '粘贴 eapi 加密后的 Hex 字符串...',
hint: 'eapi 使用 AES-ECB 加密,密钥 e82ckenh8dichen8。请求格式: url-36cd479b6b5-data-36cd479b6b5-md5',
warning: false,
reqDisabled: false,
respDisabled: false,
},
weapi: {
label: 'weapi',
badge: 'AES-CBC+RSA',
inputLabel: '十六进制字符串(Hex)',
placeholder: '粘贴 weapi 返回数据的 Hex 字符串(e_r=true 时)...',
hint: 'weapi 请求解密需要 RSA 私钥,暂不支持。仅支持返回数据解密(同 eapi 的 AES-ECB)',
warning: true,
reqDisabled: true,
respDisabled: false,
},
linuxapi: {
label: 'linuxapi',
badge: 'AES-ECB',
inputLabel: '十六进制字符串(Hex)',
placeholder: '粘贴 linuxapi 的 eparams (Hex 格式)...',
hint: 'linuxapi 请求 eparams 使用 AES-ECB 加密,密钥 rFgB&h#%2?^eDg:Q。返回为明文 JSON',
warning: false,
reqDisabled: false,
respDisabled: false,
},
xeapi: {
label: 'xeapi',
badge: 'X25519+AES',
inputLabel: 'Base64 编码字符串',
placeholder: '粘贴 xeapi 返回数据的 Base64 原始二进制...',
hint: 'xeapi 请求涉及 X25519 ECDH 密钥交换,暂不支持。返回数据使用 AES-ECB + eapiKey 加密,可能带 gzip',
warning: true,
reqDisabled: true,
respDisabled: false,
},
api: {
label: 'api',
badge: '明文',
inputLabel: 'JSON 文本',
placeholder: '粘贴 api 的请求或返回 JSON...',
hint: 'api(明文)不经加密,直接传递 JSON 数据',
warning: false,
reqDisabled: false,
respDisabled: false,
},
}
const examples = {
eapi_req:
'AD96DDB984491E79B6F429DD650C6E2AE524627AC223AC9A123C66BB0997965950FED137544A93DFC718E16F57C8C121AF537086F395570A5602A3922366D11964DAFACD7830AACABF62E5650E67F457E79C1D2E13502391FC3487216CC5BF8681843FCB8E05559487EB18AAC1BE0EFEA4F7B6A050478366153A9426C238B8869600B275704555A9EB94C92E4F3FDABE9E0BCE07645410D0AA7B675698A4CAE6CD3620633ABF0B849A4244CC8DFC5DB2646D5EA9B3954E62BFEF19AFEAFDDC34E55C3E9A1DD3167CF53D443617108141',
linuxapi_req:
'A0D9583F4C5FF68DE851D2893A49DE98CC059A8845B664AA2459CEA7271A2C0E5BCA8A188E1BE398DB4C9A3FC117E19C9BAC491F454D17D403C2389476AB0FF4296B00294AD1EBDA141C188DF918F6B9599DAA5739928FD52B4AE580D8657903CE3C6633D2E46AD242408AE219B8191E',
}
const app = Vue.createApp({
data() {
return {
crypto: 'eapi',
encryptedData: '',
result: '{}',
isReq: true,
cryptoList: [
{ value: 'eapi', label: 'eapi', badge: 'AES-ECB' },
{ value: 'weapi', label: 'weapi', badge: 'AES-CBC+RSA' },
{ value: 'linuxapi', label: 'linuxapi', badge: 'AES-ECB' },
{ value: 'xeapi', label: 'xeapi', badge: 'X25519+AES' },
{ value: 'api', label: 'api', badge: '明文' },
],
}
},
mounted() {
this.loadExample()
},
computed: {
cryptoInfo() {
return cryptoInfos[this.crypto] || cryptoInfos.eapi
},
isRequestMode() {
return this.isReq === true || this.isReq === 'true'
},
canSend() {
if (!this.isRequestMode) return false
if (!this.result || this.result === '{}' || this.result === 'null') return false
try {
JSON.parse(this.result)
return true
} catch (error) {
return false
}
},
highlightedResult() {
if (!this.result || this.result === '') return ''
try {
const parsed = typeof this.result === 'string' ? JSON.parse(this.result) : this.result
const json = JSON.stringify(parsed, null, 2)
return this.syntaxHighlight(json)
} catch (error) {
return this.escapeHtml(String(this.result))
}
},
},
watch: {
crypto(val) {
const info = cryptoInfos[val]
if (this.isReq && info.reqDisabled) {
this.isReq = false
} else if (!this.isReq && info.respDisabled) {
this.isReq = true
}
},
},
methods: {
selectCrypto(val) {
this.crypto = val
},
syntaxHighlight(json) {
const escaped = this.escapeHtml(json)
return escaped
.replace(/(?:"(?:\\.|[^"\\])*")\s*:/g, '<span class="json-key">$&</span>')
.replace(/:(\s*)(?:"(?:\\.|[^"\\])*")/g, ':<span class="json-string">$1$2</span>')
.replace(/:\s*(\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
.replace(/:\s*(true|false)/g, ': <span class="json-boolean">$1</span>')
.replace(/:\s*(null)/g, ': <span class="json-null">$1</span>')
},
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
},
formatResult(value) {
if (value == null || value === '') return ''
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
return JSON.stringify(parsed, null, 2)
} catch (error) {
return String(value)
}
},
async decrypt() {
if (!this.encryptedData || !this.encryptedData.trim()) {
alert('请先输入要解密的数据')
return
}
try {
const res = await axios({
url: `/decrypt?crypto=${this.crypto}&isReq=${this.isReq}&timestamp=${Date.now()}`,
method: 'post',
data: { data: this.encryptedData },
})
this.result = JSON.stringify(res.data.data)
} catch (error) {
console.error(error)
const msg = error?.response?.data?.message || error?.message || '解密失败,数据格式错误'
alert(msg)
this.result = '{}'
}
},
sendToApi() {
if (!this.canSend) return
const payload = JSON.parse(this.result)
const params = new URLSearchParams()
const uri = payload.uri || payload.url || payload.path || ''
params.set('uri', uri)
params.set('crypto', this.crypto)
const data = payload.params || payload.data || payload.body || payload.payload || payload.request || {}
params.set('data', JSON.stringify(data))
window.open(`/api.html?${params.toString()}`, '_blank')
},
loadExample() {
switch (this.crypto) {
case 'eapi':
this.encryptedData = examples.eapi_req
break
case 'linuxapi':
this.isReq = true
this.encryptedData = examples.linuxapi_req
break
case 'xeapi':
this.isReq = false
this.encryptedData = ''
break
default:
this.encryptedData = ''
}
if (this.encryptedData) this.decrypt()
},
loadExampleData(type) {
switch (type) {
case 'eapi_req':
this.crypto = 'eapi'
this.isReq = true
this.encryptedData = examples.eapi_req
break
case 'linuxapi_req':
this.crypto = 'linuxapi'
this.isReq = true
this.encryptedData = examples.linuxapi_req
break
}
if (this.encryptedData) this.decrypt()
},
clearAll() {
this.encryptedData = ''
this.result = '{}'
},
copyResult() {
const text = typeof this.result === 'string' ? this.result : JSON.stringify(this.result, null, 2)
navigator.clipboard.writeText(text).then(() => {
alert('已复制到剪贴板')
})
},
},
})
app.mount('#app')
</script>
</body>
</html>
+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'));
}
})
})

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