diff --git a/server.js b/server.js index e217c22..b874024 100644 --- a/server.js +++ b/server.js @@ -662,6 +662,14 @@ app.post('/api/ai/draft', requireAuth, aiLimiter, async (req, res) => { // jsdelivr CDN下载语言包,VPS需要能访问外网) // 2. 系统程序 poppler-utils,只有上传PDF时才需要,用来把PDF每页渲染成图片: // apt install -y poppler-utils +// 3. 系统程序 markitdown(微软开源,Python),用来读取DOCX/PPTX/XLSX的文字内容, +// 以及给PDF做"有没有文字层"的快速探测——有文字层就直接用markitdown拿干净文本 +// (跳过OCR,更快更准,还保留表格结构),没有文字层(扫描件/纯图片排版)才退回 +// pdftoppm+OCR这条老路。装法(root跑): +// apt install -y pipx && pipx ensurepath +// pipx install 'markitdown[pdf,docx,pptx,xlsx]' +// ln -s ~/.local/bin/markitdown /usr/local/bin/markitdown # 让Node能直接找到这个命令 +// 没装markitdown也不影响现有功能——探测/转换失败会静默退回原来的OCR流程。 const { execFile } = require('child_process'); const Tesseract = require('tesseract.js'); @@ -671,6 +679,16 @@ const uploadMemory = multer({ limits: { fileSize: 20 * 1024 * 1024, files: 30 }, // 20MB/文件,最多30个文件 }); +// 调用markitdown CLI把一个文件(DOCX/PPTX/XLSX/PDF等)转成Markdown文本 +function runMarkitdown(filePath, timeoutMs = 30000) { + return new Promise((resolve, reject) => { + execFile('markitdown', [filePath], { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => { + if (err) return reject(err); + resolve(stdout || ''); + }); + }); +} + // 把一份PDF的每一页渲染成PNG图片(用poppler-utils的pdftoppm),返回图片路径数组 function renderPdfToImages(pdfBuffer, tmpDir) { return new Promise((resolve, reject) => { @@ -860,15 +878,44 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => { try { tmpDir = fs.mkdtempSync(path.join(OCR_TMP_ROOT, 'catalog-ocr-')); const imagePaths = []; + const docTextParts = []; // DOCX/PPTX/XLSX,或者"有文字层的PDF"——MarkItDown转出来的干净文本,不用跑OCR const htmlTextParts = []; // HTML文件不用OCR,直接读文字内容(保留表格标签结构,AI能看懂rowspan这种分组关系) for (const file of req.files) { - const isPdf = (file.mimetype || '').includes('pdf') || file.originalname.toLowerCase().endsWith('.pdf'); + const lowerName = file.originalname.toLowerCase(); + const isPdf = (file.mimetype || '').includes('pdf') || lowerName.endsWith('.pdf'); const isImage = (file.mimetype || '').startsWith('image/'); const isHtml = (file.mimetype || '').includes('html') || /\.html?$/i.test(file.originalname); - if (isPdf) { + const isOfficeDoc = /\.(docx|pptx|xlsx)$/i.test(file.originalname); + if (isOfficeDoc) { + const docPath = path.join(tmpDir, `doc_${docTextParts.length}${path.extname(file.originalname)}`); + fs.writeFileSync(docPath, file.buffer); + try { + const md = await runMarkitdown(docPath); + if (md && md.trim()) docTextParts.push(md.trim()); + } catch (e) { + return res.status(400).json({ + error: `解析${file.originalname}失败,请确认服务器已安装markitdown(SSH执行:pipx install 'markitdown[docx,pptx,xlsx]'):${e.message}`, + }); + } + } else if (isPdf) { const pdfSubDir = fs.mkdtempSync(path.join(tmpDir, 'pdf-')); - const pages = await renderPdfToImages(file.buffer, pdfSubDir); - imagePaths.push(...pages); + // 先试MarkItDown直接读文字层——如果这份PDF本身带干净的文字层(不是扫描件/纯图片排版的目录), + // 这条路又快又准,还能保留表格结构;没装markitdown、或者这份PDF本来就没有文字层(返回内容 + // 短到没有意义),就静默退回老办法:整页渲染成图片再OCR,不阻断这次上传 + let usedTextLayer = false; + try { + const probePath = path.join(pdfSubDir, 'source.pdf'); + fs.writeFileSync(probePath, file.buffer); + const md = await runMarkitdown(probePath); + if (md && md.trim().length > 80) { + docTextParts.push(md.trim()); + usedTextLayer = true; + } + } catch (e) { /* markitdown不可用或转换失败,退回OCR */ } + if (!usedTextLayer) { + const pages = await renderPdfToImages(file.buffer, pdfSubDir); + imagePaths.push(...pages); + } } else if (isImage) { const ext = path.extname(file.originalname) || '.png'; const imgPath = path.join(tmpDir, `img_${imagePaths.length}${ext}`); @@ -885,12 +932,12 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => { .trim(); if (cleaned) htmlTextParts.push(cleaned); } else { - return res.status(400).json({ error: `不支持的文件类型:${file.originalname}(只支持PDF、图片或HTML)` }); + return res.status(400).json({ error: `不支持的文件类型:${file.originalname}(只支持PDF、图片、HTML、Word/PPT/Excel)` }); } } const ocrText = imagePaths.length ? await ocrImages(imagePaths) : ''; - const text = [ocrText, ...htmlTextParts].filter(Boolean).join('\n\n--- 下一份文件 ---\n\n'); + const text = [ocrText, ...docTextParts, ...htmlTextParts].filter(Boolean).join('\n\n--- 下一份文件 ---\n\n'); if (!text) return res.status(400).json({ error: '没有识别/读取到任何文字内容,请确认文件清晰、内容完整' }); // 识别出来的文本太长就截断,避免超出模型输出预算;产品目录类文档核心信息通常靠前