mcp-universal-file-converter
Version:
MCP Universal File Converter - 支持压缩包、图片、PDF、Office文档等转换成可上传的 JPG (≤2MB)
629 lines (628 loc) • 24.4 kB
JavaScript
/**
* 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;
}
}