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

novel_import

批量导入原稿件。递归扫描一个文件夹,按「文件名 → 文件头内容 → 未分类」的优先级自动识别书名并把章节文件分组,默认处于 scan 预览模式——只返回分组建议、不写盘;显式传 mode:"apply" 才按分组复制(或移动)到 novels/<书名>/。目标目录中已占用同名文件或同章号时自动改名为空闲章号,绝不覆盖既有章节

destructive 行为。mode:"apply"move:true 同时成立时,每个文件复制成功后会用 rm(..., { force: true }) 删除源文件:不进回收站、无备份、无撤销。批量中途出错会使源目录处于「部分文件已被删除」的状态。默认 move:false,未显式传真值绝不删除任何源文件。

参数

参数类型必填说明
srcstring待导入的原稿件文件夹路径,可含多本小说、可含子文件夹。MCP 形态下默认必须位于启动参数 --root 书库根之内,越界时整次调用被拒;确需导入外部目录须用 --allow-external-src 启动服务器。
modestring枚举 scan / apply。判定写为 args.mode === "apply" ? "apply" : "scan"只有字面量 "apply" 才执行导入,其余任何取值(含拼写错误与缺省)都按 scan 处理。
bookstringapply 时可选:把所有(或 files 指定的)文件强制归入该书名的组,用于合并异名同书,或为未分类文件指定归属。
filesarray<string>apply 时可选白名单,元素为相对 src 的路径。匹配前统一把反斜杠转成 / 并剥掉前导 .//。省略或传空数组则处理全部扫描到的文件。
moveboolean判定写为 args.move === true。仅在 apply 下有意义:为真时先复制再删除源文件。默认 false(源文件保留)。
recursiveboolean判定写为 args.recursive !== false,因此默认递归;只有显式传 false 才只扫顶层。
rootstring章节库根目录(含 novels 子目录)。MCP 形态下由服务器注入,越界回退书库根。

参数对象声明 additionalProperties: false,多传的键会被宿主拒绝。

输出结构

契约字段(output.schema.required):src / mode / groups / skipped / imported 五项恒返scanapply 返回体同构——scan 模式下 imported 为空数组。

字段类型内容
srcstring必返。原样回显的 src 参数
modestring必返。实际生效的模式("scan""apply"),可用于校验调用方的意图是否被识别
groupsarray必返。分组结果;两种模式下都返回完整分组,是 scan 预览的全部价值所在
groups[].bookstring组名(未分类组的组名固定为 "未分类");apply 落盘时另经 sanitizeSegment 清洗
groups[].fromstring枚举 file / content / forced / unclassified,标明该组名的来源通道
groups[].maybearray可选。可能同书的其他组名(供调用方判断是否用 book 合并),见「异名同书提示」
groups[].filesarray必返(每组必有)。元素为 { file },可选带 chapter(解析出的章号)与 title(从正文头部《》解析出的书名候选)
skippedarray<string>必返。被跳过的文件;元素是相对路径,apply 下未分类项还带原因文案。读取失败、内容为空白、未分类三种情形共用此字段
importedarray必返。元素为 { book, file, path }:清洗后的书名、最终落盘文件名(可能与源文件名不同)、绝对路径;scan 下为空数组

行为与实现

1 · 文件收集

collectTextFiles( src, recursive, out = [], prefix = "" ): entries = readdir( dir, { withFileTypes: true } ) readdir 失败(路径不存在 / 无权限)→ 直接返回已收集结果,不抛错 entries 按 name.localeCompare 升序 对每个条目: 目录 且 recursive 为真 → 递归进入,相对前缀 + "/" 文件 且 extname(小写) ∈ { .md, .markdown, .txt } → 收集 返回相对 src 的路径数组(分隔符统一为 "/")

扩展名白名单是硬边界:.docx / .json / 无扩展名文件不进入扫描结果,因此也不会出现在 skipped 中。目录不可读时返回空数组而不报错,调用方会看到「0 个文件 → 0 组」的正常返回体。

2 · 章号与书名的双通道识别

chapter = parseChapterNumber( basename(rel) ) nameFromFile = bookNameFromFileName( basename(rel) ) nameFromContent = bookNameFromContent( text ) parseChapterNumber 支持(入口先做全角宽度归一): 第1章 / 第01章 / 第一章 / 书名-01-标题 / 书名 01 / 01-标题 bookNameFromFileName:只取章号标记「之前的书名前缀」,并把 IMPORT_NOISE 停用词(原稿件 单章 调教计划 未命名 新建文档 草稿 无题 正文 手稿)剔除; 文件名以章号开头且无书名前缀时返回 undefined bookNameFromContent:只看前 12 行;优先取《》包裹的 2–30 字书名; 命中章号标记的行跳过;无《》且无书名则返回 undefined

3 · 分组优先级

isChapterFile = ( chapter !== undefined ) 或 ( nameFromContent !== undefined ) 若 forcedBook 已给出 → book = forcedBook, from = "forced" 否则若 isChapterFile 且 nameFromFile → book = nameFromFile, from = "file" 否则若 isChapterFile 且 nameFromContent → book = nameFromContent, from = "content" 否则 → book = "未分类", from = "unclassified" 组内排序:chapter 升序(无章号视为 +∞,排在最后),同章号再按 file 的 localeCompare

先判 isChapterFile 再取书名,是为了避免把「聚会的照片.md」这类非章节文件按文件名误判成书名。file 通道优先于 content 通道:文件名里的书名前缀被认为比正文头部的《》更可靠。

4 · 异名同书提示 maybe

对每两组 (A, B),组名先去空白;任一长度小于 2 则跳过: 包含关系:a.includes(b) 或 b.includes(a) → 列入 字符集合重合率:common / max( |A|, |B|, 1 ) ≥ 0.6 → 列入

该字段只是提示,不会自动合并。调用方若确认同书,应带 book 参数重新调用(from 会变成 forced)以合并为一组,否则同名书会被拆成两个目录。

5 · apply 落盘与改名规则

for group of groups: if ( group.book === "未分类" ): 每个文件 → skipped += file + "(未分类,未导入——请用 book 参数指定归属书名后重试)" continue safeBook = sanitizeSegment( group.book, "book" ) destDir = <root>/novels/<safeBook> mkdir -p for row of group.files: rawName = basename( row.file ) rawNum = parseChapterNumber( rawName ) if ( rawNum 已定义 ): if ( destDir 内存在解析出同一章号的文件 ) → destFile = nextFreeChapterFile( destDir, rawName ) elif ( destDir 内已存在同名文件 ) → destFile = nextFreeChapterFile( destDir, rawName ) else destFile = rawName copyFile( src/row.file → destDir/destFile ) if ( move === true ) rm( src/row.file, { force: true } ) imported += { book: safeBook, file: destFile, path: destFull } if ( mode === "apply" 且 imported 非空 ) → noteRoot( root ) ← 记录书库根供后续工具复用

nextFreeChapterFile 生成空闲章号时会沿用原文件名的章号格式:阿拉伯数字保持原有补零宽度(第007章 递增为 第008章);中文数字用中文数字生成(第十章第十一章,支持到 9999);书名-01-标题01-标题 保持分隔符位置;格式无法识别时退化为 第NN章 原名。占用判定按 parseChapterNumber数值比较,因此 第一章.md第01章.md 被视为同一章号。

6 · 渲染文本

<path>{src}</path> <type>novel-import-{mode}</type> <content> 扫描结果:N 个文件 → M 组(跳过 K 个文件:无法读取或未分类) [组名] (来自文件名|文件头内容|未分类|强制指定, N 个文件) - 第N章 相对路径 跳过: - 相对路径 已导入 N 个文件: - novels/<书名>/<落盘文件名> </content>

约束与边界

  • 删除行为(最高优先级)。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。因此 scanapplyskipped 长度不可直接对比。
  • 绝不覆盖既有章节。目标目录已占用同名文件或同章号时自动改用空闲章号,源文件按原名继续保留(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_EXTENSIONSparseChapterNumber 章号解析、bookNameFromFileName / bookNameFromContent 书名识别、IMPORT_NOISE 停用词、nextFreeChapterFile 空闲章号生成、sanitizeSegmentformatImport 渲染
  • mcp/server.mjs —— isInsideRoot 根目录边界判定、--allow-external-src 开关、src 越界时的 isError 应答(约 141 行与 283 行)