Compare commits

..

6 Commits

Author SHA1 Message Date
6fc4231142
feat(docs-format): 增加文档格式化工具 2026-06-18 23:05:44 +08:00
fa69736300
revert: some changes
This reverts commit c9598664367ca826b7503e6b4875ec222ad20633.
2026-06-18 22:52:35 +08:00
73a5e9d788
Revert "feat: 增加文档格式化工具"
This reverts commit 6550c7c827e18479a2e01865d6067cb5dbea5dd2.
2026-06-18 22:51:34 +08:00
dc6f56e8a8
Revert "fix(deploy): 修复部分启动失败 (#196)"
This reverts commit 395a80e74b9e69b8bfadfc19d77d57121f68087d.
2026-06-18 22:50:53 +08:00
2c9da8507d
Revert "refactor: 优化serverless入口文件"
This reverts commit 1db354464fa20bed8dc70323c066fe12a2a02348.
2026-06-18 22:50:51 +08:00
1db354464f
refactor: 优化serverless入口文件 2026-06-18 22:29:24 +08:00
9 changed files with 136 additions and 262 deletions

13
app.js
View File

@ -8,16 +8,11 @@ async function start() {
if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) {
fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8')
}
// 启动时更新anonymous_tokenVercel 构建环境下跳过网络请求喵~
if (!process.env.VERCEL_ENV) {
const generateConfig = require('./generateConfig')
await generateConfig()
}
// 启动时更新anonymous_token
const generateConfig = require('./generateConfig')
await generateConfig()
require('./server').serveNcmApi({
checkVersion: true,
})
}
start().catch((err) => {
console.error('[FATAL] 启动失败:', err)
process.exit(1)
})
start()

View File

@ -4,159 +4,38 @@ const { register_anonimous } = require('./main')
const { cookieToJson, generateRandomChineseIP } = require('./util/index')
const { getXeapiPublicKey } = require('./util/xeapiKey')
const tmpPath = require('os').tmpdir()
const logger = require('./util/logger')
const MAX_RETRIES = 10
const RETRY_DELAY_MS = 1000
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isRetryableError(err) {
const status =
(err && err.status) || (err && err.response && err.response.status)
if (status && status >= 500) {
return true
}
return false
}
/** @returns {{ success: boolean, error?: Error }} */
async function fetchAnonymousToken() {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await register_anonimous()
const body = res.body
if (body.code && body.code !== 200) {
const err = new Error(`匿名注册返回错误码 ${body.code}`)
err.status = 502
throw err
}
const cookie = body.cookie
if (cookie) {
const cookieObj = cookieToJson(cookie)
fs.writeFileSync(
path.resolve(tmpPath, 'anonymous_token'),
cookieObj.MUSIC_A,
'utf-8',
)
logger.success('[generateConfig] 匿名 token 注册成功')
return { success: true }
}
logger.warn(
`[generateConfig] 匿名注册返回了空 cookie (attempt ${attempt})`,
)
return {
success: false,
error: new Error('empty cookie from anonymous register'),
}
} catch (err) {
if (isRetryableError(err) && attempt < MAX_RETRIES) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1)
logger.warn(
`[generateConfig] 获取匿名 token 失败 (attempt ${attempt}/${MAX_RETRIES}), ${delay}ms 后重试...`,
)
await sleep(delay)
continue
}
if (attempt >= MAX_RETRIES) {
logger.error(
`[generateConfig] 获取匿名 token 已达最大重试次数 (${MAX_RETRIES}):`,
err.message,
)
} else {
logger.error(
`[generateConfig] 获取匿名 token 失败 (不可重试):`,
err.message,
)
}
return { success: false, error: err }
}
}
return { success: false, error: new Error('unreachable') }
}
/**
* 获取 xeapi public key带重试
* @returns {{ success: boolean, error?: Error }}
*/
async function fetchXeapiPublicKey() {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
let currentPublicKey = {}
try {
currentPublicKey = JSON.parse(
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
)
} catch (_) {
// 本地无缓存文件,用空对象正常请求
}
const publicKey = await getXeapiPublicKey(
currentPublicKey,
global.deviceId,
)
fs.writeFileSync(
path.resolve(tmpPath, 'xeapi_public_key'),
JSON.stringify(publicKey),
'utf-8',
)
logger.success('[generateConfig] xeapi public key 获取成功')
return { success: true }
} catch (err) {
if (isRetryableError(err) && attempt < MAX_RETRIES) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1)
logger.warn(
`[generateConfig] 获取 xeapi public key 失败 (attempt ${attempt}/${MAX_RETRIES}), ${delay}ms 后重试...`,
)
await sleep(delay)
continue
}
if (attempt >= MAX_RETRIES) {
logger.error(
`[generateConfig] 获取 xeapi public key 已达最大重试次数 (${MAX_RETRIES}):`,
err.message,
)
} else {
logger.error(
`[generateConfig] 获取 xeapi public key 失败 (不可重试):`,
err.message,
)
}
return { success: false, error: err }
}
}
return { success: false, error: new Error('unreachable') }
}
/**
* 生成配置匿名 token + xeapi public key带容错重试
* @returns {{ tokenOk: boolean, keyOk: boolean }}
*/
async function generateConfig() {
global.cnIp = generateRandomChineseIP()
// 两个任务并行执行,互不影响喵~
const [tokenResult, keyResult] = await Promise.all([
fetchAnonymousToken(),
fetchXeapiPublicKey(),
])
if (!tokenResult.success) {
logger.warn('[generateConfig] 匿名 token 获取失败')
try {
const res = await register_anonimous()
const cookie = res.body.cookie
if (cookie) {
const cookieObj = cookieToJson(cookie)
fs.writeFileSync(
path.resolve(tmpPath, 'anonymous_token'),
cookieObj.MUSIC_A,
'utf-8',
)
}
} catch (error) {
console.log(error)
}
if (!keyResult.success) {
logger.warn('[generateConfig] xeapi public key 获取失败')
}
if (tokenResult.success && keyResult.success) {
logger.success('[generateConfig] 配置初始化完成')
}
return {
tokenOk: tokenResult.success,
keyOk: keyResult.success,
try {
let currentPublicKey = {}
try {
currentPublicKey = JSON.parse(
fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'),
)
} catch (_) {}
const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId)
fs.writeFileSync(
path.resolve(tmpPath, 'xeapi_public_key'),
JSON.stringify(publicKey),
'utf-8',
)
} catch (error) {
console.log(error)
}
}
module.exports = generateConfig

1
index.mjs Normal file
View File

@ -0,0 +1 @@
import './app.js'

View File

@ -26,6 +26,7 @@ function cloudmusic_dll_encode_id(some_id) {
module.exports = async (query, request) => {
const deviceId = generateDeviceId()
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
global.deviceId = deviceId
const encodedId = CryptoJS.enc.Base64.stringify(
CryptoJS.enc.Utf8.parse(

View File

@ -11,8 +11,6 @@
"prepare": "husky install",
"docs:format": "node scripts/format-docs.js",
"docs:check": "node scripts/format-docs.js --check",
"docs:toc": "node scripts/format-docs.js --toc",
"docs:all": "node scripts/format-docs.js --dir public/docs --toc --verbose",
"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"
@ -54,7 +52,7 @@
},
"homepage": "https://neteasecloudmusicapienhanced.js.org/",
"engines": {
"node": ">=22"
"node": ">=12"
},
"lint-staged": {
"*.js": [

View File

@ -1,9 +1,3 @@
## 目录
- [NeteaseCloudMusicAPI Enhanced](#neteasecloudmusicapi-enhanced)
---
# NeteaseCloudMusicAPI Enhanced
> 🎉 全网收集最全的网易云音乐api接口 基于[NeteaseCloudMusicAPI](https://github.com/binaryify/NeteaseCloudMusicApi)的复刻版本

View File

@ -1,14 +1,14 @@
#!/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)
* node scripts/format-docs.js --toc # 同时生成目录
*
*/
const fs = require('fs')
@ -16,14 +16,13 @@ const path = require('path')
// ======================== 配置 ========================
const CONFIG = {
maxConsecutiveBlankLines: 2, // 最大连续空行数
codeBlockLang: true, // 代码块是否保留语言标记
headingSpaceBefore: true, // 标题前是否确保空行
headingSpaceAfter: true, // 标题后是否确保空行
listIndent: 2, // 列表缩进空格数
encodeSpecialChars: false, // 是否编码特殊字符
removeTrailingSpaces: true, // 是否删除行尾空格
tocMaxLevel: 3, // 目录最大标题层级
maxConsecutiveBlankLines: 2, // 最大连续空行数
codeBlockLang: true, // 代码块是否保留语言标记
headingSpaceBefore: true, // 标题前是否确保空行
headingSpaceAfter: true, // 标题后是否确保空行
listIndent: 2, // 列表缩进空格数
encodeSpecialChars: false, // 是否编码特殊字符
removeTrailingSpaces: true, // 是否删除行尾空格
}
const DEFAULT_FILE = path.resolve(__dirname, '..', 'public', 'docs', 'home.md')
@ -107,7 +106,10 @@ function parseBlocks(lines) {
i++
while (i < lines.length) {
const t = lines[i].trim()
if (/-->/.test(t) || /<\/\w+>/.test(t)) { i++; break }
if (/-->/.test(t) || /<\/\w+>/.test(t)) {
i++
break
}
i++
}
const htmlLines = lines.slice(start, i)
@ -123,17 +125,31 @@ function parseBlocks(lines) {
i++
}
const quoteLines = lines.slice(start, i)
blocks.push({ type: 'blockquote', lines: quoteLines, raw: quoteLines.join('\n') })
blocks.push({
type: 'blockquote',
lines: quoteLines,
raw: quoteLines.join('\n'),
})
continue
}
// 表格
if (/\|/.test(trimmed) && lines[i + 1] && /^\|[\s\-:]+\|/.test(lines[i + 1].trim())) {
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++ }
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') })
blocks.push({
type: 'table',
lines: tableLines,
raw: tableLines.join('\n'),
})
continue
}
@ -143,10 +159,19 @@ function parseBlocks(lines) {
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 }
if (t === '') {
i++
break
}
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])) {
i++
continue
}
// 缩进 continuation
if (/^\s{2,}/.test(lines[i])) { i++; continue }
if (/^\s{2,}/.test(lines[i])) {
i++
continue
}
break
}
const listLines = lines.slice(start, i)
@ -160,13 +185,23 @@ function parseBlocks(lines) {
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 (
/^#{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') })
blocks.push({
type: 'paragraph',
lines: paraLines,
raw: paraLines.join('\n'),
})
}
return blocks
@ -190,12 +225,20 @@ function formatMarkdown(input, options = {}) {
const newLines = [...block.lines]
// 标题前加空行(如果不是第一个块且前一个不是空行)
if (cfg.headingSpaceBefore && idx > 0 && blocks[idx - 1].type !== 'empty') {
if (
cfg.headingSpaceBefore &&
idx > 0 &&
blocks[idx - 1].type !== 'empty'
) {
newLines.unshift('')
}
// 标题后加空行(如果不是最后一个块且后一个不是空行)
if (cfg.headingSpaceAfter && idx < blocks.length - 1 && blocks[idx + 1].type !== 'empty') {
if (
cfg.headingSpaceAfter &&
idx < blocks.length - 1 &&
blocks[idx + 1].type !== 'empty'
) {
newLines.push('')
}
@ -220,10 +263,18 @@ function formatMarkdown(input, options = {}) {
newLines[0] = lang ? `${indent}${marker}${lang}` : `${indent}${marker}`
// 确保代码块前后有空行(插在标准化之后,因为只动第一行)
if (idx > 0 && blocks[idx - 1].type !== 'empty' && blocks[idx - 1].type !== 'code') {
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') {
if (
idx < blocks.length - 1 &&
blocks[idx + 1].type !== 'empty' &&
blocks[idx + 1].type !== 'code'
) {
newLines.push('')
}
@ -253,7 +304,8 @@ function formatMarkdown(input, options = {}) {
const indent = line.length - line.trimStart().length
// 如果是顶层列表项确保缩进为0
if (indent % cfg.listIndent !== 0) {
const normalizedIndent = Math.round(indent / cfg.listIndent) * cfg.listIndent
const normalizedIndent =
Math.round(indent / cfg.listIndent) * cfg.listIndent
return ' '.repeat(normalizedIndent) + trimmed
}
}
@ -267,7 +319,8 @@ function formatMarkdown(input, options = {}) {
// 处理文件开头和结尾的空行
while (resultLines.length > 0 && resultLines[0] === '') resultLines.shift()
while (resultLines.length > 0 && resultLines[resultLines.length - 1] === '') resultLines.pop()
while (resultLines.length > 0 && resultLines[resultLines.length - 1] === '')
resultLines.pop()
resultLines.push('') // 文件结尾留一个空行
return resultLines.join('\n')
@ -295,41 +348,6 @@ function compressEmptyLines(blocks, maxBlank) {
return result
}
// ======================== 目录生成 ========================
/**
* 从文档中提取标题生成目录
*/
function generateTOC(input, maxLevel = 3) {
const lines = input.split('\n')
const headings = []
for (const line of lines) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
if (level <= maxLevel) {
headings.push({ level, text: match[2].trim() })
}
}
}
if (headings.length === 0) return ''
let toc = '\n## 目录\n\n'
for (const h of headings) {
const indent = ' '.repeat(h.level - 1)
const anchor = h.text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
.replace(/^-|-$/g, '')
toc += `${indent}- [${h.text}](#${anchor})\n`
}
toc += '\n---\n'
return toc
}
// ======================== 统计信息 ========================
function getStats(input) {
@ -417,20 +435,7 @@ function processFile(filePath, options) {
}
if (hasChanges) {
// 如果指定了 --toc在文档开头插入目录
let output = formatted
if (options.toc) {
const tocPlaceholder = '<!-- TOC -->'
if (output.includes(tocPlaceholder)) {
output = output.replace(
new RegExp(`${tocPlaceholder}[\\s\\S]*?${tocPlaceholder}`),
`${tocPlaceholder}\n${generateTOC(output, options.tocMaxLevel || CONFIG.tocMaxLevel)}${tocPlaceholder}`
)
} else {
output = generateTOC(output, options.tocMaxLevel || CONFIG.tocMaxLevel) + '\n' + output
}
}
writeFile(filePath, output)
writeFile(filePath, formatted)
console.log(green(' ✨ 格式化完成!'))
if (options.verbose) {
printStats(statsAfter)
@ -454,7 +459,9 @@ function processDirectory(dirPath, options) {
return 0
}
console.log(bold(`\n📁 扫描目录: ${cyan(dirPath)} (${files.length} 个文件)\n`))
console.log(
bold(`\n📁 扫描目录: ${cyan(dirPath)} (${files.length} 个文件)\n`),
)
for (const file of files) {
const ret = processFile(file, options)
@ -467,7 +474,8 @@ function processDirectory(dirPath, options) {
// ======================== CLI ========================
function printHelp() {
console.log(bold(`
console.log(
bold(`
📝 Markdown 文档格式化工具 v1.0.0
${cyan('用法:')}
@ -479,7 +487,6 @@ ${cyan('参数:')}
${cyan('选项:')}
--dir, -d <目录> 格式化整个目录下的所有 .md 文件
--check, -c dry-run 模式,只检查不写入
--toc, -t 在文档中生成目录
--verbose, -v 显示详细统计信息
--help, -h 显示帮助信息
@ -488,8 +495,8 @@ ${cyan('示例:')}
node scripts/format-docs.js README.md
node scripts/format-docs.js --dir docs/
node scripts/format-docs.js --check
node scripts/format-docs.js --toc --verbose
`))
`),
)
}
function parseArgs() {
@ -498,7 +505,6 @@ function parseArgs() {
file: null,
dir: null,
check: false,
toc: false,
verbose: false,
help: false,
}
@ -513,10 +519,6 @@ function parseArgs() {
case '-c':
options.check = true
break
case '--toc':
case '-t':
options.toc = true
break
case '--verbose':
case '-v':
options.verbose = true
@ -572,4 +574,4 @@ if (require.main === module) {
process.exit(exitCode)
}
module.exports = { formatMarkdown, generateTOC, getStats, parseBlocks }
module.exports = { formatMarkdown, getStats, parseBlocks }

View File

@ -10,7 +10,6 @@ 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.
@ -300,15 +299,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
@ -318,6 +317,7 @@ async function constructServer(moduleDefs) {
if (ip == '::1') {
ip = global.cnIp
}
// logger.info('Requested from ip:', ip)
}
obj[2] = {
@ -327,10 +327,7 @@ async function constructServer(moduleDefs) {
return request(...obj)
})
const displayCrypto = usedCrypto || (APP_CONF.encrypt ? 'eapi' : 'api')
logger.info(
`Request Success: [${displayCrypto}] ${decode(req.originalUrl)}`,
)
logger.info(`Request Success: ${decode(req.originalUrl)}`)
// 夹带私货部分如果开启了通用解锁并且是获取歌曲URL的接口则尝试解锁如果需要的话ヾ(≧▽≦*)o
if (
@ -448,7 +445,10 @@ async function serveNcmApi(options) {
`)
logger.info(`
- Server started successfully @ http://${host ? host : 'localhost'}:${port}`)
- Server started successfully @ http://${host ? host : 'localhost'}:${port}
- Environment: ${process.env.NODE_ENV || 'development'}
- Node Version: ${process.version}
- Process ID: ${process.pid}`)
})
return appExt

View File

@ -16,5 +16,9 @@
"Access-Control-Allow-Headers": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version"
}
}
]
],
"env": {
"NODE_ENV": "production",
"ENABLE_FLAC": "true"
}
}