novel_import
批量导入原稿件。递归扫描一个文件夹,按「文件名 → 文件头内容 → 未分类」的优先级自动识别书名并把章节文件分组,默认处于 scan 预览模式——只返回分组建议、不写盘;显式传 mode:"apply" 才按分组复制(或移动)到 novels/<书名>/。目标目录中已占用同名文件或同章号时自动改名为空闲章号,绝不覆盖既有章节。
mode:"apply" 与 move:true 同时成立时,每个文件复制成功后会用 rm(..., { force: true }) 删除源文件:不进回收站、无备份、无撤销。批量中途出错会使源目录处于「部分文件已被删除」的状态。默认 move:false,未显式传真值绝不删除任何源文件。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
src | string | 是 | 待导入的原稿件文件夹路径,可含多本小说、可含子文件夹。MCP 形态下默认必须位于启动参数 --root 书库根之内,越界时整次调用被拒;确需导入外部目录须用 --allow-external-src 启动服务器。 |
mode | string | 否 | 枚举 scan / apply。判定写为 args.mode === "apply" ? "apply" : "scan":只有字面量 "apply" 才执行导入,其余任何取值(含拼写错误与缺省)都按 scan 处理。 |
book | string | 否 | apply 时可选:把所有(或 files 指定的)文件强制归入该书名的组,用于合并异名同书,或为未分类文件指定归属。 |
files | array<string> | 否 | apply 时可选白名单,元素为相对 src 的路径。匹配前统一把反斜杠转成 / 并剥掉前导 ./ 或 /。省略或传空数组则处理全部扫描到的文件。 |
move | boolean | 否 | 判定写为 args.move === true。仅在 apply 下有意义:为真时先复制再删除源文件。默认 false(源文件保留)。 |
recursive | boolean | 否 | 判定写为 args.recursive !== false,因此默认递归;只有显式传 false 才只扫顶层。 |
root | string | 否 | 章节库根目录(含 novels 子目录)。MCP 形态下由服务器注入,越界回退书库根。 |
参数对象声明 additionalProperties: false,多传的键会被宿主拒绝。
输出结构
契约字段(output.schema.required):src / mode / groups / skipped / imported 五项恒返,scan 与 apply 返回体同构——scan 模式下 imported 为空数组。
| 字段 | 类型 | 内容 |
|---|---|---|
src | string | 必返。原样回显的 src 参数 |
mode | string | 必返。实际生效的模式("scan" 或 "apply"),可用于校验调用方的意图是否被识别 |
groups | array | 必返。分组结果;两种模式下都返回完整分组,是 scan 预览的全部价值所在 |
groups[].book | string | 组名(未分类组的组名固定为 "未分类");apply 落盘时另经 sanitizeSegment 清洗 |
groups[].from | string | 枚举 file / content / forced / unclassified,标明该组名的来源通道 |
groups[].maybe | array | 可选。可能同书的其他组名(供调用方判断是否用 book 合并),见「异名同书提示」 |
groups[].files | array | 必返(每组必有)。元素为 { file },可选带 chapter(解析出的章号)与 title(从正文头部《》解析出的书名候选) |
skipped | array<string> | 必返。被跳过的文件;元素是相对路径,apply 下未分类项还带原因文案。读取失败、内容为空白、未分类三种情形共用此字段 |
imported | array | 必返。元素为 { book, file, path }:清洗后的书名、最终落盘文件名(可能与源文件名不同)、绝对路径;scan 下为空数组 |
行为与实现
1 · 文件收集
扩展名白名单是硬边界:.docx / .json / 无扩展名文件不进入扫描结果,因此也不会出现在 skipped 中。目录不可读时返回空数组而不报错,调用方会看到「0 个文件 → 0 组」的正常返回体。
2 · 章号与书名的双通道识别
3 · 分组优先级
先判 isChapterFile 再取书名,是为了避免把「聚会的照片.md」这类非章节文件按文件名误判成书名。file 通道优先于 content 通道:文件名里的书名前缀被认为比正文头部的《》更可靠。
4 · 异名同书提示 maybe
该字段只是提示,不会自动合并。调用方若确认同书,应带 book 参数重新调用(from 会变成 forced)以合并为一组,否则同名书会被拆成两个目录。
5 · apply 落盘与改名规则
nextFreeChapterFile 生成空闲章号时会沿用原文件名的章号格式:阿拉伯数字保持原有补零宽度(第007章 递增为 第008章);中文数字用中文数字生成(第十章 → 第十一章,支持到 9999);书名-01-标题 与 01-标题 保持分隔符位置;格式无法识别时退化为 第NN章 原名。占用判定按 parseChapterNumber 的数值比较,因此 第一章.md 与 第01章.md 被视为同一章号。
6 · 渲染文本
约束与边界
- 删除行为(最高优先级)。
mode:"apply"+move:true会对每个成功复制的源文件执行rm(..., { force: true }):永久删除、不进回收站、无备份、无撤销。删除按文件逐个发生在复制成功之后,因此批量中途失败(磁盘满、权限、路径过长)会使源目录处于部分删除的状态,且没有回滚。不需要清理源目录时应保持默认move:false。 - MCP 下的 src 路径边界。
src默认必须位于启动参数--root书库根之内(含根本身);越界时服务器不执行工具,直接返回isError文本并提示用--allow-external-src重启。该限制用于阻断「文档注入 → 复制任意目录的 .md/.txt 进书库再读出」的路径。参数中的root同样必须先落在书库根内,越界时被静默回退到书库根而非报错。 - 被静默跳过的情形(不报错、也不一定出现在
skipped中):src不存在或不可读 →collectTextFiles吞掉readdir错误并返回空列表,返回体为「0 个文件 → 0 组」,skipped为空。files白名单里列举了不存在的相对路径 → 该条目被无声忽略。- 条目不是普通文件(
stat失败或isFile()为假)→continue,不计入skipped。 - 扩展名不在白名单 → 从未进入扫描结果,也不计入
skipped。 - 读取或编码解码失败(
readTextFile抛错)→ 计入skipped(原始相对路径,无原因文案)。 - 内容为纯空白(
text.trim() === "")→ 计入skipped。
- 未分类文件在两种模式下处理不一致。
apply时「未分类」组整体不导入,逐文件写入skipped并附「请用book参数指定归属书名后重试」;scan时这些文件留在groups的未分类组里、不写入skipped。因此scan与apply的skipped长度不可直接对比。 - 绝不覆盖既有章节。目标目录已占用同名文件或同章号时自动改用空闲章号,源文件按原名继续保留(
move:false下)。目标文件名可能不等于源文件名,应以imported[].file/imported[].path为准。 - 书名会被清洗。组名经
sanitizeSegment剥离\ / : * ? " < > |与首尾点空白、为 Windows 保留名加下划线前缀;清洗后为空则报错。imported[].book是清洗后的目录名,可能与groups[].book不同。 - 部分写入无事务。复制循环未包裹
try:单个copyFile失败会让整个execute抛错,此前已导入的文件保留在目标目录中,返回体不再产生(调用方只拿到错误信息,无法得知哪些文件已导入)。scan模式不受此影响。 - 分组与排序是纯启发式。书名识别没有任何模型参与,全部依赖文件名与正文头部 12 行的字面规则;命名混乱的稿件会落入「未分类」或产生错误的组名。落盘前应先用
scan复核groups,必要时用book强制归属。 - 章号缺失不等于非章节文件。文件名无章号但正文头部含《书名》时仍会进组(
from:"content"),其files[]元素没有chapter字段,排序时排在组末。 - 工具开关:
execute首行执行assertToolEnabled,若该工具在「写作助手功能」UI 中被关闭,调用直接报错。
相关工具
- novel_books —— 导入后核对书目列表,确认分组是否合并成预期目录。
- novel_chapters —— 逐个作品核对章号连续性,发现自动改名产生的章号偏移。
- novel_new_chapter —— 单章创建通道,与批量导入共用同一套章号查重与防覆盖规则。
- novel_read —— 抽查导入后正文是否完整、编码是否正确。
源码位置
- lib/index.js —— 工具注册与
execute:参数 schema、输出契约、分组、maybe判定、apply 落盘循环(registerNovelImport,约 175 行) - lib/core.js ——
collectTextFiles文件收集与扩展名白名单CHAPTER_EXTENSIONS、parseChapterNumber章号解析、bookNameFromFileName/bookNameFromContent书名识别、IMPORT_NOISE停用词、nextFreeChapterFile空闲章号生成、sanitizeSegment、formatImport渲染 - mcp/server.mjs ——
isInsideRoot根目录边界判定、--allow-external-src开关、src越界时的isError应答(约 141 行与 283 行)