2026-05-10 13:12:44 +08:00

389 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* 上传引擎 · 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/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
};