我一开始其实就是想找个能把 PDF 干净利落转成 Markdown 的工具,给当时在做的知识库项目喂文档。折腾过好几个纯前端 PDF 解析库,结果要么文字顺序乱掉,要么表格和公式直接变成一堆乱码。后来看到有人提到 MinerU,又正好碰上 JS/TS SDK 这套封装,思路一下就通了:解析这种重活在服务端做,前端和 Node 服务只通过接口调用,三四十行代码就能把一个质量不错的 PDF 解析链路给串起来。
这篇文章就写给 JavaScript 和 TypeScript 开发者看。不管你是想给项目加一个“上传 PDF 自动提取内容”的功能,还是打算搭一套批量的文档解析服务,甚至想把文档转成结构化数据喂给大模型做 RAG,MinerU 这套 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 页的 PDF,CPU 模式大概需要 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 封装里,创建任务时可以指定需要的格式。不同格式对应不同使用需求:
| 输出格式 | 适用场景 | 使用时的注意事项 |
|---|---|---|
| Markdown | RAG 知识库、文档摘要、内容提取 | 保留阅读顺序和标题层级,最适合直接进向量库 |
| 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 Map<string, { 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设为 2,worker 再多也不会把 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 做一个批量压测,把成功率和耗时数据记录下来,这不光能帮你调参数,也能让你对这套解析链路的上限和下限心里有数。文档解析这种水比较深的技术,提前摸清底细,后面能省掉很多不必要的麻烦。