389 lines
11 KiB
JavaScript
389 lines
11 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 上传引擎 · Upload Engine Service
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 智库节点 Phase 4 · ZY-SVR-006
|
|||
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* - 接收用户上传的小说文件
|
|||
|
|
* - 自动检测文件格式 (TXT/EPUB/HTML/MD/DOCX)
|
|||
|
|
* - 转换为统一 TXT 格式存储
|
|||
|
|
* - 自动触发分章引擎
|
|||
|
|
* - 支持多种编码自动检测
|
|||
|
|
*
|
|||
|
|
* 支持格式:
|
|||
|
|
* - .txt (纯文本 · 自动检测编码)
|
|||
|
|
* - .epub (电子书 · 提取正文)
|
|||
|
|
* - .html (网页 · 剥离标签)
|
|||
|
|
* - .md (Markdown · 转纯文本)
|
|||
|
|
* - .docx (Word文档 · 提取正文,需额外解析)
|
|||
|
|
*
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
|
|||
|
|
const BOOKS_DIR = process.env.BOOKS_DIR || path.join(__dirname, '..', 'data', 'books');
|
|||
|
|
const UPLOAD_DIR = path.join(__dirname, '..', 'data', 'uploads');
|
|||
|
|
const UPLOAD_FILE = path.join(__dirname, '..', 'data', 'uploads-index.json');
|
|||
|
|
|
|||
|
|
// 支持的格式及 MIME 类型
|
|||
|
|
const SUPPORTED_FORMATS = {
|
|||
|
|
'.txt': { mime: ['text/plain'], name: '纯文本' },
|
|||
|
|
'.epub': { mime: ['application/epub+zip'], name: '电子书' },
|
|||
|
|
'.html': { mime: ['text/html'], name: '网页' },
|
|||
|
|
'.htm': { mime: ['text/html'], name: '网页' },
|
|||
|
|
'.md': { mime: ['text/markdown', 'text/plain'], name: 'Markdown' },
|
|||
|
|
'.docx': { mime: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], name: 'Word文档' }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|||
|
|
|
|||
|
|
let uploadsIndex = [];
|
|||
|
|
|
|||
|
|
// ─── 初始化 ───
|
|||
|
|
function init() {
|
|||
|
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|||
|
|
if (!fs.existsSync(BOOKS_DIR)) fs.mkdirSync(BOOKS_DIR, { recursive: true });
|
|||
|
|
loadIndex();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadIndex() {
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(UPLOAD_FILE)) {
|
|||
|
|
uploadsIndex = JSON.parse(fs.readFileSync(UPLOAD_FILE, 'utf8'));
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
uploadsIndex = [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveIndex() {
|
|||
|
|
try {
|
|||
|
|
const dir = path.dirname(UPLOAD_FILE);
|
|||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
fs.writeFileSync(UPLOAD_FILE, JSON.stringify(uploadsIndex, null, 2), 'utf8');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[UploadEngine] 保存索引失败:', err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检测文件格式
|
|||
|
|
*/
|
|||
|
|
function detectFormat(filename, mimeType) {
|
|||
|
|
const ext = path.extname(filename).toLowerCase();
|
|||
|
|
|
|||
|
|
if (SUPPORTED_FORMATS[ext]) {
|
|||
|
|
return {
|
|||
|
|
ext,
|
|||
|
|
format: SUPPORTED_FORMATS[ext].name,
|
|||
|
|
supported: true
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 尝试从 MIME 类型推断
|
|||
|
|
for (const [formatExt, cfg] of Object.entries(SUPPORTED_FORMATS)) {
|
|||
|
|
if (cfg.mime.includes(mimeType)) {
|
|||
|
|
return { ext: formatExt, format: cfg.name, supported: true };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { ext, format: '未知格式', supported: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将 HTML 内容转为纯文本(安全剥离所有标签)
|
|||
|
|
*/
|
|||
|
|
function htmlToText(html) {
|
|||
|
|
let text = html;
|
|||
|
|
// 移除 script 和 style 标签及内容(使用 split/join 避免 ReDoS)
|
|||
|
|
text = removeTagWithContent(text, 'script');
|
|||
|
|
text = removeTagWithContent(text, 'style');
|
|||
|
|
// 段落和换行
|
|||
|
|
text = text
|
|||
|
|
.replace(/<\/p>/gi, '\n\n')
|
|||
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|||
|
|
.replace(/<\/div>/gi, '\n')
|
|||
|
|
.replace(/<\/h[1-6]>/gi, '\n\n')
|
|||
|
|
.replace(/<h[1-6][^>]*>/gi, '\n');
|
|||
|
|
// 移除所有 HTML 标签
|
|||
|
|
text = text.replace(/<[^>]+>/g, '');
|
|||
|
|
// 解码 HTML 实体
|
|||
|
|
text = text
|
|||
|
|
.replace(/ /g, ' ')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, "'")
|
|||
|
|
.replace(/'/g, "'");
|
|||
|
|
// 清理多余空行
|
|||
|
|
text = text.replace(/\n{3,}/g, '\n\n').trim();
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 安全移除指定标签及其内容(避免正则 ReDoS)
|
|||
|
|
*/
|
|||
|
|
function removeTagWithContent(html, tagName) {
|
|||
|
|
const openTag = '<' + tagName;
|
|||
|
|
const closeTag = '</' + tagName + '>';
|
|||
|
|
let result = html;
|
|||
|
|
let maxIterations = 100;
|
|||
|
|
while (maxIterations-- > 0) {
|
|||
|
|
const openIdx = result.toLowerCase().indexOf(openTag);
|
|||
|
|
if (openIdx === -1) break;
|
|||
|
|
const closeIdx = result.toLowerCase().indexOf(closeTag, openIdx);
|
|||
|
|
if (closeIdx === -1) {
|
|||
|
|
// No close tag, remove from open tag to end of string
|
|||
|
|
result = result.slice(0, openIdx);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
result = result.slice(0, openIdx) + result.slice(closeIdx + closeTag.length);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将 Markdown 转为纯文本
|
|||
|
|
*/
|
|||
|
|
function markdownToText(md) {
|
|||
|
|
return md
|
|||
|
|
// 标题
|
|||
|
|
.replace(/^#{1,6}\s+/gm, '')
|
|||
|
|
// 加粗/斜体
|
|||
|
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|||
|
|
.replace(/\*(.+?)\*/g, '$1')
|
|||
|
|
.replace(/__(.+?)__/g, '$1')
|
|||
|
|
.replace(/_(.+?)_/g, '$1')
|
|||
|
|
// 链接
|
|||
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|||
|
|
// 图片
|
|||
|
|
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
|||
|
|
// 代码块
|
|||
|
|
.replace(/```[\s\S]*?```/g, '')
|
|||
|
|
.replace(/`([^`]+)`/g, '$1')
|
|||
|
|
// 引用
|
|||
|
|
.replace(/^>\s+/gm, '')
|
|||
|
|
// 分隔线
|
|||
|
|
.replace(/^[-*_]{3,}$/gm, '')
|
|||
|
|
// 清理
|
|||
|
|
.replace(/\n{3,}/g, '\n\n')
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 提取 EPUB 文本内容(简易版)
|
|||
|
|
*
|
|||
|
|
* EPUB 本质上是 ZIP 文件,内部包含 XHTML 文件
|
|||
|
|
* 注意: 此简易实现通过字符扫描提取文本,不解压 ZIP
|
|||
|
|
* 对于复杂 EPUB,建议转为 TXT 后重新上传
|
|||
|
|
*/
|
|||
|
|
function extractEpubText(buffer) {
|
|||
|
|
// Convert buffer to string, ignoring non-UTF8 bytes
|
|||
|
|
const content = buffer.toString('utf8');
|
|||
|
|
|
|||
|
|
// Extract text between <body> and </body> tags using indexOf (safe from ReDoS)
|
|||
|
|
const textParts = [];
|
|||
|
|
let searchStart = 0;
|
|||
|
|
const maxParts = 200;
|
|||
|
|
|
|||
|
|
while (textParts.length < maxParts) {
|
|||
|
|
const bodyOpen = content.toLowerCase().indexOf('<body', searchStart);
|
|||
|
|
if (bodyOpen === -1) break;
|
|||
|
|
const bodyTagEnd = content.indexOf('>', bodyOpen);
|
|||
|
|
if (bodyTagEnd === -1) break;
|
|||
|
|
const bodyClose = content.toLowerCase().indexOf('</body>', bodyTagEnd);
|
|||
|
|
if (bodyClose === -1) break;
|
|||
|
|
|
|||
|
|
const bodyContent = content.slice(bodyTagEnd + 1, bodyClose);
|
|||
|
|
const text = htmlToText(bodyContent);
|
|||
|
|
if (text.length > 20) {
|
|||
|
|
textParts.push(text);
|
|||
|
|
}
|
|||
|
|
searchStart = bodyClose + 7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (textParts.length > 0) {
|
|||
|
|
return textParts.join('\n\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 降级: 提取可见纯文本行
|
|||
|
|
const lines = content.split('\n').filter(line => {
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
return trimmed.length > 10
|
|||
|
|
&& !trimmed.startsWith('<')
|
|||
|
|
&& !trimmed.startsWith('PK')
|
|||
|
|
&& !/[\x00-\x08]/.test(trimmed);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return lines.join('\n') || '(EPUB解析受限,建议转为TXT后重新上传)';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 转换上传文件为纯文本
|
|||
|
|
*/
|
|||
|
|
function convertToText(buffer, format) {
|
|||
|
|
let text;
|
|||
|
|
|
|||
|
|
switch (format.ext) {
|
|||
|
|
case '.txt':
|
|||
|
|
text = buffer.toString('utf8');
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case '.html':
|
|||
|
|
case '.htm':
|
|||
|
|
text = htmlToText(buffer.toString('utf8'));
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case '.md':
|
|||
|
|
text = markdownToText(buffer.toString('utf8'));
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case '.epub':
|
|||
|
|
text = extractEpubText(buffer);
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case '.docx':
|
|||
|
|
// DOCX 是 ZIP 文件包含 XML
|
|||
|
|
// 简易提取:类似 EPUB
|
|||
|
|
text = extractEpubText(buffer);
|
|||
|
|
if (text.length < 50) {
|
|||
|
|
text = '(DOCX格式建议先用Word另存为TXT后重新上传)';
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
// 尝试作为纯文本处理
|
|||
|
|
text = buffer.toString('utf8');
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 统一换行符
|
|||
|
|
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|||
|
|
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理上传文件
|
|||
|
|
*
|
|||
|
|
* @param {object} file { originalname, mimetype, buffer, size }
|
|||
|
|
* @param {object} meta { title, author, uploader }
|
|||
|
|
* @returns {object} 上传结果
|
|||
|
|
*/
|
|||
|
|
function processUpload(file, meta) {
|
|||
|
|
if (!file || !file.buffer) {
|
|||
|
|
throw new Error('没有收到文件数据');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (file.size > MAX_FILE_SIZE) {
|
|||
|
|
throw new Error(`文件过大 (${(file.size / 1024 / 1024).toFixed(1)}MB),最大支持 50MB`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检测格式
|
|||
|
|
const format = detectFormat(file.originalname, file.mimetype);
|
|||
|
|
|
|||
|
|
// 转换为文本
|
|||
|
|
const text = convertToText(file.buffer, format);
|
|||
|
|
const wordCount = text.replace(/\s/g, '').length;
|
|||
|
|
|
|||
|
|
if (wordCount < 10) {
|
|||
|
|
throw new Error('文件内容为空或无法解析,请确认文件格式正确');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成安全的文件名
|
|||
|
|
const baseName = (meta.title || path.basename(file.originalname, path.extname(file.originalname)))
|
|||
|
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|||
|
|
.slice(0, 100);
|
|||
|
|
const author = (meta.author || '未知')
|
|||
|
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|||
|
|
.slice(0, 50);
|
|||
|
|
const uploadId = `UPL-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|||
|
|
const targetFilename = `${baseName}_${author}_upload.txt`;
|
|||
|
|
const targetPath = path.join(BOOKS_DIR, targetFilename);
|
|||
|
|
|
|||
|
|
// 保存为 TXT
|
|||
|
|
fs.writeFileSync(targetPath, text, 'utf8');
|
|||
|
|
|
|||
|
|
// 记录索引
|
|||
|
|
const record = {
|
|||
|
|
upload_id: uploadId,
|
|||
|
|
original_name: file.originalname,
|
|||
|
|
original_format: format.format,
|
|||
|
|
original_ext: format.ext,
|
|||
|
|
original_size: file.size,
|
|||
|
|
converted_file: targetFilename,
|
|||
|
|
title: meta.title || baseName,
|
|||
|
|
author: meta.author || '未知',
|
|||
|
|
uploader: meta.uploader || 'unknown',
|
|||
|
|
word_count: wordCount,
|
|||
|
|
chapter_count: 0,
|
|||
|
|
created_at: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
uploadsIndex.push(record);
|
|||
|
|
saveIndex();
|
|||
|
|
|
|||
|
|
return record;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取上传列表
|
|||
|
|
*/
|
|||
|
|
function listUploads(options) {
|
|||
|
|
let result = [...uploadsIndex];
|
|||
|
|
if (options && options.uploader) {
|
|||
|
|
result = result.filter(u => u.uploader === options.uploader);
|
|||
|
|
}
|
|||
|
|
result.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|||
|
|
if (options && options.limit) {
|
|||
|
|
result = result.slice(0, options.limit);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取支持的格式列表
|
|||
|
|
*/
|
|||
|
|
function getSupportedFormats() {
|
|||
|
|
return Object.entries(SUPPORTED_FORMATS).map(([ext, cfg]) => ({
|
|||
|
|
ext,
|
|||
|
|
name: cfg.name,
|
|||
|
|
mime: cfg.mime
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 统计
|
|||
|
|
*/
|
|||
|
|
function getStats() {
|
|||
|
|
return {
|
|||
|
|
total_uploads: uploadsIndex.length,
|
|||
|
|
total_words: uploadsIndex.reduce((s, u) => s + (u.word_count || 0), 0),
|
|||
|
|
formats: uploadsIndex.reduce((acc, u) => {
|
|||
|
|
const f = u.original_format || '未知';
|
|||
|
|
acc[f] = (acc[f] || 0) + 1;
|
|||
|
|
return acc;
|
|||
|
|
}, {})
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init();
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
processUpload,
|
|||
|
|
listUploads,
|
|||
|
|
detectFormat,
|
|||
|
|
getSupportedFormats,
|
|||
|
|
getStats
|
|||
|
|
};
|