PDF转Markdown最佳实践:MinerU JS/TS SDK 实现高质量文档解析
我一开始其实就是想找个能把 PDF 干净利落转成 Markdown 的工具给当时在做的知识库项目喂文档。折腾过好几个纯前端 PDF 解析库结果要么文字顺序乱掉要么表格和公式直接变成一堆乱码。后来看到有人提到 MinerU又正好碰上 JS/TS SDK 这套封装思路一下就通了解析这种重活在服务端做前端和 Node 服务只通过接口调用三四十行代码就能把一个质量不错的 PDF 解析链路给串起来。这篇文章就写给 JavaScript 和 TypeScript 开发者看。不管你是想给项目加一个“上传 PDF 自动提取内容”的功能还是打算搭一套批量的文档解析服务甚至想把文档转成结构化数据喂给大模型做 RAGMinerU 这套 JS/TS SDK 的思路都值得了解一下。我会从部署、接入、核心 API、实战代码到生产环境踩坑完整讲一遍我自己的使用过程。1. 先说清楚 MinerU 在 JS/TS 生态里到底解决什么问题1.1 PDF 不是文本文件解析难的根源在格式本身很多开发者第一次做 PDF 解析时都低估了这件事。PDF 的全称是 Portable Document Format它本质上是“版面描述语言”记录的是“每个字符、每条线、每张图片放在页面什么位置”而不是“这篇文章的逻辑结构是什么”。举个很简单的例子双栏排版的论文页面人眼阅读是先读左栏从上到下再读右栏从上到下。但简单用文本提取工具解析拿到的可能是左栏第一行、右栏第一行、左栏第二行、右栏第二行这样交叉拼接的文字。更别提表格被拆得七零八落、公式变成了上下标乱串的纯文本、页眉页脚混进正文这些都是常规解析方案必然翻车的地方。所以文档解析这个事的本质其实不是“提取文字”而是“还原阅读顺序和文档结构”。1.2 MinerU 的差异化在于版面还原能力MinerU 最核心的能力不是 OCR 也不是文字识别而是版面分析与阅读顺序恢复。它能自动识别标题、正文、页眉页脚、表格、图片、公式这些区域然后把它们按照人类阅读的顺序重新组织最终输出结构清晰的 Markdown 或 JSON。我当时的项目里有两类文档一类是排版规整的数字化 PDF一类是扫描版的纸质文档照片。前者用不上太重度的模型后者必须走 OCR。MinerU 的做法是把这些能力统一在一个链路里先做版面检测再根据页面情况决定哪些区域走 OCR哪些直接提取最后把结果合并成带结构的输出。这套能力如果让你自己在 JS/TS 生态里复现基本不现实因为背后是模型推理不是正则表达式能搞定的。MinerU 的价值就在于把这一大堆模型和预处理逻辑打包成了一个可调用服务JS/TS 开发者不需要懂 Python 也不需要管模型权重只需要通过接口把 PDF 传进去拿回结构化结果。1.3 JS/TS 封装层为什么值得关注MinerU 底层是 Python 实现这本身对很多团队不是问题但放到一个以 Node.js 和 TypeScript 为主的技术栈里直接引入 Python 服务会带来部署和联调成本。SDK 封装层解决的就是这件事。它把 HTTP 通信、任务状态轮询、错误重试、参数默认值全部都封装好了你在 TypeScript 项目里只需要import一个客户端就像调用一个普通异步函数一样去解析 PDF。这套封装特别适合这些场景前端需要上传 PDF 并实时展示解析结果Node.js 后端需要把文档解析做成一个独立微服务Electron 或桌面工具需要集成文档结构化能力。技术栈保持统一团队里前端同学也能很舒服地参与进来。2. 部署与接入把 MinerU 服务跑起来建立 TS 通信链路2.1 服务端部署容器化是最省心的路径MinerU 服务端依赖 Python 运行环境和一堆模型文件手动装环境非常折腾。我强烈建议直接用容器镜像跑把环境依赖都打包好一条命令就能拉起来。我本地开发环境用的命令大概是这样docker run -d --name mineru-api -p 8000:8000 \ -v ./models:/app/models \ -e MINERU_MODEL_CACHE/app/models \ mineru/mineru-api:latest需要注意两个地方模型目录要挂载到宿主机。第一次启动时服务会下载模型如果不挂载容器的临时层会被清除第二次启动又要重新下载非常浪费时间。端口按需映射。如果是在服务器上部署建议反代到 HTTPS 域名SDK 端配置域名地址即可。开发机上没有 GPU 也没关系CPU 模式能跑只是速度慢一些。我手上的测试文档一份 20 页的 PDFCPU 模式大概需要 40 到 60 秒GPU 大概只要 5 秒。开发调试阶段 CPU 足够上生产前再考虑 GPU 实例。2.2 SDK 客户端初始化超时和重试第一天就配好安装 SDK 包之后客户端初始化看起来很简单import { MinerUClient } from mineru/js-sdk; const client new MinerUClient({ baseURL: process.env.MINERU_API_URL || http://127.0.0.1:8000, timeoutMs: 120_000, maxRetries: 3, concurrency: 2, });这里有几个参数不是随便写的我踩过坑timeoutMs一定要设大。PDF 解析不是毫秒级接口一份 50 页的文档模型推理加上可能的 OCR几十秒很正常。如果沿用默认的 5 秒超时基本上每次调用都会失败。120 秒是最低起步值。maxRetries也要设。解析任务的失败可能是瞬时的比如模型加载的启动阶段、网络抖动重试三次能有效消化这类问题。但要注意解析任务本身是幂等的重试不会产生副作用可以放心开。concurrency是 SDK 客户端内部的并发控制限制同时发起的解析任务数。这个后面讲服务端架构时还会展开。2.3 最小链路验证跑通第一个解析任务初始化完成后写一个最小 demo 验证整个链路是很有必要的。不要一上来就接复杂的业务代码。下面这段 TypeScript 代码把本地的 PDF 文件传进去等任务完成把 Markdown 结果写出来import fs from node:fs/promises; import { MinerUClient } from mineru/js-sdk; const client new MinerUClient({ baseURL: http://127.0.0.1:8000, timeoutMs: 120_000, }); async function parseDemo() { const pdfBuffer await fs.readFile(./sample.pdf); const task await client.createTask({ file: pdfBuffer, filename: sample.pdf, options: { ocr: true, formats: [markdown], }, }); const result await client.waitForTask(task.id, { pollIntervalMs: 2_000, timeoutMs: 300_000, }); await fs.writeFile(./output.md, result.markdown); console.log(解析完成输出文件 output.md); } parseDemo().catch(console.error);如果这个 demo 能跑通说明部署服务、SDK 配置、基础通信链路都没有问题可以进入下一步了解更细的 API 逻辑。3. 核心 API 使用逻辑任务创建、轮询与结果取回3.1 为什么是任务式接口而不是同步返回如果你之前只写过普通的 REST 接口第一次接触 MinerU 的任务式接口可能会觉得多此一举。但文档解析恰恰是最不适合同步返回的场景。设想一下一个 HTTP 请求发出去后端需要经历完整的模型推理流程几十秒之后才返回结果。中间任何一步网络超时、代理断连、客户端重启整个解析过程都白白浪费了。服务端服务也有可能被大量同步请求拖垮。任务式接口的逻辑是先创建一个解析任务服务端立刻返回taskId客户端可以用这个 ID 不断查询任务状态等状态变成succeeded之后再拉取结果。这样做有几个很明显的好处客户端和服务端的生命周期解耦任务在服务端跑客户端哪怕重启了任务也不会丢可以灵活做并发控制不会因为同步阻塞把连接池打满方便做队列多个解析任务可以排队执行。3.2 四种典型输出格式的选用策略MinerU 支持不止一种输出格式我用的 SDK 封装里创建任务时可以指定需要的格式。不同格式对应不同使用需求输出格式适用场景使用时的注意事项MarkdownRAG 知识库、文档摘要、内容提取保留阅读顺序和标题层级最适合直接进向量库JSON需要精确处理文档结构的场景包含区块类型、坐标、层级关系适合二次程序化处理纯文本只需要文字本身时速度快但会丢失结构和排版信息图像裁剪需要单独提取图表或公式时按区块返回图片占存储空间按需开启我的经验是大多数知识库场景直接输出 Markdown 就够用了。只有当你要做精细的文档结构化比如把每个标题下的正文、表格、图片分别保存到不同字段时才需要配合 JSON 使用。图像裁剪比较吃存储除非你的业务确实需要把表格或插图单独存文件否则不建议默认开启。调用时的配置大概是这样的const task await client.createTask({ file: pdfBuffer, filename: paper.pdf, options: { formats: [markdown, json], outputImageBlocks: false, }, });3.3 参数细节OCR 开关、语言检测和表格识别MinerU 有几个关键参数会直接影响解析质量这里逐个说一下我调参后的心得。OCR 开关。对于数字化生成的 PDF也就是文字本身可以选中的那种OCR 开不开其实区别不大开了反而会增加耗时。但扫描版的 PDF本质上是一张张图片不开 OCR 就一个字都提取不出来。我建议的做法是分文件类型控制而不是全局统一设置options: { ocr: isScannedPdf ? true : false, }怎么判断一个 PDF 是不是扫描版可以看文件大小和页数如果一份几十页的文档文件只有几 MB很可能就是图片型 PDF。更准确的方式是提取第一页的文字如果整页找不出几个可索引的文本节点基本可以判定需要 OCR。语言检测。默认的auto模式对中英文混排文档通常表现不错但如果你的文档是纯英文显式指定语言能稍微提升识别速度和准确率。反过来如果文档是中文且包含少量英文让它自动检测也没问题。表格识别。表格是 PDF 解析的最大难点之一XML 或简单文本提取出来的表格基本都是乱的。MinerU 的表格识别能力相对靠谱会把表格结构重建为 Markdown 表格行。代价是增加一定的推理耗时如果你的文档里基本没有表格可以关掉。我通常的配置模板options: { ocr: true, language: auto, enableTableRecognition: true, enableFormulaRecognition: true, }4. 实战搭一个生产可用的文档解析服务4.1 服务端架构接收请求、入队、回调单机调用 SDK 很容易但放到真实业务里你会面临几个问题多个用户同时上传文档、服务端解析速度跟不上请求速度、用户等待时间过长不知道进度。我搭的解析服务用的是很经典的三段式结构前端上传 PDF后端收到后立刻创建 MinerU 任务返回一个业务taskId给前端任务进入一个内存队列由后端的 worker 逐个消费调用 MinerU SDK 执行解析解析完成后把结果存到对象存储同时更新任务状态到数据库。用 Node.js 和 TypeScript 实现的话核心代码大概长这样import express from express; import multer from multer; import { MinerUClient } from mineru/js-sdk; const app express(); const upload multer({ storage: multer.memoryStorage() }); const mineru new MinerUClient({ baseURL: process.env.MINERU_API_URL!, timeoutMs: 180_000, concurrency: 2, }); const tasks new Mapstring, { status: string; result?: any }(); app.post(/api/parse, upload.single(file), async (req, res) { const taskId crypto.randomUUID(); tasks.set(taskId, { status: queued }); // 异步执行解析不阻塞请求 void runParse(taskId, req.file.buffer, req.file.originalname); res.json({ taskId }); }); async function runParse(taskId: string, buffer: Buffer, filename: string) { try { tasks.set(taskId, { status: parsing }); const task await mineru.createTask({ file: buffer, filename, options: { ocr: true, formats: [markdown] }, }); const result await mineru.waitForTask(task.id, { timeoutMs: 600_000, }); tasks.set(taskId, { status: done, result }); } catch (err) { tasks.set(taskId, { status: failed, error: String(err) }); } } app.get(/api/tasks/:id, (req, res) { const task tasks.get(req.params.id); if (!task) { return res.status(404).json({ error: task not found }); } res.json(task); }); app.listen(3000);这里有几个设计细节值得说明先返回 taskId 再异步执行请求不会被长时间挂住前端体验好很多用一个 Map 存储任务状态只适合单机演示。生产环境建议换 Redis但逻辑模型不变SDK 内部已经做了并发控制所以在MinerUClient初始化时把concurrency设为 2worker 再多也不会把 MinerU 服务打爆。4.2 并发控制别让 MinerU 服务变成性能瓶颈MinerU 解析任务是 CPU/GPU 密集型操作并发太高会导致单个任务变慢甚至内存被占满。我初期就是没注意这个一次性把 10 个任务并发发过去结果前 6 个任务挤在一起抢资源每个都慢了一倍不止。现在我的策略是分两层控制SDK 客户端层面concurrency设置为 2 或 3这是 MinerU 服务能舒服承受的并发数业务服务层面自己的 worker 队列数量控制在 1 个因为每一个 worker 并发发出一个任务SDK 的并发参数已经做了兜底。如果解析任务量特别大建议部署多个 MinerU 服务实例然后在 SDK 初始化时配置多节点负载均衡const client new MinerUClient({ nodes: [ { baseURL: http://mineru-1:8000 }, { baseURL: http://mineru-2:8000 }, ], concurrency: 4, });SDK 会在多个节点之间自动分配任务这样扩展起来非常方便。4.3 前端对接进度轮询与实际体验优化任务式接口意味着前端需要一个「进度反馈机制」。最简单的做法是轮询async function pollTask(taskId: string) { const res await fetch(/api/tasks/${taskId}); const data await res.json(); if (data.status done) { // 渲染 Markdown renderMarkdown(data.result.markdown); return; } if (data.status failed) { showError(data.error); return; } setTimeout(() pollTask(taskId), 2000); }两秒轮询一次对设备和服务器都很友好。如果服务端用了 SSE 或 WebSocket体验当然更好但对于大多数场景2 秒轮询已经完全够用。真正影响用户体验的是文件上传阶段的反馈。PDF 文件动辄几十 MB上传过程中要展示进度条后端要设置合理的大小限制。5. 生产环境踩坑记录文档解析服务最容易翻车的地方5.1 扫描版 PDF 与 OCR 选型的坑我遇到过一种情况有些 PDF 从表面看页面排版很正常但打开之后每个字符其实都是图片贴上去的典型的「伪 PDF」。这种文档你用纯文本模式解析拿到的是满屏换行符和空字符串。排查方式很简单解析完成后检查输出的 Markdown 字符数如果一份 10 页的 PDF 输出只有几十个字符那基本就是没有启用 OCR。解决方案是把 OCR 设为自动检测或者干脆对全量数据默认开启 OCR。代价是耗时增加但准确性更重要。另外一个容易忽略的问题是扫描件的方向。有些扫描件是歪的或者横着排的MinerU 内部有自动旋转校正我实际测试下来大部分情况都能处理但如果页面方向错得太离谱最好在进 MinerU 之前用图像预处理做一次手动纠偏。5.2 长文档的内存和超时问题这是我在生产环境遇到的最头疼的问题。第一次接一个 200 页的 PDF 进来进程直接内存飙升之后任务超时。MinerU 默认会把整个文档一次性加载进内存做版面分析。虽然单页处理逻辑上应该能释放但实际使用中大文档长期间驻留会让 Node 进程的内存持续涨。我的处理方案是这样的拆页处理在后端先把 PDF 按页拆分比如每 10 页一组分批提交给 MinerU 解析结果合并每组解析得到的 Markdown 按页序拼接加上分页标记。async function parseBigPdf(buffer: Buffer, pageCount: number) { const CHUNK_SIZE 10; const markdownChunks: string[] []; for (let start 0; start pageCount; start CHUNK_SIZE) { const end Math.min(start CHUNK_SIZE, pageCount); const chunk await splitPdfPages(buffer, start, end); // 返回这一组的 Buffer const result await mineru.parse({ file: chunk, filename: chunk.pdf }); markdownChunks.push(result.markdown); } return markdownChunks.join(\n\n--- 分页 ---\n\n); }这个方案也顺带解决了超时问题因为单次解析的数据量小了很多。5.3 文件名和编码任务静默失败的头号元凶很多开发者会栽在这个看起来完全不值一提的问题上。我在测试阶段发现上传一份文件名带中文的空格和括号的 PDF任务创建成功但状态一直卡在running最后超时。排查到最后问题出在 HTTP 上传文件名编码。MinerU SDK 内部默认按 UTF-8 处理文件名但如果前端框架在传输过程中对文件名做了 URL 编码而服务端没有正确解码就会拿到一串转义后的乱码文件名。乱码文件名在部分环境下会导致文件后缀名判断失败服务端把它当成了不支持的文件类型。解决办法是从源头统一文件名规范const safeFilename originalFilename .replace(/[\\/:*?|]/g, _) .replace(/\s/g, _) .normalize(NFC);在上传入口做一个清洗服务端也做一个二次清洗双保险。6. 性能对比与选型建议什么时候该用什么时候别硬上6.1 与纯 JS 解析库的对比我在一开始说过纯前端或纯 JS 的 PDF 解析方案在面临复杂版面时明显力不从心。拿一个实际的对比来看对比维度纯 JS 解析库MinerU部署成本零部署npm 安装即可需要单独运行服务容器部署解析速度秒级完成模型推理几秒到几十秒版面还原弱基本不做版面分析强自动识别多栏、页眉页脚表格与公式基本不可用结构化重建扫描件 OCR需要额外集成自带完整链路如果你的业务场景是「PDF 文件里文字很少简单提取一下就行」纯 JS 解析库更合适省心省力。但如果你做的是知识库、内容中台、文档数据库这种对内容质量有要求的业务MinerU 的版面还原能力几乎是刚需。6.2 与通用 OCR 服务的对比通用 OCR 方案擅长的是「把图片里的文字识别出来」但它不关心文档结构。一本 100 页的 PDF 丢进去你能拿回 100 页的识别文本但每一页的标题、正文、段落之间的关系是平的没有层级。MinerU 是「版面分析 OCR」它在识别文字之前先搞清楚每个区域是什么再把文字填回结构里。这个差异带来的实际价值是输出 Markdown 有标题层级有阅读顺序表格是表格代码块是代码块引用是引用。后续无论做展示还是喂给 AI质量都高一个档次。6.3 我的选型判断维度经过这两个项目的折腾我总结出一套自己的判断标准如果只需要「能搜到文字」纯 JS 库就够如果需要高质量内容还原特别是文档要进知识库或 RAG选 MinerU如果有大量扫描件明确要求能解析出表格和公式MinerU 是更实用的选择如果对实时性要求极高比如用户上传后要在 1 秒内返回结果那模型推理类方案都不合适得换思路。我自己的经验是解析质量这件事在文档处理链路里优先级非常高。前面做不好后面不管你接了什么模型、搭了什么检索流程效果都会打折。MinerU 在质量和可维护性之间找到了一个不错的平衡点而 JS/TS SDK 让这个能力真正融入了前端和 Node 生态不再是一个「只能在大模型项目里看到的 Python 工具」。最后分享一个小技巧正式接业务之前拿二十份不同类型、不同质量的 PDF 做一个批量压测把成功率和耗时数据记录下来这不光能帮你调参数也能让你对这套解析链路的上限和下限心里有数。文档解析这种水比较深的技术提前摸清底细后面能省掉很多不必要的麻烦。