Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
cc4c6a058e
chore(packages): bump @typescript-eslint/eslint-plugin
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.46.3 to 8.53.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.53.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.53.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 15:35:03 +00:00
27 changed files with 379 additions and 5368 deletions

View File

@ -7,9 +7,9 @@
!/app.js !/app.js
!/server.js !/server.js
!/package.json !/package.json
!/package-lock.json
!/index.js !/index.js
!/generateConfig.js !/generateConfig.js
!/main.js !/main.js
!/data !/data
!/.env !/.env
!/pnpm-lock.yaml

View File

@ -16,7 +16,7 @@ updates:
# 自动合并小版本更新的 PR (可选) # 自动合并小版本更新的 PR (可选)
# 可以设置针对 patch 或 minor 版本更新自动合并 # 可以设置针对 patch 或 minor 版本更新自动合并
commit-message: commit-message:
prefix: "build(packages):" # PR 合并提交信息的前缀 prefix: "chore(packages):" # PR 合并提交信息的前缀
# 更新 GitHub Actions 依赖 # 更新 GitHub Actions 依赖
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"

View File

@ -1,8 +1,5 @@
# 更新日志 # 更新日志
### 4.30.1 | 2026.02.11
- feat: Add user playlist endpoints & domain overrides (#105)
### 4.30.0 | 2026.02.06 ### 4.30.0 | 2026.02.06
- feat: 新增音乐人黑胶会员任务接口 `/musician/vip/tasks` (#95) - feat: 新增音乐人黑胶会员任务接口 `/musician/vip/tasks` (#95)
- feat: 自动构建: 添加Windows、Linux、macOS预编译二进制文件 (#88) - feat: 自动构建: 添加Windows、Linux、macOS预编译二进制文件 (#88)

View File

@ -100,15 +100,15 @@ $ sudo docker run -d -p 3000:3000 ncm-api
## 3. 环境变量 ## 3. 环境变量
| 变量名 | 默认值 | 说明 | | 变量名 | 默认值 | 说明 |
|----------------------------|--------------------------------------|----------------------------------------------------| | -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------ |
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。若需要限制,请指定具体域名(例如 `https://example.com`)。 | | **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。若需要限制,请指定具体域名(例如 `https://example.com`)。 |
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 | | **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 | | **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 | | **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
| **ENABLE_FLAC** | `true` | 是否启用无损音质FLAC。 | | **ENABLE_FLAC** | `true` | 是否启用无损音质FLAC |
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 | | **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
| **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 | | **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 |
--- ---
@ -208,11 +208,11 @@ pnpm test
### SDK 生态 ### SDK 生态
| 语言 | 作者 | 地址 | 类型 | | 语言 | 作者 | 地址 | 类型 |
|--------|---------------------------------------------|------------------------------------------------------------------------------------------|-----| | ------ | ------------------------------------------- | ---------------------------------------------------------------------------------------- | ------ |
| Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 | | Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
| Java | [1015770492](https://github.com/1015770492) | https://github.com/1015770492/yumbo-music-utils | 第三方 | | Java | [1015770492](https://github.com/1015770492) | https://github.com/1015770492/yumbo-music-utils | 第三方 |
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 | | Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
### 依赖此项目的优秀开源项目 ### 依赖此项目的优秀开源项目

View File

@ -0,0 +1,26 @@
开始IP,结束IP,IP个数,位置
1.0.1.0 ,1.0.3.255 ,768,福州
1.0.8.0 ,1.0.15.255 ,2048,广州
1.0.32.0 ,1.0.63.255 ,8192,广州
1.1.0.0 ,1.1.0.255 ,256,福州
1.1.2.0 ,1.1.63.255 ,15872,广州
1.2.0.0 ,1.2.2.255 ,768,北京
1.2.4.0 ,1.2.127.255 ,31744,广州
1.3.0.0 ,1.3.255.255 ,65536,广州
1.4.1.0 ,1.4.127.255 ,32512,广州
1.8.0.0 ,1.8.255.255 ,65536,北京
1.10.0.0 ,1.10.9.255 ,2560,福州
1.10.11.0 ,1.10.127.255 ,29952,广州
1.12.0.0 ,1.15.255.255 ,262144,上海
1.18.128.0 ,1.18.128.255 ,256,北京
1.24.0.0 ,1.31.255.255 ,524288,赤峰
1.45.0.0 ,1.45.255.255 ,65536,北京
1.48.0.0 ,1.51.255.255 ,262144,济南
1.56.0.0 ,1.63.255.255 ,524288,伊春
1.68.0.0 ,1.71.255.255 ,262144,忻州
1.80.0.0 ,1.95.255.255 ,1048576,北京
1.116.0.0 ,1.117.255.255 ,131072,上海
1.119.0.0 ,1.119.255.255 ,65536,北京
1.180.0.0 ,1.185.255.255 ,393216,桂林
1.188.0.0 ,1.199.255.255 ,786432,洛阳
1.202.0.0 ,1.207.255.255 ,393216,铜仁
1 开始IP 结束IP IP个数 位置
2 1.0.1.0 1.0.3.255 768 福州
3 1.0.8.0 1.0.15.255 2048 广州
4 1.0.32.0 1.0.63.255 8192 广州
5 1.1.0.0 1.1.0.255 256 福州
6 1.1.2.0 1.1.63.255 15872 广州
7 1.2.0.0 1.2.2.255 768 北京
8 1.2.4.0 1.2.127.255 31744 广州
9 1.3.0.0 1.3.255.255 65536 广州
10 1.4.1.0 1.4.127.255 32512 广州
11 1.8.0.0 1.8.255.255 65536 北京
12 1.10.0.0 1.10.9.255 2560 福州
13 1.10.11.0 1.10.127.255 29952 广州
14 1.12.0.0 1.15.255.255 262144 上海
15 1.18.128.0 1.18.128.255 256 北京
16 1.24.0.0 1.31.255.255 524288 赤峰
17 1.45.0.0 1.45.255.255 65536 北京
18 1.48.0.0 1.51.255.255 262144 济南
19 1.56.0.0 1.63.255.255 524288 伊春
20 1.68.0.0 1.71.255.255 262144 忻州
21 1.80.0.0 1.95.255.255 1048576 北京
22 1.116.0.0 1.117.255.255 131072 上海
23 1.119.0.0 1.119.255.255 65536 北京
24 1.180.0.0 1.185.255.255 393216 桂林
25 1.188.0.0 1.199.255.255 786432 洛阳
26 1.202.0.0 1.207.255.255 393216 铜仁

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ const compat = new FlatCompat({
module.exports = defineConfig([ module.exports = defineConfig([
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2018,
sourceType: 'module', sourceType: 'module',
parserOptions: { parserOptions: {
parser: 'babel-eslint', parser: 'babel-eslint',

View File

@ -1,26 +1,25 @@
const uploadPlugin = require('../plugins/songUpload') const uploadPlugin = require('../plugins/songUpload')
const md5 = require('md5')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const logger = require('../util/logger.js') const logger = require('../util/logger.js')
const {
isTempFile,
getFileSize,
getFileMd5,
cleanupTempFile,
getFileExtension,
sanitizeFilename,
} = require('../util/fileHelper')
let mm let mm
module.exports = async (query, request) => { module.exports = async (query, request) => {
mm = require('music-metadata') mm = require('music-metadata')
let ext = 'mp3'
// if (query.songFile.name.indexOf('flac') > -1) {
// ext = 'flac'
// }
if (query.songFile.name.includes('.')) {
ext = query.songFile.name.split('.').pop()
}
query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString( query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString(
'utf-8', 'utf-8',
) )
const ext = getFileExtension(query.songFile.name) const filename = query.songFile.name
const filename = sanitizeFilename(query.songFile.name) .replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const bitrate = 999000 const bitrate = 999000
if (!query.songFile) { if (!query.songFile) {
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
@ -30,135 +29,119 @@ module.exports = async (query, request) => {
}, },
}) })
} }
if (!query.songFile.md5) {
const useTemp = isTempFile(query.songFile) // 命令行上传没有md5和size信息,需要填充
let fileSize = await getFileSize(query.songFile) query.songFile.md5 = md5(query.songFile.data)
let fileMd5 = await getFileMd5(query.songFile) query.songFile.size = query.songFile.data.byteLength
}
query.songFile.md5 = fileMd5 const res = await request(
query.songFile.size = fileSize `/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: query.songFile.size,
md5: query.songFile.md5,
songId: '0',
version: 1,
},
createOption(query),
)
let artist = ''
let album = ''
let songName = ''
try { try {
const res = await request( const metadata = await mm.parseBuffer(
`/api/cloud/upload/check`, query.songFile.data,
{ query.songFile.mimetype,
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: fileMd5,
songId: '0',
version: 1,
},
createOption(query),
) )
const info = metadata.common
let artist = '' if (info.title) {
let album = '' songName = info.title
let songName = ''
try {
let metadata
if (useTemp) {
metadata = await mm.parseFile(query.songFile.tempFilePath)
} else {
metadata = await mm.parseBuffer(
query.songFile.data,
query.songFile.mimetype,
)
}
const info = metadata.common
if (info.title) songName = info.title
if (info.album) album = info.album
if (info.artist) artist = info.artist
} catch (error) {
logger.info('元数据解析错误:', error.message)
} }
if (info.album) {
const tokenRes = await request( album = info.album
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: fileMd5,
},
createOption(query),
)
if (!tokenRes.body.result || !tokenRes.body.result.resourceId) {
logger.error('Token分配失败:', tokenRes.body)
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
} }
if (info.artist) {
if (res.body.needUpload) { artist = info.artist
logger.info('需要上传,开始上传流程...')
try {
const uploadInfo = await uploadPlugin(query, request)
logger.info('上传完成:', uploadInfo?.body?.result?.resourceId)
} catch (uploadError) {
logger.error('上传失败:', uploadError)
return Promise.reject(uploadError)
}
} else {
logger.info('文件已存在,跳过上传')
} }
// if (metadata.native.ID3v1) {
// metadata.native.ID3v1.forEach((item) => {
// // logger.info(item.id, item.value)
// if (item.id === 'title') {
// songName = item.value
// }
// if (item.id === 'artist') {
// artist = item.value
// }
// if (item.id === 'album') {
// album = item.value
// }
// })
// // logger.info({
// // songName,
// // album,
// // songName,
// // })
// }
// logger.info({
// songName,
// album,
// songName,
// })
} catch (error) {
logger.info(error)
}
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: query.songFile.md5,
},
createOption(query),
)
const res2 = await request( if (res.body.needUpload) {
`/api/upload/cloud/info/v2`, const uploadInfo = await uploadPlugin(query, request)
{ // logger.info('uploadInfo', uploadInfo.body.result.resourceId)
md5: fileMd5, }
songid: res.body.songId, // logger.info(tokenRes.body.result)
filename: query.songFile.name, const res2 = await request(
song: songName || filename, `/api/upload/cloud/info/v2`,
album: album || '未知专辑', {
artist: artist || '未知艺术家', md5: query.songFile.md5,
bitrate: String(bitrate), songid: res.body.songId,
resourceId: tokenRes.body.result.resourceId, filename: query.songFile.name,
}, song: songName || filename,
createOption(query), album: album || '未知专辑',
) artist: artist || '未知艺术家',
bitrate: String(bitrate),
if (res2.body.code !== 200) { resourceId: tokenRes.body.result.resourceId,
logger.error('云盘信息上传失败:', res2.body) },
return Promise.reject({ createOption(query),
status: res2.status || 500, )
body: { // logger.info({ res2, privateCloud: res2.body.privateCloud })
code: res2.body.code || 500, // logger.info(res.body.songId, 'songid')
msg: res2.body.msg || '上传云盘信息失败', const res3 = await request(
detail: res2.body, `/api/cloud/pub/v2`,
}, {
}) songid: res2.body.songId,
} },
createOption(query),
const res3 = await request( )
`/api/cloud/pub/v2`, // logger.info({ res3 })
{ return {
songid: res2.body.songId, status: 200,
}, body: {
createOption(query), ...res.body,
) ...res3.body,
// ...uploadInfo,
return { },
status: 200, cookie: res.cookie,
body: {
...res.body,
...res3.body,
},
cookie: res.cookie,
}
} finally {
if (useTemp) {
await cleanupTempFile(query.songFile.tempFilePath)
}
} }
} }

View File

@ -1,72 +0,0 @@
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const {
songId,
resourceId,
md5,
filename,
song,
artist,
album,
bitrate = 999000,
} = query
if (!songId || !resourceId || !md5 || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: songId, resourceId, md5, filename',
},
})
}
const songName = song || filename.replace(/\.[^.]+$/, '')
const res2 = await request(
`/api/upload/cloud/info/v2`,
{
md5: md5,
songid: songId,
filename: filename,
song: songName,
album: album || '未知专辑',
artist: artist || '未知艺术家',
bitrate: String(bitrate),
resourceId: resourceId,
},
createOption(query),
)
if (res2.body.code !== 200) {
return Promise.reject({
status: res2.status || 500,
body: {
code: res2.body.code || 500,
msg: res2.body.msg || '上传云盘信息失败',
detail: res2.body,
},
})
}
const res3 = await request(
`/api/cloud/pub/v2`,
{
songid: res2.body.songId,
},
createOption(query),
)
return {
status: 200,
body: {
code: 200,
data: {
songId: res2.body.songId,
...res3.body,
},
},
cookie: res2.cookie,
}
}

View File

@ -1,111 +0,0 @@
const { default: axios } = require('axios')
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const { md5, fileSize, filename, bitrate = 999000 } = query
if (!md5 || !fileSize || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: md5, fileSize, filename',
},
})
}
const ext = filename.includes('.') ? filename.split('.').pop() : 'mp3'
const checkRes = await request(
`/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: md5,
songId: '0',
version: 1,
},
createOption(query),
)
const bucket = 'jd-musicrep-privatecloud-audio-public'
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: bucket,
ext: ext,
filename: filename
.replace(/\.[^.]+$/, '')
.replace(/\s/g, '')
.replace(/\./g, '_'),
local: false,
nos_product: 3,
type: 'audio',
md5: md5,
},
createOption(query, 'weapi'),
)
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
}
let lbs
try {
lbs = (
await axios({
method: 'get',
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
timeout: 10000,
})
).data
} catch (error) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址失败',
detail: error.message,
},
})
}
if (!lbs || !lbs.upload || !lbs.upload[0]) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址无效',
detail: lbs,
},
})
}
return {
status: 200,
body: {
code: 200,
data: {
needUpload: checkRes.body.needUpload,
songId: checkRes.body.songId,
uploadToken: tokenRes.body.result.token,
objectKey: tokenRes.body.result.objectKey,
resourceId: tokenRes.body.result.resourceId,
uploadUrl: `${lbs.upload[0]}/${bucket}/${tokenRes.body.result.objectKey.replace(/\//g, '%2F')}?offset=0&complete=true&version=1.0`,
bucket: bucket,
md5: md5,
fileSize: fileSize,
filename: filename,
},
},
cookie: checkRes.cookie,
}
}

View File

@ -1,14 +0,0 @@
// 获取用户的收藏歌单列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
limit: query.limit || '100',
offset: query.offset || '0',
userId: query.uid,
isWebview: 'true',
includeRedHeart: 'true',
includeTop: 'true',
}
return request(`/api/user/playlist/collect`, data, createOption(query))
}

View File

@ -1,14 +0,0 @@
// 获取用户的创建歌单列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
limit: query.limit || '100',
offset: query.offset || '0',
userId: query.uid,
isWebview: 'true',
includeRedHeart: 'true',
includeTop: 'true',
}
return request(`/api/user/playlist/create`, data, createOption(query))
}

View File

@ -1,26 +1,25 @@
const { default: axios } = require('axios') const { default: axios } = require('axios')
const fs = require('fs')
var xml2js = require('xml2js') var xml2js = require('xml2js')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper') var parser = new xml2js.Parser(/* options */)
var parser = new xml2js.Parser()
function createDupkey() { function createDupkey() {
// 格式:3b443c7c-a87f-468d-ba38-46d407aaf23a
var s = [] var s = []
var hexDigits = '0123456789abcdef' var hexDigits = '0123456789abcdef'
for (var i = 0; i < 36; i++) { for (var i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1) s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
} }
s[14] = '4' s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = '-' s[8] = s[13] = s[18] = s[23] = '-'
return s.join('') return s.join('')
} }
module.exports = async (query, request) => { module.exports = async (query, request) => {
const ext = getFileExtension(query.songFile.name) let ext = 'mp3'
if (query.songFile.name.indexOf('flac') > -1) {
ext = 'flac'
}
const filename = const filename =
query.songName || query.songName ||
query.songFile.name query.songFile.name
@ -51,58 +50,43 @@ module.exports = async (query, request) => {
createOption(query, 'weapi'), createOption(query, 'weapi'),
) )
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F') const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
const docId = tokenRes.body.result.docId const docId = tokenRes.body.result.docId
const res = await axios({ const res = await axios({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: { headers: {
'x-nos-token': tokenRes.body.result.token, 'x-nos-token': tokenRes.body.result.token,
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg', 'X-Nos-Meta-Content-Type': 'audio/mpeg',
}, },
data: null, data: null,
}) })
// return xml
const res2 = await parser.parseStringPromise(res.data) const res2 = await parser.parseStringPromise(res.data)
const useTempFile = !!query.songFile.tempFilePath const fileSize = query.songFile.data.length
let fileSize = query.songFile.size const blockSize = 10 * 1024 * 1024 // 10MB
if (useTempFile) {
const stats = await fs.promises.stat(query.songFile.tempFilePath)
fileSize = stats.size
}
const blockSize = 10 * 1024 * 1024
let offset = 0 let offset = 0
let blockIndex = 1 let blockIndex = 1
let etags = [] let etags = []
while (offset < fileSize) { while (offset < fileSize) {
let chunk const chunk = query.songFile.data.slice(
if (useTempFile) { offset,
chunk = await readFileChunk( Math.min(offset + blockSize, fileSize),
query.songFile.tempFilePath, )
offset,
Math.min(blockSize, fileSize - offset),
)
} else {
chunk = query.songFile.data.slice(
offset,
Math.min(offset + blockSize, fileSize),
)
}
const res3 = await axios({ const res3 = await axios({
method: 'put', method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
'x-nos-token': tokenRes.body.result.token, 'x-nos-token': tokenRes.body.result.token,
'Content-Type': query.songFile.mimetype || 'audio/mpeg', 'Content-Type': 'audio/mpeg',
}, },
data: chunk, data: chunk,
}) })
// get etag
const etag = res3.headers.etag const etag = res3.headers.etag
etags.push(etag) etags.push(etag)
offset += blockSize offset += blockSize
@ -117,17 +101,19 @@ module.exports = async (query, request) => {
} }
completeStr += '</CompleteMultipartUpload>' completeStr += '</CompleteMultipartUpload>'
// 文件处理
await axios({ await axios({
method: 'post', method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`, url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: { headers: {
'Content-Type': 'text/plain;charset=UTF-8', 'Content-Type': 'text/plain;charset=UTF-8',
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg', 'X-Nos-Meta-Content-Type': 'audio/mpeg',
'x-nos-token': tokenRes.body.result.token, 'x-nos-token': tokenRes.body.result.token,
}, },
data: completeStr, data: completeStr,
}) })
// preCheck
await request( await request(
`/api/voice/workbench/voice/batch/upload/preCheck`, `/api/voice/workbench/voice/batch/upload/preCheck`,
{ {

View File

@ -1,6 +1,6 @@
{ {
"name": "@neteasecloudmusicapienhanced/api", "name": "@neteasecloudmusicapienhanced/api",
"version": "4.30.1", "version": "4.30.0",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护", "description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"scripts": { "scripts": {
"dev": "nodemon app.js", "dev": "nodemon app.js",
@ -20,6 +20,7 @@
"node_modules/axios", "node_modules/axios",
"node_modules/express", "node_modules/express",
"node_modules/express-fileupload", "node_modules/express-fileupload",
"node_modules/md5",
"node_modules/music-metadata", "node_modules/music-metadata",
"node_modules/pac-proxy-agent", "node_modules/pac-proxy-agent",
"node_modules/qrcode", "node_modules/qrcode",
@ -71,6 +72,7 @@
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"md5": "^2.3.0",
"music-metadata": "^11.12.0", "music-metadata": "^11.12.0",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pac-proxy-agent": "^7.2.0", "pac-proxy-agent": "^7.2.0",
@ -87,21 +89,21 @@
"@types/express-fileupload": "^1.5.1", "@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",
"@types/node": "25.0.9", "@types/node": "25.0.9",
"@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "^8.53.0", "@typescript-eslint/parser": "8.53.0",
"eslint": "^9.39.0", "eslint": "9.39.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-html": "^8.1.3", "eslint-plugin-html": "8.1.3",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "5.5.5",
"globals": "^16.5.0", "globals": "^16.5.0",
"husky": "^9.1.7", "husky": "9.1.7",
"intelli-espower-loader": "^1.1.0", "intelli-espower-loader": "1.1.0",
"lint-staged": "^16.2.7", "lint-staged": "16.2.7",
"mocha": "^11.7.5", "mocha": "11.7.5",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"pkg": "^5.8.1", "pkg": "^5.8.1",
"power-assert": "^1.6.1", "power-assert": "1.6.1",
"prettier": "^3.7.4", "prettier": "3.7.4",
"typescript": "^5.9.3" "typescript": "5.9.3"
} }
} }

View File

@ -1,17 +1,20 @@
const { default: axios } = require('axios') const { default: axios } = require('axios')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const logger = require('../util/logger.js') const logger = require('../util/logger.js')
const {
getUploadData,
getFileExtension,
sanitizeFilename,
} = require('../util/fileHelper')
module.exports = async (query, request) => { module.exports = async (query, request) => {
const ext = getFileExtension(query.songFile.name) let ext = 'mp3'
const filename = sanitizeFilename(query.songFile.name) // if (query.songFile.name.indexOf('flac') > -1) {
// ext = 'flac'
// }
if (query.songFile.name.includes('.')) {
ext = query.songFile.name.split('.').pop()
}
const filename = query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const bucket = 'jd-musicrep-privatecloud-audio-public' const bucket = 'jd-musicrep-privatecloud-audio-public'
// 获取key和token
const tokenRes = await request( const tokenRes = await request(
`/api/nos/token/alloc`, `/api/nos/token/alloc`,
{ {
@ -26,82 +29,31 @@ module.exports = async (query, request) => {
createOption(query, 'weapi'), createOption(query, 'weapi'),
) )
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) { // 上传
logger.error('Token分配失败:', tokenRes.body) const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
throw {
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
}
}
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
let lbs
try { try {
lbs = ( const lbs = (
await axios({ await axios({
method: 'get', method: 'get',
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`, url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
timeout: 10000,
}) })
).data ).data
} catch (error) {
logger.error('LBS获取失败:', error.message)
throw {
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址失败',
detail: error.message,
},
}
}
if (!lbs || !lbs.upload || !lbs.upload[0]) {
logger.error('无效的LBS响应:', lbs)
throw {
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址无效',
detail: lbs,
},
}
}
try {
await axios({ await axios({
method: 'post', method: 'post',
url: `${lbs.upload[0]}/${bucket}/${objectKey}?offset=0&complete=true&version=1.0`, url: `${lbs.upload[0]}/${bucket}/${objectKey}?offset=0&complete=true&version=1.0`,
headers: { headers: {
'x-nos-token': tokenRes.body.result.token, 'x-nos-token': tokenRes.body.result.token,
'Content-MD5': query.songFile.md5, 'Content-MD5': query.songFile.md5,
'Content-Type': query.songFile.mimetype || 'audio/mpeg', 'Content-Type': 'audio/mpeg',
'Content-Length': String(query.songFile.size), 'Content-Length': String(query.songFile.size),
}, },
data: getUploadData(query.songFile), data: query.songFile.data,
maxContentLength: Infinity, maxContentLength: Infinity,
maxBodyLength: Infinity, maxBodyLength: Infinity,
timeout: 300000,
}) })
logger.info('上传成功:', filename)
} catch (error) { } catch (error) {
logger.error('上传失败:', { logger.info('error', error.response)
status: error.response?.status, throw error.response
data: error.response?.data,
message: error.message,
})
throw {
status: error.response?.status || 500,
body: {
code: error.response?.status || 500,
msg: '文件上传失败',
detail: error.response?.data || error.message,
},
}
} }
return { return {
...tokenRes, ...tokenRes,

View File

@ -1,7 +1,5 @@
const { default: axios } = require('axios') const { default: axios } = require('axios')
const createOption = require('../util/option.js') const createOption = require('../util/option.js')
const { getUploadData } = require('../util/fileHelper')
module.exports = async (query, request) => { module.exports = async (query, request) => {
const data = { const data = {
bucket: 'yyimgs', bucket: 'yyimgs',
@ -12,23 +10,27 @@ module.exports = async (query, request) => {
return_body: `{"code":200,"size":"$(ObjectSize)"}`, return_body: `{"code":200,"size":"$(ObjectSize)"}`,
type: 'other', type: 'other',
} }
// 获取key和token
const res = await request( const res = await request(
`/api/nos/token/alloc`, `/api/nos/token/alloc`,
data, data,
createOption(query, 'weapi'), createOption(query, 'weapi'),
) )
// 上传图片
const res2 = await axios({ const res2 = await axios({
method: 'post', method: 'post',
url: `https://nosup-hz1.127.net/yyimgs/${res.body.result.objectKey}?offset=0&complete=true&version=1.0`, url: `https://nosup-hz1.127.net/yyimgs/${res.body.result.objectKey}?offset=0&complete=true&version=1.0`,
headers: { headers: {
'x-nos-token': res.body.result.token, 'x-nos-token': res.body.result.token,
'Content-Type': query.imgFile.mimetype || 'image/jpeg', 'Content-Type': 'image/jpeg',
}, },
data: getUploadData(query.imgFile), data: query.imgFile.data,
}) })
return { return {
// ...res.body.result,
// ...res2.data,
// ...res3.body,
url_pre: 'https://p1.music.126.net/' + res.body.result.objectKey, url_pre: 'https://p1.music.126.net/' + res.body.result.objectKey,
imgId: res.body.result.docId, imgId: res.body.result.docId,
} }

53
pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
express-fileupload: express-fileupload:
specifier: ^1.5.2 specifier: ^1.5.2
version: 1.5.2 version: 1.5.2
md5:
specifier: ^2.3.0
version: 2.3.0
music-metadata: music-metadata:
specifier: ^11.12.0 specifier: ^11.12.0
version: 11.12.0 version: 11.12.0
@ -70,37 +73,37 @@ importers:
specifier: 25.0.9 specifier: 25.0.9
version: 25.0.9 version: 25.0.9
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.46.3 specifier: 8.53.0
version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3) version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3)
'@typescript-eslint/parser': '@typescript-eslint/parser':
specifier: ^8.53.0 specifier: 8.53.0
version: 8.53.0(eslint@9.39.0)(typescript@5.9.3) version: 8.53.0(eslint@9.39.0)(typescript@5.9.3)
eslint: eslint:
specifier: ^9.39.0 specifier: 9.39.0
version: 9.39.0 version: 9.39.0
eslint-config-prettier: eslint-config-prettier:
specifier: ^10.1.8 specifier: 10.1.8
version: 10.1.8(eslint@9.39.0) version: 10.1.8(eslint@9.39.0)
eslint-plugin-html: eslint-plugin-html:
specifier: ^8.1.3 specifier: 8.1.3
version: 8.1.3 version: 8.1.3
eslint-plugin-prettier: eslint-plugin-prettier:
specifier: ^5.5.5 specifier: 5.5.5
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.0))(eslint@9.39.0)(prettier@3.7.4) version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.0))(eslint@9.39.0)(prettier@3.7.4)
globals: globals:
specifier: ^16.5.0 specifier: ^16.5.0
version: 16.5.0 version: 16.5.0
husky: husky:
specifier: ^9.1.7 specifier: 9.1.7
version: 9.1.7 version: 9.1.7
intelli-espower-loader: intelli-espower-loader:
specifier: ^1.1.0 specifier: 1.1.0
version: 1.1.0 version: 1.1.0
lint-staged: lint-staged:
specifier: ^16.2.7 specifier: 16.2.7
version: 16.2.7 version: 16.2.7
mocha: mocha:
specifier: ^11.7.5 specifier: 11.7.5
version: 11.7.5 version: 11.7.5
nodemon: nodemon:
specifier: ^3.1.11 specifier: ^3.1.11
@ -109,13 +112,13 @@ importers:
specifier: ^5.8.1 specifier: ^5.8.1
version: 5.8.1 version: 5.8.1
power-assert: power-assert:
specifier: ^1.6.1 specifier: 1.6.1
version: 1.6.1 version: 1.6.1
prettier: prettier:
specifier: ^3.7.4 specifier: 3.7.4
version: 3.7.4 version: 3.7.4
typescript: typescript:
specifier: ^5.9.3 specifier: 5.9.3
version: 5.9.3 version: 5.9.3
packages: packages:
@ -577,6 +580,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@ -674,6 +680,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
crypto-js@4.2.0: crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
@ -1406,6 +1415,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-callable@1.2.7: is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1606,6 +1618,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
media-typer@0.3.0: media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -3149,6 +3164,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
charenc@0.0.2: {}
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@ -3248,6 +3265,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypt@0.0.2: {}
crypto-js@4.2.0: {} crypto-js@4.2.0: {}
d@1.0.2: d@1.0.2:
@ -4181,6 +4200,8 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
is-buffer@1.1.6: {}
is-callable@1.2.7: {} is-callable@1.2.7: {}
is-core-module@2.16.1: is-core-module@2.16.1:
@ -4380,6 +4401,12 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
md5@2.3.0:
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
media-typer@0.3.0: {} media-typer@0.3.0: {}
media-typer@1.1.0: {} media-typer@1.1.0: {}

BIN
precompiled/app-linux/app Normal file

Binary file not shown.

BIN
precompiled/app-macos/app Normal file

Binary file not shown.

BIN
precompiled/app-win/app.exe Normal file

Binary file not shown.

View File

@ -47,59 +47,6 @@
text-decoration: underline; text-decoration: underline;
} }
.mode-section {
margin-bottom: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.mode-section label {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.mode-options {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.mode-option {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
}
.mode-option input[type="radio"] {
margin-top: 3px;
}
.mode-option-text {
display: flex;
flex-direction: column;
}
.mode-option-title {
font-size: 14px;
color: #333;
}
.mode-option-desc {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.mode-option input[type="radio"]:checked + .mode-option-text .mode-option-title {
color: #333;
font-weight: 500;
}
.upload-section { .upload-section {
margin-bottom: 32px; margin-bottom: 32px;
} }
@ -125,11 +72,6 @@
display: none; display: none;
} }
.upload-btn.disabled {
background: #ccc;
cursor: not-allowed;
}
.songs-list { .songs-list {
list-style: none; list-style: none;
} }
@ -157,74 +99,6 @@
padding: 20px; padding: 20px;
color: #666; color: #666;
} }
.progress-section {
margin-bottom: 24px;
display: none;
}
.progress-section.active {
display: block;
}
.progress-item {
margin-bottom: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
}
.progress-item .name {
font-size: 14px;
color: #333;
margin-bottom: 8px;
word-break: break-all;
}
.progress-item .status {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-bar .fill {
height: 100%;
background: #333;
border-radius: 3px;
transition: width 0.3s ease;
width: 0%;
}
.progress-item.success .fill {
background: #4caf50;
}
.progress-item.error .fill {
background: #f44336;
}
.progress-item.error .status {
color: #f44336;
}
.info-text {
font-size: 12px;
color: #999;
margin-top: 8px;
}
.warning-text {
font-size: 12px;
color: #e65100;
margin-top: 8px;
}
</style> </style>
</head> </head>
@ -233,36 +107,13 @@
<h1>云盘上传</h1> <h1>云盘上传</h1>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a> <a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<div class="mode-section">
<label>上传模式</label>
<div class="mode-options">
<label class="mode-option">
<input type="radio" name="uploadMode" value="direct" checked />
<span class="mode-option-text">
<span class="mode-option-title">客户端直传</span>
<span class="mode-option-desc">文件直接上传到云存储,支持大文件,适合 Vercel 等平台</span>
</span>
</label>
<label class="mode-option">
<input type="radio" name="uploadMode" value="proxy" />
<span class="mode-option-text">
<span class="mode-option-title">后端代理</span>
<span class="mode-option-desc">文件通过服务器转发,更简洁,需要服务器支持大文件</span>
</span>
</label>
</div>
</div>
<div class="upload-section"> <div class="upload-section">
<label class="upload-btn" id="uploadBtn"> <label class="upload-btn">
选择文件(支持多选) 选择文件(支持多选)
<input id="file" type="file" multiple accept="audio/*" /> <input id="file" type="file" multiple accept="audio/*" />
</label> </label>
<p class="info-text" id="modeInfo">支持大文件上传,文件将直接传输到云存储服务器</p>
</div> </div>
<div id="progressSection" class="progress-section"></div>
<div id="app"> <div id="app">
<div v-if="loading" class="loading">加载中...</div> <div v-if="loading" class="loading">加载中...</div>
<ul v-else-if="songs.length > 0" class="songs-list"> <ul v-else-if="songs.length > 0" class="songs-list">
@ -276,7 +127,6 @@
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script> <script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script> <script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script>
<script> <script>
const app = Vue.createApp({ const app = Vue.createApp({
data() { data() {
@ -307,272 +157,55 @@
}, },
}).mount('#app') }).mount('#app')
let isUploading = false const fileUpdateTime = {}
let uploadMode = 'direct' let fileLength = 0
const progressSection = document.getElementById('progressSection')
const uploadBtn = document.getElementById('uploadBtn')
const fileInput = document.querySelector('input[type="file"]')
const modeInfo = document.getElementById('modeInfo')
document.querySelectorAll('input[name="uploadMode"]').forEach(radio => {
radio.addEventListener('change', function() {
uploadMode = this.value
if (uploadMode === 'direct') {
modeInfo.textContent = '支持大文件上传,文件将直接传输到云存储服务器'
modeInfo.className = 'info-text'
} else {
modeInfo.textContent = '文件将通过服务器转发服务器需支持大文件上传Vercel 限制 4.5MB'
modeInfo.className = 'warning-text'
}
})
})
function main() { function main() {
fileInput.addEventListener('change', function (e) { document
const files = this.files .querySelector('input[type="file"]')
if (files.length === 0) return .addEventListener('change', function (e) {
if (isUploading) return const files = this.files
if (files.length === 0) return
uploadFilesSequentially(Array.from(files)) fileLength = files.length
this.value = '' for (let i = 0; i < files.length; i++) {
}) upload(files[i], i + 1)
}
})
} }
main() main()
async function uploadFilesSequentially(files) { function upload(file, currentIndex) {
isUploading = true var formData = new FormData()
uploadBtn.classList.add('disabled') formData.append('songFile', file)
progressSection.classList.add('active')
progressSection.innerHTML = ''
for (let i = 0; i < files.length; i++) { axios({
if (uploadMode === 'direct') {
await uploadFileDirect(files[i], i + 1, files.length)
} else {
await uploadFileProxy(files[i], i + 1, files.length)
}
}
isUploading = false
uploadBtn.classList.remove('disabled')
app.getData()
}
function createProgressItem(file, index, total) {
const item = document.createElement('div')
item.className = 'progress-item'
item.id = `progress-${index}`
item.innerHTML = `
<div class="name">${file.name} (${formatSize(file.size)})</div>
<div class="status">准备中...</div>
<div class="progress-bar"><div class="fill"></div></div>
`
progressSection.appendChild(item)
return item
}
function updateProgress(index, status, percent, isError = false) {
const item = document.getElementById(`progress-${index}`)
if (!item) return
item.querySelector('.status').textContent = status
item.querySelector('.fill').style.width = `${percent}%`
if (isError) {
item.classList.add('error')
} else if (percent >= 100) {
item.classList.add('success')
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
async function uploadFileProxy(file, index, total) {
createProgressItem(file, index, total)
try {
updateProgress(index, '上传中...', 10)
const formData = new FormData()
formData.append('songFile', file)
await axios({
method: 'post',
url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 90) + 10
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 100))
},
timeout: 600000,
})
updateProgress(index, '上传完成!', 100)
} catch (err) {
console.error(`${file.name} 上传失败:`, err)
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
if (err.response?.status === 413 || errorMsg.includes('PAYLOAD_TOO_LARGE')) {
updateProgress(index, '文件过大,请切换到客户端直传模式', 0, true)
} else {
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
}
}
}
async function calculateMD5(file) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
reader.onerror = () => reject(reader.error)
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
async function parseMediaTags(file) {
return new Promise((resolve) => {
jsmediatags.read(file, {
onSuccess: function(tag) {
resolve({
title: tag.tags.title || null,
artist: tag.tags.artist || null,
album: tag.tags.album || null,
})
},
onError: function() {
resolve({ title: null, artist: null, album: null })
}
})
})
}
async function uploadFileDirect(file, index, total) {
createProgressItem(file, index, total)
try {
updateProgress(index, '计算文件MD5...', 5)
const md5 = await calculateMD5(file)
const fileSize = file.size
const filename = file.name
updateProgress(index, '解析音频元数据...', 8)
const mediaTags = await parseMediaTags(file)
updateProgress(index, '获取上传凭证...', 10)
const tokenRes = await axios({
method: 'post',
url: `/cloud/upload/token?time=${Date.now()}`,
data: {
cookie: localStorage.getItem('cookie'),
md5: md5,
fileSize: fileSize,
filename: filename,
},
})
if (tokenRes.data.code !== 200) {
throw new Error(tokenRes.data.msg || '获取上传凭证失败')
}
const tokenData = tokenRes.data.data
if (!tokenData.needUpload) {
updateProgress(index, '文件已存在,直接导入云盘...', 80)
await completeUpload(tokenData, file, mediaTags)
updateProgress(index, '上传完成!', 100)
return
}
updateProgress(index, '开始上传到云存储...', 15)
await axios({
method: 'post',
url: tokenData.uploadUrl,
headers: {
'x-nos-token': tokenData.uploadToken,
'Content-MD5': md5,
'Content-Type': 'audio/mpeg',
'Content-Length': String(fileSize),
},
data: file,
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 70) + 15
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 85))
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
timeout: 600000,
})
updateProgress(index, '上传完成,正在导入云盘...', 90)
await completeUpload(tokenData, file, mediaTags)
updateProgress(index, '上传完成!', 100)
} catch (err) {
console.error(`${file.name} 上传失败:`, err)
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
}
}
async function completeUpload(tokenData, file, mediaTags = {}) {
const songName = mediaTags.title || file.name.replace(/\.[^.]+$/, '')
const artist = mediaTags.artist || '未知艺术家'
const album = mediaTags.album || '未知专辑'
const completeRes = await axios({
method: 'post', method: 'post',
url: `/cloud/upload/complete?time=${Date.now()}`, url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
data: { headers: {
cookie: localStorage.getItem('cookie'), 'Content-Type': 'multipart/form-data',
songId: tokenData.songId,
resourceId: tokenData.resourceId,
md5: tokenData.md5,
filename: file.name,
song: songName,
artist: artist,
album: album,
}, },
data: formData,
}) })
.then((res) => {
if (completeRes.data.code !== 200) { console.log(`${file.name} 上传成功`)
throw new Error(completeRes.data.msg || '导入云盘失败') if (currentIndex >= fileLength) {
} console.log('所有文件上传完毕')
}
return completeRes.data app.getData()
})
.catch((err) => {
console.error(`${file.name} 上传失败:`, err)
fileUpdateTime[file.name] = (fileUpdateTime[file.name] || 0) + 1
if (fileUpdateTime[file.name] >= 4) {
console.error(`文件 ${file.name} 上传失败次数过多,已停止重试`)
return
} else {
console.error(`${file.name} 上传失败 ${fileUpdateTime[file.name]} 次,正在重试...`)
upload(file, currentIndex)
}
})
} }
</script> </script>
<script src="https://fastly.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</body> </body>
</html> </html>

View File

@ -2,8 +2,6 @@
网易云音乐 NodeJS API Enhanced 网易云音乐 NodeJS API Enhanced
最后更新于: 2026.2.15
## 灵感来自 ## 灵感来自
[disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music) [disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music)
@ -2768,7 +2766,7 @@ type : 地区
参考: https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/cloud.html 参考: https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/cloud.html
访问地址: http://localhost:3000/cloud.html 访问地址: http://localhost:3000/cloud.html)
支持命令行调用,参考 module_example 目录下`song_upload.js` 支持命令行调用,参考 module_example 目录下`song_upload.js`
@ -2776,72 +2774,6 @@ type : 地区
**调用例子 :** `/cloud` **调用例子 :** `/cloud`
#### 上传模式说明
云盘上传支持两种模式:
**1. 后端代理模式 (默认)**
文件通过服务器转发到云存储,调用简单,但受服务器限制:
- Vercel Serverless Functions 限制请求体大小为 4.5MB
- 自建服务器需配置足够大的请求体限制
**2. 客户端直传模式 (推荐用于 Vercel)**
文件直接从客户端上传到云存储服务器,绕过服务器限制:
- 支持大文件上传
- 适合 Vercel、Netlify 等有请求体限制的平台
- 需要前端配合实现
#### 客户端直传相关接口
**获取上传凭证**
**接口地址 :** `/cloud/upload/token`
**必选参数 :**
- `cookie`: 网易云音乐 Cookie (在请求体中传递)
- `md5`: 文件 MD5 值
- `fileSize`: 文件大小(字节)
- `filename`: 文件名
**返回数据 :**
```json
{
"code": 200,
"data": {
"needUpload": true,
"songId": "...",
"uploadToken": "...",
"uploadUrl": "...",
"resourceId": "..."
}
}
```
**完成上传导入**
**接口地址 :** `/cloud/upload/complete`
**必选参数 :**
- `cookie`: 网易云音乐 Cookie (在请求体中传递)
- `songId`: 歌曲 ID
- `resourceId`: 资源 ID
- `md5`: 文件 MD5
- `filename`: 文件名
**可选参数 :**
- `song`: 歌曲名
- `artist`: 艺术家
- `album`: 专辑名
#### 客户端直传流程
1. 客户端计算文件 MD5
2. 调用 `/cloud/upload/token` 获取上传凭证
3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl`
4. 调用 `/cloud/upload/complete` 完成导入
### 云盘歌曲信息匹配纠正 ### 云盘歌曲信息匹配纠正
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0 说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
@ -4951,43 +4883,6 @@ let data = encodeURIComponent(
**调用例子:** `/vip/sign/info` **调用例子:** `/vip/sign/info`
### 用户的创建歌单列表
说明 : 调用此接口, 传入用户id, 获取用户的创建歌单列表
**必选参数 :**
`uid`: 用户 id
**可选参数 :**
`limit` : 返回数量 , 默认为 100
`offset` : 偏移数量,用于分页 ,如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
**接口地址 :** `/user/playlist/create`
**调用例子 :** `/user/playlist/create?uid=32953014`
### 用户的收藏歌单列表
说明 : 调用此接口, 传入用户id, 获取用户的收藏歌单列表
**必选参数 :**
`uid`: 用户 id
**可选参数 :**
`limit` : 返回数量 , 默认为 100
`offset` : 偏移数量,用于分页 ,如 :( 页数 -1)\*30, 其中 30 为 limit 的值 , 默认为 0
**接口地址 :** `/user/playlist/collect`
**调用例子 :** `/user/playlist/collect?uid=32953014`
## 离线访问此文档 ## 离线访问此文档
此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问 此文档同时也是 Progressive Web Apps(PWA), 加入了 serviceWorker, 可离线访问

View File

@ -18,33 +18,20 @@
html, body { height: 100%; } html, body { height: 100%; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
.container { max-width: 960px; margin: 40px auto; padding: 0 20px; } .container { max-width: 960px; 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 { margin-bottom: 24px; }
header.site-header h1 { font-size: 28px; font-weight: 600; margin: 0; } header.site-header h1 { font-size: 28px; font-weight: 600; margin: 0; }
.badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); } .badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
.sub { margin-top: 8px; color: var(--muted); font-size: 14px; } .sub { margin-top: 8px; color: var(--muted); font-size: 14px; }
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } .block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
.block h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; } .block h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; }
.kvs { display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; align-items: start; } .kvs { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; align-items: center; }
.kvs div:first-child { color: var(--muted); flex-shrink: 0; } .kvs div:first-child { color: var(--muted); }
.kvs div:last-child { word-break: break-all; overflow-wrap: anywhere; min-width: 0; overflow: hidden; }
@media (max-width: 480px) {
.kvs { grid-template-columns: 1fr; gap: 4px 12px; }
.kvs div:first-child { font-weight: 500; }
}
ul.links { list-style: none; padding: 0; margin: 0; } ul.links { list-style: none; padding: 0; margin: 0; }
ul.links li { margin: 8px 0; } ul.links li { margin: 8px 0; }
ul.links a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted var(--border); transition: all 0.2s ease; } ul.links a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted var(--border); transition: all 0.2s ease; }
ul.links a:hover { color: var(--accent); border-bottom-color: var(--accent); } ul.links a:hover { color: var(--accent); border-bottom-color: var(--accent); }
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; } pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow: auto; }
code { font-family: 'Courier New', monospace; font-size: 13px; } code { font-family: 'Courier New', monospace; font-size: 13px; }
@media (max-width: 480px) {
code { font-size: 12px; }
}
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; } footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; }
footer.site-footer a { color: var(--fg); text-decoration: none; transition: color 0.2s ease; } footer.site-footer a { color: var(--fg); text-decoration: none; transition: color 0.2s ease; }
footer.site-footer a:hover { color: var(--accent); } footer.site-footer a:hover { color: var(--accent); }
@ -84,18 +71,7 @@
<h2>调试部分</h2> <h2>调试部分</h2>
<pre><code>curl -s {origin}/inner/version <pre><code>curl -s {origin}/inner/version
curl -s {origin}/search?keywords=网易云</code></pre> curl -s {origin}/search?keywords=网易云</code></pre>
<div style="margin-top:10px; line-height:2;"> <p style="margin-top:10px"> · <a href="/api.html">交互式调试</a> · <a href="/qrlogin.html">二维码登录示例</a> · <a href="/unblock_test.html">解灰测试</a></p> · <a href="/audio_match_demo/index.html">听歌识曲 Demo</a></p> · <a href="/cloud.html">云盘上传</a></p> · <a href="/playlist_import.html">歌单导入</a></p> · <a href="/eapi_decrypt.html">EAPI 解密</p> · <a href="/listen_together_host.html">一起听示例</p> · <a href="/playlist_cover_update.html">更新歌单封面示例</p> · <a href="/avatar_update.html">头像更新示例</p>
<a href="/api.html">交互式调试</a> ·
<a href="/qrlogin.html">二维码登录示例</a> ·
<a href="/unblock_test.html">解灰测试</a> ·
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
<a href="/cloud.html">云盘上传</a> ·
<a href="/playlist_import.html">歌单导入</a> ·
<a href="/eapi_decrypt.html">EAPI 解密</a> ·
<a href="/listen_together_host.html">一起听示例</a> ·
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
<a href="/avatar_update.html">头像更新示例</a>
</div>
</section> </section>
<footer class="site-footer"> <footer class="site-footer">

View File

@ -178,25 +178,10 @@ async function consturctServer(moduleDefs) {
/** /**
* Body Parser and File Upload * Body Parser and File Upload
*/ */
const MAX_UPLOAD_SIZE_MB = 500 app.use(express.json({ limit: '50mb' }))
const MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024 app.use(express.urlencoded({ extended: false, limit: '50mb' }))
app.use(express.json({ limit: `${MAX_UPLOAD_SIZE_MB}mb` })) app.use(fileUpload())
app.use(
express.urlencoded({ extended: false, limit: `${MAX_UPLOAD_SIZE_MB}mb` }),
)
app.use(
fileUpload({
limits: {
fileSize: MAX_UPLOAD_SIZE_BYTES,
},
useTempFiles: true,
tempFileDir: require('os').tmpdir(),
abortOnLimit: true,
parseNested: true,
}),
)
/** /**
* Cache * Cache
@ -242,23 +227,19 @@ async function consturctServer(moduleDefs) {
const moduleResponse = await moduleDef.module(query, (...params) => { const moduleResponse = await moduleDef.module(query, (...params) => {
// 参数注入客户端IP // 参数注入客户端IP
const obj = [...params] const obj = [...params]
const options = obj[2] || {} let ip = req.ip
if (!options.randomCNIP) {
let ip = req.ip
if (ip.substring(0, 7) == '::ffff:') { if (ip.substring(0, 7) == '::ffff:') {
ip = ip.substring(7) ip = ip.substring(7)
} }
if (ip == '::1') { if (ip == '::1') {
ip = global.cnIp ip = global.cnIp
} }
// logger.info('Requested from ip:', ip) // logger.info('Requested from ip:', ip)
obj[2] = { obj[3] = {
...options, ...obj[3],
ip, ip,
}
} }
return request(...obj) return request(...obj)
}) })
logger.info(`Request Success: ${decode(req.originalUrl)}`) logger.info(`Request Success: ${decode(req.originalUrl)}`)

View File

@ -1,88 +0,0 @@
const fs = require('fs')
const crypto = require('crypto')
const logger = require('./logger')
function isTempFile(file) {
return !!(file && file.tempFilePath)
}
async function getFileSize(file) {
if (isTempFile(file)) {
const stats = await fs.promises.stat(file.tempFilePath)
return stats.size
}
return file.data ? file.data.byteLength : file.size || 0
}
async function getFileMd5(file) {
if (file.md5) {
return file.md5
}
if (isTempFile(file)) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(file.tempFilePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
if (file.data) {
return crypto.createHash('md5').update(file.data).digest('hex')
}
throw new Error('无法计算文件MD5: 缺少文件数据')
}
function getUploadData(file) {
if (isTempFile(file)) {
return fs.createReadStream(file.tempFilePath)
}
return file.data
}
async function cleanupTempFile(filePath) {
if (!filePath) return
try {
await fs.promises.unlink(filePath)
} catch (e) {
logger.info('临时文件清理失败:', e.message)
}
}
async function readFileChunk(filePath, offset, length) {
const fd = await fs.promises.open(filePath, 'r')
const buffer = Buffer.alloc(length)
await fd.read(buffer, 0, length, offset)
await fd.close()
return buffer
}
function getFileExtension(filename) {
if (!filename) return 'mp3'
if (filename.includes('.')) {
return filename.split('.').pop().toLowerCase()
}
return 'mp3'
}
function sanitizeFilename(filename) {
if (!filename) return 'unknown'
return filename
.replace(/\.[^.]+$/, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
}
module.exports = {
isTempFile,
getFileSize,
getFileMd5,
getUploadData,
cleanupTempFile,
readFileChunk,
getFileExtension,
sanitizeFilename,
}

View File

@ -1,8 +1,36 @@
const logger = require('./logger') const logger = require('./logger')
const fs = require('fs') // 预先定义常量和函数引用
const path = require('path') // 中国 IP 段来源data/ChineseIPGenerate.csv
const chinaIPRangesRaw = [
// 开始IP, 结束IP, IP个数, 位置
['1.0.1.0', '1.0.3.255', 768, '福州'],
['1.0.8.0', '1.0.15.255', 2048, '广州'],
['1.0.32.0', '1.0.63.255', 8192, '广州'],
['1.1.0.0', '1.1.0.255', 256, '福州'],
['1.1.2.0', '1.1.63.255', 15872, '广州'],
['1.2.0.0', '1.2.2.255', 768, '北京'],
['1.2.4.0', '1.2.127.255', 31744, '广州'],
['1.3.0.0', '1.3.255.255', 65536, '广州'],
['1.4.1.0', '1.4.127.255', 32512, '广州'],
['1.8.0.0', '1.8.255.255', 65536, '北京'],
['1.10.0.0', '1.10.9.255', 2560, '福州'],
['1.10.11.0', '1.10.127.255', 29952, '广州'],
['1.12.0.0', '1.15.255.255', 262144, '上海'],
['1.18.128.0', '1.18.128.255', 256, '北京'],
['1.24.0.0', '1.31.255.255', 524288, '赤峰'],
['1.45.0.0', '1.45.255.255', 65536, '北京'],
['1.48.0.0', '1.51.255.255', 262144, '济南'],
['1.56.0.0', '1.63.255.255', 524288, '伊春'],
['1.68.0.0', '1.71.255.255', 262144, '忻州'],
['1.80.0.0', '1.95.255.255', 1048576, '北京'],
['1.116.0.0', '1.117.255.255', 131072, '上海'],
['1.119.0.0', '1.119.255.255', 65536, '北京'],
['1.180.0.0', '1.185.255.255', 393216, '桂林'],
['1.188.0.0', '1.199.255.255', 786432, '洛阳'],
['1.202.0.0', '1.207.255.255', 393216, '铜仁'],
]
// IP地址转换函数 // 将原始字符串段转换为数值段并计算总数(在模块初始化时完成一次)
function ipToInt(ip) { function ipToInt(ip) {
const parts = ip.split('.').map(Number) const parts = ip.split('.').map(Number)
const a = (parts[0] << 24) >>> 0 const a = (parts[0] << 24) >>> 0
@ -21,56 +49,20 @@ function intToIp(int) {
].join('.') ].join('.')
} }
// 解析CIDR格式的IP段 const chinaIPRanges = (function buildRanges() {
function parseCIDR(cidr) { const arr = []
const [ipStr, prefixLengthStr] = cidr.split('/') let total = 0
const prefixLength = parseInt(prefixLengthStr, 10) for (let i = 0; i < chinaIPRangesRaw.length; i++) {
const r = chinaIPRangesRaw[i]
const ipInt = ipToInt(ipStr) const start = ipToInt(r[0])
const mask = (0xffffffff << (32 - prefixLength)) >>> 0 const end = ipToInt(r[1])
const start = (ipInt & mask) >>> 0 const count = r[2] || end - start + 1
const end = (start | (~mask >>> 0)) >>> 0 arr.push({ start, end, count, location: r[3] || '' })
const count = end - start + 1 total += count
return { start, end, count, cidr }
}
// 从china_ip_ranges.txt加载中国IP段CIDR格式
const chinaIPRanges = (function loadChinaIPRanges() {
try {
const filePath = path.join(__dirname, '../data/china_ip_ranges.txt')
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content
.split('\n')
.filter((line) => line.trim() && !line.startsWith('#'))
const arr = []
let total = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const range = parseCIDR(line)
arr.push(range)
total += range.count
}
// 按IP段大小排序提高随机选择效率
arr.sort((a, b) => b.count - a.count)
// attach total for convenience
arr.totalCount = total
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)
// 返回空数组generateRandomChineseIP会使用兜底逻辑
return { totalCount: 0 }
} }
// attach total for convenience
arr.totalCount = total
return arr
})() })()
const floor = Math.floor const floor = Math.floor
const random = Math.random const random = Math.random
@ -152,11 +144,16 @@ module.exports = {
// 如果没有选中(理论上不应该发生),回退到最后一个段 // 如果没有选中(理论上不应该发生),回退到最后一个段
if (!chosen) chosen = chinaIPRanges[chinaIPRanges.length - 1] if (!chosen) chosen = chinaIPRanges[chinaIPRanges.length - 1]
// 在段内随机生成一个 IP使用段真实的数值范围 // 在段内随机生成一个 IP使用段真实的数值范围,而非 csv 中的 count
const segSize = chosen.end - chosen.start + 1 const segSize = chosen.end - chosen.start + 1
const ipInt = chosen.start + Math.floor(random() * segSize) const ipInt = chosen.start + Math.floor(random() * segSize)
const ip = intToIp(ipInt) const ip = intToIp(ipInt)
logger.info('Generated Random Chinese IP:', ip, 'from CIDR:', chosen.cidr) logger.info(
'Generated Random Chinese IP:',
ip,
'location:',
chosen.location,
)
return ip return ip
}, },
// 生成chainId的函数 // 生成chainId的函数

View File

@ -194,11 +194,11 @@ const createRequest = (uri, data, options) => {
// 根据加密方式处理 // 根据加密方式处理
switch (crypto) { switch (crypto) {
case 'weapi': case 'weapi':
headers['Referer'] = options.domain || DOMAIN headers['Referer'] = DOMAIN
headers['User-Agent'] = options.ua || chooseUserAgent('weapi') headers['User-Agent'] = options.ua || chooseUserAgent('weapi')
data.csrf_token = csrfToken data.csrf_token = csrfToken
encryptData = encrypt.weapi(data) encryptData = encrypt.weapi(data)
url = (options.domain || DOMAIN) + '/weapi/' + uri.substr(5) url = DOMAIN + '/weapi/' + uri.substr(5)
break break
case 'linuxapi': case 'linuxapi':
@ -206,10 +206,10 @@ const createRequest = (uri, data, options) => {
options.ua || chooseUserAgent('linuxapi', 'linux') options.ua || chooseUserAgent('linuxapi', 'linux')
encryptData = encrypt.linuxapi({ encryptData = encrypt.linuxapi({
method: 'POST', method: 'POST',
url: (options.domain || DOMAIN) + uri, url: DOMAIN + uri,
params: data, params: data,
}) })
url = (options.domain || DOMAIN) + '/api/linux/forward' url = DOMAIN + '/api/linux/forward'
break break
case 'eapi': case 'eapi':
@ -249,9 +249,9 @@ const createRequest = (uri, data, options) => {
: ENCRYPT_RESPONSE, : ENCRYPT_RESPONSE,
) )
encryptData = encrypt.eapi(uri, data) encryptData = encrypt.eapi(uri, data)
url = (options.domain || API_DOMAIN) + '/eapi/' + uri.substr(5) url = API_DOMAIN + '/eapi/' + uri.substr(5)
} else if (crypto === 'api') { } else if (crypto === 'api') {
url = (options.domain || API_DOMAIN) + uri url = API_DOMAIN + uri
encryptData = data encryptData = data
} }
break break