← dsh-novel-writer 技术文档 / novel_chapters

novel_chapters

列出某部作品的全部章节:章号、标题、字数、行数与更新时间。工具按扩展名过滤目录下的文件,对每个文件名做章号解析(阿拉伯数字、中文章号、全角数字)与标题清洗,再逐章 stat + 读全文得到统计量。book 为必填项;解析不出章号的文件仍会列出,但 number 字段缺省。

v4.3.1 行为变更:渲染层新增同章号提示。当一本书里有两个以上文件解析出同一章号(例如拆分章节后原稿仍在),渲染文本会在列表末尾追加一段 ⚠️ 同章号多文件 N 组,逐组列出章号与撞号文件名。这不是去重、也不修改任何文件,只把「两条看起来一样的条目其实是两份文件」这件事显式说出来——因为按章号定位的工具此时会拒绝执行(见 novel_read)。返回体的 chapters 数组仍如实包含全部记录。

参数

参数类型必填说明
bookstring书名,即 novels/ 下的子目录名。空串或纯空白会被拒绝;值经 sanitizeSegment 清洗后使用。
rootstring章节库根目录。省略时按 config.root → 会话工作目录回退。

schema 声明 additionalProperties: false。没有分页、偏移或数量限制参数:一次调用返回该作品的全部章节。

输出结构

契约字段(output.schema):顶层 bookchapters 为必返;每个章节元素必须含 filetitlecharslinesupdatednumber 在 schema 的 required 之外,仅在解析出章号时出现。

字段类型内容
bookstring清洗后的书名(与传入值可能不同)
filestring章节文件名,含扩展名
numberinteger解析出的章号;可选,解析失败时该键不存在
titlestring文件名去掉扩展名、再去掉章号段与分隔符后的标题;文件名没有标题时回退读正文一级标题(v5.0.0 修正:如工具建的 第07章.md 内有 # 靠岸之后title 为「靠岸之后」);两者都取不到时为空串
charsinteger该章全文的 text.length(UTF-16 码元)
linesinteger该章行数,空文件为 0
updatedstring文件 mtime 的 ISO 8601 字符串(UTC)

渲染层(formatChapters)每行格式为 - 第NN章 标题 — M 字 / L 行 (文件名, 更新于 YYYY-MM-DD)number 缺失时章号位显示 ?,日期只取 updated 的前 10 个字符。列表之后,若存在同章号多文件,追加 ⚠️ 同章号多文件 N 组 与逐组的 第NN章: 文件A / 文件B(v4.3.1 起)。

行为与实现

1 · 文件枚举与过滤

bookDir(root, book)(即 <root>/novels/<清洗后书名>)执行 readdir({ withFileTypes: true }):非文件条目跳过,扩展名(小写后)不在 {.md, .markdown, .txt} 中的跳过。目录不存在或不可读时 scanChapters 抛出带完整路径的友好错误(提示可用 novel_books 核对书名),而不是返回空列表——这一点与 novel_books 的静默空数组不同。

2 · 章号解析 parseChapterNumber

入口先做字符宽度归一normalizeChapterWidth 把全角数字 0-9 与全角 -./−0xFEE0 逐字符映射为半角,再剥掉扩展名得到 stem。归一化不使用整串 NFKC,因为 NFKC 会把标题里的中文句号「。」也归一成「.」,随后被标题清洗的 [_\-.]+ 换成空格(「初遇。重逢」被破坏),且长度变化会破坏依赖索引切片的文件名解析。逐字符映射保持 1 字符换 1 字符、索引一一对应。

stem = normalizeChapterWidth(fileName).replace(/\.[^.]+$/, "") ① /第?(\d{1,4})[章回话]/ 第01章.md → 1 第3话.md → 3 ② /[-._\s](\d{1,4})[-._\s]/ 斗破苍穹-01-陨落.md → 1 ③ /^第?(\d+)[章回话]?[\s._\-—]*/ 01初遇.md → 1 第12345章.md → 12345 ④ /[-._\s](\d{1,4})$/ 书名-01.md → 1 ⑤ /第([零一二三四五六七八九十百千万两]+)[章回话]/ → cjkToNumber(捕获段) cjkToNumber 返回 undefined(含非数字字、或含「万」)时继续下探 全部未命中 → undefined

五条规则按序短路,先命中先返回;因此同一文件名可能被前一条规则解释成不同数值。中文章号由 cjkToNumber 累加:

total = 0; section = 0; hasUnit = false 逐字扫描「十 / 百 / 千 / 数字」: 十 → total += (section === 0 ? 1 : section) × 10; section = 0; hasUnit = true 百 → total += (section === 0 ? 1 : section) × 100; section = 0; hasUnit = true 千 → total += (section === 0 ? 1 : section) × 1000; section = 0; hasUnit = true 数字(零一二三四五六七八九两)→ section = 对应数值 其它字符 → 立即返回 undefined 返回 (hasUnit ‖ section !== 0) ? total + section : undefined 第一千零一章 → 1000 + 1 = 1001

3 · 标题清洗 cleanChapterTitle

同样先做宽度归一,再依次剥离 第?(\d{1,4})[章回话]第[零一二三四五六七八九十百千万两]+[章回话]、行首的 LEADING_NUMBER/^第?(\d+)[章回话]?[\s._\-—]*/),把 [_\-.]+ 换成空格后 trim。无标题章节返回空串,不回退成文件名(v3.6.0 行为)。v5.0.0 修正cleanChapterTitle 本身不变,但工具层在「文件名没有标题」时改为回退读正文一级标题(firstHeadingTitle,同样剥掉章号段),因此不再出现「novel_new_chapter 明明写了 title,novel_chapters 却显示空标题」;一级标题只是章号本身(# 第07章)时仍返回空串,不把章号当标题重复显示。

4 · 排序

chapters.sort((a, b) => (a.number ?? Number.MAX_SAFE_INTEGER) - (b.number ?? Number.MAX_SAFE_INTEGER) || a.file.localeCompare(b.file))

章号升序,解析不出章号的文件排到最后;同章号(或同为无章号)时按文件名 localeCompare不做去重:两个文件解析出相同章号时会同时列出。

5 · 统计量 chapterStats

chars = text.length ← UTF-16 码元,含 BOM 剥离后的全部字符与换行 lines = countTextLines(text) ← text.length === 0 ? 0 : text.split(/\r?\n/).length size = stat(file).size ← 字节数,不在返回体中 updated = stat(file).mtime.toISOString() ← UTC ISO 8601

行数走 countTextLines 这一唯一出口,保证与 novel_readtotalLines 口径一致(空文件 0 行)。文本读取先经 decodeTextBuffer 做编码探测(BOM → NUL 占比 → 严格 UTF-8 → UTF-16 双阈值 → GBK),探测失败抛错,抛错时本工具不吞异常——novel_chapters 未对该调用加 try/catch,单章编码异常会让整次调用失败(MCP 形态下呈现为 isError: true 的文本帧)。

6 · 归一化键的归属

normalizeChapterKey(把 2 / 第2章 / 第02章.md / 第01章 归一到 第02章 字符串)不由本工具调用,它服务于 novel_summary 的摘要槽位对齐。本工具只回传整数 number,不返回归一化键。两者现已对齐字符类(均含「千万」),但形态不同(整数与补零字符串),调用方自行对齐时需注意。

约束与边界

  • book 必填且被清洗sanitizeSegment 剥离 \ / : * ? " < > |、首尾的点与空白;结果为空时报「参数 book 不能为空(清洗后无有效字符)」;与 Windows 保留名(CON/NUL/COM1 等)同名时加前置下划线。因此书名必须与目录名精确匹配,清洗后的值还会回填到返回体的 book 字段。
  • 目录缺失是硬错误:抛出 书库中未找到作品目录:<路径>…,而非空数组。调用方应把该错误当作「书名或根目录错误」处理。
  • number 可能不存在:文件名不含可识别章号时该键被整体省略(实现上用条件展开写入)。调用方必须按可选字段处理,不能假设 chapters[i].number 总有值;排序也会把这类章推到末尾。
  • 含「万」的中文章号解析失败:正则字符类收录了「万」,但 cjkToNumber 未定义「万」的单位累加,逐字扫描遇「万」直接返回 undefined。故 第两万章.md 得不到 number第零章.md 同理(既无单位、section 又为 0)。
  • 同号重复文件不合并,但会在渲染文本里被点名(v4.3.1 起):两个文件解析出同一章号时返回体仍出现两条相同 number 的记录(不去重、不改文件),但渲染层会追加同章号提示分组。这类书用章号定位的工具(novel_read 等)会直接报错,必须改用文件名指定,或先把多余文件改名到空闲章号。
  • 列表无截断,成本为全文读取:章节数、字段长度都没有上限;每章都要 stat + 读全文,全部章节一次返回。删除或新增章节文件会立即反映,因为结果不做缓存。
  • chars 不等于「汉字数」:它是 text.length,Markdown 标题行、空行、标点、ASCII 一律计入;lines\r?\n 计行,空文件为 0 行。
  • updated 是 UTC 时间串:形如 2024-05-01T09:30:00.000Z,渲染层只截前 10 字符。跨时区展示需自行换算。
  • MCP 形态的 root 越界静默回退:越界值被替换为启动时确定的书库根并只记 stderr 日志,返回体不含任何越界提示。
  • 可被总开关关闭:执行前经 assertToolEnabled 校验状态文件(~/.dsh/dsh-novel-writer/state.json),关闭后直接抛错。

相关工具

  • novel_books —— 先列出全部作品名,避免因书名不精确匹配而触发本工具的目录缺失错误。
  • novel_read —— 用本工具给出的章号 / 文件名 / 标题读取正文,二者共用同一套章号解析与行数口径。
  • novel_new_chapter —— 新建章节文件,章号由 nextFreeChapterFile 复用同一解析逻辑判定占用。
  • novel_summary —— 章节摘要按 normalizeChapterKey 归一化键对齐,与本工具的整数章号是两种形态。

源码位置

  • lib/index.js —— 工具注册与 execute 实现(registerNovelChapters,参数校验、逐章统计、结果组装)
  • lib/core.js —— scanChapters(枚举、扩展名过滤、章号解析、标题清洗、排序)、parseChapterNumbercjkToNumbernormalizeChapterWidthcleanChapterTitle
  • lib/core.js —— chapterStatschars / lines / size / updated)、countTextLinesdecodeTextBufferformatChapters(渲染)
  • lib/core.js —— normalizeChapterKey(归一化键,供 novel_summary 使用)、sanitizeSegmentbookDir
  • mcp/server.mjs —— buildArgsroot 注入与越界回退)