409 lines
11 KiB
JavaScript
409 lines
11 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 智能分章引擎 · 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
|
|||
|
|
};
|