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

novel_read

阅读某部作品的某个章节,返回带行号的正文与字数统计,支持 offset / limit 分段读取长章节。章节标识可用章号、文件名或标题子串三种形态,其中精确文件名优先于章号解析;仅当按章号命中的文件不唯一时会拒绝执行并列出候选(见下文「章节定位」)。返回体带 truncated 标志与 totalLines,调用方可据此用 offset = 上一段末行 + 1 续读。

v4.3.1 行为变更:同章号多文件改为拒绝执行。当一本书里存在两份章号相同的文件(例如拆分章节后又保留了原稿),旧版按章号定位会静默返回排序靠前的那一份,另一份用章号、完整文件名、标题三条路都读不到(永久不可达)。现在改为:先按精确文件名匹配,文件名命中即返回;只给章号且命中多个文件时报错并列出全部候选。存量书库若报此错,把 chapter 写成目标文件名(可省略扩展名)即可,或先改名消除撞号(novel_chapters 会在列表末尾提示撞号分组)。

参数

参数类型必填说明
bookstring书名,即 novels/ 下的子目录名。空串或纯空白被拒绝;值经 sanitizeSegment 清洗。
chapterstring章节标识:章号(101)、文件名(第01章.md,可省略扩展名)或标题子串。文件名优先于章号;同章号多文件时按章号定位会报错,需改用文件名。定位规则见下文。
offsetinteger起始行号,从 1 开始。取值区间 1 – 9007199254740991,默认 1。
limitinteger最多返回行数。取值区间 1 – READ_LIMIT(400),默认 400。上限是硬约束,超限报错而非自动截到上限。
rootstring章节库根目录。省略时按 config.root → 会话工作目录回退。

输出结构

契约字段(output.schema)的 required 覆盖全部九个顶层字段,缺一不可;lines 的元素必须同时含 numbertext

字段类型内容
bookstring清洗后的书名
chapter / filestring命中的章节文件名,二者取值相同;不含标题字段
pathstring章节文件的拼接路径(形状取决于传入的 root
offsetinteger回显本次生效的起始行号
totalLinesinteger该章总行数,空文件为 0
charsinteger已返回部分的字数(含行间换行符),不是本章总字数
truncatedboolean是否未读到文件尾或被上限中断
linesarray{ number, text } 数组,number 为 1 起的绝对行号

渲染层(formatRead)输出 <path> / <type> / <content> 包裹的文本,正文行格式为 行号: 文本。页脚按 truncated 二选一:截断时为 (输出截断。本章共 N 行,已显示 a-b 行 / M 字。用 offset=b+1 继续阅读。),否则为 (本章共 N 行 / M 字)续读指令由渲染层给出,非截断分支不提供 offset 建议。

行为与实现

1 · 章节定位 findChapter

scanChapters 取该作品的全部章节(按章号升序、无章号者置末),再按下列顺序查找,命中即返回:

needle = String(chapter).trim() lower = needle.toLowerCase() ① 精确文件名(忽略大小写):c.file === lower 或去扩展名后相等:c.file 去掉后缀 === needle 去掉 .md / .markdown / .txt 命中即返回 —— 调用方明确指定了文件名,优先采信 ② asNumber = parseChapterNumber(needle) 命中该章号的文件数 = 1 → 返回该文件 命中数 > 1 → 抛错并列出全部候选文件名(同章号歧义,v4.3.1 起) ③ /^\d+$/.test(normalizeChapterWidth(needle)) → 返回 undefined(纯数字不再下探子串) ④ 标题或文件名的子串匹配(忽略大小写,含文件名扩展名) ⑤ 全部未命中 → 抛错「在作品 "X" 中找不到章节 "Y"(可用 novel_chapters 查看章节列表)」

章号解析复用 parseChapterNumber,与 novel_chapters 完全同源:入口先做字符宽度归一(全角 0-9 / -./−0xFEE0 映射为半角),再依次尝试阿拉伯数字章号、中段数字、行首数字、末尾数字与中文章号(cjkToNumber,支持十 / 百 / 千,不含「万」)。因此参数写 10101第1章 都能解析为同一章号。

同章号为什么必须报错而不是"取第一个"。scanChapters 不去重,两份同章号文件都会进列表,排序规则是「章号升序,同章号按文件名 localeCompare」。旧版 chapters.find(c => c.number === asNumber) 取到的就是排序靠前的那一份,另一份没有任何入口可达。v4.3.1 起改为:命中多个即拒绝(chapter 换文件名即可指定),把"静默读错稿"换成"带候选清单的报错"。
纯数字参数的下探守卫。第 ③ 步是承重逻辑:若章号匹配失败后继续走子串兜底,参数 "1" 会经标题匹配命中 第11章.md。故凡参数整体为纯数字(宽度归一后),解析失败即直接判定「找不到章节」,不做子串尝试。

2 · 分段与截断(计算原理)

text = readTextFile(<book>/<file>) ← 已做编码探测 allLines = text.split(/\r?\n/) totalLines = countTextLines(text) = text.length === 0 ? 0 : allLines.length chars = 0; lines = []; hitCharCap = false for (i = offset − 1; i < allLines.length 且 lines.length < limit; i += 1): line = allLines[i] if (line.length > 3000) line = line.slice(0, 3000) + "…(行过长已裁剪)" addChars = line.length + (lines.length > 0 ? 1 : 0) ← 换行符只算行与行之间 if (chars + addChars > READ_MAX_CHARS 且 lines.length > 0): hitCharCap = true; break chars += addChars lines.push({ number: i + 1, text: line }) truncated = hitCharCap ‖ (offset − 1 + lines.length) < totalLines

常量:READ_LIMIT = 400 行、READ_MAX_CHARS = 20000 字符、单行裁剪阈值 3000。三个限制的语义各不相同:limit调用方可控的行数上限(默认即上限);20000 字符是不可调的输出预算;3000 是单行保护,防止首行超长绕过字符预算。截断判定在累加之前完成,被中断的行不计入 chars,故 charslines.join("\n").length 严格一致。

第一行不受 20000 字预算约束。判断式带 lines.length > 0 条件,保证至少返回一行,避免 limit 与预算互相卡死产生空结果;该行的规模由 3000 字单行裁剪兜住。截断时渲染层给出的续读 offset 为「末行号 + 1」,恰好是下一段起点。

3 · 行号口径

totalLinescountTextLines 计算(空文件为 0 行),而分段循环的边界取 allLines.lengthsplit 对空串仍返回 [""],长度为 1)。两者在空文件上不一致,实现用断言 offset <= Math.max(totalLines, 1) 兜底:offset = 1 时仍能读到那一条空行,返回 lines = [{ number: 1, text: "" }]。这是 v4.3.0 统一行数口径时保留的既有行为。行号是绝对行号,不随 offset 重新编号。

4 · 编码探测 decodeTextBuffer

读取原始字节后按序判定,目的是让中文 Windows 环境下的 GBK 稿件与无 BOM 的 UTF-16 稿件都能读,且不静默返回乱码

① BOM:EF BB BF → UTF-8(剥离 3 字节) FF FE → UTF-16LE(剥离 2 字节);FE FF → UTF-16BE ② 无 BOM,NUL 占比 > 5% → 按奇偶下标 NUL 分布判 UTF-16LE / UTF-16BE ③ TextDecoder("utf-8", { fatal: true }) 严格解码,成功即返回 ④ 长度 ≥ 8 且为偶数:分别试解 LE/BE,用「高字节结构占比 ≥ 0.9 且可打印率 ≥ 0.9 且另一字节序结构占比 < 0.9」双阈值确认无 BOM UTF-16(中文正文 NUL 占比仅约 1.5%,过不了 ②) ⑤ TextDecoder("gbk") 解码,结果不含 U+FFFD → 采用 ⑥ 全部失败 → 抛错,并区分「疑似缺 BOM UTF-16」与「无法识别」两种文案

约束与边界

  • bookchapter 均必填:空串或纯空白被拒绝;booksanitizeSegment 清洗(剥离 \ / : * ? " < > | 与首尾点、空白),清洗后为空报错。
  • offset / limit 必须是整数且在区间内:非整数、null、字符串数字(如 "10")都会触发 参数 "limit" 必须是 1 到 400 之间的整数 一类错误。缺省值只在参数完全缺省时生效,传 null 不会回退默认值。
  • limit 超限即报错:请求 limit: 1000 不会静默截到 400,调用方需自行分批。
  • offset 超界报错:断言式为 offset <= max(totalLines, 1),错误文案含文件名与总行数。空文件允许 offset = 1(见行号口径)。
  • 纯数字 chapter 不做子串兜底chapter: "1" 只匹配章号等于 1 的章节,命中失败直接报「找不到章节」,不会退化到 第11章.md
  • 文件名优先于章号(v4.3.1 起):参数能与某个文件名(含省略扩展名)精确对上时直接返回该文件,不再先做章号解析。旧版顺序相反,导致 chapter: "第03章 重逢.md" 这种完整文件名也会被解析出章号并命中同章号的另一份。
  • 同章号多文件按章号定位会报错(v4.3.1 起):错误文案列出全部候选文件名与「把 chapter 写成其中一个文件名」的指引。这是刻意设计——旧版静默返回排序靠前的那一份,另一份永久不可达。
  • 子串匹配取首个命中:④ 步用的是 includes 且按章号升序列表 find 第一个,短子串(如「序」)可能命中并非目标的那一章。
  • 三类上限各自独立,且会叠加触发:单行 > 3000 字先被裁剪并追加 …(行过长已裁剪)该后缀也算入 chars),再受 20000 字预算与 limit 行数约束;任一触发都使 truncated = true
  • chars 是「已显示部分」:v3.7.0 起文案已明确区分,调用方不得把截断响应里的 chars 当作章节总字数;总字数需用 novel_chapterschars
  • 不做 Markdown 清洗、不剥离标题行lines[].text 是文件原始行文本(含 # 标题、空行),行号即物理行号。
  • 无缓存:每次调用都重新枚举目录、重新读全文;文件被外部修改后立即反映。
  • 编码不可识别时整次调用失败:工具实现内没有 try/catch,异常直接冒泡,不会返回部分内容;MCP 形态下由协议层转成 isError: true 的文本帧(正文以「错误:」开头),而非 JSON-RPC 协议错误码。
  • MCP 形态的 root 越界静默回退:越界值被替换为启动参数 --root 指定的书库根并只记 stderr 日志;返回体的 path 反映真实生效路径。
  • 可被总开关关闭:执行前经 assertToolEnabled 校验状态文件(~/.dsh/dsh-novel-writer/state.json),关闭后直接抛错。

相关工具

  • novel_chapters —— 获取章节清单与全章字数 / 行数,二者共用章号解析与行数口径。
  • novel_keywords —— 按全书或单章统计高频关键词,接受同样的章节标识形态。
  • novel_summary —— 章节摘要的读写,按 normalizeChapterKey 归一化键对齐槽位。
  • novel_continuity_check —— 逐章审计,需要读取正文时走同一套文本解码路径。

源码位置

  • lib/index.js —— 工具注册与 execute 实现(registerNovelRead:参数校验、分段循环、3000 字单行裁剪、20000 字预算、truncated 判定)
  • lib/core.js —— findChapterparseChapterNumbercjkToNumbernormalizeChapterWidthscanChaptersreadTextFile
  • lib/core.js —— decodeTextBuffer / scoreUtf16 / isTextHighByte / isPlausibleTextCode(编码探测四步)
  • lib/core.js —— countTextLines(行数唯一出口)、optionalInt(参数区间校验)、READ_LIMIT / READ_MAX_CHARS 常量、formatRead(渲染)
  • mcp/server.mjs —— buildArgsroot 注入与越界回退)、renderToolText(渲染文本优先于 JSON 回退)