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
|
||
};
|