From 6550c7c827e18479a2e01865d6067cb5dbea5dd2 Mon Sep 17 00:00:00 2001 From: MoeFurina Date: Sat, 13 Jun 2026 23:49:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generateConfig.js | 26 +- package.json | 4 + public/docs/_coverpage.md | 8 +- public/docs/home.md | 64 ++-- public/qrlogin-nocookie.html | 6 +- public/qrlogin.html | 6 +- scripts/format-docs.js | 575 +++++++++++++++++++++++++++++++++++ vercel.json | 6 +- 8 files changed, 640 insertions(+), 55 deletions(-) create mode 100644 scripts/format-docs.js diff --git a/generateConfig.js b/generateConfig.js index 6168586..4e588d9 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 = 3 +const MAX_RETRIES = 10 const RETRY_DELAY_MS = 1000 function sleep(ms) { @@ -14,21 +14,8 @@ 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 } @@ -40,7 +27,13 @@ async function fetchAnonymousToken() { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const res = await register_anonimous() - const cookie = res.body.cookie + 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( @@ -51,7 +44,7 @@ async function fetchAnonymousToken() { logger.success('[generateConfig] 匿名 token 注册成功') return { success: true } } - // 返回了但没有 cookie,视为异常但不再重试 + logger.warn( `[generateConfig] 匿名注册返回了空 cookie (attempt ${attempt})`, ) @@ -68,7 +61,6 @@ 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 0325822..107a83a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "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 fceae11..4e02019 100644 --- a/public/docs/_coverpage.md +++ b/public/docs/_coverpage.md @@ -1,3 +1,9 @@ +## 目录 + +- [NeteaseCloudMusicAPI Enhanced](#neteasecloudmusicapi-enhanced) + +--- + # NeteaseCloudMusicAPI Enhanced > 🎉 全网收集最全的网易云音乐api接口 基于[NeteaseCloudMusicAPI](https://github.com/binaryify/NeteaseCloudMusicApi)的复刻版本 @@ -11,4 +17,4 @@ [前往本家](https://github.com/binaryify/NeteaseCloudMusicApi) [快速开始](#neteasecloudmusicapienhanced) -![color](#ffffff) \ No newline at end of file +![color](#ffffff) diff --git a/public/docs/home.md b/public/docs/home.md index 8e611d9..f8ef27b 100644 --- a/public/docs/home.md +++ b/public/docs/home.md @@ -88,6 +88,7 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr 5. 直接点`Continue` 6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可 + ## 腾讯云 serverless 部署 因 `Vercel` 在国内访问太慢(不绑定自己的域名的情况下),在此提供腾讯云 serverless 部署方法 @@ -101,6 +102,7 @@ v4.29.9 加入了生成随机中国 IP 功能, 在请求时加上 `randomCNIP=tr 5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目 6. 启动文件填入: + ``` #!/bin/bash export PORT=9000 @@ -113,6 +115,7 @@ export PORT=9000 - 腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费 - 当前(2024-08-24), 用此法创建的话, 会`默认`关联一个"日志服务-日志主题"(创建过程中没有提醒), 此服务是计量收费的 + ## 可以使用代理 在 query 参数中加上 proxy=your-proxy 即可让这一次的请求使用 proxy @@ -180,6 +183,7 @@ request 相关的环境变量 5. no_proxy 6. NO_PROXY + ```shell docker pull moefurina/ncm-api @@ -214,6 +218,7 @@ $ sudo docker run -d -p 3000:3000 netease-music-api - 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持 - 目前支持算法 有 `weapi`, `eapi`, `linuxapi` 和 `xeapi` (xeapi 是一种不加密的特殊算法, 主要用于调试加密前的原始请求参数) + ## 接口文档 ### 调用前须知 @@ -268,12 +273,12 @@ AI 生成的图,仅供娱乐() #### 1. 手机登录 -**必选参数 :** +**必选参数 :** `phone`: 手机号码 `password`: 密码 -**可选参数 :** +**可选参数 :** `countrycode`: 国家码,用于国外手机号登录,例如美国传入:`1` `md5_password`: md5 加密后的密码,传入后 `password` 参数将失效 @@ -432,11 +437,11 @@ body { ### 检测手机号码是否已注册 -说明 : 调用此接口 ,可检测手机号码是否已注册 -**必选参数 :** +说明 : 调用此接口 ,可检测手机号码是否已注册 +**必选参数 :** `phone` : 手机号码 -**可选参数 :** +**可选参数 :** `countrycode`: 国家码,用于国外手机号,例如美国传入:`1` ,默认 86 即中国 **接口地址 :** `/cellphone/existence/check` @@ -445,7 +450,7 @@ body { ### 初始化昵称 -说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称 +说明 : 刚注册的账号(需登录),调用此接口 ,可初始化昵称 **必选参数 :** `nickname` : 昵称 @@ -700,7 +705,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 **可选参数 :** @@ -730,7 +735,7 @@ tags: 歌单标签 说明 : 登录后调用此接口,可以根据歌曲 id 顺序调整歌曲顺序 -**必选参数 :** +**必选参数 :** `pid`: 歌单 id `ids`: 歌曲 id 列表 @@ -1210,6 +1215,7 @@ tags: 歌单标签 > 如果你设置 limit=50&offset=100,你就会得到第 101-150 首歌曲 + ### 歌单详情动态 说明 : 调用后可获取歌单详情动态部分,如评论数,是否收藏,播放数 @@ -1403,7 +1409,7 @@ tags: 歌单标签 ### 歌单收藏者 -说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者 +说明 : 调用此接口 , 传入歌单 id 可获取歌单的所有收藏者 **必选参数 :** `id` : 歌单 id @@ -1496,6 +1502,7 @@ tags: 歌单标签 - (可能存在)JSON 歌曲元数据 + ``` {"t":0,"c":[{"tx":"作曲: "},{"tx":"柳重言","li":"http://p1.music.126.net/Icj0IcaOjH2ZZpyAM-QGoQ==/6665239487822533.jpg","or":"orpheus://nm/artist/home?id=228547&type=artist"}]} {"t":5403,"c":[{"tx":"编曲: "},{"tx":"Alex San","li":"http://p1.music.126.net/pSbvYkrzZ1RFKqoh-fA9AQ==/109951166352922615.jpg","or":"orpheus://nm/artist/home?id=28984845&type=artist"}]} @@ -1512,6 +1519,7 @@ tags: 歌单标签 * 逐字歌词 + ``` [16210,3460](16210,670,0)还(16880,410,0)没... ~~~~1 ~~~2 ~~~~3 ~~4 5 ~6 (...) @@ -1588,7 +1596,7 @@ tags: 歌单标签 说明 : 调用此接口 , 传入资源 parentCommentId 和资源类型 type 和资源 id 参数, 可获得该资源的歌曲楼层评论 -**必选参数 :** +**必选参数 :** `parentCommentId`: 楼层评论 id `id` : 资源 id @@ -1804,7 +1812,7 @@ tags: 歌单标签 说明 : 调用此接口 , 传入资源类型和资源 id,以及排序方式,可获取对应资源的评论 -**必选参数 :** +**必选参数 :** `id` : 资源 id, 如歌曲 id,mv id `type`: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型 @@ -1827,7 +1835,7 @@ tags: 歌单标签 7: 电台 ``` -**可选参数 :** +**可选参数 :** `pageNo`:分页参数,第 N 页,默认为 1 `pageSize`:分页参数,每页多少条数据,默认 20 @@ -2200,7 +2208,7 @@ privilege:权限相关信息 说明 : 调用此接口 , 可获得已收藏专辑列表 -**可选参数 :** +**可选参数 :** `limit`: 取出数量 , 默认为 25 `offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*25, 其中 25 为 limit 的值 , 默认 @@ -2521,7 +2529,7 @@ privilege:权限相关信息 说明 : 调用此接口 , 可获取全部 mv -**可选参数 :** +**可选参数 :** `area`: 地区,可选值为全部,内地,港台,欧美,日本,韩国,不填则为全部 `type`: 类型,可选值为全部,官方版,原生,现场版,网易出品,不填则为全部 @@ -2603,7 +2611,7 @@ privilege:权限相关信息 **接口地址 :** `/program/recommend` -**可选参数 :** +**可选参数 :** `limit`: 取出数量 , 默认为 10 `offset`: 偏移数量 , 用于分页 , 如 :( 页数 -1)\*10, 其中 10 为 limit 的值 , 默认 @@ -2885,6 +2893,7 @@ type : 地区 - 适合 Vercel、Netlify 等有请求体限制的平台 - 需要前端配合实现 + #### 客户端直传相关接口 **获取上传凭证** @@ -2931,6 +2940,7 @@ type : 地区 - `artist`: 艺术家 - `album`: 专辑名 + #### 客户端直传流程 1. 客户端计算文件 MD5 @@ -2938,11 +2948,12 @@ type : 地区 3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl` 4. 调用 `/cloud/upload/complete` 完成导入 + ### 云盘歌曲信息匹配纠正 说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0 -**必选参数 :** +**必选参数 :** `uid`: 用户 id `sid`: 云盘的歌曲 id @@ -3413,7 +3424,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` @@ -3572,7 +3583,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` @@ -4210,6 +4221,7 @@ ONLINE 已发布 - `voiceFeeType: 0`:返回免费的声音 - `voiceFeeType: 1`:返回收费的声音 + ### 播客声音详情 说明: 获取播客里的声音详情 @@ -5131,7 +5143,7 @@ let data = encodeURIComponent( 说明 : 登录后调用此接口, 获取我创建的博客声音 -**可选参数 :** +**可选参数 :** `limit` : 返回数量 , 默认为 20 @@ -5144,7 +5156,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM电台分类 -**必选参数 :** +**必选参数 :** `sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5156,7 +5168,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM电台收藏列表 -**必选参数 :** +**必选参数 :** `sources`: 来源列表, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5192,7 +5204,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取DIFM播放列表 -**必选参数 :** +**必选参数 :** `source`: 来源, 0: 最嗨电音 1: 古典电台 2: 爵士电台 @@ -5226,7 +5238,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 获取标签下资源列表; 接口返回的`trackId`可以用于请求`/song/url/v1`接口,用于获取声音的下载地址 -**必选参数 :** +**必选参数 :** `tag`: 标签, 由标签列表接口得到 @@ -5238,7 +5250,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 查看同类推荐 -**必选参数 :** +**必选参数 :** `id`: id, `/sati/tag/list`接口返回的`trackId` @@ -5258,7 +5270,7 @@ let data = encodeURIComponent( 说明: 调用此接口, 收藏声音 -**必选参数 :** +**必选参数 :** `id`: id, `/sati/tag/list`接口返回的`trackId` @@ -5274,7 +5286,7 @@ let data = encodeURIComponent( 说明: 调用此接口,获取跑步漫游的歌曲信息 -**必选参数:** +**必选参数:** `bpm`: 步频 diff --git a/public/qrlogin-nocookie.html b/public/qrlogin-nocookie.html index 72de9fb..2c7daa6 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') + updateStatus('等待手机扫码...', 'waiting') } else if (statusRes.code === 802) { - updateStatus('登录成功,正在保存信息...', 'waiting') + updateStatus('二维码已扫描,请在手机上确认', 'waiting') } else if (statusRes.code === 803) { clearInterval(timer) - updateStatus('授权登录成功!', 'success') + updateStatus('授权登录成功,正在保存信息...', 'success') await getLoginStatus(statusRes.cookie) localStorage.setItem('cookie', statusRes.cookie) } diff --git a/public/qrlogin.html b/public/qrlogin.html index f803304..1ad8093 100644 --- a/public/qrlogin.html +++ b/public/qrlogin.html @@ -148,12 +148,12 @@ updateStatus('二维码已过期,请刷新页面', 'error') clearInterval(timer) } else if (statusRes.code === 801) { - updateStatus('二维码已扫描,请在手机上确认', 'waiting') + updateStatus('等待手机扫码...', 'waiting') } else if (statusRes.code === 802) { - updateStatus('登录成功,正在保存信息...', 'waiting') + updateStatus('二维码已扫描,请在手机上确认', 'waiting') } else if (statusRes.code === 803) { clearInterval(timer) - updateStatus('授权登录成功!', 'success') + updateStatus('授权登录成功,正在保存信息...', 'success') await getLoginStatus(statusRes.cookie) localStorage.setItem('cookie', statusRes.cookie) } diff --git a/scripts/format-docs.js b/scripts/format-docs.js new file mode 100644 index 0000000..aa20e76 --- /dev/null +++ b/scripts/format-docs.js @@ -0,0 +1,575 @@ +#!/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 b792814..d0a6bf7 100644 --- a/vercel.json +++ b/vercel.json @@ -16,9 +16,5 @@ "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" - } + ] }