diff --git a/generateConfig.js b/generateConfig.js index 4e588d9..6168586 100644 --- a/generateConfig.js +++ b/generateConfig.js @@ -6,7 +6,7 @@ const { getXeapiPublicKey } = require('./util/xeapiKey') const tmpPath = require('os').tmpdir() const logger = require('./util/logger') -const MAX_RETRIES = 10 +const MAX_RETRIES = 3 const RETRY_DELAY_MS = 1000 function sleep(ms) { @@ -14,8 +14,21 @@ function sleep(ms) { } function isRetryableError(err) { + const msg = (err && err.message) || '' const status = (err && err.status) || (err && err.response && err.response.status) + if ( + msg.includes('ETIMEDOUT') || + msg.includes('ECONNRESET') || + msg.includes('ECONNREFUSED') || + msg.includes('socket hang up') || + msg.includes('request timeout') || + msg.includes('timeout') || + msg.includes('network') || + msg.includes('Network') + ) { + return true + } if (status && status >= 500) { return true } @@ -27,13 +40,7 @@ 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 + const cookie = res.body.cookie if (cookie) { const cookieObj = cookieToJson(cookie) fs.writeFileSync( @@ -44,7 +51,7 @@ async function fetchAnonymousToken() { logger.success('[generateConfig] 匿名 token 注册成功') return { success: true } } - + // 返回了但没有 cookie,视为异常但不再重试 logger.warn( `[generateConfig] 匿名注册返回了空 cookie (attempt ${attempt})`, ) @@ -61,6 +68,7 @@ async function fetchAnonymousToken() { await sleep(delay) continue } + // 不可重试 或 已达最大次数 if (attempt >= MAX_RETRIES) { logger.error( `[generateConfig] 获取匿名 token 已达最大重试次数 (${MAX_RETRIES}):`, diff --git a/package.json b/package.json index 107a83a..0325822 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,6 @@ "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", - "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" diff --git a/public/docs/_coverpage.md b/public/docs/_coverpage.md index 4e02019..fceae11 100644 --- a/public/docs/_coverpage.md +++ b/public/docs/_coverpage.md @@ -1,9 +1,3 @@ -## 目录 - -- [NeteaseCloudMusicAPI Enhanced](#neteasecloudmusicapi-enhanced) - ---- - # NeteaseCloudMusicAPI Enhanced > 🎉 全网收集最全的网易云音乐api接口 基于[NeteaseCloudMusicAPI](https://github.com/binaryify/NeteaseCloudMusicApi)的复刻版本 @@ -17,4 +11,4 @@ [前往本家](https://github.com/binaryify/NeteaseCloudMusicApi) [快速开始](#neteasecloudmusicapienhanced) -![color](#ffffff) +![color](#ffffff) \ No newline at end of file diff --git a/public/docs/home.md b/public/docs/home.md index f8ef27b..8e611d9 100644 --- a/public/docs/home.md +++ b/public/docs/home.md @@ -88,7 +88,6 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr 5. 直接点`Continue` 6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可 - ## 腾讯云 serverless 部署 因 `Vercel` 在国内访问太慢(不绑定自己的域名的情况下),在此提供腾讯云 serverless 部署方法 @@ -102,7 +101,6 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr 5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目 6. 启动文件填入: - ``` #!/bin/bash export PORT=9000 @@ -115,7 +113,6 @@ export PORT=9000 - 腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费 - 当前(2024-08-24), 用此法创建的话, 会`默认`关联一个"日志服务-日志主题"(创建过程中没有提醒), 此服务是计量收费的 - ## 可以使用代理 在 query 参数中加上 proxy=your-proxy 即可让这一次的请求使用 proxy @@ -183,7 +180,6 @@ request 相关的环境变量 5. no_proxy 6. NO_PROXY - ```shell docker pull moefurina/ncm-api @@ -218,7 +214,6 @@ $ sudo docker run -d -p 3000:3000 netease-music-api - 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持 - 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数) - ## 接口文档 ### 调用前须知 @@ -273,12 +268,12 @@ AI 生成的图,仅供娱乐() #### 1. 手机登录 -**必选参数 :** +**必选参数 :** `phone`: 手机号码 `password`: 密码 -**可选参数 :** +**可选参数 :** `countrycode`: 国家码,用于国外手机号登录,例如美国传入:`1` `md5_password`: md5 加密后的密码,传入后 `password` 参数将失效 @@ -437,11 +432,11 @@ body { ### 检测手机号码是否已注册 -说明 : 调用此接口 ,可检测手机号码是否已注册 -**必选参数 :** +说明 : 调用此接口 ,可检测手机号码是否已注册 +**必选参数 :** `phone` : 手机号码 -**可选参数 :** +**可选参数 :** `countrycode`: 国家码,用于国外手机号,例如美国传入:`1` ,默认 86 即中国 **接口地址 :** `/cellphone/existence/check` @@ -450,7 +445,7 @@ body { ### 初始化昵称 -说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称 +说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称 **必选参数 :** `nickname` : 昵称 @@ -705,7 +700,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 **可选参数 :** @@ -735,7 +730,7 @@ tags: 歌单标签 说明 : 登录后调用此接口,可以根据歌曲 id 顺序调整歌曲顺序 -**必选参数 :** +**必选参数 :** `pid`: 歌单 id `ids`: 歌曲 id 列表 @@ -1215,7 +1210,6 @@ tags: 歌单标签 > 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲 - ### 歌单详情动态 说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数 @@ -1409,7 +1403,7 @@ tags: 歌单标签 ### 歌单收藏者 -说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者 +说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者 **必选参数 :** `id` : 歌单 id @@ -1502,7 +1496,6 @@ 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"}]} @@ -1519,7 +1512,6 @@ tags: 歌单标签 * 逐字歌词 - ``` [16210,3460](16210,670,0)还(16880,410,0)没... ~~~~1 ~~~2 ~~~~3 ~~4 5 ~6 (...) @@ -1596,7 +1588,7 @@ tags: 歌单标签 说明 : 调用此接口 , 传入资源 parentCommentId 和资源类型 type 和资源 id 参数, 可获得该资源的歌曲楼层评论 -**必选参数 :** +**必选参数 :** `parentCommentId`: 楼层评论 id `id` : 资源 id @@ -1812,7 +1804,7 @@ tags: 歌单标签 说明 : 调用此接口 , 传入资源类型和资源 id,以及排序方式,可获取对应资源的评论 -**必选参数 :** +**必选参数 :** `id` : 资源 id, 如歌曲 id,mv id `type`: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型 @@ -1835,7 +1827,7 @@ tags: 歌单标签 7: 电台 ``` -**可选参数 :** +**可选参数 :** `pageNo`:分页参数,第 N 页,默认为 1 `pageSize`:分页参数,每页多少条数据,默认 20 @@ -2208,7 +2200,7 @@ privilege:权限相关信息 说明 : 调用此接口 , 可获得已收藏专辑列表 -**可选参数 :** +**可选参数 :** `limit`: 取出数量 , 默认为 25 `offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*25, 其中 25 为 limit 的值 , 默认 @@ -2529,7 +2521,7 @@ privilege:权限相关信息 说明 : 调用此接口 , 可获取全部 mv -**可选参数 :** +**可选参数 :** `area`: 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部 `type`: 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部 @@ -2611,7 +2603,7 @@ privilege:权限相关信息 **接口地址 :** `/program/recommend` -**可选参数 :** +**可选参数 :** `limit`: 取出数量 , 默认为 10 `offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*10, 其中 10 为 limit 的值 , 默认 @@ -2893,7 +2885,6 @@ type : 地区 - 适合 Vercel、Netlify 等有请求体限制的平台 - 需要前端配合实现 - #### 客户端直传相关接口 **获取上传凭证** @@ -2940,7 +2931,6 @@ type : 地区 - `artist`: 艺术家 - `album`: 专辑名 - #### 客户端直传流程 1. 客户端计算文件 MD5 @@ -2948,12 +2938,11 @@ type : 地区 3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl` 4. 调用 `/cloud/upload/complete` 完成导入 - ### 云盘歌曲信息匹配纠正 说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0 -**必选参数 :** +**必选参数 :** `uid`: 用户 id `sid`: 云盘的歌曲 id @@ -3424,7 +3413,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` @@ -3583,7 +3572,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` @@ -4221,7 +4210,6 @@ ONLINE 已发布 - `voiceFeeType: 0`:返回免费的声音 - `voiceFeeType: 1`:返回收费的声音 - ### 播客声音详情 说明: 获取播客里的声音详情 @@ -5143,7 +5131,7 @@ let data = encodeURIComponent( 说明 : 登录后调用此接口, 获取我创建的博客声音 -**可选参数 :** +**可选参数 :** `limit` : 返回数量 , 默认为 20 @@ -5156,7 +5144,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM电台分类 -**必选参数 :** +**必选参数 :** `sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5168,7 +5156,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM电台收藏列表 -**必选参数 :** +**必选参数 :** `sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5204,7 +5192,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM播放列表 -**必选参数 :** +**必选参数 :** `source`: 来源, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5238,7 +5226,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取标签下资源列表; 接口返回的`trackId`可以用于请求`/song/url/v1`接口,用于获取声音的下载地址 -**必选参数 :** +**必选参数 :** `tag`: 标签, 由标签列表接口得到 @@ -5250,7 +5238,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 查看同类推荐 -**必选参数 :** +**必选参数 :** `id`: id, `/sati/tag/list`接口返回的`trackId` @@ -5270,7 +5258,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 收藏声音 -**必选参数 :** +**必选参数 :** `id`: id, `/sati/tag/list`接口返回的`trackId` @@ -5286,7 +5274,7 @@ let data = encodeURIComponent( 说明: 调用此接口,获取跑步漫游的歌曲信息 -**必选参数:** +**必选参数:** `bpm`: 步频 diff --git a/public/qrlogin-nocookie.html b/public/qrlogin-nocookie.html index 2c7daa6..72de9fb 100644 --- a/public/qrlogin-nocookie.html +++ b/public/qrlogin-nocookie.html @@ -148,12 +148,12 @@ updateStatus('二维码已过期,请刷新页面', 'error') clearInterval(timer) } else if (statusRes.code === 801) { - updateStatus('等待手机扫码...', 'waiting') - } else if (statusRes.code === 802) { updateStatus('二维码已扫描,请在手机上确认', 'waiting') + } else if (statusRes.code === 802) { + updateStatus('登录成功,正在保存信息...', 'waiting') } else if (statusRes.code === 803) { clearInterval(timer) - updateStatus('授权登录成功,正在保存信息...', 'success') + updateStatus('授权登录成功!', 'success') await getLoginStatus(statusRes.cookie) localStorage.setItem('cookie', statusRes.cookie) } diff --git a/public/qrlogin.html b/public/qrlogin.html index 1ad8093..f803304 100644 --- a/public/qrlogin.html +++ b/public/qrlogin.html @@ -148,12 +148,12 @@ updateStatus('二维码已过期,请刷新页面', 'error') clearInterval(timer) } else if (statusRes.code === 801) { - updateStatus('等待手机扫码...', 'waiting') - } else if (statusRes.code === 802) { updateStatus('二维码已扫描,请在手机上确认', 'waiting') + } else if (statusRes.code === 802) { + updateStatus('登录成功,正在保存信息...', 'waiting') } else if (statusRes.code === 803) { clearInterval(timer) - updateStatus('授权登录成功,正在保存信息...', 'success') + updateStatus('授权登录成功!', 'success') await getLoginStatus(statusRes.cookie) localStorage.setItem('cookie', statusRes.cookie) } diff --git a/scripts/format-docs.js b/scripts/format-docs.js deleted file mode 100644 index aa20e76..0000000 --- a/scripts/format-docs.js +++ /dev/null @@ -1,575 +0,0 @@ -#!/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') -const path = require('path') - -// ======================== 配置 ======================== -const CONFIG = { - maxConsecutiveBlankLines: 2, // 最大连续空行数 - codeBlockLang: true, // 代码块是否保留语言标记 - headingSpaceBefore: true, // 标题前是否确保空行 - headingSpaceAfter: true, // 标题后是否确保空行 - listIndent: 2, // 列表缩进空格数 - encodeSpecialChars: false, // 是否编码特殊字符 - removeTrailingSpaces: true, // 是否删除行尾空格 - tocMaxLevel: 3, // 目录最大标题层级 -} - -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(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 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) { - 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) { - // 如果指定了 --toc,在文档开头插入目录 - let output = formatted - if (options.toc) { - const tocPlaceholder = '' - 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) - 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 模式,只检查不写入 - --toc, -t 在文档中生成目录 - --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 - node scripts/format-docs.js --toc --verbose -`)) -} - -function parseArgs() { - const args = process.argv.slice(2) - const options = { - file: null, - dir: null, - check: false, - toc: 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 '--toc': - case '-t': - options.toc = 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, generateTOC, getStats, parseBlocks } diff --git a/vercel.json b/vercel.json index d0a6bf7..b792814 100644 --- a/vercel.json +++ b/vercel.json @@ -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" + } }