Browse Source

fix(book-analysis): 支持无章节短篇导入

无章节标记时将非空全文作为单章保存,保留空文件校验并补充批量导入回归测试。
darknessomi 1 tháng trước cách đây
mục cha
commit
1f17014ba9

+ 19 - 4
src/lib/novel/book-analysis/analysis-engine.spec.ts

@@ -84,9 +84,24 @@ describe("parseNovelChapters 章节识别(锚定行首,防幻影章节)",
     expect(chapters[0].content).not.toMatch(/^[ \t\u3000]+第一章/)
   })
 
-  it("没有章节标记时抛出明确错误", () => {
-    expect(() => parseNovelChapters("这是一篇没有章节标题的小说正文。")).toThrow(
-      "未能识别到章节标记",
-    )
+  it("没有章节标记时将非空全文作为单章且不丢正文", () => {
+    const text = [
+      "短篇标题",
+      "一段导语。",
+      "01",
+      "第一节正文。",
+      "02",
+      "第二节正文。",
+    ].join("\r\n")
+
+    expect(parseNovelChapters(text)).toEqual([{
+      title: "全文",
+      content: text,
+      order: 1,
+    }])
+  })
+
+  it("空白文件仍拒绝导入", () => {
+    expect(() => parseNovelChapters(" \r\n\t ")).toThrow("小说文件内容为空")
   })
 })

+ 10 - 1
src/lib/novel/book-analysis/analysis-engine.ts

@@ -43,7 +43,16 @@ export function parseNovelChapters(content: string): ParsedNovelChapter[] {
   const matches = Array.from(content.matchAll(chapterRegex))
 
   if (matches.length === 0) {
-    throw new Error("未能识别到章节标记,请确保小说文件包含\"第X章\"格式的章节标题")
+    const fullText = content.trim()
+    if (!fullText) throw new Error("小说文件内容为空")
+
+    // 短篇小说可能没有“第X章”标题。保留完整原文并作为单章导入,
+    // 避免为了满足解析器而要求用户手工篡改源文件。
+    return [{
+      title: "全文",
+      content: fullText,
+      order: 1,
+    }]
   }
 
   return matches.map((match, index) => {

+ 48 - 4
src/lib/novel/book-analysis/batch-import-engine.spec.ts

@@ -254,10 +254,12 @@ describe("analysis engine chapter helpers", () => {
     ])
   })
 
-  it("没有章节标记时保留旧中文错误", () => {
-    expect(() => parseNovelChapters("没有章节标题")).toThrow(
-      "未能识别到章节标记,请确保小说文件包含\"第X章\"格式的章节标题",
-    )
+  it("没有章节标记时按全文单章解析", () => {
+    expect(parseNovelChapters("短篇标题\n没有章节标题的正文")).toEqual([{
+      title: "全文",
+      content: "短篇标题\n没有章节标题的正文",
+      order: 1,
+    }])
   })
 
   it("构建与旧引擎相同的章节 Markdown", () => {
@@ -318,6 +320,48 @@ wordCount: 9
 })
 
 describe("runBatchImportTask", () => {
+  it("无章节短篇按全文单章导入并发布到作品库", async () => {
+    const shortStory = [
+      "短篇标题",
+      "开场导语。",
+      "01",
+      "正文内容。",
+    ].join("\r\n")
+    const shortStorySha256 = await hashNormalizedNovel(shortStory)
+    task = { ...task, sourceSha256: shortStorySha256 }
+    fsMocks.files.set(CACHED_SOURCE_PATH, shortStory)
+
+    const result = await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    expect(result.metadata.totalChapters).toBe(1)
+    expect(result.metadata.totalWords).toBe(shortStory.length)
+    expect(result.chapters).toEqual([{
+      id: "ch-0001",
+      title: "全文",
+      order: 1,
+      wordCount: shortStory.length,
+      path: `${BOOK_PATH}/chapters/ch-0001.md`,
+    }])
+    expect(fsMocks.files.get(`${BOOK_PATH}/chapters/ch-0001.md`)).toBe(
+      buildChapterMarkdown(task.bookId, {
+        title: "全文",
+        content: shortStory,
+        order: 1,
+      }),
+    )
+    expect(libraryMocks.upsertBookLibraryEntry).toHaveBeenCalledWith(
+      PROJECT_PATH,
+      expect.objectContaining({
+        bookId: task.bookId,
+        totalChapters: 1,
+        totalWords: shortStory.length,
+        status: "completed",
+      }),
+    )
+  })
+
   it.each(["completed", "skipped"] as const)(
     "直接调用 engine 时拒绝终态 %s 且不写正式目录",
     async (status) => {