guanghulab/server/novel-db/app/services/chapter-engine.js

409 lines
11 KiB
JavaScript
Raw Normal View History

/**
*
* 智能分章引擎 · Chapter Engine Service
*
*
* 智库节点 Phase 2 · ZY-SVR-006
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* 功能:
* - 识别 TXT 章节分隔规律正则匹配多种格式
* - 无章节标识时按内容结构分段空行/字数阈值
* - 输出整齐 TXT + 目录索引 JSON
* - 支持重新分章更新索引
*
* 章节识别规则优先级从高到低:
* 1. 标准格式: "第X章 标题" / "第X回 标题"
* 2. 数字格式: "Chapter N" / "章节N"
* 3. 简写格式: "1." / "001" 开头的行
* 4. 分隔符: "──" / "***" / "===" 等分隔线
* 5. 兜底: 按空行+字数阈值切分
*
*
*/
'use strict';
const fs = require('fs');
const path = require('path');
const BOOKS_DIR = process.env.BOOKS_DIR || path.join(__dirname, '..', 'data', 'books');
const CHAPTERS_DIR = process.env.CHAPTERS_DIR || path.join(__dirname, '..', 'data', 'chapters');
// ─── 章节匹配正则 ───
const CHAPTER_PATTERNS = [
// 第X章/回/节/篇/卷 (支持中文数字和阿拉伯数字)
/^第[一二三四五六七八九十百千万\d]+[章回节篇卷]\s*.*/,
// Chapter N / CHAPTER N
/^[Cc]hapter\s+\d+/,
// 章节N / 章N
/^章节?\s*\d+/,
// 序章/楔子/尾声/番外
/^(序章|楔子|引子|尾声|番外|后记|前言|附录)\s*/,
// 纯数字行(如 "001" 或 "1."
/^\d{1,4}[.、.]\s*.+/
];
// 分隔符模式
const SEPARATOR_PATTERN = /^[─━═\-\*]{5,}$/;
// 初始化目录
function init() {
if (!fs.existsSync(CHAPTERS_DIR)) {
fs.mkdirSync(CHAPTERS_DIR, { recursive: true });
}
}
/**
* 验证 ID 安全性防止路径遍历和原型链污染
*/
function sanitizeId(id) {
if (!id || typeof id !== 'string') return null;
// 禁止原型链污染
if (id === '__proto__' || id === 'constructor' || id === 'prototype') return null;
// 禁止路径遍历字符
if (id.includes('..') || id.includes('/') || id.includes('\\')) return null;
return id;
}
/**
* 对一本书进行分章
* @param {string} filename - 书籍文件名 ( data/books/ )
* @returns {object} { book_id, title, total_chapters, chapters: [...], toc: [...] }
*/
function splitChapters(filename) {
if (!sanitizeId(filename)) {
throw new Error('非法文件名');
}
const filePath = path.join(BOOKS_DIR, filename);
// 防止路径遍历
const resolvedPath = path.resolve(filePath);
const resolvedBase = path.resolve(BOOKS_DIR);
if (!resolvedPath.startsWith(resolvedBase)) {
throw new Error('非法路径');
}
if (!fs.existsSync(filePath)) {
throw new Error(`书籍文件不存在: ${filename}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
// 提取书名(文件名或第一行)
const bookId = filename.replace('.txt', '');
const bookMeta = extractBookMeta(lines);
// 尝试按章节标识分割
let chapters = splitByPatterns(lines);
// 如果没有找到章节标识,按分隔符分割
if (chapters.length <= 1) {
chapters = splitBySeparators(lines);
}
// 如果仍然只有一章,按空行+字数阈值切分
if (chapters.length <= 1) {
chapters = splitByParagraphs(lines);
}
// 构建目录索引
const toc = chapters.map((ch, idx) => ({
index: idx,
title: ch.title,
start_line: ch.start_line,
char_count: ch.content.length,
word_count: ch.content.replace(/\s/g, '').length
}));
// 保存分章结果
const result = {
book_id: bookId,
title: bookMeta.title || bookId,
author: bookMeta.author || '未知',
total_chapters: chapters.length,
total_chars: content.length,
split_method: chapters.length > 1 ? detectMethod(chapters) : 'single',
created_at: new Date().toISOString(),
toc,
chapters: chapters.map((ch, idx) => ({
index: idx,
title: ch.title,
content: ch.content
}))
};
// 保存到 chapters 目录
const chapterDir = path.join(CHAPTERS_DIR, bookId);
const resolvedChapterDir = path.resolve(chapterDir);
const resolvedChaptersBase = path.resolve(CHAPTERS_DIR);
if (!resolvedChapterDir.startsWith(resolvedChaptersBase)) {
throw new Error('非法路径');
}
if (!fs.existsSync(chapterDir)) {
fs.mkdirSync(chapterDir, { recursive: true });
}
// 保存索引
const indexFilePath = path.join(chapterDir, 'index.json');
fs.writeFileSync(
indexFilePath,
JSON.stringify({ ...result, chapters: undefined }, null, 2),
'utf8'
);
// 保存每章内容
for (let i = 0; i < chapters.length; i++) {
const chFilePath = path.join(chapterDir, `chapter-${String(i).padStart(4, '0')}.txt`);
fs.writeFileSync(chFilePath, chapters[i].content, 'utf8');
}
return result;
}
/**
* 提取书籍元信息从前几行
*/
function extractBookMeta(lines) {
const meta = { title: '', author: '' };
const header = lines.slice(0, 10).join('\n');
// 匹配书名《xxx》
const titleMatch = header.match(/《(.+?)》/);
if (titleMatch) meta.title = titleMatch[1];
// 匹配作者
const authorMatch = header.match(/作者[:]\s*(.+)/);
if (authorMatch) meta.author = authorMatch[1].trim();
return meta;
}
/**
* 按章节正则模式分割
*/
function splitByPatterns(lines) {
const chapters = [];
let currentChapter = null;
let lineNum = 0;
for (const line of lines) {
lineNum++;
const trimmed = line.trim();
// 检查是否匹配章节模式
const isChapterHead = CHAPTER_PATTERNS.some(p => p.test(trimmed));
if (isChapterHead) {
if (currentChapter) {
chapters.push(currentChapter);
}
currentChapter = {
title: trimmed,
start_line: lineNum,
content: ''
};
} else if (currentChapter) {
currentChapter.content += line + '\n';
}
}
if (currentChapter) {
chapters.push(currentChapter);
}
return chapters;
}
/**
* 按分隔符分割
*/
function splitBySeparators(lines) {
const chapters = [];
let currentContent = '';
let chapterNum = 0;
let startLine = 1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (SEPARATOR_PATTERN.test(trimmed) && currentContent.trim().length > 50) {
chapterNum++;
chapters.push({
title: `段落 ${chapterNum}`,
start_line: startLine,
content: currentContent.trim()
});
currentContent = '';
startLine = i + 2;
} else {
currentContent += lines[i] + '\n';
}
}
// 最后一段
if (currentContent.trim().length > 0) {
chapterNum++;
chapters.push({
title: `段落 ${chapterNum}`,
start_line: startLine,
content: currentContent.trim()
});
}
return chapters;
}
/**
* 按空行+字数阈值切分兜底方案
*/
function splitByParagraphs(lines, threshold = 3000) {
const chapters = [];
let currentContent = '';
let chapterNum = 0;
let startLine = 1;
let consecutiveEmpty = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === '') {
consecutiveEmpty++;
} else {
consecutiveEmpty = 0;
}
currentContent += lines[i] + '\n';
// 当连续空行≥2且已超过阈值时切分
if (consecutiveEmpty >= 2 && currentContent.trim().length >= threshold) {
chapterNum++;
chapters.push({
title: `自动段落 ${chapterNum}`,
start_line: startLine,
content: currentContent.trim()
});
currentContent = '';
startLine = i + 2;
consecutiveEmpty = 0;
}
}
// 最后一段
if (currentContent.trim().length > 0) {
chapterNum++;
chapters.push({
title: chapters.length === 0 ? '全文' : `自动段落 ${chapterNum}`,
start_line: startLine,
content: currentContent.trim()
});
}
return chapters;
}
/**
* 检测使用了哪种分割方法
*/
function detectMethod(chapters) {
if (chapters.length === 0) return 'none';
const firstTitle = chapters[0].title;
if (CHAPTER_PATTERNS.some(p => p.test(firstTitle))) return 'pattern';
if (firstTitle.startsWith('段落')) return 'separator';
if (firstTitle.startsWith('自动段落')) return 'paragraph';
return 'mixed';
}
/**
* 获取已分章的书籍列表
*/
function listChapteredBooks() {
if (!fs.existsSync(CHAPTERS_DIR)) return [];
return fs.readdirSync(CHAPTERS_DIR)
.filter(d => {
const indexPath = path.join(CHAPTERS_DIR, d, 'index.json');
return fs.existsSync(indexPath);
})
.map(d => {
const indexPath = path.join(CHAPTERS_DIR, d, 'index.json');
try {
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
} catch {
return null;
}
})
.filter(Boolean);
}
/**
* 获取某本书的分章详情
*/
function getBookChapters(bookId) {
if (!sanitizeId(bookId)) return null;
const indexPath = path.join(CHAPTERS_DIR, bookId, 'index.json');
const resolvedPath = path.resolve(indexPath);
const resolvedBase = path.resolve(CHAPTERS_DIR);
if (!resolvedPath.startsWith(resolvedBase)) return null;
if (!fs.existsSync(indexPath)) return null;
try {
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
} catch {
return null;
}
}
/**
* 获取某章内容
*/
function getChapterContent(bookId, chapterIndex) {
if (!sanitizeId(bookId)) return null;
const chapterPath = path.join(
CHAPTERS_DIR,
bookId,
`chapter-${String(chapterIndex).padStart(4, '0')}.txt`
);
// 防止路径遍历
const resolvedPath = path.resolve(chapterPath);
const resolvedBase = path.resolve(CHAPTERS_DIR);
if (!resolvedPath.startsWith(resolvedBase)) return null;
if (!fs.existsSync(chapterPath)) return null;
return fs.readFileSync(chapterPath, 'utf8');
}
/**
* 获取分章引擎统计
*/
function getStats() {
const books = listChapteredBooks();
return {
total_chaptered_books: books.length,
total_chapters: books.reduce((sum, b) => sum + (b.total_chapters || 0), 0),
total_chars: books.reduce((sum, b) => sum + (b.total_chars || 0), 0),
methods: {
pattern: books.filter(b => b.split_method === 'pattern').length,
separator: books.filter(b => b.split_method === 'separator').length,
paragraph: books.filter(b => b.split_method === 'paragraph').length
}
};
}
// 初始化
init();
module.exports = {
splitChapters,
listChapteredBooks,
getBookChapters,
getChapterContent,
getStats,
CHAPTER_PATTERNS
};