mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-10-20 22:51:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d89aa906 | ||
|
|
8c212be86b | ||
|
|
89de56d2a2 | ||
|
|
d298025d7c | ||
|
|
a3287a0180 | ||
|
|
9adfd50c58 | ||
|
|
fda7c9e5b3 | ||
|
|
a8266c6d7b | ||
|
|
64a6b9677a | ||
|
|
20d3a1a7a5 | ||
|
|
4826c65042 | ||
|
|
7822ab08d3 | ||
|
|
41bd6d82ce | ||
|
|
6f8dd1d971 | ||
|
|
ddd0af5474 | ||
|
|
fbd2e06419 | ||
|
|
4442fb4341 | ||
|
|
35d1c61cb4 | ||
|
|
a183e153ef | ||
|
|
2fa78c3826 | ||
|
|
5ae1eab14c | ||
|
|
321c25bd7d | ||
|
|
f527c1ef73 | ||
|
|
0a5a1242f2 | ||
|
|
6946dc8e14 | ||
|
|
22b78779ca | ||
|
|
35607f5050 | ||
|
|
60af95ac06 | ||
|
|
46073e5f50 | ||
|
|
bf12abe2f1 | ||
|
|
1491b91829 | ||
|
|
63f669d7f8 | ||
|
|
d525204dda | ||
|
|
6eb31984fd | ||
|
|
9faeb0aa25 | ||
|
|
f6db3e76e9 | ||
|
|
9163d48f0a | ||
|
|
1454ee216c | ||
|
|
83ee2cda34 | ||
|
|
3c352b5752 | ||
|
|
60ee927e32 | ||
|
|
eb07a525eb | ||
|
|
6fc4231142 | ||
|
|
fa69736300 | ||
|
|
73a5e9d788 | ||
|
|
dc6f56e8a8 | ||
|
|
2c9da8507d | ||
|
|
1db354464f | ||
|
|
395a80e74b | ||
|
|
6550c7c827 | ||
|
|
c959866436 | ||
|
|
e3a1c041b6 | ||
|
|
e2324e26da | ||
|
|
f01e770c35 | ||
|
|
abb56ec014 | ||
|
|
0300c1b51e | ||
|
|
9896242333 | ||
|
|
e3812be97b | ||
|
|
e7ff0da4ba | ||
|
|
65261fa658 | ||
|
|
cb5b2e7d0f | ||
|
|
f58fd4b50d | ||
|
|
1a5f91a192 | ||
|
|
50cc26f297 | ||
|
|
c92613b19e | ||
|
|
7aacc8990b | ||
|
|
8e934789a1 | ||
|
|
d26e6b0615 | ||
|
|
a9c33de182 | ||
|
|
39e0e0718d | ||
|
|
6175d55f89 |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Instructions for NeteaseCloudMusicApiEnhanced
|
||||
|
||||
## Quick Start
|
||||
- **Package Manager**: Use `pnpm` (not npm or yarn).
|
||||
- **Node Version**: Requires Node.js 18 or later.
|
||||
|
||||
## Developer Commands
|
||||
- **Install dependencies**: `pnpm i`
|
||||
- **Start server**: `pnpm start` or `node app.js`
|
||||
- **Start dev server**: `pnpm dev` (uses nodemon)
|
||||
- **Run tests**: `pnpm test` (uses Mocha)
|
||||
- **Linting**: `pnpm lint` (check) or `pnpm lint-fix` (auto-fix)
|
||||
|
||||
## Architecture & Entrypoints
|
||||
- **Executable Server**: `app.js` is the main entrypoint for running the API server.
|
||||
- **Module Exports**: `main.js` is the entrypoint when the project is imported as a Node.js dependency.
|
||||
- **API Endpoints**: Located in the `module/` directory. Each file typically corresponds to an API route.
|
||||
- **Core Utilities**: Request handling, encryption, and core utilities are found in the `util/` directory.
|
||||
|
||||
## Important Gotchas & Quirks
|
||||
- **Environment Variables**: The server defaults to port 3000 but can be overridden with the `PORT` environment variable.
|
||||
- **Proxy Variables**: Be very careful with proxy environment variables (`http_proxy`, `https_proxy`, `no_proxy`). The request library (like axios) will automatically pick these up. If they point to an unavailable proxy (especially common in Docker environments), requests will fail silently or throw connection errors.
|
||||
- **Code Style**: The project uses ESLint and Prettier. Always run `pnpm lint-fix` before committing changes to ensure formatting consistency.
|
||||
-1418
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,4 +13,4 @@ RUN yarn --network-timeout=100000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "/sbin/tini", "--", "node", "app.js" ]
|
||||
CMD [ "/sbin/tini", "--", "node", "app.js" ]
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署(如果原版诈尸, 我会及时同步 or 归档)。
|
||||
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+746
@@ -1474,6 +1474,20 @@ export function artist_new_song(
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_new_song_playall(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_new_song_mv_list_v2(
|
||||
params: {
|
||||
startTimestamp?: number | string
|
||||
before?: number | string
|
||||
sourceType?: number | string
|
||||
limit?: number | string
|
||||
firstRequest?: boolean | string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_detail(
|
||||
params: {
|
||||
id: number | string
|
||||
@@ -1594,6 +1608,10 @@ export function vip_growthpoint_get(
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function vip_growthpoint_getall(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_fans(
|
||||
params: { id: number | string } & MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
@@ -1841,3 +1859,731 @@ export function voice_lyric(
|
||||
id: number | string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function aidj_content_rcmd(
|
||||
params: {
|
||||
latitude?: string | number
|
||||
longitude?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function album_privilege(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_detail_dynamic(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function artist_follow_count(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function broadcast_category_region_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function broadcast_channel_collect_list(
|
||||
params: MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function broadcast_channel_currentinfo(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function broadcast_channel_list(
|
||||
params: {
|
||||
categoryId?: string | number
|
||||
regionId?: string | number
|
||||
lastId?: string | number
|
||||
score?: string | number
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function broadcast_sub(
|
||||
params: {
|
||||
t: SubAction
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function chart_detail(
|
||||
params: {
|
||||
chartCode: string | number
|
||||
targetId: string | number
|
||||
targetType: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function chart_song_detail(
|
||||
params: {
|
||||
chartCode: string | number
|
||||
targetId: string | number
|
||||
targetType: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function cloud_import(
|
||||
params: {
|
||||
md5: string
|
||||
id?: string | number
|
||||
bitrate?: string | number
|
||||
fileSize?: string | number
|
||||
artist?: string
|
||||
album?: string
|
||||
song?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function cloud_lyric_get(
|
||||
params: {
|
||||
uid: string | number
|
||||
sid: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function cloud_upload_complete(
|
||||
params: {
|
||||
songId: string | number
|
||||
resourceId: string | number
|
||||
md5: string
|
||||
filename: string
|
||||
song?: string
|
||||
artist?: string
|
||||
album?: string
|
||||
bitrate?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function cloud_upload_token(
|
||||
params: {
|
||||
md5: string
|
||||
fileSize: string | number
|
||||
filename: string
|
||||
bitrate?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function comment_info_list(
|
||||
params: {
|
||||
ids?: string
|
||||
type?: CommentType
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function comment_report(
|
||||
params: {
|
||||
id: string | number
|
||||
cid: string | number
|
||||
reason: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function creator_authinfo_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function dj_difm_all_style_channel(
|
||||
params: {
|
||||
sources?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function dj_difm_channel_subscribe(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function dj_difm_channel_unsubscribe(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function dj_difm_playing_tracks_list(
|
||||
params: {
|
||||
channelId: string | number
|
||||
limit?: string | number
|
||||
source?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function dj_difm_subscribe_channels_get(
|
||||
params: {
|
||||
sources?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function fanscenter_basicinfo_age_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function fanscenter_basicinfo_gender_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function fanscenter_basicinfo_province_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function fanscenter_overview_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function fanscenter_trend_list(
|
||||
params: {
|
||||
startTime?: string | number
|
||||
endTime?: string | number
|
||||
type?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function lbs_city_code(
|
||||
params: {
|
||||
bizCode?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listen_data_realtime_report(
|
||||
params: {
|
||||
type?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listen_data_report(
|
||||
params: {
|
||||
type?: string
|
||||
endTime?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listen_data_song_play_rank(
|
||||
params: {
|
||||
type?: string
|
||||
endTime?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listen_data_today_song(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listen_data_total(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function listen_data_year_report(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_accept(
|
||||
params: {
|
||||
roomId: string | number
|
||||
inviterId: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_end(
|
||||
params: {
|
||||
roomId: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_heatbeat(
|
||||
params: {
|
||||
roomId: string | number
|
||||
songId: string | number
|
||||
playStatus: string | number
|
||||
progress: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_play_command(
|
||||
params: {
|
||||
roomId: string | number
|
||||
commandType: string | number
|
||||
progress?: string | number
|
||||
playStatus: string | number
|
||||
formerSongId: string | number
|
||||
targetSongId: string | number
|
||||
clientSeq: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_room_check(
|
||||
params: {
|
||||
roomId: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_room_create(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_sync_list_command(
|
||||
params: {
|
||||
roomId: string | number
|
||||
commandType: string | number
|
||||
userId: string | number
|
||||
version: string | number
|
||||
randomList: string
|
||||
displayList: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function listentogether_sync_playlist_get(
|
||||
params: {
|
||||
roomId: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function mlog_music_rcmd(
|
||||
params: {
|
||||
mvid?: string | number
|
||||
songid?: string | number
|
||||
limit?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function music_first_listen_info(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function personal_fm_mode(
|
||||
params: {
|
||||
mode: string
|
||||
submode?: string
|
||||
limit?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playlist_category_list(
|
||||
params: {
|
||||
cat?: string
|
||||
limit?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playlist_detail_rcmd_get(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playlist_import_name_task_create(
|
||||
params: {
|
||||
local?: string
|
||||
importStarPlaylist?: boolean
|
||||
['name']?: string
|
||||
['description']?: string
|
||||
['id']?: string | number
|
||||
['url']?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playlist_import_task_status(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playlist_privacy(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function playmode_song_vector(
|
||||
params: {
|
||||
ids: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function radio_sport_get(
|
||||
params: {
|
||||
bpm?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function recent_listen_list(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function recommend_songs_dislike(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function register_anonimous(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function sati_resource_list(
|
||||
params: {
|
||||
tag: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function sati_resource_list_more(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function sati_resource_sub(
|
||||
params: {
|
||||
id: string | number
|
||||
cancel?: string | boolean
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function sati_resource_sub_list(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function sati_tag_list(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function sati_timescene_resources_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function search_match(
|
||||
params: {
|
||||
title?: string
|
||||
album?: string
|
||||
artist?: string
|
||||
duration?: string | number
|
||||
md5?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function search_suggest_pc(
|
||||
params: {
|
||||
keyword: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function send_album(
|
||||
params: {
|
||||
id: string | number
|
||||
msg?: string
|
||||
user_ids: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function send_song(
|
||||
params: {
|
||||
id: string | number
|
||||
msg?: string
|
||||
user_ids: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_chorus(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_creators(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_downlist(
|
||||
params: MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_download_url_v1(
|
||||
params: {
|
||||
id: string | number
|
||||
level: SoundQualityType
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_dynamic_cover(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_like(
|
||||
params: {
|
||||
id: string | number
|
||||
like?: string | boolean
|
||||
uid?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_like_check(
|
||||
params: {
|
||||
ids: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_lyrics_mark(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_lyrics_mark_add(
|
||||
params: {
|
||||
id: string | number
|
||||
markId?: string
|
||||
data?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_lyrics_mark_del(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_lyrics_mark_user_page(
|
||||
params: MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_monthdownlist(
|
||||
params: MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_music_detail(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_red_count(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_singledownlist(
|
||||
params: MultiPageConfig & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_url_match(
|
||||
params: {
|
||||
id: string | number
|
||||
source?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function song_url_ncmget(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function song_url_v1_302(
|
||||
params: {
|
||||
id: string | number
|
||||
level: SoundQualityType
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function starpick_comments_summary(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function summary_annual(
|
||||
params: {
|
||||
year: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function threshold_detail_get(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function toplist_detail_v2(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function ugc_album_get(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_artist_get(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_artist_search(
|
||||
params: {
|
||||
keyword: string
|
||||
limit?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_detail(
|
||||
params: {
|
||||
auditStatus?: string
|
||||
type?: string | number
|
||||
sortBy?: string
|
||||
order?: string
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_mv_get(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_song_get(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function ugc_user_devote(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function user_detail_new(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_follow_mixed(
|
||||
params: {
|
||||
size?: string | number
|
||||
cursor?: string | number
|
||||
scene?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_medal(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_mutualfollow_get(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_playlist_collect(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_playlist_create(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_social_status(
|
||||
params: {
|
||||
uid: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_social_status_edit(
|
||||
params: {
|
||||
type: string | number
|
||||
iconUrl?: string
|
||||
content?: string
|
||||
actionUrl?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_social_status_rcmd(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function user_social_status_support(
|
||||
params: RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function verify_getQr(
|
||||
params: {
|
||||
vid: string | number
|
||||
type: string | number
|
||||
token: string
|
||||
evid: string
|
||||
sign: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function verify_qrcodestatus(
|
||||
params: {
|
||||
qr: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function vip_sign(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function vip_sign_info(params: RequestBaseConfig): Promise<Response>
|
||||
|
||||
export function vip_tasks_v1(
|
||||
params: {
|
||||
id?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voice_detail(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voice_upload(
|
||||
params: {
|
||||
songFile: {
|
||||
name: string
|
||||
data: string | Buffer
|
||||
}
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voicelist_detail(
|
||||
params: {
|
||||
id: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voicelist_list(
|
||||
params: {
|
||||
voiceListId: string | number
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voicelist_my_created(
|
||||
params: {
|
||||
limit?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voicelist_search(
|
||||
params: {
|
||||
keyword?: string
|
||||
limit?: string | number
|
||||
offset?: string | number
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
export function voicelist_trans(
|
||||
params: {
|
||||
radioId?: string | number
|
||||
programId?: string | number
|
||||
position?: string | number
|
||||
} & MultiPageConfig &
|
||||
RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 获取广告
|
||||
|
||||
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]
|
||||
if (ad?.extJson) {
|
||||
const ext = JSON.parse(ad.extJson)
|
||||
reqId = ext?.contextInfo?.req_id || ''
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
ads: raw?.ads || null,
|
||||
message: raw?.message || null,
|
||||
extra: { reqId },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// 看广告免费听歌 - 领取免费听权益
|
||||
// 请求流程(基于逆向网易云音乐 v9.5.45 RN Hermes 源码):
|
||||
// 1. 从广告平台拉广告 → 用户看完/点击广告 → 获取 ad 对象的 extJson.contextInfo.req_id 作为 reqUid
|
||||
// 2. 调用本接口传入 reqUid 及相关权益参数,领取免费听权益
|
||||
// 3. 服务器返回 gainFlag 等标识,用于展示领取结果
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
const adGet = require('./ad_get.js')
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const time = Date.now()
|
||||
|
||||
// 如果未传入 reqUid,自动从 ad_get 获取最新广告的 req_id
|
||||
let reqUid = query.reqUid || ''
|
||||
if (!reqUid) {
|
||||
try {
|
||||
const adRes = await adGet(
|
||||
{ ...query, type_ids: query.type_ids || '["400002_0"]' },
|
||||
request,
|
||||
)
|
||||
reqUid = adRes?.body?.extra?.reqId || ''
|
||||
console.log(`自动获取 reqUid: ${reqUid}`)
|
||||
} catch (e) {
|
||||
// 获取广告失败,后续请求会因缺少 reqUid 被拒绝
|
||||
}
|
||||
}
|
||||
|
||||
const rightsParam = {
|
||||
// 必填: 广告请求 ID,自动从 ad_get 获取
|
||||
reqUid,
|
||||
|
||||
// 广告创意类型 (默认 1)
|
||||
creativeType: parseInt(query.creativeType || 2),
|
||||
|
||||
// 时间戳
|
||||
exposureTime: query.exposureTime || time,
|
||||
clickTime: query.clickTime || time,
|
||||
|
||||
// 权益领取方式
|
||||
rightsGainMethod: parseInt(query.rightsGainMethod || 2),
|
||||
|
||||
// 权益时长相关
|
||||
rightsGainDuration: query.rightsGainDuration
|
||||
? parseInt(query.rightsGainDuration)
|
||||
: undefined,
|
||||
extraRightsGainMethod: query.extraRightsGainMethod
|
||||
? parseInt(query.extraRightsGainMethod)
|
||||
: undefined,
|
||||
extraRightsGainDuration: query.extraRightsGainDuration
|
||||
? parseInt(query.extraRightsGainDuration)
|
||||
: undefined,
|
||||
nextRightsGainDuration: query.nextRightsGainDuration
|
||||
? parseInt(query.nextRightsGainDuration)
|
||||
: undefined,
|
||||
|
||||
// 来源标识 | 权益扩展信息
|
||||
source: query.source || undefined,
|
||||
rightsExtJson: query.rightsExtJson || undefined,
|
||||
|
||||
// 应用信息(下载类广告)
|
||||
appInfo: query.appInfo ? JSON.parse(query.appInfo) : undefined,
|
||||
installed: query.installed ? parseInt(query.installed) : undefined,
|
||||
}
|
||||
|
||||
// 清理 undefined 字段
|
||||
Object.keys(rightsParam).forEach((key) => {
|
||||
if (rightsParam[key] === undefined) delete rightsParam[key]
|
||||
})
|
||||
|
||||
// 将参数序列化为 reqParam 字符串 (与 RN 源码完全一致)
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -25,6 +25,6 @@ module.exports = (query, request) => {
|
||||
return request(
|
||||
`/api/resource/comments/${query.t}`,
|
||||
data,
|
||||
createOption(query, 'weapi'),
|
||||
createOption(query, 'eapi', 'v2'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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', 'v3'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// 私信和通知接口
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
const data = {}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 易盾反作弊 Token 注册端点
|
||||
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
|
||||
//
|
||||
// GET /register/checktoken → 返回当前 token(尚无则自动获取)
|
||||
// POST /register/checktoken → 强制刷新 token
|
||||
// GET /register/checktoken?refresh=1 → 强制刷新
|
||||
//
|
||||
const { default: axios } = require('axios')
|
||||
const { APP_CONF } = require('../util/config.json')
|
||||
|
||||
const URL = APP_CONF.dunDomainV2 + '/v2/config/js?pn=YD00000558929251'
|
||||
let _token = ''
|
||||
|
||||
async function fetch() {
|
||||
const res = await axios.get(URL, { timeout: 10000 })
|
||||
const data = res.data
|
||||
if (data && data.code === 200 && data.result && data.result.conf) {
|
||||
return data.result.conf
|
||||
}
|
||||
throw new Error('易盾返回异常: ' + JSON.stringify(data).substring(0, 200))
|
||||
}
|
||||
|
||||
// 端点处理
|
||||
module.exports = async (query) => {
|
||||
const refresh = query.refresh === '1' || query.refresh === 'true'
|
||||
let token = refresh ? null : _token
|
||||
if (!token) {
|
||||
token = await fetch()
|
||||
_token = token
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { code: 200, token, registered: !!token },
|
||||
}
|
||||
}
|
||||
|
||||
// 模块加载时自动获取一次 Token,确保 request.js 使用时不为空
|
||||
fetch()
|
||||
.then((token) => {
|
||||
_token = token
|
||||
})
|
||||
.catch(() => {
|
||||
// 静默失败,后续请求时会重试
|
||||
})
|
||||
|
||||
// 给 request.js 读取用
|
||||
module.exports.getToken = () => {
|
||||
if (!_token) {
|
||||
// 如果 Token 为空,异步触发获取
|
||||
fetch()
|
||||
.then((token) => {
|
||||
_token = token
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
return _token
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 易盾反作弊 Token 注册端点
|
||||
// 调用后获取实时 token 并存入共享存储,供后续带 checkToken 的请求使用
|
||||
//
|
||||
// GET /register/checktoken → 返回当前 token(尚无则自动获取)
|
||||
// POST /register/checktoken → 强制刷新 token
|
||||
// GET /register/checktoken?refresh=1 → 强制刷新
|
||||
//
|
||||
const { default: axios } = require('axios')
|
||||
const { APP_CONF } = require('../util/config.json')
|
||||
|
||||
const URL = APP_CONF.dunDomainV3 + '/v3/b?pn=YD00000558929251'
|
||||
let _token = ''
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 端点处理
|
||||
module.exports = async (query) => {
|
||||
const refresh = query.refresh === '1' || query.refresh === 'true'
|
||||
let token = refresh ? null : _token
|
||||
if (!token) {
|
||||
token = await fetch()
|
||||
_token = token
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { code: 200, token, registered: !!token },
|
||||
}
|
||||
}
|
||||
|
||||
// 给 request.js 读取用
|
||||
module.exports.getToken = () => _token
|
||||
@@ -0,0 +1,74 @@
|
||||
const { default: axios } = require('axios')
|
||||
const encrypt = require('../util/crypto')
|
||||
const { APP_CONF } = require('../util/config.json')
|
||||
|
||||
const generateNonce = () => {
|
||||
let nonce = ''
|
||||
for (let i = 0; i < 16; i++) {
|
||||
nonce += Math.floor(Math.random() * 10).toString()
|
||||
}
|
||||
return nonce
|
||||
}
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const nonce = generateNonce()
|
||||
const timestamp = String(Date.now())
|
||||
const deviceId = query.deviceId || global.deviceId || ''
|
||||
const currentKeyVersion = query.currentKeyVersion || ''
|
||||
|
||||
const data = {
|
||||
appVersion: '9.1.65',
|
||||
currentKeyVersion,
|
||||
deviceId,
|
||||
nonce,
|
||||
os: 'android',
|
||||
requestType: 'active',
|
||||
signature: encrypt.xeapiSign(timestamp, nonce),
|
||||
t1: '',
|
||||
t2: '',
|
||||
timestamp,
|
||||
uid: '',
|
||||
}
|
||||
|
||||
const res = await axios({
|
||||
method: 'POST',
|
||||
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)',
|
||||
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
|
||||
},
|
||||
data: new URLSearchParams(data).toString(),
|
||||
proxy: false,
|
||||
})
|
||||
|
||||
if (
|
||||
!res.data ||
|
||||
res.data.code !== 200 ||
|
||||
!res.data.data ||
|
||||
!res.data.data.encryptedData
|
||||
) {
|
||||
throw new Error('xeapi public key request failed')
|
||||
}
|
||||
if (
|
||||
!res.data.data.signature ||
|
||||
encrypt.xeapiSign(res.data.data.timestamp, nonce) !==
|
||||
res.data.data.signature
|
||||
) {
|
||||
throw new Error('xeapi public key response signature mismatch')
|
||||
}
|
||||
|
||||
const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData)
|
||||
if (!publicKey.sk) {
|
||||
throw new Error('xeapi public key response missing sk')
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...publicKey,
|
||||
deviceId,
|
||||
},
|
||||
cookie: [],
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编活动信息
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/activity/get`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编获取用户详情
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/user/get`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 云小编每日签到
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = (query, request) => {
|
||||
return request(`/api/rep/ugc/user/sign`, {}, createOption(query, 'eapi'))
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// 此版本不再采用 br 作为音质区分的标准
|
||||
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
|
||||
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
|
||||
// 当level为sky时, 可通过 immerseType 选择沉浸声类型, 支持 c51(c51类型)、ste(环绕立体声类型)、aac(aac类型), 默认为 c51
|
||||
|
||||
const logger = require('../util/logger.js')
|
||||
const createOption = require('../util/option.js')
|
||||
@@ -51,7 +52,7 @@ 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`,
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
// 黑胶乐签签到信息
|
||||
// 黑胶乐签未来签到信息
|
||||
|
||||
const createOption = require('../util/option.js')
|
||||
module.exports = (query, request) => {
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
+26
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neteasecloudmusicapienhanced/api",
|
||||
"version": "4.34.0",
|
||||
"version": "4.39.0",
|
||||
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
|
||||
"scripts": {
|
||||
"dev": "nodemon app.js",
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "eslint \"**/*.{js,ts}\"",
|
||||
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
|
||||
"prepare": "husky install",
|
||||
"docs:format": "node scripts/format-docs.js",
|
||||
"docs:check": "node scripts/format-docs.js --check",
|
||||
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
|
||||
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
|
||||
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
|
||||
@@ -41,6 +43,14 @@
|
||||
],
|
||||
"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"
|
||||
},
|
||||
@@ -65,14 +75,14 @@
|
||||
"data"
|
||||
],
|
||||
"dependencies": {
|
||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.2",
|
||||
"axios": "^1.16.1",
|
||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.4",
|
||||
"axios": "^1.18.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"gzip": "^0.1.0",
|
||||
"music-metadata": "^11.12.3",
|
||||
"music-metadata": "^11.14.0",
|
||||
"node-forge": "^1.4.0",
|
||||
"pac-proxy-agent": "^7.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -82,19 +92,19 @@
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@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.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^9.39.4",
|
||||
"@types/node": "25.9.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-html": "^8.1.4",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"intelli-espower-loader": "^1.1.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
@@ -102,7 +112,11 @@
|
||||
"nodemon": "^3.1.14",
|
||||
"pkg": "^5.8.1",
|
||||
"power-assert": "^1.6.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+325
-285
File diff suppressed because it is too large
Load Diff
+494
-44
@@ -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">
|
||||
@@ -136,19 +322,41 @@
|
||||
<option value="" selected>(默认)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="timeout">超时(ms)</label>
|
||||
<input type="number" id="timeout" name="timeout" value="30000" min="0" step="1000" placeholder="0 表示无超时">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label></label>
|
||||
<button type="submit">发送请求</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- ===== 自定义请求头 ===== -->
|
||||
<div class="section-container" id="headersSection">
|
||||
<div class="section-title">
|
||||
请求头 (Headers)
|
||||
<span class="count-badge" id="headerCount">4</span>
|
||||
</div>
|
||||
<div id="headersList"></div>
|
||||
<button class="add-btn" onclick="addHeaderRow('', '')">+ 添加请求头</button>
|
||||
</div>
|
||||
|
||||
<div class="data-result">
|
||||
<div>
|
||||
<label for="result">响应结果</label>
|
||||
<textarea id="result" name="result" readonly></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="data">请求数据</label>
|
||||
<textarea id="data" name="data">
|
||||
<div class="param-container">
|
||||
<div class="param-header">
|
||||
<span class="param-header-title">请求参数</span>
|
||||
<div class="param-tabs">
|
||||
<button class="tab-btn active" data-mode="json" onclick="switchParamTab('json')">JSON</button>
|
||||
<button class="tab-btn" data-mode="kv" onclick="switchParamTab('kv')">键值对</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-panel" id="jsonPanel">
|
||||
<textarea id="data" name="data">
|
||||
{
|
||||
"cp": false,
|
||||
"id": "2058263032",
|
||||
@@ -160,64 +368,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# 网易云音乐 API Enhanced
|
||||
# NeteaseCloudMusicAPI Enhanced
|
||||
|
||||
> 🔍 网易云音乐API Node.js服务的复兴项目
|
||||
> 🎉 全网收集最全的网易云音乐api接口 基于[NeteaseCloudMusicAPI](https://github.com/binaryify/NeteaseCloudMusicApi)的复刻版本
|
||||
|
||||
- 基于原版网易云API新增更多有趣的功能
|
||||
- 具备登录接口,多达200多个接口
|
||||
- 更完善的文档
|
||||
- ⚡ 四种加密模式 · 后端代理
|
||||
- 🪛 具备多达200多个接口
|
||||
- 📄 更完善的文档
|
||||
|
||||
|
||||
[Github](https://github.com/neteasecloudmusicapienhanced/api-enhanced)
|
||||
[Get Started](#neteasecloudmusicapienhanced)
|
||||
[前往本家](https://github.com/binaryify/NeteaseCloudMusicApi)
|
||||
[快速开始](#neteasecloudmusicapienhanced)
|
||||
|
||||

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

|
||||
|
||||
!> 本项目不提供线上 demo, 只提供在线文档服务, 请不要轻易信任使用他人提供的公开服务,以免发生安全问题,泄露自己的账号和密码
|
||||
|
||||
!> 为使用方便,降低门槛, 文档示例接口直接使用了 GET 请求,本项目同时支持 GET/POST 请按实际需求使用 (POST 请求 url 必须添加时间戳,使每次请求 url 不一样,不然请求会被缓存)
|
||||
@@ -266,17 +273,19 @@ $ sudo docker run -d -p 3000:3000 netease-music-api
|
||||
|
||||
#### 1. 手机登录
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`phone`: 手机号码
|
||||
|
||||
`password`: 密码
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`countrycode`: 国家码,用于国外手机号登录,例如美国传入:`1`
|
||||
|
||||
`md5_password`: md5 加密后的密码,传入后 `password` 参数将失效
|
||||
|
||||
`captcha`: 验证码,使用 [`/captcha/sent`](#发送验证码)接口传入手机号获取验证码,调用此接口传入验证码,可使用验证码登录,传入后 `password` 参数将失效
|
||||
`captcha`: 验证码,使用 `/captcha/sent` 或 `/captcha/sent/v1`接口传入手机号获取验证码,调用此接口传入验证码,可使用验证码登录,传入后 `password` 参数将失效
|
||||
|
||||
`sca`: 网易易盾滑块验证token, 获取方式未知
|
||||
|
||||
**接口地址 :** `/login/cellphone`
|
||||
|
||||
@@ -390,6 +399,19 @@ body {
|
||||
|
||||
**调用例子 :** `/captcha/sent?phone=13xxx`
|
||||
|
||||
### 新版发送验证码
|
||||
|
||||
说明 : 调用此接口 ,传入手机号码, 可发送验证码
|
||||
|
||||
**必选参数 :** `phone`: 手机号码
|
||||
|
||||
**可选参数 :**
|
||||
`ctcode`: 国家区号,默认 86 即中国
|
||||
|
||||
**接口地址 :** `/captcha/sent/v1`
|
||||
|
||||
**调用例子 :** `/captcha/sent/v1?phone=13xxx`
|
||||
|
||||
### 验证验证码
|
||||
|
||||
说明 : 调用此接口 ,传入手机号码和验证码, 可校验验证码是否正确
|
||||
@@ -430,11 +452,11 @@ body {
|
||||
|
||||
### 检测手机号码是否已注册
|
||||
|
||||
说明 : 调用此接口 ,可检测手机号码是否已注册
|
||||
**必选参数 :**
|
||||
说明 : 调用此接口 ,可检测手机号码是否已注册
|
||||
**必选参数 :**
|
||||
`phone` : 手机号码
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`countrycode`: 国家码,用于国外手机号,例如美国传入:`1` ,默认 86 即中国
|
||||
|
||||
**接口地址 :** `/cellphone/existence/check`
|
||||
@@ -443,7 +465,7 @@ body {
|
||||
|
||||
### 初始化昵称
|
||||
|
||||
说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称
|
||||
说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称
|
||||
**必选参数 :**
|
||||
`nickname` : 昵称
|
||||
|
||||
@@ -698,7 +720,7 @@ tags: 歌单标签
|
||||
|
||||
说明 : 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传图片 formData(name 为'imgFile'),可更新歌单封面(参考:https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/playlist_cover_update.html)
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`id`: 歌单 id 3143833470
|
||||
|
||||
**可选参数 :**
|
||||
@@ -728,7 +750,7 @@ tags: 歌单标签
|
||||
|
||||
说明 : 登录后调用此接口,可以根据歌曲 id 顺序调整歌曲顺序
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`pid`: 歌单 id
|
||||
|
||||
`ids`: 歌曲 id 列表
|
||||
@@ -1208,6 +1230,7 @@ tags: 歌单标签
|
||||
|
||||
> 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲
|
||||
|
||||
|
||||
### 歌单详情动态
|
||||
|
||||
说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数
|
||||
@@ -1251,9 +1274,11 @@ tags: 歌单标签
|
||||
`lossless`=>`无损`, `hires`=>`Hi-Res`, `jyeffect` => `高清环绕声`, `sky` => `沉浸环绕声`, `dolby` => `杜比全景声`, `jymaster` => `超清母带`
|
||||
`unblock`: 是否使用使用歌曲解锁, 分为`true`和`false`
|
||||
|
||||
**可选参数 :** `immerseType`: 沉浸声环绕声类型, 分为 `c51` => `c51类型`, `ste` => `环绕立体声类型`, `aac` => `aac类型`, 仅在 `level=sky` 时生效, 默认为 `c51`
|
||||
|
||||
**接口地址 :** `/song/url/v1`
|
||||
|
||||
**调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless`
|
||||
**调用例子 :** `/song/url/v1?id=1969519579&level=exhigh` `/song/url/v1?id=1969519579,33894312&level=lossless` `/song/url/v1?id=1969519579&level=sky&immerseType=ste`
|
||||
|
||||
说明:`杜比全景声`音质需要设备支持,不同的设备可能会返回不同码率的 url。cookie 需要传入`os=pc`保证返回正常码率的 url。
|
||||
|
||||
@@ -1387,8 +1412,6 @@ tags: 歌单标签
|
||||
|
||||
说明 : 调用此接口 , 传入类型和歌单 id 可收藏歌单或者取消收藏歌单
|
||||
|
||||
!> 警告: 在`v4.29.7`版本后, 在网易云登陆后请求要带上`timestamp`字段, 否则会导致请求不合法
|
||||
|
||||
**必选参数 :**
|
||||
|
||||
`t` : 类型,1:收藏,2:取消收藏
|
||||
@@ -1401,7 +1424,7 @@ tags: 歌单标签
|
||||
|
||||
### 歌单收藏者
|
||||
|
||||
说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者
|
||||
说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者
|
||||
**必选参数 :**
|
||||
|
||||
`id` : 歌单 id
|
||||
@@ -1494,6 +1517,7 @@ tags: 歌单标签
|
||||
|
||||
- (可能存在)JSON 歌曲元数据
|
||||
|
||||
|
||||
```
|
||||
{"t":0,"c":[{"tx":"作曲: "},{"tx":"柳重言","li":"http://p1.music.126.net/Icj0IcaOjH2ZZpyAM-QGoQ==/6665239487822533.jpg","or":"orpheus://nm/artist/home?id=228547&type=artist"}]}
|
||||
{"t":5403,"c":[{"tx":"编曲: "},{"tx":"Alex San","li":"http://p1.music.126.net/pSbvYkrzZ1RFKqoh-fA9AQ==/109951166352922615.jpg","or":"orpheus://nm/artist/home?id=28984845&type=artist"}]}
|
||||
@@ -1510,6 +1534,7 @@ tags: 歌单标签
|
||||
|
||||
* 逐字歌词
|
||||
|
||||
|
||||
```
|
||||
[16210,3460](16210,670,0)还(16880,410,0)没...
|
||||
~~~~1 ~~~2 ~~~~3 ~~4 5 ~6 (...)
|
||||
@@ -1586,7 +1611,7 @@ tags: 歌单标签
|
||||
|
||||
说明 : 调用此接口 , 传入资源 parentCommentId 和资源类型 type 和资源 id 参数, 可获得该资源的歌曲楼层评论
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`parentCommentId`: 楼层评论 id
|
||||
|
||||
`id` : 资源 id
|
||||
@@ -1802,7 +1827,7 @@ tags: 歌单标签
|
||||
|
||||
说明 : 调用此接口 , 传入资源类型和资源 id,以及排序方式,可获取对应资源的评论
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`id` : 资源 id, 如歌曲 id,mv id
|
||||
|
||||
`type`: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型
|
||||
@@ -1825,7 +1850,7 @@ tags: 歌单标签
|
||||
7: 电台
|
||||
```
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`pageNo`:分页参数,第 N 页,默认为 1
|
||||
|
||||
`pageSize`:分页参数,每页多少条数据,默认 20
|
||||
@@ -2198,7 +2223,7 @@ privilege:权限相关信息
|
||||
|
||||
说明 : 调用此接口 , 可获得已收藏专辑列表
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`limit`: 取出数量 , 默认为 25
|
||||
|
||||
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*25, 其中 25 为 limit 的值 , 默认
|
||||
@@ -2490,6 +2515,30 @@ privilege:权限相关信息
|
||||
|
||||
**调用例子 :** `/scrobble?id=518066366&sourceid=36780169&time=291`
|
||||
|
||||
#### 听歌打卡 V2 (NCBL 加密版)
|
||||
|
||||
说明 : 调用此接口,使用桌面客户端 NCBL 加密日志格式上报听歌记录
|
||||
|
||||
**必选参数 :** `id`: 歌曲 id, `time`: 播放时长(秒)
|
||||
|
||||
**可选参数 :** `sourceid`: 来源列表 id, `source`: 来源名称(默认 list), `name`: 歌曲名, `artist`: 艺术家, `bitrate`: 码率(默认 320), `level`: 音质等级(默认 exhigh), `total`: 歌曲总时长(秒)
|
||||
|
||||
**接口地址 :** `/scrobble/v1`
|
||||
|
||||
**调用例子 :** `/scrobble/v1?id=518066366&sourceid=36780169&time=291`
|
||||
|
||||
### 提交歌曲播放状态
|
||||
|
||||
说明 : 调用此接口可提交歌曲播放状态,支持会话追踪和播放模式记录,未传入 `sessionId` 时后端会自动生成
|
||||
|
||||
**必选参数 :** `id`: 歌曲 id
|
||||
|
||||
**可选参数 :** `sessionId`: 播放会话 ID(12 位大写字母和数字),不传则自动生成, `progress`: 播放进度(秒),默认 0, `playMode`: 播放模式,默认 `list_loop`, `type`: 资源类型,默认 `song`
|
||||
|
||||
**接口地址 :** `/relay/play/state/submit`
|
||||
|
||||
**调用例子 :** `/relay/play/state/submit?id=518066366&progress=30`
|
||||
|
||||
### 热门歌手
|
||||
|
||||
说明 : 调用此接口 , 可获取热门歌手数据
|
||||
@@ -2507,7 +2556,7 @@ privilege:权限相关信息
|
||||
|
||||
说明 : 调用此接口 , 可获取全部 mv
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`area`: 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部
|
||||
`type`: 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部
|
||||
|
||||
@@ -2589,7 +2638,7 @@ privilege:权限相关信息
|
||||
|
||||
**接口地址 :** `/program/recommend`
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
`limit`: 取出数量 , 默认为 10
|
||||
|
||||
`offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*10, 其中 10 为 limit 的值 , 默认
|
||||
@@ -2871,6 +2920,7 @@ type : 地区
|
||||
- 适合 Vercel、Netlify 等有请求体限制的平台
|
||||
- 需要前端配合实现
|
||||
|
||||
|
||||
#### 客户端直传相关接口
|
||||
|
||||
**获取上传凭证**
|
||||
@@ -2917,6 +2967,7 @@ type : 地区
|
||||
- `artist`: 艺术家
|
||||
- `album`: 专辑名
|
||||
|
||||
|
||||
#### 客户端直传流程
|
||||
|
||||
1. 客户端计算文件 MD5
|
||||
@@ -2924,11 +2975,12 @@ type : 地区
|
||||
3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl`
|
||||
4. 调用 `/cloud/upload/complete` 完成导入
|
||||
|
||||
|
||||
### 云盘歌曲信息匹配纠正
|
||||
|
||||
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
`uid`: 用户 id
|
||||
|
||||
`sid`: 云盘的歌曲 id
|
||||
@@ -3399,7 +3451,7 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
||||
|
||||
`limit` : 返回数量 , 默认为 30
|
||||
|
||||
`offset` : 偏移数量,用于分页 , 如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
|
||||
`offset` : 偏移数量,用于分页 , 如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
|
||||
**接口地址 :** `/album/list`
|
||||
|
||||
**调用例子 :** `/album/list?limit=10`
|
||||
@@ -3558,7 +3610,7 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
||||
|
||||
**可选参数 :** `limit`: 取出评论数量 , 默认为 10
|
||||
|
||||
`offset`: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)\*10, 其中 10 为 limit 的值
|
||||
`offset`: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)\*10, 其中 10 为 limit 的值
|
||||
**接口地址 :** `/yunbei/tasks/expense`
|
||||
|
||||
**调用例子 :** `/yunbei/tasks/expense?limit=1`
|
||||
@@ -3575,6 +3627,32 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
||||
|
||||
**调用例子 :** `/artist/new/song?limit=1` `/artist/new/song?limit=1&before=1602777625000`
|
||||
|
||||
### 关注歌手最近新歌 - 播放全部
|
||||
|
||||
说明 : 登录后调用此接口可获取所有关注歌手最近的 50 首新歌
|
||||
|
||||
**接口地址 :** `/artist/new/song/playall`
|
||||
|
||||
**调用例子 :** `/artist/new/song/playall`
|
||||
|
||||
### 关注歌手新作品(歌曲/MV)
|
||||
|
||||
说明 : 登录后调用此接口可分页获取关注歌手的新歌曲和 MV
|
||||
|
||||
**可选参数 :** `limit`: 返回数量,默认为 10
|
||||
|
||||
`startTimestamp`: 时间游标,首次请求默认为当前时间,后续请求传入上一页返回的时间游标
|
||||
|
||||
`before`: `startTimestamp` 的别名
|
||||
|
||||
`sourceType`: 作品来源类型,已确认 `1` 表示新歌,其他取值待确认,默认为 `1`
|
||||
|
||||
`firstRequest`: 是否首次请求,默认为 `true`,翻页时传 `false`
|
||||
|
||||
**接口地址 :** `/artist/new/song/mv/list/v2`
|
||||
|
||||
**调用例子 :** `/artist/new/song/mv/list/v2` `/artist/new/song/mv/list/v2?startTimestamp=1783680576099&sourceType=1&limit=10&firstRequest=false`
|
||||
|
||||
### 关注歌手新 MV
|
||||
|
||||
说明 :登录后调用此接口可获取关注歌手新 MV
|
||||
@@ -3705,6 +3783,14 @@ type='1009' 获取其 id, 如`/search?keywords= 代码时间 &type=1009`
|
||||
|
||||
**调用例子 :** `/vip/growthpoint/get?ids=7043206830_7` `/vip/growthpoint/get?ids=8613118351_1,8607552957_1`
|
||||
|
||||
### 一键领取所有 vip 成长值
|
||||
|
||||
说明 : 登录后调用此接口 , 一键领取所有已完成的会员任务成长值奖励
|
||||
|
||||
**接口地址 :** `/vip/growthpoint/getall`
|
||||
|
||||
**调用例子 :** `/vip/growthpoint/getall`
|
||||
|
||||
### 歌手粉丝
|
||||
|
||||
说明 : 调用此接口 , 传入歌手 id, 可获取歌手粉丝
|
||||
@@ -4196,6 +4282,7 @@ ONLINE 已发布
|
||||
- `voiceFeeType: 0`:返回免费的声音
|
||||
- `voiceFeeType: 1`:返回收费的声音
|
||||
|
||||
|
||||
### 播客声音详情
|
||||
|
||||
说明: 获取播客里的声音详情
|
||||
@@ -4772,6 +4859,23 @@ bitrate = Math.floor(br / 1000)
|
||||
|
||||
**接口地址 :** `/listen/data/today/song`
|
||||
|
||||
### 听歌足迹 - 歌曲播放排行
|
||||
|
||||
说明 : 登录后调用此接口, 获取歌曲播放排行, 返回每首歌的播放次数 (playCount); 默认返回前 20 名 (Top20), 不足则返回全部
|
||||
|
||||
**必选参数 :**
|
||||
|
||||
`type`: 维度类型 周 week 月 month
|
||||
|
||||
**可选参数 :**
|
||||
|
||||
`endTime` : 周: 每周周六 0 点的时间戳 月: 每月最后一天 0 点的时间戳
|
||||
不填就是本周/月的
|
||||
|
||||
**接口地址 :** `/listen/data/song/play/rank`
|
||||
|
||||
**调用例子 :** `/listen/data/song/play/rank?type=month`
|
||||
|
||||
### 听歌足迹 - 总收听时长
|
||||
|
||||
说明 : 登录后调用此接口, 获取总收听时长; 相关接口可能需要 vip 权限
|
||||
@@ -5028,7 +5132,7 @@ let data = encodeURIComponent(
|
||||
|
||||
**调用例子:** `/vip/sign`
|
||||
|
||||
### 黑胶乐签打卡信息
|
||||
### 黑胶乐签未来打卡信息
|
||||
|
||||
说明: 登录后调用此接口, 获取黑胶乐签打卡信息
|
||||
|
||||
@@ -5117,7 +5221,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明 : 登录后调用此接口, 获取我创建的博客声音
|
||||
|
||||
**可选参数 :**
|
||||
**可选参数 :**
|
||||
|
||||
`limit` : 返回数量 , 默认为 20
|
||||
|
||||
@@ -5130,7 +5234,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 获取DIFM电台分类
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||
|
||||
@@ -5142,7 +5246,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 获取DIFM电台收藏列表
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||
|
||||
@@ -5178,7 +5282,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 获取DIFM播放列表
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`source`: 来源, 0: 最嗨电音 1: 古典电台 2: 爵士电台
|
||||
|
||||
@@ -5212,7 +5316,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 获取标签下资源列表; 接口返回的`trackId`可以用于请求`/song/url/v1`接口,用于获取声音的下载地址
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`tag`: 标签, 由标签列表接口得到
|
||||
|
||||
@@ -5224,7 +5328,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 查看同类推荐
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
||||
|
||||
@@ -5244,7 +5348,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口, 收藏声音
|
||||
|
||||
**必选参数 :**
|
||||
**必选参数 :**
|
||||
|
||||
`id`: id, `/sati/tag/list`接口返回的`trackId`
|
||||
|
||||
@@ -5260,7 +5364,7 @@ let data = encodeURIComponent(
|
||||
|
||||
说明: 调用此接口,获取跑步漫游的歌曲信息
|
||||
|
||||
**必选参数:**
|
||||
**必选参数:**
|
||||
|
||||
`bpm`: 步频
|
||||
|
||||
@@ -5336,6 +5440,323 @@ let data = encodeURIComponent(
|
||||
|
||||
说明 : 调用此接口,可获取城市榜、城市风格榜等指定维度音乐排行榜歌曲列表
|
||||
|
||||
**必选参数 :**
|
||||
|
||||
`chartCode`: 榜单编码,如 `CITY_SONG_CHART`、`CITY_STYLE_SONG_CHART`
|
||||
|
||||
`targetId`: 目标 id,城市榜如 `110000`,城市风格榜如 北京华语流行榜 `110000_1020`。城市风格榜格式通常为 `城市 id_曲风 id`,其中曲风 id 可通过[曲风列表](#曲风列表)接口 `/style/list` 获取。城市榜的城市列表可通过[多级行政区划数据](#多级行政区划数据)接口传入 `bizCode=chart` 获取;城市风格榜的城市列表可通过该接口传空 `bizCode` 获取
|
||||
|
||||
`targetType`: 目标类型,如 `CITY`、`CITY_STYLE`
|
||||
|
||||
**接口地址 :** `/chart/song/detail`
|
||||
|
||||
**调用例子 :** `/chart/song/detail?chartCode=CITY_STYLE_SONG_CHART&targetId=110000_1020&targetType=CITY_STYLE`
|
||||
|
||||
### 会员任务 - 新版
|
||||
|
||||
说明 : 登录后调用此接口, 获取会员任务
|
||||
|
||||
**可选参数** `id`: 用户 id, 传入后可获取指定用户的会员任务, 不传入则获取当前登录用户的会员任务
|
||||
|
||||
**接口地址 :** `/vip/task/v1`
|
||||
|
||||
**调用例子 :** `/vip/task/v1` `/vip/task/v1?id=32953014`
|
||||
|
||||
### 黑胶乐签详情
|
||||
|
||||
说明 : 登录后调用此接口, 传入时间戳, 获取黑胶乐签详情
|
||||
|
||||
**必选参数 :** `timestamp`: 时间戳, 单位毫秒, 如 `1704067200000` 表示 2024 年 12 月 31 日 0 点 (不传入会出现随机的乐签详情)
|
||||
|
||||
**接口地址 :** `/vip/sign/detail`
|
||||
|
||||
**调用例子 :** `/vip/sign/detail`
|
||||
|
||||
### 黑胶乐签历史
|
||||
|
||||
说明 : 登录后调用此接口, 获取黑胶乐签历史
|
||||
|
||||
**接口地址 :** `/vip/sign/history`
|
||||
|
||||
**调用例子 :** `/vip/sign/history`
|
||||
|
||||
### 直接获取云盘歌曲下载链接
|
||||
|
||||
说明 : 调用此接口, 传入云盘歌曲 id, 可直接获取云盘歌曲下载链接
|
||||
|
||||
**必选参数 :** `id`: 云盘歌曲 id
|
||||
|
||||
**接口地址 :** `/song/cloud/download`
|
||||
|
||||
**调用例子 :** `/song/cloud/download?id=123456789`'
|
||||
|
||||
### 获取广告
|
||||
|
||||
说明 : 调用此接口, 可获取广告
|
||||
|
||||
**接口地址 :** `/ad/get`
|
||||
|
||||
**调用例子 :** `/ad/get`
|
||||
|
||||
### 获取30分钟免费听歌时长
|
||||
|
||||
说明 : 登录后调用此接口, 获取30分钟免费听歌时长
|
||||
|
||||
!> 警告: 通过调取接口出现的任何问题由调用者自行承担
|
||||
|
||||
**可选参数 :** `reqUid` 通过`/ad/get` 获取的广告ID
|
||||
|
||||
**接口地址 :** `/ad/listening/rights/gain`
|
||||
|
||||
**调用例子 :** `/ad/listening/rights/gain`
|
||||
|
||||
### 获取免费听时长状态
|
||||
|
||||
说明 : 登录后调用此接口, 获取免费听剩余时长
|
||||
|
||||
**接口地址 :** `/ad/listening/rights`
|
||||
|
||||
**调用例子 :** `/ad/listening/rights`
|
||||
|
||||
### 云小编 - 获取用户详情
|
||||
|
||||
说明: 登录后调用此接口, 获取云小编用户详情
|
||||
|
||||
**接口地址:** `/rep/ugc/user/get`
|
||||
|
||||
**调用例子:** `/rep/ugc/user/get`
|
||||
|
||||
### 云小编 - 每日签到
|
||||
|
||||
说明: 登录后调用此接口, 进行云小编签到, 领取 5 积分, 签到后 `/rep/ugc/user/get` 返回 `data.signed = 1`
|
||||
|
||||
**接口地址:** `/rep/ugc/user/sign`
|
||||
|
||||
**调用例子:** `/rep/ugc/user/sign`
|
||||
|
||||
### 云小编 - 查询会员任务状态
|
||||
|
||||
说明: 登录后调用此接口, 查询云小编会员任务状态, 当 `data.status = 20` 时, 可调用 `/rep/ugc/user/collect-vip` 领取会员
|
||||
|
||||
**接口地址:** `/rep/ugc/user/vip`
|
||||
|
||||
**调用例子:** `/rep/ugc/user/vip`
|
||||
|
||||
### 云小编 - 活动信息
|
||||
|
||||
说明: 登录后调用此接口, 查询云小编会员活动信息
|
||||
|
||||
**接口地址:** `/rep/ugc/activity/get`
|
||||
|
||||
**调用例子:** `/rep/ugc/activity/get`
|
||||
|
||||
### 云小编 - 获取任务
|
||||
|
||||
> 注意: 调用前请先使用官方客户端完成云小编“情绪标签审核”入站考试
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`type`: 任务类型, 1: 歌曲曲风审核, 2: 歌曲语种审核, 3: 歌曲原唱审核, 4: 情绪标签审核, 默认 `4`
|
||||
|
||||
**接口地址:** `/thinktank/audit/resource/detail`
|
||||
|
||||
**调用例子:** `/thinktank/audit/resource/detail?type=4`
|
||||
|
||||
### 云小编 - 提交任务
|
||||
|
||||
> 注意: 投票结果会在后台审核, 一致 +3 积分, 不一致 -2 积分
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`type`: 任务类型, 1: 歌曲曲风审核, 2: 歌曲语种审核, 3: 歌曲原唱审核, 4: 情绪标签审核, 默认 `4`
|
||||
|
||||
**必选参数:**
|
||||
|
||||
`taskId`: 任务 ID, 调用 `/thinktank/audit/resource/detail` 获取 `data.taskId`
|
||||
|
||||
`judgement`: 审核结果, 1: 同意, 2: 否决, 3: 跳过 (不算次数)
|
||||
|
||||
**接口地址:** `/thinktank/audit/resource/update`
|
||||
|
||||
**调用例子:** `/thinktank/audit/resource/update?type=4&taskId=123456&judgement=1`
|
||||
|
||||
### 云小编 - 领取任务积分
|
||||
|
||||
说明: 完成任务后调用此接口, 领取云小编任务积分
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`activityId`: 活动 ID, 调用 `/rep/ugc/activity/get` 获取, 默认 `5001`
|
||||
|
||||
**接口地址:** `/rep/ugc/activity/collect`
|
||||
|
||||
**调用例子:** `/rep/ugc/activity/collect?activityId=5001`
|
||||
|
||||
### 云小编 - 领取一日会员
|
||||
|
||||
说明: 达成领取条件 (`/rep/ugc/user/vip`) 后调用此接口, 领取一日会员
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`activityId`: 活动 ID, 调用 `/rep/ugc/activity/get` 获取, 默认 `5001`
|
||||
|
||||
**接口地址:** `/rep/ugc/user/collect-vip`
|
||||
|
||||
**调用例子:** `/rep/ugc/user/collect-vip?activityId=5001`
|
||||
|
||||
### 云小编 - 剩余抽奖次数
|
||||
|
||||
说明: 登录后调用此接口, 获取今日云小编抽奖剩余次数
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`activityId`: 活动 ID, 默认 `6501202`
|
||||
|
||||
**接口地址:** `/middle/play/lottery/remain/chance`
|
||||
|
||||
**调用例子:** `/middle/play/lottery/remain/chance?activityId=6501202`
|
||||
|
||||
### 云小编 - 每日抽奖
|
||||
|
||||
说明: 登录后调用此接口, 消耗 200 积分进行抽奖, 每日最多抽 3 次
|
||||
|
||||
> 注意: 抽奖失败也消耗每日次数, 请先调用 `/rep/ugc/user/get` 查询可用积分
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`activityId`: 活动 ID, 默认 `6501202`
|
||||
|
||||
`drawCount`: 未知, 默认 `1`
|
||||
|
||||
`checkToken`: 易盾反作弊 Token, 默认自动获取
|
||||
|
||||
**接口地址:** `/middle/play/do/lottery`
|
||||
|
||||
**调用例子:** `/middle/play/do/lottery?activityId=6501202&drawCount=1`
|
||||
|
||||
### 发送/删除评论
|
||||
|
||||
说明 : 调用此接口,可发送评论或者删除评论
|
||||
|
||||
1. 发送评论
|
||||
|
||||
**必选参数**
|
||||
|
||||
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
|
||||
|
||||
```
|
||||
0: 歌曲
|
||||
|
||||
1: mv
|
||||
|
||||
2: 歌单
|
||||
|
||||
3: 专辑
|
||||
|
||||
4: 电台
|
||||
|
||||
5: 视频
|
||||
|
||||
6: 动态
|
||||
```
|
||||
|
||||
`id`: 对应资源 id
|
||||
|
||||
`content`: 要发送的内容
|
||||
|
||||
**调用例子** : `/comment/add?type=1&id=5436712&content=test` (往广岛之恋 mv 发送评论: test)
|
||||
|
||||
2. 回复评论
|
||||
|
||||
**必选参数**
|
||||
|
||||
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
|
||||
|
||||
```
|
||||
0: 歌曲
|
||||
|
||||
1: mv
|
||||
|
||||
2: 歌单
|
||||
|
||||
3: 专辑
|
||||
|
||||
4: 电台
|
||||
|
||||
5: 视频
|
||||
|
||||
6: 动态
|
||||
```
|
||||
|
||||
`id`: 对应资源 id
|
||||
|
||||
`cid`: 评论 id
|
||||
|
||||
`content`: 要发送的内容
|
||||
|
||||
**调用例子** : `/comment/add?type=1&id=5436712&cid=1535550516319&content=test` (往广岛之恋 mv 回复test评论: test)
|
||||
|
||||
3. 删除评论
|
||||
|
||||
**必选参数**
|
||||
|
||||
`type`: 数字,资源类型,对应歌曲,mv,专辑,歌单,电台,视频对应以下类型
|
||||
|
||||
```
|
||||
0: 歌曲
|
||||
|
||||
1: mv
|
||||
|
||||
2: 歌单
|
||||
|
||||
3: 专辑
|
||||
|
||||
4: 电台节目
|
||||
|
||||
5: 视频
|
||||
|
||||
6: 动态
|
||||
|
||||
7: 电台
|
||||
|
||||
```
|
||||
|
||||
`id`: 对应资源 id
|
||||
|
||||
`cid`: 评论 id
|
||||
|
||||
**调用例子** : `/comment?type=1&id=5436712&cid=1535550516319` (在广岛之恋 mv 删除评论)
|
||||
|
||||
### 获取在线设备列表
|
||||
|
||||
说明: 登录后调用此接口, 获取在线设备列表
|
||||
|
||||
**接口地址:** `/device/list`
|
||||
|
||||
**调用例子:** `/device/list`
|
||||
|
||||
### 发送安全验证码
|
||||
|
||||
说明: 登录后调用此接口, 传入手机号, 可发送安全验证码
|
||||
|
||||
**必选参数 :** `phone`: 手机号
|
||||
|
||||
**接口地址 :** `/captcha/safe/sent`
|
||||
|
||||
**调用例子 :** `/captcha/safe/sent?phone=13XXXXXXXXX`
|
||||
|
||||
### 强制下线设备
|
||||
|
||||
说明: 登录后调用此接口, 传入设备 id, 可强制下线设备的登录会话
|
||||
|
||||
**必选参数 :** `key`: 设备的 `deviceKey`, 可通过 `/device/list` 获取
|
||||
|
||||
`captcha`: 安全验证码, 可通过 `/captcha/safe/sent` 获取
|
||||
|
||||
**接口地址 :** `/device/kickoff`
|
||||
|
||||
**调用例子 :** `/device/kickoff?key=00ALDFGEXXXXXXXXXXXXXXXXX&captcha=1234`
|
||||
|
||||
## 离线访问此文档
|
||||
|
||||
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 858 KiB |
@@ -108,7 +108,7 @@
|
||||
name: '网易云音乐 API Enhanced',
|
||||
repo: 'https://github.com/neteasecloudmusicapienhanced/api-enhanced',
|
||||
coverpage: true,
|
||||
homepage: 'https://cdn.jsdelivr.net/gh/NeteaseCloudMusicApiEnhanced/api-enhanced@main/public/docs/home.md',
|
||||
homepage: 'home.md',
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/docsify@4.11.3/lib/docsify.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>免费听 — 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #333;
|
||||
--muted: #666;
|
||||
--border: #ddd;
|
||||
--bg: #f5f5f5;
|
||||
--panel: #ffffff;
|
||||
--accent: #333;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 40px auto; padding: 0 20px; }
|
||||
@media (max-width: 480px) {
|
||||
.container { margin: 20px auto; padding: 0 16px; }
|
||||
header.site-header h1 { font-size: 22px; }
|
||||
.block { padding: 16px; }
|
||||
}
|
||||
header.site-header { margin-bottom: 24px; }
|
||||
header.site-header h1 { font-size: 24px; font-weight: 600; margin: 0; }
|
||||
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
|
||||
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||
.block h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
|
||||
.block .desc { margin: 0 0 12px; color: var(--muted); font-size: 13px; }
|
||||
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 200px; overflow: auto; }
|
||||
pre.data { max-height: 360px; }
|
||||
.row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
|
||||
button:hover { background: #eee; }
|
||||
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
|
||||
button.primary:hover { opacity: .85; }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
button.danger { background: #a11; color: #fff; border-color: #a11; }
|
||||
button.danger:hover { opacity: .85; }
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); }
|
||||
.tag.ok { background: #e6f7e6; color: #1a7a1a; border-color: #b7e6b7; }
|
||||
.tag.no { background: #ffe6e6; color: #a11; border-color: #f5c6c6; }
|
||||
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
|
||||
.flex { display: flex; align-items: center; gap: 10px; }
|
||||
.flex.sb { justify-content: space-between; }
|
||||
.step { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.step .num { width: 26px; height: 26px; border-radius: 50%; background: var(--fg); color: var(--panel); display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; flex-shrink: 0; margin-top: 1px; }
|
||||
.step .num.done { background: #1a7a1a; }
|
||||
.step .body { flex: 1; min-width: 0; }
|
||||
input[type=text] { width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); font-size: 14px; font-family: inherit; margin-top: 6px; }
|
||||
input[type=text]:focus { outline: 2px solid var(--fg); outline-offset: -1px; border-color: transparent; }
|
||||
footer.site-footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
|
||||
footer.site-footer a { color: var(--fg); text-decoration: none; }
|
||||
footer.site-footer a:hover { border-bottom: 1px dotted var(--fg); }
|
||||
.info-grid { display: grid; grid-template-columns: 80px 1fr; gap: 4px 12px; font-size: 13px; margin-top: 8px; }
|
||||
.info-grid .lbl { color: var(--muted); }
|
||||
|
||||
/* ---- 剩余时长计时器 ---- */
|
||||
.timer-wrap { text-align: center; padding: 12px 0 4px; }
|
||||
.timer-row { display: flex; align-items: baseline; justify-content: center; gap: 2px; font-variant-numeric: tabular-nums; }
|
||||
.timer-num { font-size: 36px; font-weight: 700; letter-spacing: 1px; line-height: 1.2; font-family: 'SF Mono', 'Courier New', monospace; min-width: 2ch; text-align: center; }
|
||||
.timer-label { font-size: 13px; color: var(--muted); margin-right: 12px; }
|
||||
.timer-label:last-child { margin-right: 0; }
|
||||
.timer-sep { font-size: 28px; font-weight: 300; color: #bbb; margin: 0 2px; }
|
||||
.timer-unit { display: flex; flex-direction: column; align-items: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header class="site-header">
|
||||
<h1>🎧 免费听 · 看广告获取时长</h1>
|
||||
<p class="sub">模拟 App 内看激励视频广告 → 领取免费听权益的完整流程</p>
|
||||
</header>
|
||||
|
||||
<!-- 步骤 1: 注册 Token -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s1num">1</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>注册反作弊 Token</h3>
|
||||
<span id="s1tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">调用易盾接口获取实时 token,广告接口需要此 token 才能正常响应</p>
|
||||
<pre id="s1pre">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step1" class="primary">① 注册 Token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2: 获取广告 -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s2num">2</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>获取广告 & 提取 reqId</h3>
|
||||
<span id="s2tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">请求广告位,从返回的 extJson.contextInfo.req_id 提取用于领取权益的标识</p>
|
||||
<pre id="s2pre" class="data">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step2" class="primary" disabled>② 获取广告</button>
|
||||
<button id="step2b" disabled>换一个广告位</button>
|
||||
</div>
|
||||
<div class="info-grid" id="s2info" style="display:none">
|
||||
<div class="lbl">广告 ID</div><div id="s2adId">-</div>
|
||||
<div class="lbl">reqId</div><div id="s2reqId">-</div>
|
||||
<div class="lbl">文案</div><div id="s2text">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 3: 领取权益 -->
|
||||
<div class="block">
|
||||
<div class="step">
|
||||
<div class="num" id="s3num">3</div>
|
||||
<div class="body">
|
||||
<div class="flex sb">
|
||||
<h3>领取免费听权益</h3>
|
||||
<span id="s3tag" class="tag no">未开始</span>
|
||||
</div>
|
||||
<p class="desc">传入 reqId 及相关参数,领取免费听权益(模拟点击广告后的领取请求)</p>
|
||||
<pre id="s3pre" class="data">(尚未执行)</pre>
|
||||
<div class="row">
|
||||
<button id="step3" class="primary" disabled>③ 领取权益</button>
|
||||
</div>
|
||||
<div class="info-grid" id="s3info" style="display:none">
|
||||
<div class="lbl">结果</div><div id="s3result">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一键全流程 -->
|
||||
<div class="block">
|
||||
<div class="flex sb">
|
||||
<h3>一键运行</h3>
|
||||
<span id="allTag" class="tag no">就绪</span>
|
||||
</div>
|
||||
<p class="desc" style="margin-top:4px">按顺序自动执行以上三个步骤</p>
|
||||
<div class="row">
|
||||
<button id="runAll" class="primary">▶ 一键获取免费听时长</button>
|
||||
<button id="clearAll" class="danger">✕ 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剩余时长计时器 -->
|
||||
<div class="block" id="rightsBlock">
|
||||
<div class="flex sb">
|
||||
<h3 id="rightsTitleDisplay">🕐 免费听时长剩余</h3>
|
||||
<span id="rightsTag" class="tag no">未查询</span>
|
||||
</div>
|
||||
<p class="desc" id="rightsSubDesc">查询你的免费听权益剩余时长</p>
|
||||
<div class="timer-wrap">
|
||||
<div class="timer-row" id="timerRow">
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerDays">--</span>
|
||||
<span class="timer-label">天</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerHours">--</span>
|
||||
<span class="timer-label">时</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerMinutes">--</span>
|
||||
<span class="timer-label">分</span>
|
||||
</div>
|
||||
<span class="timer-sep">:</span>
|
||||
<div class="timer-unit">
|
||||
<span class="timer-num" id="timerSeconds">--</span>
|
||||
<span class="timer-label">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="justify-content:center">
|
||||
<button id="queryRightsBtn" class="primary">⟳ 查询剩余时长</button>
|
||||
<button id="claim30Btn">领取30分钟</button>
|
||||
</div>
|
||||
<div class="info-grid" id="rightsInfo" style="display:none">
|
||||
<div class="lbl">状态</div><div id="rightsStatus">-</div>
|
||||
<div class="lbl">结束时间</div><div id="rightsEndTimeDisplay">-</div>
|
||||
<div class="lbl">今日已覆盖</div><div id="rightsCoverToday">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<a href="/">← 返回首页</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const base = location.origin
|
||||
|
||||
function setTag(id, type, text) {
|
||||
const el = $(id)
|
||||
el.className = 'tag ' + type
|
||||
el.textContent = text
|
||||
}
|
||||
|
||||
function setNum(id, done) {
|
||||
$(id).className = 'num' + (done ? ' done' : '')
|
||||
}
|
||||
|
||||
function nocache(url) {
|
||||
const sep = url.indexOf('?') > -1 ? '&' : '?'
|
||||
return url + sep + '_t=' + Date.now()
|
||||
}
|
||||
|
||||
// ===== 步骤 1: 注册 Token =====
|
||||
async function step1() {
|
||||
const btn = $('step1')
|
||||
btn.disabled = true
|
||||
setTag('s1tag', 'busy', '注册中…')
|
||||
setNum('s1num', false)
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/register/checktoken/v3?refresh=1'))
|
||||
const data = await res.json()
|
||||
$('s1pre').textContent = JSON.stringify(data, null, 2)
|
||||
if (data.token) {
|
||||
setTag('s1tag', 'ok', '已注册')
|
||||
setNum('s1num', true)
|
||||
$('step2').disabled = false
|
||||
$('step2b').disabled = false
|
||||
return data.token
|
||||
}
|
||||
setTag('s1tag', 'no', '失败')
|
||||
return null
|
||||
} catch (e) {
|
||||
$('s1pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s1tag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 步骤 2: 获取广告 =====
|
||||
async function step2(typeId) {
|
||||
const btn = $('step2')
|
||||
const btnB = $('step2b')
|
||||
btn.disabled = btnB.disabled = true
|
||||
setTag('s2tag', 'busy', '获取中…')
|
||||
setNum('s2num', false)
|
||||
$('s2info').style.display = 'none'
|
||||
try {
|
||||
let url = base + '/ad/get' + (typeId ? '?type_ids=' + encodeURIComponent(typeId) : '')
|
||||
const res = await fetch(nocache(url))
|
||||
const data = await res.json()
|
||||
$('s2pre').textContent = JSON.stringify(data, null, 2)
|
||||
|
||||
if (data.code === 200 && data.extra && data.extra.reqId) {
|
||||
setTag('s2tag', 'ok', '已获取')
|
||||
setNum('s2num', true)
|
||||
$('s2info').style.display = 'grid'
|
||||
|
||||
const adKeys = data.ads ? Object.keys(data.ads) : []
|
||||
let adId = '-', text = '-'
|
||||
if (adKeys.length > 0) {
|
||||
const ad = data.ads[adKeys[0]]
|
||||
adId = ad.ad_id || '-'
|
||||
text = ad.text || '-'
|
||||
}
|
||||
|
||||
$('s2adId').textContent = adId
|
||||
$('s2reqId').textContent = data.extra.reqId
|
||||
$('s2text').textContent = text
|
||||
|
||||
$('step3').disabled = false
|
||||
return data.extra.reqId
|
||||
}
|
||||
setTag('s2tag', 'no', '未获取到 reqId')
|
||||
return null
|
||||
} catch (e) {
|
||||
$('s2pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s2tag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btnB.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('step2').onclick = () => step2()
|
||||
$('step2b').onclick = () => step2('["400002_0"]')
|
||||
|
||||
// ===== 步骤 3: 领取权益 =====
|
||||
async function step3() {
|
||||
const btn = $('step3')
|
||||
btn.disabled = true
|
||||
setTag('s3tag', 'busy', '领取中…')
|
||||
setNum('s3num', false)
|
||||
$('s3info').style.display = 'none'
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/ad/listening/rights/gain'))
|
||||
const data = await res.json()
|
||||
$('s3pre').textContent = JSON.stringify(data, null, 2)
|
||||
|
||||
if (data.code === 200) {
|
||||
setTag('s3tag', 'ok', '已领取')
|
||||
setNum('s3num', true)
|
||||
$('s3info').style.display = 'grid'
|
||||
const body = data.data || {}
|
||||
$('s3result').textContent = JSON.stringify(body).substring(0, 200)
|
||||
// 领取成功后自动刷新剩余时长
|
||||
queryRights()
|
||||
return true
|
||||
}
|
||||
setTag('s3tag', 'no', '领取失败')
|
||||
return false
|
||||
} catch (e) {
|
||||
$('s3pre').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
setTag('s3tag', 'no', e.message)
|
||||
return false
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('step3').onclick = step3
|
||||
|
||||
// ===== 一键运行 =====
|
||||
$('runAll').onclick = async function () {
|
||||
const btn = this
|
||||
btn.disabled = true
|
||||
setTag('allTag', 'busy', '运行中…')
|
||||
|
||||
await step1()
|
||||
await step2()
|
||||
await step3()
|
||||
|
||||
setTag('allTag', 'ok', '完成')
|
||||
btn.disabled = false
|
||||
}
|
||||
|
||||
// ===== 清空 =====
|
||||
$('clearAll').onclick = function () {
|
||||
const steps = ['s1', 's2', 's3']
|
||||
const tags = { s1: '未开始', s2: '未开始', s3: '未开始' }
|
||||
steps.forEach(s => {
|
||||
setTag(s + 'tag', 'no', tags[s])
|
||||
setNum(s + 'num', false)
|
||||
$(s + 'pre').textContent = '(尚未执行)'
|
||||
const info = $(s + 'info')
|
||||
if (info) info.style.display = 'none'
|
||||
})
|
||||
$('step1').disabled = false
|
||||
$('step2').disabled = true
|
||||
$('step2b').disabled = true
|
||||
$('step3').disabled = true
|
||||
setTag('allTag', 'no', '就绪')
|
||||
$('runAll').disabled = false
|
||||
|
||||
// 重置计时器
|
||||
stopCountdown()
|
||||
$('timerDays').textContent = '--'
|
||||
$('timerHours').textContent = '--'
|
||||
$('timerMinutes').textContent = '--'
|
||||
$('timerSeconds').textContent = '--'
|
||||
setTag('rightsTag', 'no', '未查询')
|
||||
$('rightsInfo').style.display = 'none'
|
||||
$('rightsTitleDisplay').textContent = '🕐 免费听时长剩余'
|
||||
$('rightsSubDesc').textContent = '查询你的免费听权益剩余时长'
|
||||
$('runAll').disabled = true
|
||||
}
|
||||
|
||||
// ===== 剩余时长计时器 =====
|
||||
let countdownTimer = null
|
||||
let remainingMs = 0
|
||||
|
||||
/** 毫秒 → { d, h, m, s } */
|
||||
function parseMs(ms) {
|
||||
if (ms <= 0) return { d: 0, h: 0, m: 0, s: 0 }
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
return {
|
||||
d: Math.floor(totalSec / 86400),
|
||||
h: Math.floor((totalSec % 86400) / 3600),
|
||||
m: Math.floor((totalSec % 3600) / 60),
|
||||
s: totalSec % 60
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimer(ms) {
|
||||
const t = parseMs(ms)
|
||||
$('timerDays').textContent = String(t.d).padStart(2, '0')
|
||||
$('timerHours').textContent = String(t.h).padStart(2, '0')
|
||||
$('timerMinutes').textContent = String(t.m).padStart(2, '0')
|
||||
$('timerSeconds').textContent = String(t.s).padStart(2, '0')
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(ms) {
|
||||
stopCountdown()
|
||||
remainingMs = Math.max(0, ms)
|
||||
renderTimer(remainingMs)
|
||||
if (remainingMs <= 0) {
|
||||
setTag('rightsTag', 'no', '已用完')
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingMs = Math.max(0, remainingMs - 1000)
|
||||
renderTimer(remainingMs)
|
||||
if (remainingMs <= 0) {
|
||||
stopCountdown()
|
||||
setTag('rightsTag', 'no', '已过期')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function queryRights() {
|
||||
const btn = $('queryRightsBtn')
|
||||
btn.disabled = true
|
||||
setTag('rightsTag', 'busy', '查询中…')
|
||||
try {
|
||||
const res = await fetch(nocache(base + '/ad/listening/rights'))
|
||||
const data = await res.json()
|
||||
if (data.code === 200 && data.data) {
|
||||
const d = data.data
|
||||
$('rightsTitleDisplay').textContent = '🕐 ' + (d.title || '免费听时长剩余')
|
||||
$('rightsSubDesc').textContent = d.vipInfoContent || '查询你的免费听权益剩余时长'
|
||||
$('rightsInfo').style.display = 'grid'
|
||||
|
||||
// 状态
|
||||
const status = d.status || ''
|
||||
$('rightsStatus').textContent = status
|
||||
|
||||
// 结束时间
|
||||
if (d.rightsEndTime) {
|
||||
const endDate = new Date(d.rightsEndTime)
|
||||
$('rightsEndTimeDisplay').textContent = endDate.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
|
||||
// 今日已覆盖
|
||||
$('rightsCoverToday').textContent = d.rightsCoverToday ? '是 ✓' : '否'
|
||||
|
||||
// 倒计时
|
||||
if (d.rightsRemainingTime > 0) {
|
||||
startCountdown(d.rightsRemainingTime)
|
||||
setTag('rightsTag', 'ok', '已解锁 ✓')
|
||||
} else {
|
||||
renderTimer(0)
|
||||
setTag('rightsTag', 'no', '已用完')
|
||||
}
|
||||
|
||||
// 领取按钮(仅当 step3 可用时启用)
|
||||
if (d.cardContent && d.cardContent.actionTitle && $('step3').disabled === false) {
|
||||
$('claim30Btn').disabled = false
|
||||
$('claim30Btn').textContent = d.cardContent.actionTitle
|
||||
} else {
|
||||
$('claim30Btn').disabled = true
|
||||
}
|
||||
|
||||
if (d.rightsUpperLimit) {
|
||||
$('rightsStatus').textContent += ' (已达上限)'
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
setTag('rightsTag', 'no', '查询失败')
|
||||
return null
|
||||
} catch (e) {
|
||||
setTag('rightsTag', 'no', e.message)
|
||||
return null
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
$('queryRightsBtn').onclick = queryRights
|
||||
|
||||
$('claim30Btn').onclick = async function () {
|
||||
const ok = await step3()
|
||||
if (ok) await queryRights()
|
||||
}
|
||||
|
||||
// 页面加载后自动查询一次
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(queryRights, 300)
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-1
@@ -95,7 +95,12 @@ curl -s {origin}/search?keywords=网易云</code></pre>
|
||||
<a href="/api_decrypt.html">API 解密</a> ·
|
||||
<a href="/listen_together_host.html">一起听示例</a> ·
|
||||
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
|
||||
<a href="/avatar_update.html">头像更新示例</a>
|
||||
<a href="/avatar_update.html">头像更新示例</a> ·
|
||||
<a href="/scrobble.html">听歌打卡示例</a> ·
|
||||
<a href="/yidun.html">获取 CheckToken</a> ·
|
||||
<a href="/free_listen.html">免费听权益</a> ·
|
||||
<a href="/ugc.html">云小编任务中心</a> ·
|
||||
<a href="/ugc_lottery.html">云小编每日抽奖</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -148,12 +148,12 @@
|
||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||
clearInterval(timer)
|
||||
} else if (statusRes.code === 801) {
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
updateStatus('等待手机扫码...', 'waiting')
|
||||
} else if (statusRes.code === 802) {
|
||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
} else if (statusRes.code === 803) {
|
||||
clearInterval(timer)
|
||||
updateStatus('授权登录成功!', 'success')
|
||||
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||
await getLoginStatus(statusRes.cookie)
|
||||
localStorage.setItem('cookie', statusRes.cookie)
|
||||
}
|
||||
|
||||
+3
-3
@@ -148,12 +148,12 @@
|
||||
updateStatus('二维码已过期,请刷新页面', 'error')
|
||||
clearInterval(timer)
|
||||
} else if (statusRes.code === 801) {
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
updateStatus('等待手机扫码...', 'waiting')
|
||||
} else if (statusRes.code === 802) {
|
||||
updateStatus('登录成功,正在保存信息...', 'waiting')
|
||||
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
|
||||
} else if (statusRes.code === 803) {
|
||||
clearInterval(timer)
|
||||
updateStatus('授权登录成功!', 'success')
|
||||
updateStatus('授权登录成功,正在保存信息...', 'success')
|
||||
await getLoginStatus(statusRes.cookie)
|
||||
localStorage.setItem('cookie', statusRes.cookie)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>听歌打卡 - 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 520px;
|
||||
margin: 40px auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
color: #333;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #333;
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---- Form ---- */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
label .tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
label .tag.r {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
label .tag.o {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-fill {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quick-fill span {
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quick-fill span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.result.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.result.info {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer-link a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>听歌打卡</h1>
|
||||
<p class="subtitle">同步听歌记录至网易云音乐,增加听歌排行计数</p>
|
||||
|
||||
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
|
||||
|
||||
<!-- Cookie (共享) -->
|
||||
<div class="form-group">
|
||||
<label for="cookie">Cookie <span class="tag o">可选</span></label>
|
||||
<textarea id="cookie" placeholder="留空则默认读取本地存储的登录态" rows="2"></textarea>
|
||||
<div class="quick-fill">
|
||||
<span onclick="loadLocalCookie()">读取本地 Cookie</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="v0" onclick="switchTab('v0')">原版</button>
|
||||
<button class="tab-btn" data-tab="v1" onclick="switchTab('v1')">V2 (NCBL 加密版)</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ Tab: v0 (原版 /scrobble) ============ -->
|
||||
<div class="tab-content active" id="tab-v0">
|
||||
<div class="form-group">
|
||||
<label>歌曲 ID <span class="tag r">必填</span></label>
|
||||
<input type="text" id="v0-songId" placeholder="例如: 2756058128" value="2756058128" />
|
||||
<div class="quick-fill">
|
||||
示例:
|
||||
<span onclick="fillV0('2756058128', '288651229')">妖精小姐的魔法邀约</span>
|
||||
<span onclick="fillV0('2637402867', '251025018')">Echoes of Memoria</span>
|
||||
<span onclick="fillV0('36307815', '3394198')">Lose Control</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>来源 ID (歌单或专辑 ID) <span class="tag r">必填</span></label>
|
||||
<input type="text" id="v0-sourceId" placeholder="例如: 2756058128" value="2756058128" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>播放时间 (秒) <span class="tag o">可选</span></label>
|
||||
<input type="number" id="v0-time" placeholder="300" value="300" />
|
||||
</div>
|
||||
<button class="btn" onclick="submitV0()">立即打卡 (原版)</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ Tab: v1 (NCBL 加密版 /scrobble/v1) ============ -->
|
||||
<div class="tab-content" id="tab-v1">
|
||||
<div class="form-group">
|
||||
<label>歌曲 ID <span class="tag r">必填</span></label>
|
||||
<input type="text" id="v1-songId" placeholder="例如: 518066366" value="518066366" />
|
||||
<div class="quick-fill">
|
||||
示例:
|
||||
<span onclick="fillV1('518066366', '36780169', '291')">默认示例</span>
|
||||
<span onclick="fillV1('2756058128', '288651229', '300')">妖精小姐的魔法邀约</span>
|
||||
<span onclick="fillV1('2637402867', '251025018', '300')">Echoes of Memoria</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>播放时间 (秒) <span class="tag r">必填</span></label>
|
||||
<input type="number" id="v1-time" placeholder="例如: 291" value="291" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>来源 ID <span class="tag o">可选</span></label>
|
||||
<input type="text" id="v1-sourceId" placeholder="歌单或专辑 ID" value="36780169" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>歌曲名 <span class="tag o">可选</span></label>
|
||||
<input type="text" id="v1-name" placeholder="歌曲名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>艺术家 <span class="tag o">可选</span></label>
|
||||
<input type="text" id="v1-artist" placeholder="歌手名" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>码率 <span class="tag o">可选</span></label>
|
||||
<input type="number" id="v1-bitrate" placeholder="默认 320" value="320" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音质等级 <span class="tag o">可选</span></label>
|
||||
<input type="text" id="v1-level" placeholder="默认 exhigh" value="exhigh" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>歌曲总时长 (秒) <span class="tag o">可选</span></label>
|
||||
<input type="number" id="v1-total" placeholder="留空则与播放时间相同" />
|
||||
</div>
|
||||
<button class="btn" onclick="submitV1()">立即打卡 (NCBL 加密版)</button>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div id="result" class="result" style="display: none;"></div>
|
||||
|
||||
<div class="footer-link">
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
|
||||
<script>
|
||||
/* ---- DOM refs ---- */
|
||||
const cookieInput = document.getElementById('cookie')
|
||||
const resultDiv = document.getElementById('result')
|
||||
|
||||
/* ---- init ---- */
|
||||
if (localStorage.getItem('cookie')) {
|
||||
cookieInput.value = localStorage.getItem('cookie')
|
||||
}
|
||||
|
||||
/* ---- Tab switching ---- */
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name))
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name))
|
||||
hideResult()
|
||||
}
|
||||
|
||||
/* ---- Cookie ---- */
|
||||
function loadLocalCookie() {
|
||||
const local = localStorage.getItem('cookie')
|
||||
if (local) {
|
||||
cookieInput.value = local
|
||||
showResult('已成功读取本地 Cookie', 'success')
|
||||
} else {
|
||||
showResult('未在本地发现登录 Cookie,请先登录或手动粘贴', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Quick fill ---- */
|
||||
function fillV0(id, sourceId) {
|
||||
document.getElementById('v0-songId').value = id
|
||||
document.getElementById('v0-sourceId').value = sourceId
|
||||
}
|
||||
|
||||
function fillV1(id, sourceId, time) {
|
||||
document.getElementById('v1-songId').value = id
|
||||
document.getElementById('v1-sourceId').value = sourceId
|
||||
document.getElementById('v1-time').value = time
|
||||
}
|
||||
|
||||
/* ---- Result ---- */
|
||||
function hideResult() {
|
||||
resultDiv.style.display = 'none'
|
||||
}
|
||||
|
||||
function showResult(message, type) {
|
||||
resultDiv.textContent = message
|
||||
resultDiv.className = 'result ' + type
|
||||
resultDiv.style.display = 'block'
|
||||
}
|
||||
|
||||
function disableAll(disabled) {
|
||||
document.querySelectorAll('.btn').forEach(b => b.disabled = disabled)
|
||||
}
|
||||
|
||||
/* ---- Submit: 原版 /scrobble ---- */
|
||||
async function submitV0() {
|
||||
const id = document.getElementById('v0-songId').value.trim()
|
||||
const sourceid = document.getElementById('v0-sourceId').value.trim()
|
||||
const time = document.getElementById('v0-time').value.trim() || '300'
|
||||
|
||||
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
|
||||
if (!sourceid) { showResult('请输入来源 ID !', 'error'); return }
|
||||
|
||||
disableAll(true)
|
||||
showResult('打卡中,请稍候...', 'info')
|
||||
|
||||
try {
|
||||
const params = { id, sourceid: sourceid || id, time, timestamp: Date.now() }
|
||||
const cookie = cookieInput.value.trim()
|
||||
if (cookie) params.cookie = cookie
|
||||
|
||||
const res = await axios({ method: 'get', url: '/scrobble', params })
|
||||
if (res.data.code === 200) {
|
||||
showResult('✅ 打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
|
||||
} else {
|
||||
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
|
||||
}
|
||||
} catch (error) {
|
||||
const errData = error.response ? error.response.data : { error: error.message }
|
||||
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
|
||||
} finally {
|
||||
disableAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Submit: NCBL 加密版 /scrobble/v1 ---- */
|
||||
async function submitV1() {
|
||||
const id = document.getElementById('v1-songId').value.trim()
|
||||
const time = document.getElementById('v1-time').value.trim()
|
||||
|
||||
if (!id) { showResult('请输入歌曲 ID !', 'error'); return }
|
||||
if (!time || isNaN(+time) || +time <= 0) { showResult('请输入有效的播放时长(秒)!', 'error'); return }
|
||||
|
||||
disableAll(true)
|
||||
showResult('打卡中(NCBL 加密),请稍候...', 'info')
|
||||
|
||||
try {
|
||||
const params = {
|
||||
id,
|
||||
time,
|
||||
sourceid: document.getElementById('v1-sourceId').value.trim() || undefined,
|
||||
name: document.getElementById('v1-name').value.trim() || undefined,
|
||||
artist: document.getElementById('v1-artist').value.trim() || undefined,
|
||||
bitrate: document.getElementById('v1-bitrate').value.trim() || undefined,
|
||||
level: document.getElementById('v1-level').value.trim() || undefined,
|
||||
total: document.getElementById('v1-total').value.trim() || undefined,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
const cookie = cookieInput.value.trim()
|
||||
if (cookie) params.cookie = cookie
|
||||
|
||||
// 清理 undefined 参数
|
||||
Object.keys(params).forEach(k => params[k] === undefined && delete params[k])
|
||||
|
||||
const res = await axios({ method: 'get', url: '/scrobble/v1', params })
|
||||
if (res.data.code === 200) {
|
||||
showResult('✅ NCBL 加密打卡成功! \n\n' + JSON.stringify(res.data, null, 2), 'success')
|
||||
} else {
|
||||
showResult('请求已发送,返回:\n\n' + JSON.stringify(res.data, null, 2), 'info')
|
||||
}
|
||||
} catch (error) {
|
||||
const errData = error.response ? error.response.data : { error: error.message }
|
||||
showResult('❌ 打卡失败\n\n' + JSON.stringify(errData, null, 2), 'error')
|
||||
} finally {
|
||||
disableAll(false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 992 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+882
@@ -0,0 +1,882 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="docs/netease.png" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="docs/netease.png" />
|
||||
<title>云小编任务中心 - 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
color: #333;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Section ---- */
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* ---- Form ---- */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
label .tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
label .tag.r {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
label .tag.o {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
label .tag.g {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-fill {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quick-fill span {
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quick-fill span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
padding: 6px 16px;
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm.green {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-sm.green:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.btn-sm.green:disabled {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.btn-sm.red {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm.red:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-sm.red:disabled {
|
||||
background: #fca5a5;
|
||||
}
|
||||
|
||||
.btn-sm.gray {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.btn-sm.gray:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.btn-sm.gray:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.result.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.result.info {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer-link a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Info Card ---- */
|
||||
.info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-card .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.info-card .info-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-card .btn-sm {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#userPoint {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- Song Review ---- */
|
||||
.song-review {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.song-review .cover {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.song-review .song-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.song-review audio {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.song-review .song-tag {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.song-review .btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.song-review .lyric {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
white-space: pre-wrap;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>云小编任务中心</h1>
|
||||
<p class="subtitle">管理云音乐审核任务、签到与会员权益领取</p>
|
||||
|
||||
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
|
||||
|
||||
<!-- Cookie -->
|
||||
<div class="form-group">
|
||||
<label for="cookie">Cookie <span class="tag o">可选</span></label>
|
||||
<textarea id="cookie" placeholder="留空则默认读取本地存储的登录态" rows="2"></textarea>
|
||||
<div class="quick-fill">
|
||||
<span onclick="loadLocalCookie()">读取本地 Cookie</span>
|
||||
<span onclick="clearCookie()">清除 Cookie</span>
|
||||
<span onclick="saveCookie()">保存 Cookie</span>
|
||||
<span onclick="loadAll()">一键载入</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">用户信息</span>
|
||||
<button class="btn-sm" id="userGet">获取用户详情</button>
|
||||
</div>
|
||||
<div class="info-card" id="user">
|
||||
<img src="docs/netease.png" alt="头像" class="avatar" id="userAvatar" />
|
||||
<span class="info-text" id="userName">未登录</span>
|
||||
<span id="userPoint">0</span>
|
||||
<button class="btn-sm green" id="userSign" disabled>签到</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">会员领取</span>
|
||||
<button class="btn-sm" id="vipGet">获取会员详情</button>
|
||||
</div>
|
||||
<div class="info-card" id="vip">
|
||||
<span class="info-text" id="vipStatus">未载入</span>
|
||||
<button class="btn-sm" id="vipCollect" disabled>领取</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">审核任务</span>
|
||||
<button class="btn-sm" id="taskGet">获取任务详情</button>
|
||||
</div>
|
||||
<div class="info-card" id="task">
|
||||
<span class="info-text" id="taskInfo">[<span id="taskCount">0</span>/<span id="taskGoal">0</span>] 审核歌曲赚贡献分 (+<span id="taskPoint">0</span>)</span>
|
||||
<button class="btn-sm" id="taskStart" disabled>去完成</button>
|
||||
</div>
|
||||
|
||||
<!-- Song Review -->
|
||||
<div class="song-review" id="song" style="display: none;">
|
||||
<img alt="封面" class="cover" id="songCover" />
|
||||
<div class="song-title" id="songTitle">未载入</div>
|
||||
<audio controls id="songAudio"></audio>
|
||||
<div class="song-tag" id="songTag"></div>
|
||||
<div class="btn-group">
|
||||
<button class="btn-sm green" id="songApprove" disabled>同意</button>
|
||||
<button class="btn-sm red" id="songReject" disabled>否决</button>
|
||||
<button class="btn-sm gray" id="songSkip" disabled>跳过 (不计次数)</button>
|
||||
</div>
|
||||
<div class="lyric" id="songLyric">[00:00.00] 暂无歌词</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result" class="result" style="display: none;"></div>
|
||||
|
||||
<div class="footer-link">
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var k_cookie = 'ncm_cookie';
|
||||
var cookie = localStorage.getItem(k_cookie) || '';
|
||||
|
||||
/* ---- DOM refs ---- */
|
||||
var cookieInput = document.getElementById('cookie');
|
||||
var resultDiv = document.getElementById('result');
|
||||
|
||||
/* ---- Init ---- */
|
||||
if (cookie) {
|
||||
cookieInput.value = cookie;
|
||||
}
|
||||
|
||||
/* ---- Cookie ---- */
|
||||
window.loadLocalCookie = function () {
|
||||
var local = localStorage.getItem(k_cookie);
|
||||
if (local) {
|
||||
cookie = local;
|
||||
cookieInput.value = local;
|
||||
showResult('已成功读取本地 Cookie', 'success');
|
||||
} else {
|
||||
showResult('未在本地发现登录 Cookie,请先登录或手动粘贴', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.clearCookie = function () {
|
||||
cookie = '';
|
||||
cookieInput.value = '';
|
||||
localStorage.removeItem(k_cookie);
|
||||
showResult('已清除本地 Cookie', 'info');
|
||||
};
|
||||
|
||||
window.saveCookie = function () {
|
||||
var ck = getCookie()
|
||||
if (ck) localStorage.setItem(k_cookie, ck)
|
||||
showResult('已保存本地 Cookie', 'info');
|
||||
}
|
||||
|
||||
/* ---- Result ---- */
|
||||
function hideResult() {
|
||||
resultDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
function showResult(message, type) {
|
||||
resultDiv.textContent = message;
|
||||
resultDiv.className = 'result ' + type;
|
||||
resultDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function getCookie() {
|
||||
var ck = cookieInput.value.trim();
|
||||
if (ck) cookie = ck;
|
||||
return ck || cookie || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起请求 (兼容旧版)
|
||||
* @param {string} url
|
||||
* @param {Record<string,string>} params
|
||||
* @param {(resp:Record<string,any>)=>void} done
|
||||
*/
|
||||
function request(url, params, done) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'json';
|
||||
xhr.timeout = 1e4;
|
||||
xhr.onload = function () {
|
||||
var r = xhr.response;
|
||||
if (r.code !== 200) {
|
||||
showResult('错误 ' + r.code + ': ' + (r.message || '未知错误'), 'error');
|
||||
} else {
|
||||
done(r);
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
showResult('网络请求失败: ' + xhr.statusText, 'error');
|
||||
};
|
||||
var ck = getCookie();
|
||||
if (ck) params.cookie = ck;
|
||||
var s = '?_=' + Date.now();
|
||||
for (var k in params) {
|
||||
s += '&' + k + '=' + encodeURIComponent(params[k]);
|
||||
}
|
||||
xhr.open('GET', url + s);
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
/// 用户信息 ///
|
||||
var userAvatar = document.getElementById('userAvatar');
|
||||
var userName = document.getElementById('userName');
|
||||
var userPoint = document.getElementById('userPoint');
|
||||
var userSign = document.getElementById('userSign');
|
||||
var userGet = document.getElementById('userGet');
|
||||
|
||||
userGet.onclick = function () {
|
||||
request('rep/ugc/user/get', {}, function (res) {
|
||||
var d = res.data;
|
||||
if (d.imgUrl) userAvatar.src = d.imgUrl + '?param=50y50';
|
||||
userName.textContent = d.name || '游客' + d.id;
|
||||
userPoint.textContent = d.availablePoints;
|
||||
userSign.disabled = d.signed;
|
||||
showResult('用户信息加载成功', 'success');
|
||||
});
|
||||
};
|
||||
|
||||
userSign.onclick = function () {
|
||||
if (!userSign.disabled) {
|
||||
userSign.disabled = true;
|
||||
request('rep/ugc/user/sign', {}, function (res) {
|
||||
var d = res.data;
|
||||
userSign.disabled = d;
|
||||
showResult('签到' + (d ? '成功 ✓' : '失败 ✗'), d ? 'success' : 'error');
|
||||
if (d) userGet.click();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/// 会员领取 ///
|
||||
var vipStatus = document.getElementById('vipStatus');
|
||||
var vipCollect = document.getElementById('vipCollect');
|
||||
var vipGet = document.getElementById('vipGet');
|
||||
|
||||
vipGet.onclick = function () {
|
||||
request('rep/ugc/user/vip', {}, function (res) {
|
||||
var d = res.data;
|
||||
vipStatus.textContent = d.status + ': ' + d.title;
|
||||
vipCollect.disabled = d.status !== 20;
|
||||
showResult('会员详情加载成功', 'success');
|
||||
});
|
||||
};
|
||||
|
||||
vipCollect.onclick = function () {
|
||||
if (!vipCollect.disabled) {
|
||||
vipCollect.disabled = true;
|
||||
request('rep/ugc/user/collect-vip', {}, function (res) {
|
||||
var d = res.data;
|
||||
vipCollect.disabled = d;
|
||||
showResult('领取' + (d ? '成功 ✓' : '失败 ✗'), d ? 'success' : 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/// 审核任务 ///
|
||||
var taskCount = document.getElementById('taskCount');
|
||||
var taskGoal = document.getElementById('taskGoal');
|
||||
var taskPoint = document.getElementById('taskPoint');
|
||||
var taskStartBtn = document.getElementById('taskStart');
|
||||
var taskGetBtn = document.getElementById('taskGet');
|
||||
|
||||
taskGetBtn.onclick = function () {
|
||||
request('rep/ugc/activity/get', {}, function (res) {
|
||||
var d = res.data;
|
||||
taskCount.textContent = d.count;
|
||||
taskGoal.textContent = d.goalCount;
|
||||
taskPoint.textContent = d.points;
|
||||
// 注:超过 100 可能有 bug,导致 thinktank/audit/resource/detail 获取的任务 ID 一直是 5002,目标数量也不对,暂时限制前两个任务
|
||||
taskStartBtn.disabled = d.activityId !== 5001 && d.activityId !== 5002; // 5001: 1-50, 5002: 51-100
|
||||
showResult('任务详情加载成功', 'success');
|
||||
// 任务完成,领取积分
|
||||
if (d.count >= d.goalCount) {
|
||||
request('rep/ugc/activity/collect', { activityId: d.activityId }, function (res) {
|
||||
var d = res.data;
|
||||
showResult('积分领取' + (d ? '成功,可以兑换会员了!' : '失败'), d ? 'success' : 'error');
|
||||
// 刷新任务状态
|
||||
if (d) loadAll();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
taskStartBtn.onclick = function () {
|
||||
if (!taskStartBtn.disabled) {
|
||||
songAudio.pause();
|
||||
examInit();
|
||||
}
|
||||
};
|
||||
|
||||
/// 歌曲审核区域 ///
|
||||
var song = document.getElementById('song');
|
||||
var songCover = document.getElementById('songCover');
|
||||
var songTitle = document.getElementById('songTitle');
|
||||
var songAudio = document.getElementById('songAudio');
|
||||
var songTag = document.getElementById('songTag');
|
||||
var songApprove = document.getElementById('songApprove');
|
||||
var songReject = document.getElementById('songReject');
|
||||
var songSkip = document.getElementById('songSkip');
|
||||
var songLyric = document.getElementById('songLyric');
|
||||
|
||||
var examType = 'emotionEnter';
|
||||
// 考试初始化、检查状态
|
||||
function examInit() {
|
||||
request('rep/ugc/exam/info/get', { examType: examType }, function (res) {
|
||||
var d = res.data;
|
||||
switch (d.process) {
|
||||
case 'whole_exam_end':
|
||||
// 通过考试
|
||||
if (d.hasPassExamination) {
|
||||
taskStart();
|
||||
return;
|
||||
}
|
||||
request('rep/ugc/exam/result/get', { examType: examType, taskId: d.taskId }, function (res) {
|
||||
var d = res.data;
|
||||
showResult('考试未通过:正确数:' + d.wrightNum + ',正确率:' + d.wrightRate, 'error');
|
||||
});
|
||||
case 'no':
|
||||
// 未通过或未开始
|
||||
request('rep/ugc/exam/start', { examType: examType }, function (res) {
|
||||
examStart(res.data.taskId);
|
||||
});
|
||||
break;
|
||||
case 'process':
|
||||
// 正在考试
|
||||
examStart(d.taskId);
|
||||
break;
|
||||
default:
|
||||
showResult('未知考试进度:' + d.process, 'error');
|
||||
return;
|
||||
}
|
||||
// 在上个信息不为异常时显示考试中提示
|
||||
if (d.process !== 'whole_exam_end' && !resultDiv.className.endsWith('error')) showResult('正在考试,请认真审题!', 'info');
|
||||
});
|
||||
}
|
||||
// 考试取题开始
|
||||
function examStart(taskId) {
|
||||
request('rep/ugc/exam/question/single/get', { examType: examType, taskId: taskId }, function (res) {
|
||||
var d = res.data;
|
||||
|
||||
songCover.src = d.coverUrl + '?param=100y100';
|
||||
songTitle.textContent = '[' + d.resId + '] ' + d.resName + ' - ' + d.artists;
|
||||
songTag.textContent = '[' + (d.auditCount + 1) + '/' + d.auditTaskCount + '] 当前结果: ' + d.questionContent + '?';
|
||||
songLyric.textContent = d.lyric ? d.transLyric ? lrctran(d.lyric, d.transLyric) : d.lyric : '[00:00.00] 暂无歌词';
|
||||
songLyric.scrollTop = 0;
|
||||
// 有时候就是没有播放链接,只能根据歌词硬审
|
||||
if (d.songUrl) {
|
||||
songAudio.src = d.songUrl;
|
||||
songAudio.play().catch(function () { /* 自动播放被浏览器阻止 */ });
|
||||
} else {
|
||||
songAudio.src = ''; // 清理播放器
|
||||
}
|
||||
songApprove.onclick = function () { if (!songApprove.disabled) examSubmit(taskId, d.questionId, 'A') };
|
||||
songReject.onclick = function () { if (!songReject.disabled) examSubmit(taskId, d.questionId, 'B') };
|
||||
songSkip.onclick = null;
|
||||
|
||||
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
|
||||
song.style.display = 'block';
|
||||
|
||||
setTimeout(function () {
|
||||
songApprove.disabled = songReject.disabled = false;
|
||||
}, 3e3);
|
||||
});
|
||||
}
|
||||
// 考试提交
|
||||
function examSubmit(taskId, questionId, answer) {
|
||||
songAudio.pause();
|
||||
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
|
||||
request(
|
||||
'rep/ugc/exam/submit',
|
||||
{ examType: examType, taskId: taskId, questionId: questionId, answer: answer },
|
||||
function (res) {
|
||||
var d = res.data;
|
||||
if (d.result) {
|
||||
showResult('回答正确,加载下一首...', 'success');
|
||||
} else {
|
||||
showResult('回答错误:' + d.analysis, 'error');
|
||||
}
|
||||
examInit();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务
|
||||
*/
|
||||
function taskStart() {
|
||||
request('thinktank/audit/resource/detail', { type: '4' }, function (res) {
|
||||
var d = res.data;
|
||||
|
||||
if (d.auditCount >= 50) {
|
||||
request('rep/ugc/activity/collect', { activityId: '5001' }, function (res) {
|
||||
var d = res.data;
|
||||
showResult('积分领取' + (d ? '成功,可以兑换会员了!' : '失败'), d ? 'success' : 'error');
|
||||
// 刷新任务状态
|
||||
if (d) {
|
||||
vipGet.click();
|
||||
taskGetBtn.click();
|
||||
}
|
||||
});
|
||||
songAudio.pause()
|
||||
return;
|
||||
}
|
||||
|
||||
songCover.src = d.coverUrl + '?param=100y100';
|
||||
songTitle.textContent = '[' + d.resId + '] ' + d.resName + ' - ' + d.artists;
|
||||
songTag.textContent = '[' + (d.auditCount + 1) + '/' + d.auditTaskCount + '] 当前结果: ' + d.initResult + '?';
|
||||
songLyric.textContent = d.lyric ? d.transLyric ? lrctran(d.lyric, d.transLyric) : d.lyric : '[00:00.00] 暂无歌词';
|
||||
songLyric.scrollTop = 0;
|
||||
songAudio.src = d.songUrl;
|
||||
songAudio.play().catch(function () { /* 自动播放被浏览器阻止 */ });
|
||||
|
||||
songApprove.onclick = function () { if (!songApprove.disabled) taskSubmit(d.taskId, '1'); };
|
||||
songReject.onclick = function () { if (!songReject.disabled) taskSubmit(d.taskId, '2'); };
|
||||
songSkip.onclick = function () { if (!songSkip.disabled) taskSubmit(d.taskId, '3'); };
|
||||
|
||||
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
|
||||
song.style.display = 'block';
|
||||
|
||||
setTimeout(function () {
|
||||
songApprove.disabled = songReject.disabled = songSkip.disabled = false;
|
||||
}, 3e3);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交任务
|
||||
* @param {string} taskId
|
||||
* @param {'1'|'2'|'3'} judgement
|
||||
*/
|
||||
function taskSubmit(taskId, judgement) {
|
||||
songAudio.pause();
|
||||
songApprove.disabled = songReject.disabled = songSkip.disabled = true;
|
||||
request(
|
||||
'thinktank/audit/resource/update',
|
||||
{ type: '4', taskId: taskId, judgement: judgement },
|
||||
function (res) {
|
||||
var d = res.data;
|
||||
if (d.result) {
|
||||
showResult((d.test ? '抽查' : '审核') + '提交成功,加载下一首...', 'success');
|
||||
taskStart();
|
||||
} else if (d.test) {
|
||||
showResult('抽查失败: [' + d.dayErrorNum + '/' + d.dayErrorNumStandard + '] ' + d.errorAnalysis, 'error');
|
||||
taskStart();
|
||||
} else {
|
||||
showResult('未知状态: ' + JSON.stringify(d), 'error');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**解析歌词时间戳和文本 */
|
||||
function lrctrim(lyrics) {
|
||||
var lines = lyrics.split('\n');
|
||||
var data = [];
|
||||
lines.forEach(function (line, index) {
|
||||
var matches = line.match(/\[(\d{2}):(\d{2}[\.:]?\d*)\]/);
|
||||
if (matches) {
|
||||
var minutes = parseInt(matches[1], 10);
|
||||
var seconds = parseFloat(matches[2].replace('.', ':')) || 0;
|
||||
var timestamp = minutes * 6e4 + seconds * 1e3;
|
||||
var text = line.replace(/\[\d{2}:\d{2}[\.:]?\d*\]/g, '').trim();
|
||||
text = text.replace(/\s\s+/g, ' '); // Replace multiple spaces with a single space
|
||||
data.push([timestamp, index, text]);
|
||||
}
|
||||
});
|
||||
data.sort(function (a, b) {
|
||||
return a[0] - b[0];
|
||||
});
|
||||
return data;
|
||||
}
|
||||
/**合并原文歌词和翻译歌词 */
|
||||
function lrctran(lyric, tlyric) {
|
||||
lyric = lrctrim(lyric);
|
||||
tlyric = lrctrim(tlyric);
|
||||
var len1 = lyric.length;
|
||||
var len2 = tlyric.length;
|
||||
var result = '';
|
||||
for (var i = 0, j = 0; i < len1 && j < len2; i++) {
|
||||
while (lyric[i][0] > tlyric[j][0] && j + 1 < len2) {
|
||||
j++;
|
||||
}
|
||||
if (lyric[i][0] === tlyric[j][0]) {
|
||||
tlyric[j][2] = tlyric[j][2].replace('/', '');
|
||||
if (tlyric[j][2]) {
|
||||
lyric[i][2] += ' (' + tlyric[j][2] + ')';
|
||||
}
|
||||
j++;
|
||||
}
|
||||
}
|
||||
for (var k = 0; k < len1; k++) {
|
||||
var t = lyric[k][0];
|
||||
result += '['.concat(
|
||||
String(Math.floor(t / 6e4)).padStart(2, '0'),
|
||||
':',
|
||||
String(Math.floor((t % 6e4) / 1e3)).padStart(2, '0'),
|
||||
'.',
|
||||
String(t % 1e3).padStart(3, '0'),
|
||||
']',
|
||||
lyric[k][2],
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 一键载入 ///
|
||||
window.loadAll = function () {
|
||||
userGet.click();
|
||||
vipGet.click();
|
||||
taskGetBtn.click();
|
||||
showResult('正在载入所有数据...', 'info');
|
||||
};
|
||||
|
||||
window.loadLocalCookie();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,670 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="docs/netease.png" />
|
||||
<title>云小编每日抽奖 - 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 520px;
|
||||
margin: 40px auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle b {
|
||||
color: #d63031;
|
||||
}
|
||||
|
||||
/* ---- Section ---- */
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* ---- Form Elements ---- */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
label .tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
label .tag.o {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
padding: 6px 16px;
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm.gray {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.btn-sm.gray:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.btn-sm.gray:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-sm.green {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-sm.green:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.btn-sm.green:disabled {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.quick-fill {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quick-fill span {
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quick-fill span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Lottery Display ---- */
|
||||
.lottery-display {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rolling-display {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
min-height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rolling-display.idle {
|
||||
color: #999;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- Winner Result ---- */
|
||||
.winner-display {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 20px 16px;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.winner-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #065f46;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.winner-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.confetti-row {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.confetti-emoji {
|
||||
font-size: 1.6rem;
|
||||
animation: bounce 0.6s ease infinite alternate;
|
||||
}
|
||||
|
||||
.confetti-emoji:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.confetti-emoji:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Status ---- */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #059669;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 1.5s ease infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.status-dot.idle {
|
||||
background: #9ca3af;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer-link a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 500px) {
|
||||
.container {
|
||||
padding: 20px 16px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.rolling-display {
|
||||
font-size: 1.8rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.winner-name {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>云小编每日抽奖</h1>
|
||||
<p class="subtitle">每次抽奖消耗 <b>200 个曲库编辑部积分</b>,每日最多可抽奖 3 次</p>
|
||||
|
||||
<!-- Cookie 管理 -->
|
||||
<div class="form-group">
|
||||
<label for="cookieInput">令牌 <span class="tag o">可选</span></label>
|
||||
<input type="text" id="cookieInput" placeholder="留空则默认读取本地存储的令牌" autocomplete="off" />
|
||||
<div class="quick-fill">
|
||||
<span onclick="readLocalCookie()">读取本地令牌</span>
|
||||
<span onclick="clearCookie()">清除令牌</span>
|
||||
<span onclick="saveCookie()">保存令牌</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 抽奖区域 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">每日抽奖</span>
|
||||
</div>
|
||||
|
||||
<div class="lottery-display">
|
||||
<div class="rolling-display idle" id="rollingDisplay">等待抽奖</div>
|
||||
<div id="resultContainer"></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="lotteryBtn">
|
||||
🎲 开始抽奖
|
||||
</button>
|
||||
|
||||
<div class="status-bar" id="statusBar">
|
||||
<span class="status-dot idle" id="statusDot"></span>
|
||||
<span class="status-text" id="statusText">就绪,点击按钮开始抽奖</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<div class="footer-link">
|
||||
<a href="/">← 返回首页</a>
|
||||
<span style="margin: 0 8px; color: #ddd;">·</span>
|
||||
<a href="/ugc.html">云小编任务中心</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ============ DOM 元素 ============
|
||||
const cookieInput = document.getElementById('cookieInput');
|
||||
const rollingDisplay = document.getElementById('rollingDisplay');
|
||||
const resultContainer = document.getElementById('resultContainer');
|
||||
const lotteryBtn = document.getElementById('lotteryBtn');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
// ============ 状态 ============
|
||||
let isDrawing = false;
|
||||
let rollingTimer = null;
|
||||
|
||||
// ============ 状态栏更新 ============
|
||||
function setStatus(type, message) {
|
||||
statusText.textContent = message;
|
||||
statusDot.className = 'status-dot';
|
||||
if (type === 'error') {
|
||||
statusDot.classList.add('error');
|
||||
} else if (type === 'info') {
|
||||
statusDot.style.background = '#059669';
|
||||
} else {
|
||||
statusDot.classList.add('idle');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 重置结果 ============
|
||||
function resetResult() {
|
||||
resultContainer.innerHTML = '';
|
||||
rollingDisplay.textContent = '等待抽奖';
|
||||
rollingDisplay.className = 'rolling-display idle';
|
||||
setStatus('idle', '就绪,点击按钮开始抽奖');
|
||||
}
|
||||
|
||||
// ============ 本地令牌管理 ============
|
||||
const cookieKey = 'ncm_cookie';
|
||||
let cookie = localStorage.getItem(cookieKey) || '';
|
||||
if (cookie) {
|
||||
cookieInput.value = cookie;
|
||||
}
|
||||
|
||||
window.readLocalCookie = function () {
|
||||
const local = localStorage.getItem(cookieKey);
|
||||
if (local) {
|
||||
cookie = local;
|
||||
cookieInput.value = local;
|
||||
setStatus('info', '已读取本地令牌');
|
||||
} else {
|
||||
setStatus('error', '未在本地发现令牌,请手动输入');
|
||||
}
|
||||
};
|
||||
|
||||
window.clearCookie = function () {
|
||||
cookie = '';
|
||||
cookieInput.value = '';
|
||||
localStorage.removeItem(cookieKey);
|
||||
setStatus('info', '已清除本地令牌');
|
||||
};
|
||||
|
||||
window.saveCookie = function () {
|
||||
const ck = cookieInput.value.trim();
|
||||
if (ck) {
|
||||
cookie = ck;
|
||||
localStorage.setItem(cookieKey, ck);
|
||||
setStatus('info', '已保存本地令牌');
|
||||
} else {
|
||||
setStatus('error', '令牌内容为空,未保存');
|
||||
}
|
||||
};
|
||||
|
||||
function getCookie() {
|
||||
const ck = cookieInput.value.trim();
|
||||
if (ck) cookie = ck;
|
||||
return ck || cookie || '';
|
||||
}
|
||||
|
||||
// ============ 调用后端接口 ============
|
||||
async function request(url, params) {
|
||||
try {
|
||||
url += '?timestamp=' + Date.now() + params;
|
||||
const ck = getCookie();
|
||||
if (ck) url += '&cookie=' + encodeURIComponent(ck);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await response.json();
|
||||
} catch (error) {
|
||||
console.error('数据解析错误:', error);
|
||||
throw new Error('接口请求错误: ' + response.status + ': ' + response.statusText);
|
||||
}
|
||||
|
||||
if (res.code !== 200) {
|
||||
throw new Error('接口返回错误: ' + res.code + ': ' + (res.message || '未知错误'));
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('接口请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLotteryResult() {
|
||||
const user = await request('rep/ugc/user/get', '');
|
||||
|
||||
if ((user.data?.availablePoints ?? 0) < 200) {
|
||||
throw new Error('用户剩余积分不足,请先完成任务获取积分');
|
||||
}
|
||||
|
||||
const chance = await request('middle/play/lottery/remain/chance', '&activityId=6501202');
|
||||
|
||||
if (chance.data === 0) {
|
||||
throw new Error('今日机会已用完,明天再来吧');
|
||||
}
|
||||
|
||||
const res = await request('middle/play/do/lottery', '&activityId=6501202&drawCount=1');
|
||||
|
||||
const winner = res.data?.prizeDetailInfoMap?.['8186002']?.prizeName;
|
||||
if (!winner) {
|
||||
throw new Error('无法解析响应数据');
|
||||
}
|
||||
|
||||
return [winner, res.data.restChance];
|
||||
}
|
||||
|
||||
// ============ 滚动动画 ============
|
||||
function startRollingAnimation(finalWinner) {
|
||||
const placeholderNames = ['🎰', '🎲', '🎯', '✨', '💫', '🌟', '🎪', '🎭'];
|
||||
const totalDuration = 2800;
|
||||
const startInterval = 50;
|
||||
const endInterval = 280;
|
||||
const startTime = Date.now();
|
||||
|
||||
rollingDisplay.className = 'rolling-display';
|
||||
resultContainer.innerHTML = '';
|
||||
|
||||
function roll() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / totalDuration, 1.0);
|
||||
const easeOutProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const currentInterval = startInterval + (endInterval - startInterval) * easeOutProgress;
|
||||
|
||||
const randomPlaceholder = placeholderNames[Math.floor(Math.random() * placeholderNames.length)];
|
||||
rollingDisplay.textContent = randomPlaceholder;
|
||||
|
||||
if (progress >= 1.0) {
|
||||
clearTimeout(rollingTimer);
|
||||
rollingDisplay.textContent = finalWinner;
|
||||
showWinnerResult(finalWinner);
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
rollingTimer = setTimeout(roll, currentInterval);
|
||||
}
|
||||
|
||||
roll();
|
||||
}
|
||||
|
||||
function showWinnerResult(winner) {
|
||||
resultContainer.innerHTML =
|
||||
'<div class="winner-display">' +
|
||||
'<div class="winner-label">🏆 恭喜中奖</div>' +
|
||||
'<div class="winner-name">' + escapeHTML(winner) + '</div>' +
|
||||
'<div class="confetti-row">' +
|
||||
'<span class="confetti-emoji">🎉</span>' +
|
||||
'<span class="confetti-emoji">🎊</span>' +
|
||||
'<span class="confetti-emoji">✨</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
isDrawing = false;
|
||||
lotteryBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ============ 主抽奖流程 ============
|
||||
async function startLottery() {
|
||||
if (isDrawing) return;
|
||||
|
||||
isDrawing = true;
|
||||
lotteryBtn.disabled = true;
|
||||
resultContainer.innerHTML = '';
|
||||
rollingDisplay.className = 'rolling-display';
|
||||
rollingDisplay.textContent = '🎰';
|
||||
setStatus('info', '正在获取抽奖结果...');
|
||||
|
||||
try {
|
||||
const res = await fetchLotteryResult();
|
||||
setStatus('info', '抽奖成功,剩余次数 ' + res[1]);
|
||||
startRollingAnimation(res[0]);
|
||||
} catch (error) {
|
||||
rollingDisplay.textContent = '抽奖失败';
|
||||
rollingDisplay.className = 'rolling-display idle';
|
||||
setStatus('error', '抽奖失败: ' + error.message);
|
||||
isDrawing = false;
|
||||
lotteryBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 事件绑定 ============
|
||||
lotteryBtn.addEventListener('click', startLottery);
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === ' ' && document.activeElement !== cookieInput) {
|
||||
e.preventDefault();
|
||||
if (!isDrawing && !lotteryBtn.disabled) {
|
||||
startLottery();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 回车键在输入框中触发保存
|
||||
cookieInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
saveCookie();
|
||||
}
|
||||
});
|
||||
|
||||
// ============ 初始化 ============
|
||||
resetResult();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Token 注册器 — 网易云音乐 API Enhanced</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #333;
|
||||
--muted: #666;
|
||||
--border: #ddd;
|
||||
--bg: #f5f5f5;
|
||||
--panel: #ffffff;
|
||||
--accent: #333;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
|
||||
.container { max-width: 720px; margin: 40px auto; padding: 0 20px; }
|
||||
@media (max-width: 480px) {
|
||||
.container { margin: 20px auto; padding: 0 16px; }
|
||||
header.site-header h1 { font-size: 22px; }
|
||||
.block { padding: 16px; }
|
||||
}
|
||||
header.site-header { margin-bottom: 24px; }
|
||||
header.site-header h1 { font-size: 24px; font-weight: 600; margin: 0; }
|
||||
.sub { margin-top: 4px; color: var(--muted); font-size: 14px; }
|
||||
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||
.block h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
|
||||
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-family: 'Courier New', monospace; font-size: 13px; max-height: 160px; overflow: auto; }
|
||||
.row { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
button { padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border); font-size: 14px; font-weight: 500; cursor: pointer; background: var(--panel); color: var(--fg); transition: all .15s; }
|
||||
button:hover { background: #eee; }
|
||||
button.primary { background: var(--fg); color: var(--panel); border-color: var(--fg); }
|
||||
button.primary:hover { opacity: .85; }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); }
|
||||
.tag.ok { background: #e6f7e6; color: #1a7a1a; border-color: #b7e6b7; }
|
||||
.tag.no { background: #ffe6e6; color: #a11; border-color: #f5c6c6; }
|
||||
.tag.busy { background: #fff3cd; color: #856404; border-color: #ffeeba; }
|
||||
.flex { display: flex; align-items: center; gap: 10px; }
|
||||
.flex.sb { justify-content: space-between; }
|
||||
.mt-2 { margin-top: 14px; }
|
||||
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header class="site-header">
|
||||
<h1>🔑 Token 注册器</h1>
|
||||
<p class="sub">从易盾获取反作弊 token 并注册到服务器,供广告等需要 checkToken 的接口使用</p>
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
<div class="flex sb">
|
||||
<h3>当前 Token</h3>
|
||||
<span id="statusTag" class="tag no">未注册</span>
|
||||
</div>
|
||||
<pre id="tokenDisplay">(空)</pre>
|
||||
<div class="row">
|
||||
<button id="btnGet" class="primary">获取 Token</button>
|
||||
<button id="btnRefresh">强制刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<h3>服务器响应</h3>
|
||||
<pre id="respDisplay">{}</pre>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<a href="/">← 返回首页</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const base = location.origin
|
||||
|
||||
async function call(refresh) {
|
||||
const btn = $('btnGet')
|
||||
const btnR = $('btnRefresh')
|
||||
btn.disabled = btnR.disabled = true
|
||||
|
||||
const tag = $('statusTag')
|
||||
tag.className = 'tag busy'
|
||||
tag.textContent = '获取中…'
|
||||
|
||||
try {
|
||||
const url = base + '/register/checktoken/v2' + (refresh ? '?refresh=1' : '')
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
$('respDisplay').textContent = JSON.stringify(data, null, 2)
|
||||
|
||||
if (data.token) {
|
||||
$('tokenDisplay').textContent = data.token
|
||||
tag.className = 'tag ok'
|
||||
tag.textContent = '已注册'
|
||||
} else {
|
||||
$('tokenDisplay').textContent = '(获取失败)'
|
||||
tag.className = 'tag no'
|
||||
tag.textContent = '失败'
|
||||
}
|
||||
} catch (e) {
|
||||
$('respDisplay').textContent = JSON.stringify({ error: e.message }, null, 2)
|
||||
tag.className = 'tag no'
|
||||
tag.textContent = e.message
|
||||
}
|
||||
|
||||
btn.disabled = btnR.disabled = false
|
||||
}
|
||||
|
||||
$('btnGet').onclick = () => call(false)
|
||||
$('btnRefresh').onclick = () => call(true)
|
||||
call(false)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 📝 Markdown 文档格式化工具喵~
|
||||
* 支持格式化标题、代码块、空行和缩进!
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/format-docs.js # 格式化默认文档 (public/docs/home.md)
|
||||
* node scripts/format-docs.js <文件路径> # 格式化指定文件
|
||||
* node scripts/format-docs.js --dir <目录> # 格式化整个目录的 .md 文件
|
||||
* node scripts/format-docs.js --check # 只检查不写入 (dry-run)
|
||||
*
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// ======================== 配置 ========================
|
||||
const CONFIG = {
|
||||
maxConsecutiveBlankLines: 2, // 最大连续空行数
|
||||
codeBlockLang: true, // 代码块是否保留语言标记
|
||||
headingSpaceBefore: true, // 标题前是否确保空行
|
||||
headingSpaceAfter: true, // 标题后是否确保空行
|
||||
listIndent: 2, // 列表缩进空格数
|
||||
encodeSpecialChars: false, // 是否编码特殊字符
|
||||
removeTrailingSpaces: true, // 是否删除行尾空格
|
||||
}
|
||||
|
||||
const DEFAULT_FILE = path.resolve(__dirname, '..', 'public', 'docs', 'home.md')
|
||||
|
||||
// ======================== 颜色工具 ========================
|
||||
const color = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`
|
||||
const green = color('32')
|
||||
const cyan = color('36')
|
||||
const yellow = color('33')
|
||||
const red = color('31')
|
||||
const bold = color('1')
|
||||
const dim = color('2')
|
||||
|
||||
// ======================== 核心格式化函数 ========================
|
||||
|
||||
/**
|
||||
* 解析 Markdown 的块结构,返回块数组
|
||||
* 块类型: 'heading', 'code', 'list', 'paragraph', 'empty', 'hr', 'blockquote', 'table', 'html'
|
||||
*/
|
||||
function parseBlocks(lines) {
|
||||
const blocks = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// 空行
|
||||
if (trimmed === '') {
|
||||
blocks.push({ type: 'empty', lines: [line], raw: line })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 代码块 (``` 或 ~~~)
|
||||
if (/^```/.test(trimmed) || /^~~~/.test(trimmed)) {
|
||||
const marker = trimmed.match(/^(```|~~~)/)[1]
|
||||
const lang = trimmed.slice(marker.length).trim()
|
||||
const start = i
|
||||
i++
|
||||
while (i < lines.length && !lines[i].trim().startsWith(marker)) {
|
||||
i++
|
||||
}
|
||||
if (i < lines.length) i++ // 跳过结束标记
|
||||
const codeLines = lines.slice(start, i)
|
||||
blocks.push({
|
||||
type: 'code',
|
||||
lines: codeLines,
|
||||
lang,
|
||||
marker,
|
||||
raw: codeLines.join('\n'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 标题
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
lines: [line],
|
||||
level,
|
||||
text: headingMatch[2],
|
||||
raw: line,
|
||||
})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 分割线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
|
||||
blocks.push({ type: 'hr', lines: [line], raw: line })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// HTML 注释或标签
|
||||
if (/^<!--/.test(trimmed) || /^<\w+/.test(trimmed)) {
|
||||
const start = i
|
||||
i++
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim()
|
||||
if (/-->/.test(t) || /<\/\w+>/.test(t)) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
const htmlLines = lines.slice(start, i)
|
||||
blocks.push({ type: 'html', lines: htmlLines, raw: htmlLines.join('\n') })
|
||||
continue
|
||||
}
|
||||
|
||||
// 引用块 (注: 空行不吞噬,留给后续解析,避免干扰上下文的空行判断)
|
||||
if (/^>/.test(trimmed)) {
|
||||
const start = i
|
||||
i++
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('>')) {
|
||||
i++
|
||||
}
|
||||
const quoteLines = lines.slice(start, i)
|
||||
blocks.push({
|
||||
type: 'blockquote',
|
||||
lines: quoteLines,
|
||||
raw: quoteLines.join('\n'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 表格
|
||||
if (
|
||||
/\|/.test(trimmed) &&
|
||||
lines[i + 1] &&
|
||||
/^\|[\s\-:]+\|/.test(lines[i + 1].trim())
|
||||
) {
|
||||
const start = i
|
||||
i += 2
|
||||
while (i < lines.length && /\|/.test(lines[i].trim())) {
|
||||
i++
|
||||
}
|
||||
const tableLines = lines.slice(start, i)
|
||||
blocks.push({
|
||||
type: 'table',
|
||||
lines: tableLines,
|
||||
raw: tableLines.join('\n'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 列表项(有序或无序)
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line)) {
|
||||
const start = i
|
||||
i++
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim()
|
||||
if (t === '') {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// 缩进 continuation
|
||||
if (/^\s{2,}/.test(lines[i])) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
const listLines = lines.slice(start, i)
|
||||
blocks.push({ type: 'list', lines: listLines, raw: listLines.join('\n') })
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
const start = i
|
||||
i++
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
// 如果遇到新的块元素则停止
|
||||
const t = lines[i].trim()
|
||||
if (
|
||||
/^#{1,6}\s/.test(t) ||
|
||||
/^```/.test(t) ||
|
||||
/^~~~/.test(t) ||
|
||||
/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])
|
||||
)
|
||||
break
|
||||
// 分割线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) break
|
||||
i++
|
||||
}
|
||||
const paraLines = lines.slice(start, i)
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
lines: paraLines,
|
||||
raw: paraLines.join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文档
|
||||
*/
|
||||
function formatMarkdown(input, options = {}) {
|
||||
const cfg = { ...CONFIG, ...options }
|
||||
const lines = input.split('\n')
|
||||
let blocks = parseBlocks(lines)
|
||||
|
||||
// ---------- 格式化步骤 ----------
|
||||
|
||||
// 1. 确保标题前后有空行
|
||||
if (cfg.headingSpaceBefore || cfg.headingSpaceAfter) {
|
||||
blocks = blocks.map((block, idx) => {
|
||||
if (block.type !== 'heading') return block
|
||||
|
||||
const newLines = [...block.lines]
|
||||
|
||||
// 标题前加空行(如果不是第一个块且前一个不是空行)
|
||||
if (
|
||||
cfg.headingSpaceBefore &&
|
||||
idx > 0 &&
|
||||
blocks[idx - 1].type !== 'empty'
|
||||
) {
|
||||
newLines.unshift('')
|
||||
}
|
||||
|
||||
// 标题后加空行(如果不是最后一个块且后一个不是空行)
|
||||
if (
|
||||
cfg.headingSpaceAfter &&
|
||||
idx < blocks.length - 1 &&
|
||||
blocks[idx + 1].type !== 'empty'
|
||||
) {
|
||||
newLines.push('')
|
||||
}
|
||||
|
||||
return { ...block, lines: newLines }
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 格式化代码块
|
||||
blocks = blocks.map((block, idx) => {
|
||||
if (block.type !== 'code') return block
|
||||
|
||||
const newLines = [...block.lines]
|
||||
|
||||
// 先提取代码块标记信息 (第一行是 ``` 或 ~~~)
|
||||
const markerMatch = newLines[0].trim().match(/^(```|~~~)/)
|
||||
if (!markerMatch) return block // 安全兜底
|
||||
const marker = markerMatch[1]
|
||||
const lang = newLines[0].trim().slice(marker.length).trim().toLowerCase()
|
||||
|
||||
// 标准化语言标记
|
||||
const indent = newLines[0].match(/^\s*/)[0]
|
||||
newLines[0] = lang ? `${indent}${marker}${lang}` : `${indent}${marker}`
|
||||
|
||||
// 确保代码块前后有空行(插在标准化之后,因为只动第一行)
|
||||
if (
|
||||
idx > 0 &&
|
||||
blocks[idx - 1].type !== 'empty' &&
|
||||
blocks[idx - 1].type !== 'code'
|
||||
) {
|
||||
newLines.unshift('')
|
||||
}
|
||||
if (
|
||||
idx < blocks.length - 1 &&
|
||||
blocks[idx + 1].type !== 'empty' &&
|
||||
blocks[idx + 1].type !== 'code'
|
||||
) {
|
||||
newLines.push('')
|
||||
}
|
||||
|
||||
return { ...block, lines: newLines }
|
||||
})
|
||||
|
||||
// 3. 压缩多余空行
|
||||
blocks = compressEmptyLines(blocks, cfg.maxConsecutiveBlankLines)
|
||||
|
||||
// 4. 删除行尾空格
|
||||
if (cfg.removeTrailingSpaces) {
|
||||
blocks = blocks.map((block) => ({
|
||||
...block,
|
||||
lines: block.lines.map((l) => l.replace(/[ \t]+$/, '')),
|
||||
}))
|
||||
}
|
||||
|
||||
// 5. 修复列表缩进
|
||||
blocks = blocks.map((block) => {
|
||||
if (block.type !== 'list') return block
|
||||
return {
|
||||
...block,
|
||||
lines: block.lines.map((line) => {
|
||||
const trimmed = line.trimStart()
|
||||
// 检测列表标记
|
||||
if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) {
|
||||
const indent = line.length - line.trimStart().length
|
||||
// 如果是顶层列表项,确保缩进为0
|
||||
if (indent % cfg.listIndent !== 0) {
|
||||
const normalizedIndent =
|
||||
Math.round(indent / cfg.listIndent) * cfg.listIndent
|
||||
return ' '.repeat(normalizedIndent) + trimmed
|
||||
}
|
||||
}
|
||||
return line
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// 重新拼装
|
||||
const resultLines = blocks.flatMap((b) => b.lines)
|
||||
|
||||
// 处理文件开头和结尾的空行
|
||||
while (resultLines.length > 0 && resultLines[0] === '') resultLines.shift()
|
||||
while (resultLines.length > 0 && resultLines[resultLines.length - 1] === '')
|
||||
resultLines.pop()
|
||||
resultLines.push('') // 文件结尾留一个空行
|
||||
|
||||
return resultLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩连续空行块
|
||||
*/
|
||||
function compressEmptyLines(blocks, maxBlank) {
|
||||
const result = []
|
||||
let blankCount = 0
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'empty') {
|
||||
blankCount++
|
||||
if (blankCount <= maxBlank) {
|
||||
result.push(block)
|
||||
}
|
||||
} else {
|
||||
blankCount = 0
|
||||
result.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ======================== 统计信息 ========================
|
||||
|
||||
function getStats(input) {
|
||||
const lines = input.split('\n')
|
||||
const blocks = parseBlocks(lines)
|
||||
|
||||
return {
|
||||
totalLines: lines.length,
|
||||
nonEmptyLines: lines.filter((l) => l.trim() !== '').length,
|
||||
headings: blocks.filter((b) => b.type === 'heading').length,
|
||||
codeBlocks: blocks.filter((b) => b.type === 'code').length,
|
||||
lists: blocks.filter((b) => b.type === 'list').length,
|
||||
tables: blocks.filter((b) => b.type === 'table').length,
|
||||
blockquotes: blocks.filter((b) => b.type === 'blockquote').length,
|
||||
hr: blocks.filter((b) => b.type === 'hr').length,
|
||||
characters: input.length,
|
||||
}
|
||||
}
|
||||
|
||||
function printStats(stats) {
|
||||
console.log(bold('\n📊 文档统计信息:'))
|
||||
console.log(` 总行数: ${cyan(String(stats.totalLines))}`)
|
||||
console.log(` 非空行数: ${cyan(String(stats.nonEmptyLines))}`)
|
||||
console.log(` 字符数: ${cyan(String(stats.characters))}`)
|
||||
console.log(` 标题数: ${cyan(String(stats.headings))}`)
|
||||
console.log(` 代码块数: ${cyan(String(stats.codeBlocks))}`)
|
||||
console.log(` 列表数: ${cyan(String(stats.lists))}`)
|
||||
console.log(` 表格数: ${cyan(String(stats.tables))}`)
|
||||
console.log(` 引用块数: ${cyan(String(stats.blockquotes))}`)
|
||||
console.log(` 分割线数: ${cyan(String(stats.hr))}`)
|
||||
}
|
||||
|
||||
// ======================== 文件处理 ========================
|
||||
|
||||
function readFile(filePath) {
|
||||
try {
|
||||
let content = fs.readFileSync(filePath, 'utf-8')
|
||||
// 统一换行符为 \n,避免 Windows 的 \r\n 导致 diff 不稳定
|
||||
content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error(red(`❌ 无法读取文件: ${filePath}`))
|
||||
console.error(dim(err.message))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, 'utf-8')
|
||||
console.log(green(`✅ 已写入: ${filePath}`))
|
||||
} catch (err) {
|
||||
console.error(red(`❌ 写入失败: ${filePath}`))
|
||||
console.error(dim(err.message))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function processFile(filePath, options) {
|
||||
const relativePath = path.relative(process.cwd(), filePath)
|
||||
console.log(bold(`\n📄 处理文件: ${cyan(relativePath)}`))
|
||||
|
||||
const input = readFile(filePath)
|
||||
const statsBefore = getStats(input)
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(dim(' 格式化前:'))
|
||||
printStats(statsBefore)
|
||||
}
|
||||
|
||||
const formatted = formatMarkdown(input, options)
|
||||
const statsAfter = getStats(formatted)
|
||||
|
||||
const hasChanges = input !== formatted
|
||||
|
||||
if (options.check) {
|
||||
if (hasChanges) {
|
||||
console.log(yellow(' ⚠️ 文件需要格式化 (dry-run, 未写入)'))
|
||||
const added = statsAfter.totalLines - statsBefore.totalLines
|
||||
console.log(dim(` 行数变化: ${added > 0 ? '+' : ''}${added}`))
|
||||
} else {
|
||||
console.log(green(' ✅ 文件已格式良好'))
|
||||
}
|
||||
return hasChanges ? 1 : 0
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
writeFile(filePath, formatted)
|
||||
console.log(green(' ✨ 格式化完成!'))
|
||||
if (options.verbose) {
|
||||
printStats(statsAfter)
|
||||
}
|
||||
} else {
|
||||
console.log(green(' ✅ 文档已经格式良好,无需修改'))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function processDirectory(dirPath, options) {
|
||||
let exitCode = 0
|
||||
const files = fs
|
||||
.readdirSync(dirPath)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => path.join(dirPath, f))
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(yellow(`⚠️ 在 ${dirPath} 中未找到 .md 文件`))
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(
|
||||
bold(`\n📁 扫描目录: ${cyan(dirPath)} (${files.length} 个文件)\n`),
|
||||
)
|
||||
|
||||
for (const file of files) {
|
||||
const ret = processFile(file, options)
|
||||
if (ret !== 0) exitCode = ret
|
||||
}
|
||||
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ======================== CLI ========================
|
||||
|
||||
function printHelp() {
|
||||
console.log(
|
||||
bold(`
|
||||
📝 Markdown 文档格式化工具 v1.0.0
|
||||
|
||||
${cyan('用法:')}
|
||||
node scripts/format-docs.js [文件路径] [选项]
|
||||
|
||||
${cyan('参数:')}
|
||||
文件路径 要格式化的 .md 文件 (默认: public/docs/home.md)
|
||||
|
||||
${cyan('选项:')}
|
||||
--dir, -d <目录> 格式化整个目录下的所有 .md 文件
|
||||
--check, -c dry-run 模式,只检查不写入
|
||||
--verbose, -v 显示详细统计信息
|
||||
--help, -h 显示帮助信息
|
||||
|
||||
${cyan('示例:')}
|
||||
node scripts/format-docs.js
|
||||
node scripts/format-docs.js README.md
|
||||
node scripts/format-docs.js --dir docs/
|
||||
node scripts/format-docs.js --check
|
||||
`),
|
||||
)
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
const options = {
|
||||
file: null,
|
||||
dir: null,
|
||||
check: false,
|
||||
verbose: false,
|
||||
help: false,
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--dir':
|
||||
case '-d':
|
||||
options.dir = args[++i]
|
||||
break
|
||||
case '--check':
|
||||
case '-c':
|
||||
options.check = true
|
||||
break
|
||||
case '--verbose':
|
||||
case '-v':
|
||||
options.verbose = true
|
||||
break
|
||||
case '--help':
|
||||
case '-h':
|
||||
options.help = true
|
||||
break
|
||||
default:
|
||||
if (!args[i].startsWith('--') && !args[i].startsWith('-')) {
|
||||
options.file = path.resolve(process.cwd(), args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// ======================== 主函数 ========================
|
||||
|
||||
function main() {
|
||||
const options = parseArgs()
|
||||
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(bold(`\n${cyan('🐱 文档格式化工具')} ${dim('(用❤️制作)')}\n`))
|
||||
|
||||
if (options.dir) {
|
||||
const dirPath = path.resolve(process.cwd(), options.dir)
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
console.error(red(`❌ 目录不存在: ${dirPath}`))
|
||||
return 1
|
||||
}
|
||||
return processDirectory(dirPath, options)
|
||||
}
|
||||
|
||||
const filePath = options.file || DEFAULT_FILE
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(red(`❌ 文件不存在: ${filePath}`))
|
||||
return 1
|
||||
}
|
||||
|
||||
return processFile(filePath, options)
|
||||
}
|
||||
|
||||
// ======================== 启动 ========================
|
||||
|
||||
if (require.main === module) {
|
||||
const exitCode = main()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
module.exports = { formatMarkdown, getStats, parseBlocks }
|
||||
@@ -10,6 +10,7 @@ const { cookieToJson } = require('./util/index')
|
||||
const fileUpload = require('express-fileupload')
|
||||
const decode = require('safe-decode-uri-component')
|
||||
const logger = require('./util/logger.js')
|
||||
const { APP_CONF } = require('./util/config.json')
|
||||
|
||||
/**
|
||||
* The version check result.
|
||||
@@ -299,15 +300,15 @@ async function constructServer(moduleDefs) {
|
||||
)
|
||||
|
||||
try {
|
||||
let usedCrypto = ''
|
||||
const moduleResponse = await moduleDef.module(query, (...params) => {
|
||||
// 参数注入客户端IP
|
||||
const obj = [...params]
|
||||
const options = obj[2] || {}
|
||||
usedCrypto = options.crypto || ''
|
||||
let ip = ''
|
||||
|
||||
|
||||
if (options.randomCNIP) {
|
||||
ip = global.cnIp
|
||||
// logger.info('Using random Chinese IP for request:', ip)
|
||||
} else {
|
||||
ip = req.ip
|
||||
|
||||
@@ -317,9 +318,8 @@ async function constructServer(moduleDefs) {
|
||||
if (ip == '::1') {
|
||||
ip = global.cnIp
|
||||
}
|
||||
// logger.info('Requested from ip:', ip)
|
||||
}
|
||||
|
||||
|
||||
obj[2] = {
|
||||
...options,
|
||||
ip,
|
||||
@@ -327,7 +327,10 @@ async function constructServer(moduleDefs) {
|
||||
|
||||
return request(...obj)
|
||||
})
|
||||
logger.info(`Request Success: ${decode(req.originalUrl)}`)
|
||||
const displayCrypto = usedCrypto || (APP_CONF.encrypt ? 'eapi' : 'api')
|
||||
logger.info(
|
||||
`Request Success: [${displayCrypto}] ${decode(req.originalUrl)}`,
|
||||
)
|
||||
|
||||
// 夹带私货部分:如果开启了通用解锁,并且是获取歌曲URL的接口,则尝试解锁(如果需要的话)ヾ(≧▽≦*)o
|
||||
if (
|
||||
@@ -422,7 +425,7 @@ async function serveNcmApi(options) {
|
||||
options.checkVersion &&
|
||||
checkVersion().then(({ npmVersion, ourVersion, status }) => {
|
||||
if (status == VERSION_CHECK_RESULT.NOT_LATEST) {
|
||||
logger.info(
|
||||
logger.warn(
|
||||
`最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`,
|
||||
)
|
||||
}
|
||||
@@ -444,11 +447,9 @@ async function serveNcmApi(options) {
|
||||
╠═╣╠═╝║ ║╣ ║║║╠═╣╠═╣║║║║ ║╣ ║║
|
||||
╩ ╩╩ ╩ ╚═╝╝╚╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝
|
||||
`)
|
||||
logger.info(`
|
||||
- Server started successfully @ http://${host ? host : 'localhost'}:${port}
|
||||
- Environment: ${process.env.NODE_ENV || 'development'}
|
||||
- Node Version: ${process.version}
|
||||
- Process ID: ${process.pid}`)
|
||||
logger.info(
|
||||
`Server started successfully @ http://${host ? host : 'localhost'}:${port}`,
|
||||
)
|
||||
})
|
||||
|
||||
return appExt
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
},
|
||||
"APP_CONF": {
|
||||
"apiDomain": "https://interface.music.163.com",
|
||||
"eapiDomain": "https://interfacepc.music.163.com",
|
||||
"xeapiDomain": "https://interface3.music.163.com",
|
||||
"domain": "https://music.163.com",
|
||||
"clDomian": "https://clientlog.music.163.com",
|
||||
"clDomian3": "https://clientlog3.music.163.com",
|
||||
"dunDomainV3": "https://ac.dun.163yun.com",
|
||||
"dunDomainV2": "https://ac.dun.163.com",
|
||||
"encrypt": true,
|
||||
"encryptResponse": false,
|
||||
"clientSign": "18:C0:4D:B9:8F:FE@@@453832335F384641365F424635335F303030315F303031425F343434415F343643365F333638332@@@@@@6ff673ef74955b38bce2fa8562d95c976ed4758b1227c4e9ee345987cee17bc9",
|
||||
|
||||
+3
-3
@@ -62,9 +62,9 @@ const chinaIPRanges = (function loadChinaIPRanges() {
|
||||
// attach total for convenience
|
||||
arr.totalCount = total
|
||||
|
||||
logger.info(
|
||||
`Loaded ${arr.length} Chinese IP ranges from china_ip_ranges.txt, total ${total} IPs`,
|
||||
)
|
||||
// logger.info(
|
||||
// `Loaded ${arr.length} Chinese IP ranges from china_ip_ranges.txt, total ${total} IPs`,
|
||||
// )
|
||||
return arr
|
||||
} catch (error) {
|
||||
logger.error('Failed to load china_ip_ranges.txt:', error.message)
|
||||
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
// NCBL 加密工具 —— 复制自 @netease-report-listen-song
|
||||
// 提供 ChaCha20 / RSA-256 / NCBL 加密 / PLV+PLD 构建 / 设备上下文提取
|
||||
|
||||
const crypto = require('crypto')
|
||||
const zlib = require('zlib')
|
||||
const axios = require('axios')
|
||||
const { APP_CONF } = require('./config.json')
|
||||
const DOMAIN3 = APP_CONF.clDomian3
|
||||
|
||||
// ---- ChaCha20 纯 JS 实现 ----
|
||||
const SIGMA = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
|
||||
|
||||
const rotl = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0
|
||||
|
||||
const quarterRound = (s, a, b, c, d) => {
|
||||
s[a] = (s[a] + s[b]) >>> 0
|
||||
s[d] ^= s[a]
|
||||
s[d] = rotl(s[d], 16)
|
||||
s[c] = (s[c] + s[d]) >>> 0
|
||||
s[b] ^= s[c]
|
||||
s[b] = rotl(s[b], 12)
|
||||
s[a] = (s[a] + s[b]) >>> 0
|
||||
s[d] ^= s[a]
|
||||
s[d] = rotl(s[d], 8)
|
||||
s[c] = (s[c] + s[d]) >>> 0
|
||||
s[b] ^= s[c]
|
||||
s[b] = rotl(s[b], 7)
|
||||
}
|
||||
|
||||
const chachaBlock = (key, counter, nonce) => {
|
||||
const state = new Uint32Array(16)
|
||||
state[0] = SIGMA[0]
|
||||
state[1] = SIGMA[1]
|
||||
state[2] = SIGMA[2]
|
||||
state[3] = SIGMA[3]
|
||||
for (let i = 0; i < 8; i++) state[4 + i] = key.readUInt32LE(i * 4)
|
||||
state[12] = counter >>> 0
|
||||
state[13] = nonce.readUInt32LE(0)
|
||||
state[14] = nonce.readUInt32LE(4)
|
||||
state[15] = nonce.readUInt32LE(8)
|
||||
|
||||
const work = state.slice()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarterRound(work, 0, 4, 8, 12)
|
||||
quarterRound(work, 1, 5, 9, 13)
|
||||
quarterRound(work, 2, 6, 10, 14)
|
||||
quarterRound(work, 3, 7, 11, 15)
|
||||
quarterRound(work, 0, 5, 10, 15)
|
||||
quarterRound(work, 1, 6, 11, 12)
|
||||
quarterRound(work, 2, 7, 8, 13)
|
||||
quarterRound(work, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
const out = Buffer.allocUnsafe(64)
|
||||
for (let i = 0; i < 16; i++)
|
||||
out.writeUInt32LE((work[i] + state[i]) >>> 0, i * 4)
|
||||
return out
|
||||
}
|
||||
|
||||
const chacha20 = (key, counter, nonce, data) => {
|
||||
const out = Buffer.allocUnsafe(data.length)
|
||||
for (let off = 0; off < data.length; off += 64) {
|
||||
const ks = chachaBlock(key, (counter + (off >>> 6)) >>> 0, nonce)
|
||||
const end = Math.min(off + 64, data.length)
|
||||
for (let i = off; i < end; i++) out[i] = data[i] ^ ks[i - off]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- RSA-256 Key Wrap (网易 NCBL 专用) ----
|
||||
// 256-bit 模数 N 已被因式分解,故可直接计算私钥
|
||||
const RSA_N =
|
||||
0xfd90bd466ff9bc8a3fec2fbcf263b90d5c564879fa5d7aab89b31c1d5cb4139dn
|
||||
const RSA_E = 65537n
|
||||
|
||||
const beToBig = (buf) => {
|
||||
let n = 0n
|
||||
for (const b of buf) n = (n << 8n) | BigInt(b)
|
||||
return n
|
||||
}
|
||||
|
||||
const bigToBe = (n, len) => {
|
||||
const out = Buffer.alloc(len)
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
out[i] = Number(n & 0xffn)
|
||||
n >>= 8n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const modPow = (base, exp, mod) => {
|
||||
let result = 1n
|
||||
base %= mod
|
||||
while (exp > 0n) {
|
||||
if (exp & 1n) result = (result * base) % mod
|
||||
base = (base * base) % mod
|
||||
exp >>= 1n
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const rsaWrap = (keyA) => {
|
||||
return bigToBe(modPow(beToBig(keyA), RSA_E, RSA_N), 32)
|
||||
}
|
||||
|
||||
// ---- NCBL 加密格式 ----
|
||||
const MAGIC = Buffer.from('NCBL', 'ascii')
|
||||
const NCBL_VERSION = 3
|
||||
const HEADER_FIXED_LEN = 70
|
||||
const META_BLOCK_TYPE = 0x4343
|
||||
const DEFAULT_MAX_FRAME = 0x8000
|
||||
|
||||
const getCompress = () => {
|
||||
if (typeof zlib.zstdCompressSync === 'function') {
|
||||
return { compress: (buf) => zlib.zstdCompressSync(buf), name: 'zstd' }
|
||||
}
|
||||
return { compress: (buf) => zlib.gzipSync(buf), name: 'gzip' }
|
||||
}
|
||||
|
||||
const encryptNCBL = (meta, body, opts = {}) => {
|
||||
const metaBuf = Buffer.isBuffer(meta) ? meta : Buffer.from(meta, 'utf-8')
|
||||
const bodyBuf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf-8')
|
||||
const maxFrame = opts.maxFrame || DEFAULT_MAX_FRAME
|
||||
|
||||
const keyA = opts.keyA || crypto.randomBytes(32)
|
||||
if (keyA[0] >= 0xa3) keyA[0] = 0xa2
|
||||
|
||||
const keyB = rsaWrap(keyA)
|
||||
|
||||
const uuid = opts.uuid || crypto.randomBytes(16)
|
||||
if (!opts.uuid) {
|
||||
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
||||
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
||||
}
|
||||
const nonce = uuid.subarray(0, 12)
|
||||
const counter = uuid.readUInt32LE(12) >>> 2
|
||||
const baseSeq = opts.baseSeq || crypto.randomBytes(2).readUInt16LE(0)
|
||||
|
||||
const metaCipher = chacha20(keyB, counter, nonce, metaBuf)
|
||||
const metaBlock = Buffer.concat([
|
||||
(() => {
|
||||
const h = Buffer.allocUnsafe(4)
|
||||
h.writeUInt16LE(META_BLOCK_TYPE, 0)
|
||||
h.writeUInt16LE(metaCipher.length, 2)
|
||||
return h
|
||||
})(),
|
||||
metaCipher,
|
||||
])
|
||||
const headerLen = HEADER_FIXED_LEN + metaBlock.length
|
||||
|
||||
const { compress } = getCompress()
|
||||
const compressed = compress(bodyBuf)
|
||||
|
||||
const frames = []
|
||||
let seq = baseSeq
|
||||
for (let off = 0; off < compressed.length || off === 0; off += maxFrame) {
|
||||
const slice = compressed.subarray(off, off + maxFrame)
|
||||
const cipher = chacha20(keyA, counter, nonce, slice)
|
||||
const head = Buffer.allocUnsafe(6)
|
||||
head.writeUInt16LE(cipher.length, 0)
|
||||
head.writeUInt32LE(seq >>> 0, 2)
|
||||
frames.push(head, cipher)
|
||||
seq++
|
||||
if (compressed.length === 0) break
|
||||
}
|
||||
|
||||
const trailing = Buffer.concat(frames)
|
||||
const frameCount = seq - baseSeq
|
||||
|
||||
const header = Buffer.alloc(HEADER_FIXED_LEN)
|
||||
MAGIC.copy(header, 0)
|
||||
header.writeUInt32LE(NCBL_VERSION, 4)
|
||||
header.writeUInt16LE(headerLen, 8)
|
||||
uuid.copy(header, 10)
|
||||
keyB.copy(header, 26)
|
||||
header.writeUInt32LE(baseSeq >>> 0, 58)
|
||||
header.writeUInt32LE((baseSeq + frameCount - 1) >>> 0, 62)
|
||||
header.writeUInt32LE(trailing.length, 66)
|
||||
|
||||
return Buffer.concat([header, metaBlock, trailing])
|
||||
}
|
||||
|
||||
// ---- 日志记录格式 ----
|
||||
const FIELD_SEP = '\x01'
|
||||
|
||||
const buildRecord = ({ time, action, data }) => {
|
||||
const json = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
return [time, action, json].join(FIELD_SEP)
|
||||
}
|
||||
|
||||
const buildRecords = (records) => records.map(buildRecord).join('')
|
||||
|
||||
// ---- PLV / PLD 构建器 (桌面客户端格式) ----
|
||||
const buildPlv = (ctx, song, source) => {
|
||||
const now = Date.now()
|
||||
const addRefer = `[F:63][${now}#933#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][23][cell_pc_songlist_song:2|page_pc_songlist_songflow|page_mine_like_music][${song.id}:song:x:x|:::|${source.id}:list::]`
|
||||
const multiRefers = [
|
||||
'[F:26][s][18][_ai]',
|
||||
'[F:26][s][12][_ai]',
|
||||
`[F:63][${now}#933#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][8][cell_pc_main_tab_entrance:6|page_pc_main_tab][我喜欢的音乐:spm::|:::]`,
|
||||
'[F:26][s][5][_ai]',
|
||||
'[F:26][s][0][_ai]',
|
||||
]
|
||||
|
||||
return {
|
||||
mode: 'circulation',
|
||||
download: 0,
|
||||
alg: '',
|
||||
status: 'front',
|
||||
id: String(song.id),
|
||||
bitrate: song.bitrate,
|
||||
type: 'song',
|
||||
is_listentogether: 0,
|
||||
source: source.name,
|
||||
is_heart: 0,
|
||||
resource_ratio: '',
|
||||
resource_time: song.time,
|
||||
musiceffect_id: '',
|
||||
app_mode: 2,
|
||||
bitrate_level: song.level,
|
||||
_addrefer: addRefer,
|
||||
_multirefers: multiRefers,
|
||||
vipType: ctx.auth.vipType,
|
||||
fee: 1,
|
||||
file: 4,
|
||||
rightSource: 0,
|
||||
sourceId: source.id,
|
||||
sourcetype: source.type,
|
||||
libra_abt: '',
|
||||
channel: ctx.app.channel,
|
||||
curStartChannel: '',
|
||||
}
|
||||
}
|
||||
|
||||
const buildPld = (ctx, song, source, played) => {
|
||||
const now = Date.now()
|
||||
const addRefer = `[F:63][${now}#616#${ctx.app.version}#${ctx.app.versionCode}#c9156c3][e][2][92][btn_pc_cover_play|cell_pc_songlist_song:6|page_pc_songlist_songflow|page_mine_like_music][:::|${song.id}:song:x:x|:::|${source.id}:list::]`
|
||||
const multiRefers = [
|
||||
'[F:26][s][87][_ai]',
|
||||
'[F:26][s][81][_ai]',
|
||||
'[F:26][s][75][_ai]',
|
||||
'[F:26][s][69][_ai]',
|
||||
'[F:26][s][63][_ai]',
|
||||
]
|
||||
|
||||
return {
|
||||
mode: 'circulation',
|
||||
download: 0,
|
||||
alg: '',
|
||||
status: 'front',
|
||||
id: String(song.id),
|
||||
time: played,
|
||||
type: 'song',
|
||||
is_listentogether: 0,
|
||||
source: source.name,
|
||||
is_heart: 0,
|
||||
realtime: played,
|
||||
resource_ratio: '',
|
||||
resource_time: song.time,
|
||||
musiceffect_id: '1001',
|
||||
app_mode: 1,
|
||||
lyriceffect: 'default',
|
||||
displayMode: 'classic',
|
||||
bitrate: song.bitrate,
|
||||
bitrate_level: song.level,
|
||||
_addrefer: addRefer,
|
||||
_multirefers: multiRefers,
|
||||
vipType: ctx.auth.vipType,
|
||||
fee: 8,
|
||||
file: 4,
|
||||
rightSource: 0,
|
||||
sourceId: source.id,
|
||||
sourcetype: source.type,
|
||||
end: 'interrupt',
|
||||
libra_abt: '',
|
||||
channel: ctx.app.channel,
|
||||
curStartChannel: '',
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cookie → 设备/认证上下文 转换 ----
|
||||
const extractContext = (cookieObj) => {
|
||||
return {
|
||||
app: {
|
||||
id: cookieObj.appid || '',
|
||||
urs: '',
|
||||
pid: '',
|
||||
nsm: cookieObj.WEVNSM || '1.0.0',
|
||||
cid:
|
||||
cookieObj.WNMCID ||
|
||||
`${crypto.randomBytes(3).toString('hex')}.${Date.now()}.01.0`,
|
||||
channel: cookieObj.channel || 'netease',
|
||||
version: cookieObj.appver || '3.1.35',
|
||||
versionCode: cookieObj.versioncode || '205293',
|
||||
buildCode: cookieObj.buildver || '',
|
||||
buildType: 'release',
|
||||
packageId: '',
|
||||
},
|
||||
device: {
|
||||
id: cookieObj.deviceId || cookieObj.sDeviceId || '',
|
||||
ti: cookieObj.NMTID || '',
|
||||
sign: cookieObj.clientSign || '',
|
||||
model: cookieObj.mode || cookieObj.mobilename || '',
|
||||
nnid: cookieObj._ntes_nnid || ',',
|
||||
nuid: cookieObj._ntes_nuid || '',
|
||||
csrf: cookieObj.__csrf || '',
|
||||
systemType: cookieObj.os || 'pc',
|
||||
systemVersion:
|
||||
cookieObj.osver ||
|
||||
'Microsoft-Windows-10-Professional-build-19045-64bit',
|
||||
},
|
||||
auth: {
|
||||
id: cookieObj.uid || '',
|
||||
token: cookieObj.MUSIC_U || '',
|
||||
sessionId: cookieObj['JSESSIONID-WYYY'] || '',
|
||||
vipType: cookieObj.vipType || '',
|
||||
},
|
||||
startTime: Date.now(),
|
||||
processId: Math.floor(Math.random() * 90000) + 10000,
|
||||
}
|
||||
}
|
||||
|
||||
// 从 query.cookie 解析 cookie 对象
|
||||
// 支持字符串 ("key=val; key2=val2") 或已解析的对象
|
||||
// 注意:不做 URL decode!MUSIC_U 中的 '+' 会被 decodeURIComponent 错误地转为空格
|
||||
const parseCookie = (cookie) => {
|
||||
if (typeof cookie === 'object' && cookie !== null) return cookie
|
||||
if (typeof cookie === 'string') {
|
||||
const obj = {}
|
||||
cookie.split(';').forEach((part) => {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx > 0) {
|
||||
const key = part.substring(0, idx).trim()
|
||||
const val = part.substring(idx + 1).trim()
|
||||
if (key) obj[key] = val
|
||||
}
|
||||
})
|
||||
return obj
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// ---- 辅助函数 ----
|
||||
const randomUUID = () => {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID().replace(/-/g, '')
|
||||
}
|
||||
return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/x/g, () =>
|
||||
Math.floor(Math.random() * 16).toString(16),
|
||||
)
|
||||
}
|
||||
|
||||
const randomHex = (len) => crypto.randomBytes(len / 2).toString('hex')
|
||||
|
||||
// ---- HTTP 上传工具 (NCBL 专用) ----
|
||||
const buildMultipart = (payload) => {
|
||||
const boundary = randomUUID()
|
||||
const fileName = `op_${Math.floor(Math.random() * 90000) + 10000}_0_${Math.floor(Math.random() * 4294967295) + 1}`
|
||||
|
||||
const CRLF = '\r\n'
|
||||
const headerLines = [
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="file"; filename="${fileName}"`,
|
||||
'Content-Type: multipart/form-data',
|
||||
'',
|
||||
'',
|
||||
].join(CRLF)
|
||||
const footer = `${CRLF}--${boundary}--${CRLF}`
|
||||
|
||||
return {
|
||||
boundary,
|
||||
fileName,
|
||||
multipartBody: Buffer.concat([
|
||||
Buffer.from(headerLines, 'utf-8'),
|
||||
payload,
|
||||
Buffer.from(footer, 'utf-8'),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
const buildCookieStr = (ctx) => {
|
||||
const parts = [
|
||||
`JSESSIONID-WYYY=${ctx.auth.sessionId}`,
|
||||
`MUSIC_U=${ctx.auth.token}`,
|
||||
`NMTID=${ctx.device.ti}`,
|
||||
`WEVNSM=${ctx.app.nsm}`,
|
||||
`WNMCID=${ctx.app.cid}`,
|
||||
`__csrf=${ctx.device.csrf}`,
|
||||
`__remember_me=true`,
|
||||
`_iuqxldmzr_=33`,
|
||||
`_ntes_nnid=${ctx.device.nnid}`,
|
||||
`_ntes_nuid=${ctx.device.nuid}`,
|
||||
`appver=${ctx.app.version}.${ctx.app.versionCode}`,
|
||||
`channel=${ctx.app.channel}`,
|
||||
`clientSign=${ctx.device.sign}`,
|
||||
`deviceId=${ctx.device.id}`,
|
||||
`mode=${ctx.device.model}`,
|
||||
`ntes_kaola_ad=1`,
|
||||
`os=${ctx.device.systemType}`,
|
||||
`osver=${ctx.device.systemVersion}`,
|
||||
]
|
||||
return parts.join('; ')
|
||||
}
|
||||
|
||||
const buildMetaJson = (ctx) =>
|
||||
JSON.stringify({
|
||||
'JSESSIONID-WYYY': ctx.auth.sessionId,
|
||||
MUSIC_U: ctx.auth.token,
|
||||
NMTID: ctx.device.ti,
|
||||
WEVNSM: ctx.app.nsm,
|
||||
WNMCID: ctx.app.cid,
|
||||
__csrf: ctx.device.csrf,
|
||||
_iuqxldmzr_: '33',
|
||||
_ntes_nnid: ctx.device.nnid,
|
||||
_ntes_nuid: ctx.device.nuid,
|
||||
appver: `${ctx.app.version}.${ctx.app.versionCode}`,
|
||||
channel: ctx.app.channel,
|
||||
clientSign: ctx.device.sign,
|
||||
deviceId: ctx.device.id,
|
||||
mode: ctx.device.model,
|
||||
ntes_kaola_ad: '1',
|
||||
os: ctx.device.systemType,
|
||||
osver: ctx.device.systemVersion,
|
||||
})
|
||||
|
||||
const doUpload = async (ctx, metaJson, body, cookieStr, label) => {
|
||||
const uploadUrl = DOMAIN3 + '/api/clientlog/encrypt/upload?multiupload=true'
|
||||
|
||||
const payload = encryptNCBL(metaJson, body)
|
||||
const { boundary, fileName, multipartBody } = buildMultipart(payload)
|
||||
|
||||
const resp = await axios({
|
||||
method: 'POST',
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
Referer: 'https://music.163.com/di',
|
||||
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/${ctx.app.version}`,
|
||||
'Accept-Encoding': 'gzip,deflate',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.8',
|
||||
Cookie: cookieStr,
|
||||
},
|
||||
data: multipartBody,
|
||||
maxBodyLength: 10 * 1024 * 1024,
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
const respBody = resp.data
|
||||
const code = respBody?.code
|
||||
const success =
|
||||
code === 200 && respBody?.data?.successfiles?.includes?.(fileName)
|
||||
|
||||
return { success, fileName, payload, respBody }
|
||||
}
|
||||
|
||||
// ---- 导出 ----
|
||||
module.exports = {
|
||||
chacha20,
|
||||
rsaWrap,
|
||||
encryptNCBL,
|
||||
getCompress,
|
||||
MAGIC,
|
||||
NCBL_VERSION,
|
||||
HEADER_FIXED_LEN,
|
||||
META_BLOCK_TYPE,
|
||||
DEFAULT_MAX_FRAME,
|
||||
buildRecord,
|
||||
buildRecords,
|
||||
FIELD_SEP,
|
||||
buildPlv,
|
||||
buildPld,
|
||||
extractContext,
|
||||
parseCookie,
|
||||
randomUUID,
|
||||
randomHex,
|
||||
buildMultipart,
|
||||
buildCookieStr,
|
||||
buildMetaJson,
|
||||
doUpload,
|
||||
}
|
||||
+8
-3
@@ -1,14 +1,19 @@
|
||||
const createOption = (query, crypto = '') => {
|
||||
const createOption = (query, crypto = '', checkToken = false) => {
|
||||
return {
|
||||
crypto: query.crypto || crypto || '',
|
||||
cookie: query.cookie || process.env.NETEASE_COOKIE,
|
||||
ua: query.ua || '',
|
||||
proxy: query.proxy,
|
||||
realIP: query.realIP,
|
||||
randomCNIP: process.env.ENABLE_RANDOM_CN_IP === 'true' ? !['false', false].includes(query.randomCNIP) : ['true', true].includes(query.randomCNIP),
|
||||
randomCNIP:
|
||||
process.env.ENABLE_RANDOM_CN_IP === 'true'
|
||||
? !['false', false].includes(query.randomCNIP)
|
||||
: ['true', true].includes(query.randomCNIP),
|
||||
e_r: query.e_r || undefined,
|
||||
domain: query.domain || '',
|
||||
checkToken: query.checkToken || false,
|
||||
checkToken: query.checkToken || checkToken,
|
||||
headers: query.headers || {},
|
||||
timeout: query.timeout || 0,
|
||||
}
|
||||
}
|
||||
module.exports = createOption
|
||||
|
||||
+45
-7
@@ -18,6 +18,12 @@ const {
|
||||
} = require('./index')
|
||||
const { URLSearchParams, URL } = require('url')
|
||||
const { APP_CONF } = require('../util/config.json')
|
||||
const {
|
||||
getToken: antiCheatTokenV2,
|
||||
} = require('../module/register_checktoken_v2')
|
||||
const {
|
||||
getToken: antiCheatTokenV3,
|
||||
} = require('../module/register_checktoken_v3')
|
||||
|
||||
// 预先读取匿名token并缓存
|
||||
const anonymous_token = fs.readFileSync(
|
||||
@@ -88,6 +94,12 @@ const osMap = {
|
||||
osver: '16.2',
|
||||
channel: 'distribution',
|
||||
},
|
||||
osx: {
|
||||
os: 'osx',
|
||||
appver: '3.1.10.5100',
|
||||
osver: '15.5',
|
||||
channel: 'netease',
|
||||
},
|
||||
}
|
||||
|
||||
// 预先定义userAgentMap
|
||||
@@ -100,7 +112,7 @@ const userAgentMap = {
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
},
|
||||
api: {
|
||||
pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.0.18.203152',
|
||||
pc: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.1.29.205117',
|
||||
android:
|
||||
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)',
|
||||
iphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)',
|
||||
@@ -110,7 +122,8 @@ const userAgentMap = {
|
||||
// 预先定义常量
|
||||
const DOMAIN = APP_CONF.domain
|
||||
const API_DOMAIN = APP_CONF.apiDomain
|
||||
const XEAPI_DOMAIN = 'https://interface3.music.163.com'
|
||||
const EAPI_DOMAIN = APP_CONF.eapiDomain
|
||||
const XEAPI_DOMAIN = APP_CONF.xeapiDomain
|
||||
const ENCRYPT_RESPONSE = APP_CONF.encryptResponse
|
||||
const SPECIAL_STATUS_CODES = new Set([201, 302, 400, 502, 800, 801, 802, 803])
|
||||
|
||||
@@ -175,6 +188,16 @@ const generateRequestId = () => {
|
||||
}
|
||||
|
||||
const createRequest = (uri, data, options) => {
|
||||
let token = ''
|
||||
switch (options.checkToken) {
|
||||
case 'v2':
|
||||
token = antiCheatTokenV2()
|
||||
break
|
||||
case 'v3':
|
||||
token = antiCheatTokenV3()
|
||||
break
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 变量声明和初始化
|
||||
const headers = options.headers ? { ...options.headers } : {}
|
||||
@@ -220,6 +243,9 @@ const createRequest = (uri, data, options) => {
|
||||
headers['Referer'] = options.domain || DOMAIN
|
||||
headers['User-Agent'] = options.ua || chooseUserAgent('weapi')
|
||||
data.csrf_token = csrfToken
|
||||
if (options.checkToken) {
|
||||
headers['X-antiCheatToken'] = token
|
||||
}
|
||||
encryptData = encrypt.weapi(data)
|
||||
url = (options.domain || DOMAIN) + '/weapi/' + uri.substr(5)
|
||||
break
|
||||
@@ -258,6 +284,9 @@ const createRequest = (uri, data, options) => {
|
||||
headers['x-sdeviceid'] = cookie.sDeviceId || cookie.deviceId
|
||||
headers['x-buildver'] = xeapiBuildver
|
||||
if (cookie.MUSIC_U) headers['x-music-u'] = cookie.MUSIC_U
|
||||
if (options.checkToken) {
|
||||
headers['X-antiCheatToken'] = token
|
||||
}
|
||||
const xeapiCookie = {
|
||||
...cookie,
|
||||
os: xeapiOs,
|
||||
@@ -296,24 +325,28 @@ const createRequest = (uri, data, options) => {
|
||||
__csrf: csrfToken,
|
||||
channel: cookie.channel,
|
||||
requestId: generateRequestId(),
|
||||
...(options.checkToken
|
||||
? { 'X-antiCheatToken': APP_CONF.checkToken }
|
||||
: {}),
|
||||
// clientSign: APP_CONF.clientSign,
|
||||
}
|
||||
|
||||
if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U
|
||||
if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A
|
||||
if (options.checkToken) {
|
||||
header['X-antiCheatToken'] = token
|
||||
}
|
||||
|
||||
headers['Cookie'] = createHeaderCookie(header)
|
||||
headers['User-Agent'] = options.ua || chooseUserAgent('api', 'iphone')
|
||||
headers['User-Agent'] =
|
||||
options.ua ||
|
||||
(cookie.os === 'osx'
|
||||
? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
: chooseUserAgent('api', 'iphone'))
|
||||
|
||||
if (crypto === 'eapi') {
|
||||
// headers['x-aeapi'] = true // 服务器会使用gzip压缩返回值
|
||||
data.header = header
|
||||
|
||||
encryptData = encrypt.eapi(uri, data)
|
||||
url = (options.domain || API_DOMAIN) + '/eapi/' + uri.substr(5)
|
||||
url = (options.domain || EAPI_DOMAIN) + '/eapi/' + uri.substr(5)
|
||||
} else if (crypto === 'api') {
|
||||
url = (options.domain || API_DOMAIN) + uri
|
||||
encryptData = data
|
||||
@@ -334,6 +367,11 @@ const createRequest = (uri, data, options) => {
|
||||
httpsAgent: createHttpsAgent(),
|
||||
}
|
||||
|
||||
// 自定义超时
|
||||
if (options.timeout > 0) {
|
||||
settings.timeout = options.timeout
|
||||
}
|
||||
|
||||
// 使用返回值加密
|
||||
const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r
|
||||
const use_xeapi = crypto === 'xeapi'
|
||||
|
||||
+8
-52
@@ -1,59 +1,15 @@
|
||||
const { default: axios } = require('axios')
|
||||
const encrypt = require('./crypto')
|
||||
const { APP_CONF } = require('./config.json')
|
||||
|
||||
const generateNonce = () => {
|
||||
let nonce = ''
|
||||
for (let i = 0; i < 16; i++) {
|
||||
nonce += Math.floor(Math.random() * 10).toString()
|
||||
}
|
||||
return nonce
|
||||
}
|
||||
const registerXeapiKey = require('../module/register_xeapikey')
|
||||
|
||||
const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => {
|
||||
const nonce = generateNonce()
|
||||
const timestamp = String(Date.now())
|
||||
const data = {
|
||||
appVersion: '9.1.65',
|
||||
currentKeyVersion: currentPublicKey.version || '',
|
||||
deviceId,
|
||||
nonce,
|
||||
os: 'android',
|
||||
requestType: 'active',
|
||||
signature: encrypt.xeapiSign(timestamp, nonce),
|
||||
t1: '',
|
||||
t2: '',
|
||||
timestamp,
|
||||
uid: '',
|
||||
}
|
||||
const res = await axios({
|
||||
method: 'POST',
|
||||
url: APP_CONF.apiDomain + '/api/gorilla/anti/crawler/security/key/get',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)',
|
||||
Cookie: deviceId ? `deviceId=${encodeURIComponent(deviceId)}` : '',
|
||||
const result = await registerXeapiKey(
|
||||
{
|
||||
deviceId,
|
||||
currentKeyVersion: currentPublicKey.version || '',
|
||||
},
|
||||
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')
|
||||
}
|
||||
null,
|
||||
)
|
||||
|
||||
const publicKey = encrypt.xeapiDecryptPublicKey(res.data.data.encryptedData)
|
||||
const publicKey = result.body
|
||||
if (!publicKey.sk && currentPublicKey.sk) {
|
||||
publicKey.sk = currentPublicKey.sk
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user