novel_read
阅读某部作品的某个章节,返回带行号的正文与字数统计,支持 offset / limit 分段读取长章节。章节标识可用章号、文件名或标题子串三种形态,其中精确文件名优先于章号解析;仅当按章号命中的文件不唯一时会拒绝执行并列出候选(见下文「章节定位」)。返回体带 truncated 标志与 totalLines,调用方可据此用 offset = 上一段末行 + 1 续读。
chapter 写成目标文件名(可省略扩展名)即可,或先改名消除撞号(novel_chapters 会在列表末尾提示撞号分组)。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | 是 | 书名,即 novels/ 下的子目录名。空串或纯空白被拒绝;值经 sanitizeSegment 清洗。 |
chapter | string | 是 | 章节标识:章号(1 或 01)、文件名(第01章.md,可省略扩展名)或标题子串。文件名优先于章号;同章号多文件时按章号定位会报错,需改用文件名。定位规则见下文。 |
offset | integer | 否 | 起始行号,从 1 开始。取值区间 1 – 9007199254740991,默认 1。 |
limit | integer | 否 | 最多返回行数。取值区间 1 – READ_LIMIT(400),默认 400。上限是硬约束,超限报错而非自动截到上限。 |
root | string | 否 | 章节库根目录。省略时按 config.root → 会话工作目录回退。 |
输出结构
契约字段(output.schema)的 required 覆盖全部九个顶层字段,缺一不可;lines 的元素必须同时含 number 与 text。
| 字段 | 类型 | 内容 |
|---|---|---|
book | string | 清洗后的书名 |
chapter / file | string | 命中的章节文件名,二者取值相同;不含标题字段 |
path | string | 章节文件的拼接路径(形状取决于传入的 root) |
offset | integer | 回显本次生效的起始行号 |
totalLines | integer | 该章总行数,空文件为 0 |
chars | integer | 已返回部分的字数(含行间换行符),不是本章总字数 |
truncated | boolean | 是否未读到文件尾或被上限中断 |
lines | array | { number, text } 数组,number 为 1 起的绝对行号 |
渲染层(formatRead)输出 <path> / <type> / <content> 包裹的文本,正文行格式为 行号: 文本。页脚按 truncated 二选一:截断时为 (输出截断。本章共 N 行,已显示 a-b 行 / M 字。用 offset=b+1 继续阅读。),否则为 (本章共 N 行 / M 字)。续读指令由渲染层给出,非截断分支不提供 offset 建议。
行为与实现
1 · 章节定位 findChapter
先 scanChapters 取该作品的全部章节(按章号升序、无章号者置末),再按下列顺序查找,命中即返回:
章号解析复用 parseChapterNumber,与 novel_chapters 完全同源:入口先做字符宽度归一(全角 0-9 / -./ 按 −0xFEE0 映射为半角),再依次尝试阿拉伯数字章号、中段数字、行首数字、末尾数字与中文章号(cjkToNumber,支持十 / 百 / 千,不含「万」)。因此参数写 1、01、01、第1章 都能解析为同一章号。
scanChapters 不去重,两份同章号文件都会进列表,排序规则是「章号升序,同章号按文件名 localeCompare」。旧版 chapters.find(c => c.number === asNumber) 取到的就是排序靠前的那一份,另一份没有任何入口可达。v4.3.1 起改为:命中多个即拒绝(chapter 换文件名即可指定),把"静默读错稿"换成"带候选清单的报错"。
"1" 会经标题匹配命中 第11章.md。故凡参数整体为纯数字(宽度归一后),解析失败即直接判定「找不到章节」,不做子串尝试。
2 · 分段与截断(计算原理)
常量:READ_LIMIT = 400 行、READ_MAX_CHARS = 20000 字符、单行裁剪阈值 3000。三个限制的语义各不相同:limit 是调用方可控的行数上限(默认即上限);20000 字符是不可调的输出预算;3000 是单行保护,防止首行超长绕过字符预算。截断判定在累加之前完成,被中断的行不计入 chars,故 chars 与 lines.join("\n").length 严格一致。
lines.length > 0 条件,保证至少返回一行,避免 limit 与预算互相卡死产生空结果;该行的规模由 3000 字单行裁剪兜住。截断时渲染层给出的续读 offset 为「末行号 + 1」,恰好是下一段起点。
3 · 行号口径
totalLines 经 countTextLines 计算(空文件为 0 行),而分段循环的边界取 allLines.length(split 对空串仍返回 [""],长度为 1)。两者在空文件上不一致,实现用断言 offset <= Math.max(totalLines, 1) 兜底:offset = 1 时仍能读到那一条空行,返回 lines = [{ number: 1, text: "" }]。这是 v4.3.0 统一行数口径时保留的既有行为。行号是绝对行号,不随 offset 重新编号。
4 · 编码探测 decodeTextBuffer
读取原始字节后按序判定,目的是让中文 Windows 环境下的 GBK 稿件与无 BOM 的 UTF-16 稿件都能读,且不静默返回乱码:
约束与边界
book与chapter均必填:空串或纯空白被拒绝;book经sanitizeSegment清洗(剥离\ / : * ? " < > |与首尾点、空白),清洗后为空报错。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_chapters 的chars。- 不做 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 ——
findChapter、parseChapterNumber、cjkToNumber、normalizeChapterWidth、scanChapters、readTextFile - lib/core.js ——
decodeTextBuffer/scoreUtf16/isTextHighByte/isPlausibleTextCode(编码探测四步) - lib/core.js ——
countTextLines(行数唯一出口)、optionalInt(参数区间校验)、READ_LIMIT/READ_MAX_CHARS常量、formatRead(渲染) - mcp/server.mjs ——
buildArgs(root注入与越界回退)、renderToolText(渲染文本优先于 JSON 回退)