UNPKG

mcp-universal-file-converter

Version:

MCP Universal File Converter - 支持压缩包、图片、PDF、Office文档等转换成可上传的 JPG (≤2MB)

629 lines (628 loc) 24.4 kB
/** * MCP Universal File Converter * 支持压缩包、图片、PDF、Office文档等转换成可上传的 JPG (≤2MB) */ import fs from "fs"; import path from "path"; import AdmZip from "adm-zip"; import { createExtractorFromFile } from "node-unrar-js"; import Seven from "node-7z"; import sevenBin from "7zip-bin"; import sharp from "sharp"; import pkg from "pdfjs-dist/legacy/build/pdf.js"; const { getDocument, GlobalWorkerOptions } = pkg; import { createCanvas } from "@napi-rs/canvas"; import { execSync } from "child_process"; import OpenAI from "openai"; import { createRequire } from "module"; // 配置 PDF.js Worker const require = createRequire(import.meta.url); GlobalWorkerOptions.workerSrc = require.resolve('pdfjs-dist/legacy/build/pdf.worker.min.js'); // API配置 let apiConfig = { apiKey: '', model: '', baseURL: 'https://ark.cn-beijing.volces.com/api/v3' }; /** * 设置API配置 */ export function setApiConfig(config) { if (config.apiKey) apiConfig.apiKey = config.apiKey; if (config.model) apiConfig.model = config.model; } // 允许的扩展名 const imageExts = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif", ".heic", ".svg"]; const docExts = [".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"]; const archiveExts = [".zip", ".rar", ".7z"]; /** * 递归列出所有文件 */ function listFilesRecursively(dir) { let results = []; for (const item of fs.readdirSync(dir)) { const full = path.join(dir, item); const stat = fs.statSync(full); if (stat.isDirectory()) { results = results.concat(listFilesRecursively(full)); } else { results.push(full); } } return results; } /** * 解压 zip */ async function unzipFile(zipPath, destDir) { try { const zip = new AdmZip(zipPath); const tempDir = path.join(destDir, "temp_extract_" + Date.now()); // 先解压到临时目录 zip.extractAllTo(tempDir, true); // 移除 macOS 自动创建的 __MACOSX 文件夹 const macOsDir = path.join(tempDir, "__MACOSX"); if (fs.existsSync(macOsDir)) { fs.rmSync(macOsDir, { recursive: true, force: true }); } // 将解压内容移动到目标目录 const extractedItems = fs.readdirSync(tempDir); for (const item of extractedItems) { const srcPath = path.join(tempDir, item); const destPath = path.join(destDir, item); fs.renameSync(srcPath, destPath); } // 删除临时目录 fs.rmSync(tempDir, { recursive: true, force: true }); // 删除源压缩包 fs.unlinkSync(zipPath); return true; } catch (err) { console.error("解压 zip 失败:", zipPath, err); return false; } } /** * 解压 rar */ async function unrarFile(rarPath, destDir) { try { const tempDir = path.join(destDir, "temp_extract_" + Date.now()); fs.mkdirSync(tempDir, { recursive: true }); const extractor = await createExtractorFromFile({ filepath: rarPath, targetPath: tempDir }); const extractResult = await extractor.extract(); // 处理解压的文件 const files = [...extractResult.files]; // 将文件写入到临时目录 for (const file of files) { if (!file.fileHeader.flags.directory) { const filePath = path.join(tempDir, file.fileHeader.name); const fileDir = path.dirname(filePath); // 确保目录存在 if (!fs.existsSync(fileDir)) { fs.mkdirSync(fileDir, { recursive: true }); } // 写入文件内容 const content = file.extraction; if (content) { fs.writeFileSync(filePath, content); } } } // 移除 macOS 自动创建的 __MACOSX 文件夹 const macOsDir = path.join(tempDir, "__MACOSX"); if (fs.existsSync(macOsDir)) { fs.rmSync(macOsDir, { recursive: true, force: true }); } // 将解压内容移动到目标目录 const extractedItems = fs.readdirSync(tempDir); for (const item of extractedItems) { const srcPath = path.join(tempDir, item); const destPath = path.join(destDir, item); fs.renameSync(srcPath, destPath); } // 删除临时目录 fs.rmSync(tempDir, { recursive: true, force: true }); // 删除源压缩包 fs.unlinkSync(rarPath); return true; } catch (err) { console.error("解压 rar 失败:", rarPath, err); return false; } } /** * 解压 7z */ async function unzip7z(filePath, destDir) { return new Promise((resolve) => { // 动态检查和修复7z二进制文件权限 try { if (fs.existsSync(sevenBin.path7za)) { const stats = fs.statSync(sevenBin.path7za); if (!(stats.mode & parseInt('111', 8))) { // 如果没有执行权限,尝试添加 try { fs.chmodSync(sevenBin.path7za, '755'); console.log("已修复7z二进制文件执行权限"); } catch (chmodErr) { console.warn("无法修复7z权限:", chmodErr); } } } } catch (permErr) { console.warn("检查7z权限时出错:", permErr); } const tempDir = path.join(destDir, "temp_extract_" + Date.now()); try { fs.mkdirSync(tempDir, { recursive: true }); } catch (err) { console.error("创建临时目录失败:", err); resolve(false); return; } let extractionSuccessful = false; // 尝试使用node-7z内置的7z二进制文件 let stream; try { // 首先尝试使用node-7z内置的7z stream = Seven.extractFull(filePath, tempDir, { $bin: sevenBin.path7za }); } catch (binError) { console.error("无法使用内置7z,尝试系统7z:", binError); try { // 如果内置失败,尝试系统7z execSync('7z', { stdio: 'ignore' }); stream = Seven.extractFull(filePath, tempDir, { $bin: "7z" }); } catch (systemError) { console.error("7z工具不可用:", systemError); resolve(false); return; } } stream.on("end", () => { try { // 验证解压是否成功 if (!fs.existsSync(tempDir)) { console.error("解压失败: 临时目录不存在"); resolve(false); return; } // 移除 macOS 自动创建的 __MACOSX 文件夹 const macOsDir = path.join(tempDir, "__MACOSX"); if (fs.existsSync(macOsDir)) { fs.rmSync(macOsDir, { recursive: true, force: true }); } // 将解压内容移动到目标目录 const extractedItems = fs.readdirSync(tempDir); if (extractedItems.length === 0) { console.error("解压失败: 没有提取到任何文件"); fs.rmSync(tempDir, { recursive: true, force: true }); resolve(false); return; } for (const item of extractedItems) { const srcPath = path.join(tempDir, item); let destPath = path.join(destDir, item); // 处理文件名冲突 let counter = 1; while (fs.existsSync(destPath)) { const ext = path.extname(item); const name = path.basename(item, ext); destPath = path.join(destDir, `${name}_${counter}${ext}`); counter++; } fs.renameSync(srcPath, destPath); } // 删除临时目录 fs.rmSync(tempDir, { recursive: true, force: true }); // 只有在解压成功后才删除源压缩包 fs.unlinkSync(filePath); resolve(true); } catch (err) { console.error("移动解压文件失败:", err); // 清理临时目录 try { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } catch (cleanupErr) { console.error("清理临时目录失败:", cleanupErr); } resolve(false); } }); stream.on("error", (err) => { console.error("解压 7z 失败:", filePath, err); // 清理临时目录 try { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } } catch (cleanupErr) { console.error("清理临时目录失败:", cleanupErr); } resolve(false); }); }); } /** * 压缩并转换图片到 jpg ≤ 2MB 并且 最大像素压缩到 7200万,等比例压缩 */ async function convertImageToJpg(inputPath, outputPath) { try { // 预检查:如果图片已经符合要求,则跳过处理 const inputExt = path.extname(inputPath).toLowerCase(); if (inputExt === '.jpg' || inputExt === '.jpeg') { // 检查文件大小 const stats = fs.statSync(inputPath); const fileSizeInBytes = stats.size; const maxSizeInBytes = 2 * 1024 * 1024; // 2MB // 检查像素数量 const metadata = await sharp(inputPath).metadata(); const width = metadata.width || 0; const height = metadata.height || 0; const maxPixels = 72000000; // 7200万像素 // 如果文件大小和像素都符合要求,直接复制到输出路径 if (fileSizeInBytes <= maxSizeInBytes && width * height <= maxPixels) { console.log("图片已符合要求,跳过处理:", inputPath); if (inputPath !== outputPath) { fs.copyFileSync(inputPath, outputPath); } return true; } } // 开始图片转换和优化处理 let quality = 80; let buffer = await sharp(inputPath).jpeg({ quality }).toBuffer(); // 确保最终文件大小小于等于2MB while (buffer.length > 2 * 1024 * 1024 && quality > 20) { quality -= 10; buffer = await sharp(inputPath).jpeg({ quality }).toBuffer(); } // 如果仍然大于2MB,继续降低质量直到满足要求 while (buffer.length > 2 * 1024 * 1024 && quality > 10) { quality -= 5; buffer = await sharp(inputPath).jpeg({ quality }).toBuffer(); } // 压缩图片到最大像素 7200万,等比例压缩 const metadata = await sharp(inputPath).metadata(); const width = metadata.width || 0; const height = metadata.height || 0; const maxPixels = 72000000; let sharpInstance = sharp(inputPath); if (width * height > maxPixels) { const scale = Math.sqrt(maxPixels / (width * height)); const newWidth = Math.round(width * scale); const newHeight = Math.round(height * scale); sharpInstance = sharpInstance.resize(newWidth, newHeight); } buffer = await sharpInstance.jpeg({ quality }).toBuffer(); fs.writeFileSync(outputPath, buffer); return true; } catch (err) { console.error("图片转换失败:", inputPath, err); return false; } } /** * PDF 转 JPG */ async function convertPdfToJpg(pdfPath, outputDir) { try { const pdfBuffer = fs.readFileSync(pdfPath); const pdfData = new Uint8Array(pdfBuffer); const loadingTask = getDocument({ data: pdfData }); const pdf = await loadingTask.promise; const baseName = path.basename(pdfPath, ".pdf"); // 只转换第一页 const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 2.0 }); const canvas = createCanvas(viewport.width, viewport.height); const context = canvas.getContext('2d'); const renderContext = { canvasContext: context, viewport: viewport }; await page.render(renderContext).promise; // 将canvas转换为buffer const buffer = canvas.toBuffer('image/jpeg', 0.8); // 保存图片 const outputPath = path.join(outputDir, `${baseName}.jpg`); fs.writeFileSync(outputPath, buffer); return true; } catch (err) { console.error("PDF 转图片失败:", pdfPath, err); return false; } } /** * Office 转 PDF(需要安装 libreoffice) */ function convertOfficeToPdf(filePath) { try { const dir = path.dirname(filePath); execSync(`libreoffice --headless --convert-to pdf "${filePath}" --outdir "${dir}"`); const pdfPath = path.join(dir, `${path.basename(filePath, path.extname(filePath))}.pdf`); return fs.existsSync(pdfPath) ? pdfPath : null; } catch (err) { console.error("Office 转 PDF 失败:", filePath, err); return null; } } /** * 处理单个文件 - 两阶段处理:先格式转换,再优化图片 */ async function processFile(filePath) { const ext = path.extname(filePath).toLowerCase(); const dir = path.dirname(filePath); // 第一阶段:格式转换 - 将各种格式转换为图片 let convertedImagePaths = []; if (ext === ".pdf") { // PDF转换为多个JPG图片 const ok = await convertPdfToJpg(filePath, dir); if (!ok) { return false; } // 收集转换后的图片路径 const baseName = path.basename(filePath, ext); const files = fs.readdirSync(dir); convertedImagePaths = files .filter(f => f.startsWith(baseName) && f.endsWith('.jpg')) .map(f => path.join(dir, f)); } else if (docExts.includes(ext) && ext !== ".pdf") { // Office文档先转PDF,再转JPG const pdfPath = convertOfficeToPdf(filePath); if (pdfPath) { const ok = await convertPdfToJpg(pdfPath, dir); if (!ok) { return false; } // 收集转换后的图片路径 const baseName = path.basename(filePath, ext); const files = fs.readdirSync(dir); convertedImagePaths = files .filter(f => f.startsWith(baseName) && f.endsWith('.jpg')) .map(f => path.join(dir, f)); } else { return false; } } else if (imageExts.includes(ext)) { // 图片文件直接加入待处理列表 convertedImagePaths = [filePath]; } else { console.warn("不支持的文件类型:", filePath); return true; } // 第二阶段:统一优化所有图片的大小、像素、质量等 let allSuccess = true; for (const imagePath of convertedImagePaths) { const imageExt = path.extname(imagePath).toLowerCase(); if (imageExt !== '.jpg') { // 非JPG图片需要转换格式并优化 const outputPath = path.join(dir, `${path.basename(imagePath, imageExt)}.jpg`); const ok = await convertImageToJpg(imagePath, outputPath); if (!ok) { allSuccess = false; } } else { // 已经是JPG的图片,重新优化大小和质量 const tempPath = imagePath + '.tmp'; const ok = await convertImageToJpg(imagePath, tempPath); if (ok) { let retryCount = 0; const maxRetries = 3; let success = false; while (retryCount < maxRetries && !success) { try { // Windows下先尝试删除原文件再重命名 if (process.platform === 'win32') { if (fs.existsSync(imagePath)) { fs.unlinkSync(imagePath); } fs.renameSync(tempPath, imagePath); } else { fs.renameSync(tempPath, imagePath); } success = true; } catch (error) { retryCount++; console.error(`重命名文件失败 (尝试 ${retryCount}/${maxRetries}): ${error}`); if (retryCount < maxRetries) { // 等待一段时间后重试 await new Promise(resolve => setTimeout(resolve, 100 * retryCount)); } else { allSuccess = false; // 清理临时文件 if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch (cleanupError) { console.error(`清理临时文件失败: ${cleanupError}`); } } } } } } else { allSuccess = false; // 清理临时文件 if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch (cleanupError) { console.error(`清理临时文件失败: ${cleanupError}`); } } } } } return allSuccess; } /** * 解压文件 */ async function unFile(filePath) { try { const ext = path.extname(filePath).toLowerCase(); const dir = path.dirname(filePath); if (archiveExts.includes(ext)) { if (ext === ".zip") return await unzipFile(filePath, dir); if (ext === ".rar") return await unrarFile(filePath, dir); if (ext === ".7z") return await unzip7z(filePath, dir); } return true; } catch (err) { console.error("解压 zip 失败:", filePath, err); return false; } } const getOpenai = () => { return new OpenAI({ apiKey: apiConfig.apiKey, baseURL: apiConfig.baseURL, }); }; async function detectLicenseName(filePath) { if (!apiConfig.apiKey) { return true; } try { const ext = path.extname(filePath).toLowerCase(); if (ext !== '.jpg') { return true; } const imageBase64 = fs.readFileSync(filePath).toString("base64"); const response = await getOpenai().chat.completions.create({ messages: [ { role: 'user', content: [ { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageBase64}`, }, }, { type: 'text', text: '识别这张图片的资质类型,只输出名称,不要多余解释。如果是资质的副本,则输出资质名称的时候后面拼接(副本)。比如营业职照(副本)' }, ], }, ], model: 'doubao-seed-1-6-flash-250715', }); const reName = response?.choices?.[0]?.message?.content?.trim(); console.log(response?.choices?.[0]?.message?.content, '----rename----'); if (reName) { // 文件重命名,处理文件名冲突 let newPath = path.join(path.dirname(filePath), reName + path.extname(filePath)); // 如果目标文件已存在,添加数字后缀 let counter = 1; while (fs.existsSync(newPath) && newPath !== filePath) { const ext = path.extname(filePath); newPath = path.join(path.dirname(filePath), `${reName}_${counter}${ext}`); counter++; } // 只有当新路径与原路径不同时才重命名 if (newPath !== filePath) { fs.renameSync(filePath, newPath); } return true; } return true; } catch (e) { console.error('detectLicenseName error: ', e); return false; } } /** * 处理子文件夹 - 返回处理结果的生成器 */ export async function* processSubFolder(subDir) { let allSuccess = true; try { // 第一步:解压所有压缩文件 const files = listFilesRecursively(subDir); yield { type: 'info', message: `开始处理文件夹: ${path.basename(subDir)},共发现 ${files.length} 个文件`, success: true }; for (const file of files) { const ext = path.extname(file).toLowerCase(); if (['.zip', '.rar', '.7z'].includes(ext)) { yield { type: 'info', message: `正在解压: ${path.basename(file)}`, success: true }; const ok = await unFile(file); if (ok) { yield { type: 'success', message: `解压成功: ${path.basename(file)}`, success: true }; } else { yield { type: 'error', message: `解压失败: ${path.basename(file)}`, success: false }; allSuccess = false; } } } // 第二步:处理所有文件(转换格式) const files_ = listFilesRecursively(subDir); for (const file of files_) { const ext = path.extname(file).toLowerCase(); if (['.zip', '.rar', '.7z'].includes(ext)) continue; // 跳过压缩文件 yield { type: 'info', message: `正在处理: ${path.basename(file)}`, success: true }; const ok = await processFile(file); if (ok) { yield { type: 'success', message: `处理成功: ${path.basename(file)}`, success: true }; } else { yield { type: 'error', message: `处理失败: ${path.basename(file)}`, success: false }; allSuccess = false; } } // 第三步:智能重命名 const file__ = listFilesRecursively(subDir); const jpgFiles = file__.filter(f => path.extname(f).toLowerCase() === '.jpg'); for (const file of jpgFiles) { yield { type: 'info', message: `正在智能重命名: ${path.basename(file)}`, success: true }; const ok = await detectLicenseName(file); if (ok) { yield { type: 'success', message: `重命名完成: ${path.basename(file)}`, success: true }; } else { yield { type: 'warning', message: `重命名失败: ${path.basename(file)}`, success: false }; // 重命名失败不影响整体成功状态 } } yield { type: 'info', message: `文件夹处理完成: ${path.basename(subDir)}`, success: allSuccess }; return allSuccess; } catch (error) { yield { type: 'error', message: `处理文件夹时发生错误: ${error}`, success: false }; return false; } }