
Mineru Ocr
- 4 installs
- 196 repo stars
- Updated April 5, 2026
- cat-xierluo/suitagent
Converts local files, remote document URLs, or web pages to Markdown via MinerU, with OCR, table, and formula recognition.
About
Converts documents and web pages to Markdown using MinerU, defaulting to a login-free lightweight endpoint and switching to the standard API when a token is set. Developers use it for PDF-to-Markdown, OCR, table and formula recognition, and web content extraction.
- Auto mode: login-free endpoint or token-based standard API
- OCR with table and formula recognition, archive retention
Mineru Ocr by the numbers
- 4 all-time installs (skills.sh)
- Ranked #530 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/suitagent --skill mineru-ocrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 196 |
| Last updated | April 5, 2026 |
| Repository | cat-xierluo/suitagent ↗ |
What it does
Converts local files, remote document URLs, or web pages to Markdown via MinerU, with OCR, table, and formula recognition.
Files
MinerU PDF 转 Markdown
前置配置
默认 Auto 模式:未配置 Token 时,自动使用官方免登录轻量接口;配置 Token 后,自动切换到标准 API。
免配置快速使用
- 无需登录、无需创建
.env - 适合快速转换本地小文件或远程文档 URL
- 受官方轻量接口限制影响:按 IP 限频、单文件 10 MB、最多 20 页
升级到标准 API(可选)
如遇到以下情况,建议配置 Token:
- 文件超过 10 MB
- 页数超过 20 页
- 轻量接口遇到 IP 限频
- 需要提取网页 URL
- 希望更稳定地使用标准 API 配额
申请 API Token
1. 访问 https://mineru.net/apiManage/token 2. 注册/登录账号 3. 创建 API Token 并复制(格式:eyJ0eXAiOiJKV1QiLCJhbGc...)
配置 Token
方式一:让 AI 配置
"帮我配置 MinerU,Token 是:xxx"方式二:手动配置
cd .claude/skills/mineru-ocr/config
cp .env.example .env
nano .env # 填入 MINERU_API_TOKEN方式三:复用官方 CLI 已保存的 Token
如果你已经跑过官方 mineru-open-api auth,本 skill 也会尝试回退读取 ~/.mineru/config.yaml 中保存的 Token。
当前读取优先级为:
1. 本 skill 的 .claude/skills/mineru-ocr/config/.env 中的 MINERU_API_TOKEN 2. 环境变量 MINERU_API_TOKEN 3. 环境变量 MINERU_TOKEN 4. 官方 CLI 配置 ~/.mineru/config.yaml
Token 更新
按当前规则,Token 有效期 3 个月(约 90 天)。过期后转换失败(错误 401 或 Unauthorized)。
更新方法:告诉 AI "我的 MinerU Token 过期了,新的 Token 是:xxx"
---
功能说明
通过 MinerU 将文档转换为 Markdown 格式,支持:
- Auto 模式:无 Token 走免登录轻量接口,有 Token 走标准 API
- PDF、DOC、DOCX、PPT、PPTX、PNG、JPG、JPEG
- 本地文件、远程文档 URL、网页 URL
- OCR 文字识别
- 表格识别和保留
- 数学公式识别
- 结果归档,保留 Markdown、接口响应和相关图片资源,便于回溯
能力边界
| 场景 | 免登录轻量接口 | 标准 Token API |
|---|---|---|
| 本地 PDF / 图片 / Docx / Pptx | 支持 | 支持 |
| 远程文档 URL(PDF、图片、Doc/Docx、PPT/PPTx) | 支持 | 支持 |
| 网页 URL / HTML | 不支持 | 支持 |
| 单文件大小 | 10 MB 内 | 200 MB 内 |
| 页数限制 | 20 页内 | 600 页内 |
| 表格识别 | 不支持,按官方口径需升级到标准模式 | 支持 |
| 公式识别 | 不支持,按官方口径需升级到标准模式 | 支持 |
| 输出 | Markdown | Zip 结果包 + Markdown / JSON / 额外格式 |
轻量模式适用
- 初次使用、快速试跑
- 本地小文件
- 远程 PDF / Doc / Ppt 链接
- 不要求表格识别、公式识别
标准 API 适用
- 大文件、长文档
- 对表格、公式、复杂版面更敏感的场景
- 网页 URL 提取
- 希望获得更完整的结果包并保留更多中间结果
使用方法
/usr/bin/osascript -l JavaScript .claude/skills/mineru-ocr/scripts/convert.js "/path/to/file.pdf"
/usr/bin/osascript -l JavaScript .claude/skills/mineru-ocr/scripts/convert.js "https://cdn-mineru.openxlab.org.cn/demo/example.pdf"
/usr/bin/osascript -l JavaScript .claude/skills/mineru-ocr/scripts/convert.js "https://example.com/article"
/usr/bin/osascript -l JavaScript .claude/skills/mineru-ocr/scripts/convert.js checktoken配置选项
编辑 .claude/skills/mineru-ocr/config/.env:
| 选项 | 默认值 | 说明 |
|---|---|---|
| MINERU_API_TOKEN | 空 | 可选;填写后强制走标准 Token API |
| MINERU_ENABLE_OCR | true | 启用 OCR |
| MINERU_ENABLE_TABLE | true | 启用表格识别;主要对标准 Token API 生效 |
| MINERU_ENABLE_FORMULA | false | 启用公式识别;主要对标准 Token API 生效 |
| MINERU_LANGUAGE_CODE | ch | 语言代码 |
| MINERU_API_BASE | https://mineru.net/api/v4 | 标准 API 地址 |
| MINERU_MODEL_VERSION | pipeline | 标准 Token API 模型;法律文档建议默认 pipeline,复杂版面可改 vlm |
| MINERU_PAGE_RANGES | 空 | 标准 Token API 页码范围,如 1-20、2,4-6 |
| MINERU_POLL_MAX | 20 | 最大轮询次数 |
| MINERU_POLL_SLEEP | 10 | 轮询间隔(秒) |
| MINERU_LOG_LEVEL | medium | 日志等级 |
输出
- 本地文件:Markdown 保存到源文件同目录
- 远程文档 URL / 网页 URL:Markdown 默认保存到执行命令时的当前目录
- 归档:
.claude/skills/mineru-ocr/archive/日期_时间_文件名/ - 归档内容:转换得到的 Markdown、接口响应、原始输入文件,以及可下载的图片资源清单
法律文档建议
- 默认保持
MINERU_MODEL_VERSION=pipeline - 对起诉状、证据目录、法院通知、传票这类正式材料,优先追求稳定和低幻觉风险,不建议默认改成
vlm - 只有在扫描质量差、版面极复杂、表格密集时,再考虑切到
vlm - 如果只需要先看卷宗前几页,可设置
MINERU_PAGE_RANGES=1-20
Token 自检
当你想确认当前 Token 是否有效时,可运行:
/usr/bin/osascript -l JavaScript .claude/skills/mineru-ocr/scripts/convert.js checktoken- 未配置 Token:会提示当前处于轻量模式
- Token 有效:会提示标准 API 可用
- Token 失效:会明确提示重新申请与更新
云端模式 / 自定义地址
当前 skill 仅面向 官方云端 API。
如需走你自己的云端转发网关,且该网关 兼容官方 v4 API,可在 .env 中修改:
MINERU_API_BASE=https://your-gateway.example.com/api/v4当前脚本默认适配的是官方云端 v4 API 工作流。
如果你部署的是官方 mineru-api / mineru-router FastAPI 服务,它们主要暴露的是 /tasks、/file_parse 等接口,不在本 skill 当前支持范围内。这类场景建议:
- 优先使用官方 CLI / SDK 直连自建服务
- 本 skill 继续只维护云端模式,避免混入第二套协议
关于官方 Crawl
官方 skill 中的网页提取主要是通过 CLI 的 mineru-open-api crawl <url> 实现的,属于 Token 模式能力,不是轻量接口能力。
这里的 CLI 是 官方提供的命令行封装层。CLI 底层仍然会调用 MinerU 的云端 API;它不是本地离线解析器。
你当前这个 skill 现在也支持网页 URL,但仅在 已配置 Token 时启用;未配置 Token 时,网页 URL 会提示用户改用标准 API。
故障排除
| 问题 | 解决方案 |
|---|---|
| 轻量接口限频 | 稍后重试,或配置 Token 切换到标准 API |
| 文件过大 / 页数过多 | 配置 Token,改走标准 API |
| 网页 URL 无法轻量解析 | 轻量接口不支持 HTML,请配置 Token |
| 401/Unauthorized | Token 已过期,重新申请并更新 |
| 转换超时 | 增加 MINERU_POLL_MAX 或检查文件大小 |
| 配额不足 | 检查 MinerU 账户额度 |
变更日志
本项目的所有重要变更都将记录在此文件。
[1.2.0] - 2026-03-31
新增
- 新增远程文档 URL 支持:可直接提交远程 PDF、Doc/Docx、PPT/PPTx、图片链接进行转换
- 新增网页 URL 支持:配置 Token 后可直接提取网页 URL
- 新增
checktoken自检入口,用于快速检查当前 Token 是否可用 - 新增
MINERU_MODEL_VERSION与MINERU_PAGE_RANGES,用于精准模式下控制模型与页码范围
改进
- 为轻量模式补充页数预检查,超过 20 页时提前提示切换到标准 API
- 为轻量模式成功结果增加升级提示,明确能力边界
- 更新 SKILL 文档,补充能力矩阵、官方 Crawl 说明与私有部署连接说明
- 将轻量模式的表格 / 公式能力说明收紧为按官方口径不支持,并明确本 skill 只维护云端协议
- 补充 Token 读取优先级说明,并支持回退读取官方 CLI 的
~/.mineru/config.yaml
技术优化
- 重构输入源识别逻辑,区分本地文件、远程文档 URL 与网页 URL
- 统一本地与远程输入的归档行为,继续保留 archive 回溯能力
- 修复
checktoken在网络不可达时只返回000的问题,改为输出可读的连接诊断信息 - 轻量模式请求体按官方口径收紧,不再附带表格 / 公式参数
[1.1.0] - 2026-03-31
新增
- 新增默认 Auto 模式:无 Token 时自动使用官方免登录轻量接口,有 Token 时自动切换到标准 API
- 为轻量接口结果补充归档:保留 Markdown、接口响应与图片资源下载清单,延续原有 archive 回溯能力
改进
- 将 SKILL 文档从“必须先配置 Token”调整为“默认开箱即用,超限再升级”
- 将 Token 有效期说明更新为 3 个月(约 90 天)
技术优化
- 删除
MINERU_USER_TOKEN及一批未被脚本读取的冗余配置项 - 将配置读取改为可选,不再因缺少
.env阻塞轻量模式使用 - 优化轻量接口对 IP 限频、单文件大小和页数限制的错误提示
[1.0.1] - 2025-01-07
改进
- 智能配置检测:脚本自动检测配置问题并给出友好提示
- Token 过期检测:自动识别 401/403 错误,提示 Token 过期
- 文档整合:将 SETUP.md 整合到 SKILL.md,简化文档结构
技术优化
- 添加配置文件存在性检查
- 添加 API Token 配置验证
- 添加 API 响应状态码检测(401/403)
- 优化错误提示信息,包含申请链接和配置方法
---
[1.0.0] - 2025-01-07
新增
- MinerU PDF 转 Markdown 转换器 Skill 初始版本
- 支持 PDF、DOC、DOCX、PPT、PPTX、PNG、JPG、JPEG 格式
- OCR 文字识别功能
- 表格识别和保留
- 数学公式识别(可选)
- 通过
.env文件的配置系统 - 转换结果的自动轮询
- 完整转换结果的归档系统
- 多语言支持(可配置语言代码)
技术实现
- 使用 MinerU API v4 进行文档转换
- 实现上传 → 轮询 → 下载工作流程
- 在源文件目录生成 Markdown 文件
- 将完整结果(图片、元数据、原始文件)归档到 Skill 内部
- 可配置的轮询参数(最大尝试次数、休眠间隔)
- 可调节的详细程度结构化日志
- 使用相对路径解析,兼容 JXA 环境
# ===== MinerU 标准 API(可选)=====
# 不填 Token 时,脚本默认走免登录轻量接口。
# 如需更大文件、更高页数、表格/公式等稳定能力,再配置标准 API Token。
MINERU_API_BASE=https://mineru.net/api/v4
MINERU_API_TOKEN=
# ===== 转换选项 =====
MINERU_ENABLE_OCR=true
MINERU_ENABLE_TABLE=true
MINERU_ENABLE_FORMULA=false
MINERU_LANGUAGE_CODE=ch
MINERU_MODEL_VERSION=pipeline
MINERU_PAGE_RANGES=
# ===== 轮询控制 =====
MINERU_POLL_MAX=20
MINERU_POLL_SLEEP=10
MINERU_LOG_LEVEL=medium
决策记录
(按日期倒序记录;同日决策按记录顺序排列。ID 规则:D-YYYY-MM-DD-序号)
D-2026-03-31-01 引入 Auto 双通道解析
- 日期: 2026-03-31
- 背景: 官方已提供免登录轻量接口,现有技能却仍强制用户先配置 Token,首次使用门槛过高。
- 决策: 默认采用 Auto 模式;未配置 Token 时使用官方免登录轻量接口,已配置 Token 时只走现有标准 API。
- 影响: 新用户可开箱即用;老用户既有 Token 配置与标准 API 归档链路保持不变。
- 状态: 已执行
D-2026-03-31-02 保留归档能力,不改造成纯官方 CLI 包装
- 日期: 2026-03-31
- 背景: 官方 skill 更偏向 API/CLI 包装,而现有技能的核心价值之一是 archive 回溯能力,包括 Markdown、接口结果和图片相关资源的保留。
- 决策: 不直接重写为官方 CLI 包装,继续保留现有脚本式调用与 archive 目录设计,只在入口层增加轻量接口分支。
- 影响: 技能仍保留本地可追溯性;轻量接口结果也会写入 archive,减少功能退化。
- 状态: 已执行
D-2026-03-31-03 清理冗余配置
- 日期: 2026-03-31
- 背景:
MINERU_USER_TOKEN和多项调试/锁配置未被实际使用,且官方 skill 也未暴露这些概念,容易造成配置误导。 - 决策: 从模板中移除
MINERU_USER_TOKEN及未使用配置,仅保留当前脚本真正会读取的最小配置集。 - 影响: 配置模板更清晰,减少复制模板后带着伪配置运行的风险。
- 状态: 已执行
D-2026-03-31-04 扩展远程输入源
- 日期: 2026-03-31
- 背景: 官方技能支持本地文件、远程文档 URL 和网页提取,而现有技能仅支持本地文件,覆盖面不足。
- 决策: 在保持当前 archive 设计的前提下,为现有脚本增加远程文档 URL 支持;网页 URL 仅在 Token 模式下启用。
- 影响: skill 使用场景扩展到“本地文件 + 远程文档 + 网页 URL”;同时保持轻量模式与标准模式的能力边界清晰。
- 状态: 已执行
D-2026-03-31-05 Token 自检采用诊断探测
- 日期: 2026-03-31
- 背景: 官方 CLI 提供
auth --verify,现有 skill 缺少类似诊断入口。 - 决策: 为脚本增加
checktoken诊断入口,通过携带 Authorization 访问标准 API 任务查询接口进行轻量诊断,不触发实际解析任务。 - 影响: 用户可更快判断 Token 是否有效;自检结果依赖 API 地址与网络可达性。
- 状态: 已执行
D-2026-03-31-06 云端优先,不适配自部署 FastAPI 协议
- 日期: 2026-03-31
- 背景: 官方自部署
mineru-api/mineru-router使用/tasks、/file_parse等接口,与云端api/v4协议不同;若同时兼容会显著增加维护复杂度。 - 决策: 当前 skill 只维护官方云端模式;自部署场景交由官方 CLI / SDK 处理,不在本 skill 内再接入第二套协议。
- 影响: skill 的使用路径更清晰;如后续确有需求,再单独设计
self-hosted模式。 - 状态: 已执行
D-2026-04-01-01 法律文档场景只补最有价值的精准参数
- 日期: 2026-04-01
- 背景: 官方精准 API 提供
model_version、page_ranges、extra_formats、callback等多项高级参数,但当前 skill 面向法律文档 OCR 与归档回溯,不适合一次性做成全量参数面板。 - 决策: 仅补充
MINERU_MODEL_VERSION与MINERU_PAGE_RANGES。默认模型保持pipeline,只在复杂版面场景下允许切换到vlm;其余高级参数暂不接入。 - 影响: skill 保持简洁,同时为法律场景提供两个最实用的精准控制开关。
- 状态: 已执行
D-2026-04-01-02 Token 读取兼容官方 CLI,但本 skill 配置优先
- 日期: 2026-04-01
- 背景: 官方 skill / CLI 会把 Token 存到
~/.mineru/config.yaml;若当前 skill 不兼容读取,用户在两套入口之间切换时需要重复配置。 - 决策: 增加对
~/.mineru/config.yaml的回退读取,但保持本 skill 的.env和环境变量优先,避免覆盖现有本地配置。 - 影响: 已使用官方
mineru-open-api auth的用户可以无缝复用 Token;你自己的.envToken 不受影响。 - 状态: 已执行
D-2026-01-07-01 规范文档结构
- 日期: 2026-01-07
- 背景: mineru-ocr 技能缺少 AGENTS.md 规范要求的 DECISIONS.md 和 TASKS.md 文档。
- 决策: 补充 DECISIONS.md 记录设计决策,补充 TASKS.md 记录任务跟踪,为后续升级维护做准备。
- 影响: 提升技能的可维护性和可追溯性,便于未来功能迭代。
- 状态: 已执行
D-2025-01-07-02 使用 config/ 目录存储配置
- 日期: 2026-01-07(追溯决策)
- 背景: 技能需要存储 API Token 等敏感配置信息,不应硬编码在脚本中。
- 决策: 使用
config/目录存储配置文件,包含.env.example模板和.env实际配置,避免将敏感信息提交到版本控制。 - 影响: 提升安全性,用户可自行配置而不需修改代码;
.env应加入.gitignore。 - 状态: 已执行
D-2025-01-07-03 保留 archive/ 归档目录
- 日期: 2026-01-07(追溯决策)
- 背景: 转换任务会产生中间文件(JSON、图片、原始文件),这些文件对调试和历史追溯有价值。
- 决策: 在技能目录内保留
archive/目录,按日期_时间_文件名/格式归档每次转换的完整数据。 - 影响: 便于问题排查和结果追溯;归档目录可能占用较大空间,需要定期清理。
- 状态: 已执行
D-2025-01-07-04 选择 MinerU API
- 日期: 2026-01-07(追溯决策)
- 背景: 需要将 PDF、Word、PPT 等文档高质量转换为 Markdown,要求保留表格、公式等复杂格式。
- 决策: 选择 MinerU API 服务,因其支持 OCR、表格识别、公式识别,且转换质量高。
- 影响: 依赖外部 API 服务;按当前规则,Token 有效期为 90 天,需要定期更新;无网络时无法使用。
- 状态: 已执行
D-2025-01-07-05 使用 JXA 脚本
- 日期: 2026-01-07(追溯决策)
- 背景: 需要调用 MinerU API 并处理轮询逻辑,要求跨平台兼容。
- 决策: 使用 JavaScript for Automation (JXA) 编写
scripts/convert.js,通过osascript在 macOS 上调用,便于 JSON 处理和 HTTP 请求。 - 影响: 依赖 macOS
osascript运行环境;跨平台兼容性需要考虑(当前主要针对 macOS)。 - 状态: 已执行
工作日志
2026-03-31 17:22 (Codex)
- 目标: 将 mineru-ocr 调整为默认开箱即用,同时保留现有标准 API 与 archive 设计。
- 操作: 对照官方轻量接口与现有脚本,新增 Auto 模式与轻量接口分支,清理
MINERU_USER_TOKEN和未使用配置项,更新 SKILL/TASKS/CHANGELOG 文档。 - 结果: 无 Token 场景可直接使用免登录轻量接口;有 Token 场景继续走现有标准 API;archive 回溯能力保留。
- 下一步: 可继续补充更细的轻量接口失败案例和 archive 清理策略。
2026-03-31 18:20 (Codex)
- 目标: 将输入源能力扩展到远程文档 URL,并补充 Token 自检与私有部署文档说明。
- 操作: 参照官方 Agent 轻量接口与标准 API 文档,新增远程 URL 路由、网页 URL 的 Token 模式支持、
checktoken自检入口,并更新能力边界文档。 - 结果: skill 现在可处理本地文件、远程文档 URL,以及 Token 模式下的网页 URL;同时明确了官方 crawl 与当前 skill 的关系。
- 下一步: 如需直连官方
mineru-api/mineru-router自建 FastAPI,可再单独增加 self-hosted 模式。
2026-03-31 19:05 (Codex)
- 目标: 修正
checktoken的可用性与错误提示,避免网络异常时只返回裸000。 - 操作: 为脚本补充
runShellResult(),将 Token 自检改为显式区分 Token 失效、网络不可达和 API 地址异常三类情况;同步更新任务状态与变更日志。 - 结果: 自检在无法连网或 API 不可达时会输出可读诊断,不再返回难以理解的
000。 - 下一步: 如需进一步增强,可把同类网络诊断逻辑扩展到正式转换链路。
2026-03-31 19:18 (Codex)
- 目标: 将能力边界与协议范围对齐到官方口径。
- 操作: 更新 SKILL 文档,明确轻量模式不支持表格/公式识别,并确认本 skill 只维护云端 API 模式,不接入自部署 FastAPI 协议。
- 结果: 用户对轻量模式和自部署范围的预期更准确,减少误配和误解。
- 下一步: 如需进一步贴近官方生态,可后续补充对
~/.mineru/config.yaml的 Token 兼容读取。
2026-04-01 00:06 (Codex)
- 目标: 为法律文档场景补充最有价值的精准模式高级参数。
- 操作: 在精准模式中新增
MINERU_MODEL_VERSION与MINERU_PAGE_RANGES,同时将轻量模式请求体收紧到官方文档定义范围,并更新配置模板与使用说明。 - 结果: 用户可在保持默认稳定性的前提下,按需切换
pipeline/vlm或只解析指定页码。 - 下一步: 如需进一步兼容官方生态,可考虑读取
~/.mineru/config.yaml中的 Token。
2026-04-01 00:20 (Codex)
- 目标: 兼容官方 CLI Token 存储,同时保留现有 skill 配置优先级。
- 操作: 为脚本增加
~/.mineru/config.yaml的回退读取逻辑,并在文档中写明 Token 读取优先级。 - 结果: 用户既可继续使用本 skill 自己的
.env,也可在未配置本 skill 时复用官方 CLI 已保存的 Token。 - 下一步: 如后续确认官方
config.yaml存在更多字段变体,可再补充更完整的 YAML 键兼容。
2026-04-01 00:31 (Codex)
- 目标: 清理本地
.env中的历史冗余配置,同时保留现有真实 Token。 - 操作: 删除
MINERU_USER_TOKEN、锁、调试、旧默认项等未使用键;保留标准 API、OCR/表格/公式、轮询和日志配置,并补入MINERU_MODEL_VERSION、MINERU_PAGE_RANGES。 - 结果: 本地配置与当前脚本读取逻辑完全对齐,不再存在历史遗留的伪配置项。
- 下一步: 如后续需要,可再根据实际使用习惯调整
MINERU_LOG_LEVEL或MINERU_PAGE_RANGES默认值。
MIT License
Copyright (c) 2025 MinerU Skill Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Third-Party Dependencies
This Skill utilizes the following third-party services:
- **MinerU API** (https://mineru.net) - Document conversion service
- Separate terms of service and pricing may apply
- Requires valid API token for operation
This license applies only to the Skill code and does not extend to the
underlying MinerU API service or its usage terms.
// MinerU PDF → Markdown Converter
// 默认 auto 模式:有 Token 走标准 API,无 Token 走免登录轻量接口
ObjC.import("Foundation");
ObjC.import("stdlib");
function readTextFile(path) {
const data = $.NSData.dataWithContentsOfFile(path);
if (!data) {
return "";
}
return ObjC.unwrap($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding)) || "";
}
function runShellResult(cmd) {
const sq = (value) => "'" + String(value).replace(/'/g, "'\\''") + "'";
const nonce = `${Date.now()}_${Math.floor(Math.random() * 1000000)}`;
const outPath = `/tmp/mineru_jxa_stdout_${nonce}.txt`;
const errPath = `/tmp/mineru_jxa_stderr_${nonce}.txt`;
const statusPath = `/tmp/mineru_jxa_status_${nonce}.txt`;
const wrapped = `( ${cmd} ) > ${sq(outPath)} 2> ${sq(errPath)}; printf %s $? > ${sq(statusPath)}`;
const systemStatus = $.system(`/bin/zsh -lc ${sq(wrapped)}`);
void systemStatus;
const outputText = readTextFile(outPath);
const errorText = readTextFile(errPath);
const statusText = readTextFile(statusPath).trim();
const exitCode = parseInt(statusText || "1", 10);
return {
stdout: String(outputText).replace(/\n$/, ""),
stderr: String(errorText).replace(/\n$/, ""),
exitCode: isNaN(exitCode) ? 1 : exitCode
};
}
function runShell(cmd) {
const result = runShellResult(cmd);
if (result.exitCode !== 0) {
throw new Error((result.stderr || result.stdout || `shell failed (${result.exitCode})`).trim());
}
return result.stdout;
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function getSkillRoot(sh) {
const projectRoot = sh("pwd").trim();
return projectRoot + "/.claude/skills/mineru-ocr";
}
function loadConfig(skillRoot) {
const sh = runShell;
const envPath = skillRoot + "/config/.env";
const envExists = sh(`/bin/test -f ${shellQuote(envPath)} && echo 1 || echo 0`).trim() === "1";
const config = {
__envPath: envPath,
__envExists: envExists
};
if (!envExists) {
return config;
}
const envContent = sh('/bin/cat ' + shellQuote(envPath) + ' 2>/dev/null || echo ""');
if (!envContent) {
throw new Error("无法读取配置文件: " + envPath);
}
const lines = envContent.match(/[^\r\n]+/g) || [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const equalIndex = trimmed.indexOf("=");
if (equalIndex <= 0) {
continue;
}
const key = trimmed.substring(0, equalIndex).trim();
let value = trimmed.substring(equalIndex + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
config[key] = value;
}
return config;
}
function sanitizeConfigValue(value) {
const text = String(value || "").trim();
if (!text) {
return "";
}
const lowered = text.toLowerCase();
if (lowered === "your_token_here" ||
lowered === "your_mineru_api_token_here" ||
lowered.indexOf("example") > -1) {
return "";
}
return text;
}
function parseBoolean(value, fallback) {
if (typeof value === "undefined" || value === null || String(value).trim() === "") {
return fallback;
}
const lowered = String(value).trim().toLowerCase();
if (["true", "1", "yes", "on"].indexOf(lowered) > -1) {
return true;
}
if (["false", "0", "no", "off"].indexOf(lowered) > -1) {
return false;
}
return fallback;
}
function parseInteger(value, fallback) {
const parsed = parseInt(value, 10);
return isNaN(parsed) ? fallback : parsed;
}
function normalizePageRanges(value) {
const text = String(value || "").trim();
if (!text) {
return "";
}
return text.replace(/\s+/g, "");
}
function resolveTokenModelVersion(configuredValue, sourceType) {
if (sourceType === "remote_html_url") {
return "MinerU-HTML";
}
const value = String(configuredValue || "").trim();
if (value === "vlm") {
return "vlm";
}
return "pipeline";
}
function isHttpUrl(value) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function hasKnownExtension(name, exts) {
const lowered = String(name || "").toLowerCase();
for (let i = 0; i < exts.length; i++) {
if (lowered.endsWith("." + exts[i])) {
return true;
}
}
return false;
}
function extractPathWithoutQuery(url) {
return String(url || "").split("?")[0].split("#")[0];
}
function deriveNameFromUrl(url) {
const path = extractPathWithoutQuery(url);
const parts = path.split("/");
return parts[parts.length - 1] || "remote-document";
}
function sanitizeFileName(name, fallback) {
const cleaned = String(name || "")
.replace(/[^0-9A-Za-z._\-\u4e00-\u9fff]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || fallback;
}
function isHtmlLikeUrl(url) {
const path = extractPathWithoutQuery(url).toLowerCase();
if (path.endsWith(".html") || path.endsWith(".htm")) {
return true;
}
const docExts = ["pdf", "doc", "docx", "ppt", "pptx", "png", "jpg", "jpeg", "jp2", "webp", "gif", "bmp", "xls", "xlsx"];
return !hasKnownExtension(path, docExts);
}
function zeroPad(num) {
return num < 10 ? "0" + num : String(num);
}
function buildArchiveSubDir(skillRoot, baseNameNoExt) {
const now = new Date();
const dateStr = now.getFullYear() + zeroPad(now.getMonth() + 1) + zeroPad(now.getDate());
const timeStr = zeroPad(now.getHours()) + zeroPad(now.getMinutes()) + zeroPad(now.getSeconds());
return `${skillRoot}/archive/${dateStr}_${timeStr}_${baseNameNoExt}`;
}
function writeTextFile(sh, filePath, content) {
sh(`/usr/bin/printf %s ${shellQuote(content)} > ${shellQuote(filePath)}`);
}
function writeJsonFile(sh, filePath, payload) {
writeTextFile(sh, filePath, JSON.stringify(payload, null, 2));
}
function readOfficialCliToken(sh) {
const homeDir = sanitizeConfigValue(sh("/usr/bin/printenv HOME 2>/dev/null || true"));
if (!homeDir) {
return "";
}
const yamlPath = `${homeDir}/.mineru/config.yaml`;
const exists = sh(`/bin/test -f ${shellQuote(yamlPath)} && echo 1 || echo 0`).trim() === "1";
if (!exists) {
return "";
}
const yamlContent = sh(`/bin/cat ${shellQuote(yamlPath)} 2>/dev/null || echo ""`);
if (!yamlContent) {
return "";
}
const patterns = [
/^\s*token\s*:\s*["']?([^"'#\r\n]+)["']?\s*$/m,
/^\s*api_token\s*:\s*["']?([^"'#\r\n]+)["']?\s*$/m,
/^\s*mineru_token\s*:\s*["']?([^"'#\r\n]+)["']?\s*$/m
];
for (let i = 0; i < patterns.length; i++) {
const match = yamlContent.match(patterns[i]);
if (match && match[1]) {
const token = sanitizeConfigValue(match[1]);
if (token) {
return token;
}
}
}
return "";
}
function resolveApiToken(sh, config) {
const configuredToken = sanitizeConfigValue(config.MINERU_API_TOKEN);
if (configuredToken) {
return configuredToken;
}
const envToken = sanitizeConfigValue(sh("/usr/bin/printenv MINERU_API_TOKEN 2>/dev/null || true"));
if (envToken) {
return envToken;
}
const fallbackEnvToken = sanitizeConfigValue(sh("/usr/bin/printenv MINERU_TOKEN 2>/dev/null || true"));
if (fallbackEnvToken) {
return fallbackEnvToken;
}
return readOfficialCliToken(sh);
}
function buildTokenSetupHelp(skillRoot, reason) {
return "\n===============================================\n" +
"建议配置 MinerU Token\n" +
"===============================================\n" +
(reason ? reason + "\n\n" : "") +
"当前默认使用免登录轻量接口,适合快速转换;如遇到限流、大文件或页数超限,请切换到标准 API。\n\n" +
"配置方法:\n" +
"1. 访问申请 Token:\n" +
" https://mineru.net/apiManage/token\n\n" +
"2. 告诉 AI:\"帮我配置 MinerU,Token 是:xxx\"\n\n" +
" 或手动配置:\n" +
" cp " + skillRoot + "/config/.env.example " + skillRoot + "/config/.env\n" +
" nano " + skillRoot + "/config/.env\n\n" +
"当前按最新规则记为:Token 有效期 3 个月(约 90 天)。\n";
}
function buildExpiredTokenHelp(skillRoot, httpStatus) {
return "\n===============================================\n" +
"MinerU API Token 无效或已过期\n" +
"===============================================\n" +
"HTTP 状态: " + httpStatus + "\n\n" +
"当前按最新规则记为:Token 有效期 3 个月(约 90 天)。\n\n" +
"解决方法:\n" +
"1. 访问重新申请 Token:\n" +
" https://mineru.net/apiManage/token\n\n" +
"2. 告诉 AI:\"我的 MinerU Token 过期了,新的 Token 是:xxx\"\n\n" +
" 或手动更新:\n" +
" nano " + skillRoot + "/config/.env\n";
}
function getAllowedExts(mode) {
if (mode === "light") {
return ["pdf", "docx", "pptx", "png", "jpg", "jpeg", "jp2", "webp", "gif", "bmp", "xls", "xlsx"];
}
return ["pdf", "doc", "docx", "ppt", "pptx", "png", "jpg", "jpeg"];
}
function tryGetLocalPdfPageCount(sh, filePath) {
try {
const mdlsOutput = sh(`/usr/bin/mdls -name kMDItemNumberOfPages -raw ${shellQuote(filePath)} 2>/dev/null || true`).trim();
const count = parseInteger(mdlsOutput, -1);
return count > 0 ? count : null;
} catch (error) {
return null;
}
}
function buildOutputInfo(sh, sourceType, sourceValue, baseNameNoExt) {
if (sourceType === "local_file") {
return {
outputDir: sh(`/usr/bin/dirname ${shellQuote(sourceValue)}`).trim(),
outputBaseName: baseNameNoExt
};
}
return {
outputDir: sh("pwd").trim(),
outputBaseName: sanitizeFileName(baseNameNoExt, "remote_document")
};
}
function buildSourceInfo(sh, source, mode, skillRoot) {
if (!source) {
throw new Error("未提供文件路径或 URL");
}
if (isHttpUrl(source)) {
const sourceUrl = String(source).trim();
const fileName = deriveNameFromUrl(sourceUrl);
const dotIndex = fileName.lastIndexOf(".");
const ext = dotIndex > -1 ? fileName.substring(dotIndex + 1).toLowerCase() : "";
const htmlLike = isHtmlLikeUrl(sourceUrl);
if (mode === "light" && htmlLike) {
throw new Error(buildTokenSetupHelp(skillRoot, "网页 URL 提取需要标准 Token API;免登录轻量接口仅支持远程文档 URL,不支持 HTML。"));
}
const baseNameNoExt = dotIndex > -1 ? fileName.substring(0, dotIndex) : fileName || "remote-document";
const output = buildOutputInfo(sh, htmlLike ? "remote_html_url" : "remote_doc_url", sourceUrl, baseNameNoExt);
return {
sourceType: htmlLike ? "remote_html_url" : "remote_doc_url",
sourceValue: sourceUrl,
fileName: sanitizeFileName(fileName || baseNameNoExt, htmlLike ? "web_page.html" : "remote_document"),
baseNameNoExt: output.outputBaseName,
ext: ext,
outputDir: output.outputDir,
sizeBytes: null,
pageCount: null
};
}
const filePath = source;
const fileExists = sh(`/bin/test -f ${shellQuote(filePath)} && echo 1 || echo 0`).trim();
if (fileExists !== "1") {
throw new Error(`文件不存在: ${filePath}`);
}
const fileName = sh(`/usr/bin/basename ${shellQuote(filePath)}`).trim();
const dotIndex = fileName.lastIndexOf(".");
const ext = dotIndex > -1 ? fileName.substring(dotIndex + 1).toLowerCase() : "";
const allowedExts = getAllowedExts(mode);
if (allowedExts.indexOf(ext) === -1) {
throw new Error(
`当前${mode === "light" ? "免登录轻量接口" : "标准 Token API"}不支持该文件类型: ${ext || "unknown"}。\n` +
`支持格式: ${allowedExts.join(", ")}`
);
}
const sizeBytes = parseInteger(sh(`/usr/bin/stat -f%z ${shellQuote(filePath)}`), 0);
const pageCount = ext === "pdf" ? tryGetLocalPdfPageCount(sh, filePath) : null;
if (mode === "light" && sizeBytes > 10 * 1024 * 1024) {
throw new Error(buildTokenSetupHelp(
skillRoot,
`当前文件大小约 ${(sizeBytes / 1024 / 1024).toFixed(2)} MB,已超过免登录轻量接口 10 MB 限制。`
));
}
if (mode === "light" && pageCount && pageCount > 20) {
throw new Error(buildTokenSetupHelp(
skillRoot,
`当前 PDF 共 ${pageCount} 页,已超过免登录轻量接口 20 页限制。`
));
}
const baseNameNoExt = dotIndex > -1 ? fileName.substring(0, dotIndex) : fileName;
const output = buildOutputInfo(sh, "local_file", filePath, baseNameNoExt);
return {
sourceType: "local_file",
sourceValue: filePath,
fileName: fileName,
baseNameNoExt: output.outputBaseName,
ext: ext,
outputDir: output.outputDir,
sizeBytes: sizeBytes,
pageCount: pageCount
};
}
function collectRemoteImageUrls(markdownContent) {
const urls = [];
const seen = {};
const regex = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/g;
let match;
while ((match = regex.exec(markdownContent)) !== null) {
const url = match[1];
if (!seen[url]) {
seen[url] = true;
urls.push(url);
}
}
return urls;
}
function safeAssetName(url, index) {
const cleanUrl = url.split("?")[0].split("#")[0];
const parts = cleanUrl.split("/");
let fileName = parts[parts.length - 1] || ("image_" + zeroPad(index + 1) + ".bin");
fileName = fileName.replace(/[^A-Za-z0-9._-]+/g, "_");
if (!fileName) {
fileName = "image_" + zeroPad(index + 1) + ".bin";
}
return zeroPad(index + 1) + "_" + fileName;
}
function downloadRemoteImages(sh, archiveSubDir, markdownContent) {
const urls = collectRemoteImageUrls(markdownContent);
const manifest = [];
if (!urls.length) {
return manifest;
}
const imageDir = `${archiveSubDir}/images`;
sh(`/bin/mkdir -p ${shellQuote(imageDir)}`);
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const assetName = safeAssetName(url, i);
const outPath = `${imageDir}/${assetName}`;
const http = sh(`/usr/bin/curl -s -L ${shellQuote(url)} -w '%{http_code}' -o ${shellQuote(outPath)}`).trim();
manifest.push({
url: url,
archive_path: `images/${assetName}`,
downloaded: http === "200",
http_code: http
});
}
return manifest;
}
function finalizeResult(sh, skillRoot, workDir, info, mdFile, extraMeta, log) {
const outputMdPath = `${info.outputDir}/${info.baseNameNoExt}.md`;
sh(`/bin/cp ${shellQuote(mdFile)} ${shellQuote(outputMdPath)}`);
log(`已保存 Markdown: ${outputMdPath}`, 2);
const archiveSubDir = buildArchiveSubDir(skillRoot, info.baseNameNoExt);
sh(`/bin/mkdir -p ${shellQuote(archiveSubDir)}`);
sh(`/bin/cp -R ${shellQuote(workDir)}/. ${shellQuote(archiveSubDir)}/`);
// 清理已解压的 zip、输入文件副本和 API 返回的原始文件以节省空间
sh(`/bin/rm -f ${shellQuote(archiveSubDir + "/result.zip")}`);
sh(`/bin/rm -f ${shellQuote(archiveSubDir + "/input." + info.ext)}`);
sh(`/usr/bin/find ${shellQuote(archiveSubDir)} -maxdepth 1 -name '*_origin.*' -delete`);
const archivedMdPath = `${archiveSubDir}/full.md`;
const mdExists = sh(`/bin/test -f ${shellQuote(archivedMdPath)} && echo 1 || echo 0`).trim() === "1";
let imageManifest = [];
if (mdExists) {
const archivedMdContent = sh(`/bin/cat ${shellQuote(archivedMdPath)}`);
imageManifest = downloadRemoteImages(sh, archiveSubDir, archivedMdContent);
}
// 将 full.md 改名为与输出文件一致的名称
const archivedMdNewPath = `${archiveSubDir}/${info.baseNameNoExt}.md`;
sh(`/bin/mv ${shellQuote(archivedMdPath)} ${shellQuote(archivedMdNewPath)}`);
writeJsonFile(sh, `${archiveSubDir}/conversion_meta.json`, {
mode: extraMeta.mode,
source_file: extraMeta.sourceFile,
output_markdown: outputMdPath,
archive_path: archiveSubDir,
detail: extraMeta.detail,
images: imageManifest
});
log(`已归档到: ${archiveSubDir}`, 2);
return {
success: true,
outputPath: outputMdPath,
archivePath: archiveSubDir,
mode: extraMeta.mode,
message: `成功转换 ${info.fileName} -> ${info.baseNameNoExt}.md(${extraMeta.mode === "token" ? "标准 Token API" : "免登录轻量接口"})` +
(extraMeta.mode === "light" ? "。提示:轻量模式适合快速使用,若遇到页数/体积/IP 限频,请配置 Token。" : "")
};
}
function convertLocalFileWithTokenApi(sh, skillRoot, filePath, info, options, log) {
const API_BASE = sanitizeConfigValue(options.apiBase) || "https://mineru.net/api/v4";
const API_TOKEN = options.apiToken;
const workDir = sh("/usr/bin/mktemp -d -t mineru_").trim();
const inputFile = `${workDir}/input.${info.ext}`;
try {
sh(`/bin/cp ${shellQuote(filePath)} ${shellQuote(inputFile)}`);
const dataId = `convert_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
const fileItem = {
name: info.fileName,
is_ocr: !!options.enableOcr,
data_id: dataId
};
if (options.pageRanges) {
fileItem.page_ranges = options.pageRanges;
}
const req1 = {
enable_formula: !!options.enableFormula,
language: options.languageCode,
enable_table: !!options.enableTable,
model_version: options.modelVersion,
files: [fileItem]
};
const resp1Path = `${workDir}/upload_ticket.json`;
const http1 = sh(`/usr/bin/curl -s -X POST ${shellQuote(API_BASE + "/file-urls/batch")} ` +
`-H ${shellQuote("Authorization: Bearer " + API_TOKEN)} ` +
`-H 'Content-Type: application/json' ` +
`--data-raw ${shellQuote(JSON.stringify(req1))} ` +
`-w '%{http_code}' -o ${shellQuote(resp1Path)}`).trim();
if (http1 !== "200" && http1 !== "201") {
const resp1 = sh(`/bin/cat ${shellQuote(resp1Path)} 2>/dev/null || echo ""`);
if (http1 === "401" || http1 === "403" || resp1.indexOf("Unauthorized") > -1 || resp1.indexOf("invalid") > -1) {
throw new Error(buildExpiredTokenHelp(skillRoot, http1));
}
throw new Error(`申请上传地址失败 (HTTP ${http1}): ${resp1}`);
}
const uploadTicket = JSON.parse(sh(`/bin/cat ${shellQuote(resp1Path)}`));
const batchId = uploadTicket.batch_id || (uploadTicket.data && uploadTicket.data.batch_id) || "";
const fileUrls = uploadTicket.file_urls || (uploadTicket.data && uploadTicket.data.file_urls) || [];
const ossHeaders = uploadTicket.headers || (uploadTicket.data && uploadTicket.data.headers) || [];
const uploadURLRaw = Array.isArray(fileUrls) && fileUrls.length > 0 ? fileUrls[0] : "";
if (!batchId || !uploadURLRaw) {
throw new Error(`API 响应缺少 batch_id 或 file_urls: ${JSON.stringify(uploadTicket)}`);
}
let uploadURL = uploadURLRaw;
try {
uploadURL = JSON.parse(uploadURLRaw);
} catch (error) {
uploadURL = uploadURLRaw;
}
uploadURL = String(uploadURL).replace(/[\n\r\t]+/g, " ");
let headerFlags = "";
if (Array.isArray(ossHeaders)) {
const parts = [];
for (let i = 0; i < ossHeaders.length; i++) {
const header = ossHeaders[i];
const key = Object.keys(header)[0];
const value = header[key];
if (key && typeof value !== "undefined") {
parts.push(`-H ${shellQuote(key + ": " + value)}`);
}
}
headerFlags = parts.join(" ");
}
const uploadHttp = sh(`/usr/bin/curl -s -X PUT ${headerFlags} -T ${shellQuote(inputFile)} ${shellQuote(uploadURL)} -w '%{http_code}'`).trim();
if (uploadHttp !== "200" && uploadHttp !== "201") {
throw new Error(`文件上传失败 (HTTP ${uploadHttp})`);
}
log("已上传到标准 Token API,开始轮询结果...", 2);
const pollURL = `${API_BASE}/extract-results/batch/${batchId}`;
const pollRespPath = `${workDir}/token_poll.json`;
let pollCount = 0;
let resultUrl = "";
while (pollCount < options.pollMax && !resultUrl) {
sh(`/bin/sleep ${options.pollSleep}`);
pollCount += 1;
sh(`/usr/bin/curl -s ${shellQuote(pollURL)} -H ${shellQuote("Authorization: Bearer " + API_TOKEN)} -o ${shellQuote(pollRespPath)}`);
const pollResponse = JSON.parse(sh(`/bin/cat ${shellQuote(pollRespPath)}`));
const resultList = (pollResponse.data && pollResponse.data.extract_result) || [];
if (Array.isArray(resultList) && resultList.length > 0) {
let doneItem = null;
let failedItem = null;
for (let i = 0; i < resultList.length; i++) {
const item = resultList[i];
if (item && item.state === "done" && item.full_zip_url) {
doneItem = item;
break;
}
if (item && item.state === "failed") {
failedItem = item;
}
}
if (doneItem) {
resultUrl = doneItem.full_zip_url;
break;
}
if (failedItem) {
throw new Error(`MinerU 处理失败: ${failedItem.err_msg || "未知错误"}`);
}
if (pollCount % 10 === 0) {
log(`标准 Token API 处理中 (${pollCount}/${options.pollMax})`, 2);
}
}
}
if (!resultUrl) {
throw new Error(`处理超时,已尝试 ${pollCount} 次`);
}
const resultFile = `${workDir}/result.zip`;
sh(`/usr/bin/curl -s -L -o ${shellQuote(resultFile)} ${shellQuote(resultUrl)}`);
sh(`cd ${shellQuote(workDir)} && /usr/bin/unzip -q result.zip`);
let discoveredMdFile = sh(`/usr/bin/find ${shellQuote(workDir)} -name "*.md" -type f | /usr/bin/head -1`).trim();
if (!discoveredMdFile) {
throw new Error("未找到 Markdown 文件");
}
const mdFile = `${workDir}/full.md`;
if (discoveredMdFile !== mdFile) {
sh(`/bin/cp ${shellQuote(discoveredMdFile)} ${shellQuote(mdFile)}`);
}
return finalizeResult(sh, skillRoot, workDir, info, mdFile, {
mode: "token",
sourceFile: filePath,
detail: {
api_base: API_BASE,
batch_id: batchId,
result_zip_url: resultUrl
}
}, log);
} finally {
sh(`/bin/rm -rf ${shellQuote(workDir)}`);
}
}
function convertRemoteUrlWithTokenApi(sh, skillRoot, sourceUrl, info, options, log) {
const API_BASE = sanitizeConfigValue(options.apiBase) || "https://mineru.net/api/v4";
const API_TOKEN = options.apiToken;
const workDir = sh("/usr/bin/mktemp -d -t mineru_url_").trim();
try {
const requestBody = {
url: sourceUrl,
language: options.languageCode,
is_ocr: !!options.enableOcr,
enable_table: !!options.enableTable,
enable_formula: !!options.enableFormula,
model_version: options.modelVersion
};
if (options.pageRanges && info.sourceType !== "remote_html_url") {
requestBody.page_ranges = options.pageRanges;
}
const createRespPath = `${workDir}/token_url_create.json`;
const createHttp = sh(`/usr/bin/curl -s -X POST ${shellQuote(API_BASE + "/extract/task")} ` +
`-H ${shellQuote("Authorization: Bearer " + API_TOKEN)} ` +
`-H 'Content-Type: application/json' ` +
`--data-raw ${shellQuote(JSON.stringify(requestBody))} ` +
`-w '%{http_code}' -o ${shellQuote(createRespPath)}`).trim();
const createBody = sh(`/bin/cat ${shellQuote(createRespPath)} 2>/dev/null || echo ""`);
if (createHttp === "401" || createHttp === "403") {
throw new Error(buildExpiredTokenHelp(skillRoot, createHttp));
}
if (createHttp !== "200" && createHttp !== "201") {
throw new Error(`创建远程 URL 解析任务失败 (HTTP ${createHttp}): ${createBody}`);
}
const createResp = JSON.parse(createBody);
if (createResp.code !== 0 || !createResp.data || !createResp.data.task_id) {
throw new Error(`创建远程 URL 解析任务失败: ${createResp.msg || createBody}`);
}
log("已提交到标准 Token API(URL 模式),开始轮询结果...", 2);
const pollRespPath = `${workDir}/token_url_poll.json`;
let pollCount = 0;
let resultUrl = "";
while (pollCount < options.pollMax && !resultUrl) {
sh(`/bin/sleep ${options.pollSleep}`);
pollCount += 1;
const pollHttp = sh(`/usr/bin/curl -s ${shellQuote(API_BASE + "/extract/task/" + createResp.data.task_id)} ` +
`-H ${shellQuote("Authorization: Bearer " + API_TOKEN)} ` +
`-w '%{http_code}' -o ${shellQuote(pollRespPath)}`).trim();
const pollBody = sh(`/bin/cat ${shellQuote(pollRespPath)} 2>/dev/null || echo ""`);
if (pollHttp === "401" || pollHttp === "403") {
throw new Error(buildExpiredTokenHelp(skillRoot, pollHttp));
}
if (pollHttp !== "200" && pollHttp !== "201") {
throw new Error(`查询远程 URL 任务失败 (HTTP ${pollHttp}): ${pollBody}`);
}
const pollResp = JSON.parse(pollBody);
const data = pollResp.data || {};
if (data.state === "done" && data.full_zip_url) {
resultUrl = data.full_zip_url;
break;
}
if (data.state === "failed") {
throw new Error(`MinerU 处理失败: ${data.err_msg || "未知错误"}`);
}
if (pollCount % 10 === 0) {
log(`标准 Token API(URL 模式)处理中 (${pollCount}/${options.pollMax})`, 2);
}
}
if (!resultUrl) {
throw new Error(`处理超时,已尝试 ${pollCount} 次`);
}
const resultFile = `${workDir}/result.zip`;
sh(`/usr/bin/curl -s -L -o ${shellQuote(resultFile)} ${shellQuote(resultUrl)}`);
sh(`cd ${shellQuote(workDir)} && /usr/bin/unzip -q result.zip`);
const discoveredMdFile = sh(`/usr/bin/find ${shellQuote(workDir)} -name "*.md" -type f | /usr/bin/head -1`).trim();
if (!discoveredMdFile) {
throw new Error("未找到 Markdown 文件");
}
const mdFile = `${workDir}/full.md`;
sh(`/bin/cp ${shellQuote(discoveredMdFile)} ${shellQuote(mdFile)}`);
return finalizeResult(sh, skillRoot, workDir, info, mdFile, {
mode: "token",
sourceFile: sourceUrl,
detail: {
api_base: API_BASE,
task_id: createResp.data.task_id,
result_zip_url: resultUrl,
source_type: info.sourceType
}
}, log);
} finally {
sh(`/bin/rm -rf ${shellQuote(workDir)}`);
}
}
function normalizeLightFailure(skillRoot, httpStatus, rawBody, detail) {
const body = String(rawBody || "");
const reason = String(detail || body || "").toLowerCase();
if (httpStatus === "429" || reason.indexOf("429") > -1 || reason.indexOf("rate") > -1 || reason.indexOf("频率") > -1) {
return buildTokenSetupHelp(skillRoot, "免登录轻量接口当前触发 IP 限频,请稍后重试或改用标准 Token API。");
}
if (reason.indexOf("10mb") > -1 || reason.indexOf("20 page") > -1 || reason.indexOf("20页") > -1 ||
reason.indexOf("file too large") > -1 || reason.indexOf("page") > -1 || reason.indexOf("size") > -1) {
return buildTokenSetupHelp(skillRoot, "免登录轻量接口命中了单文件大小或页数限制,请配置 Token 后重试。");
}
return null;
}
function convertLocalFileWithLightApi(sh, skillRoot, filePath, info, options, log) {
const LIGHT_API_BASE = "https://mineru.net/api/v1/agent";
const workDir = sh("/usr/bin/mktemp -d -t mineru_light_").trim();
const inputFile = `${workDir}/input.${info.ext}`;
try {
sh(`/bin/cp ${shellQuote(filePath)} ${shellQuote(inputFile)}`);
const submitReq = {
file_name: info.fileName,
language: options.languageCode,
is_ocr: !!options.enableOcr
};
const submitRespPath = `${workDir}/light_submit.json`;
const submitHttp = sh(`/usr/bin/curl -s -X POST ${shellQuote(LIGHT_API_BASE + "/parse/file")} ` +
`-H 'Content-Type: application/json' ` +
`--data-raw ${shellQuote(JSON.stringify(submitReq))} ` +
`-w '%{http_code}' -o ${shellQuote(submitRespPath)}`).trim();
const submitBody = sh(`/bin/cat ${shellQuote(submitRespPath)} 2>/dev/null || echo ""`);
const submitHint = normalizeLightFailure(skillRoot, submitHttp, submitBody, "");
if (submitHint) {
throw new Error(submitHint);
}
if (submitHttp !== "200" && submitHttp !== "201") {
throw new Error(`免登录轻量接口提交失败 (HTTP ${submitHttp}): ${submitBody}`);
}
const submitResp = JSON.parse(submitBody);
if (submitResp.code !== 0 || !submitResp.data) {
const detail = submitResp.msg || submitResp.message || submitBody;
const normalized = normalizeLightFailure(skillRoot, submitHttp, submitBody, detail);
if (normalized) {
throw new Error(normalized);
}
throw new Error(`免登录轻量接口提交失败: ${detail}`);
}
const taskId = submitResp.data.task_id || "";
const uploadUrl = submitResp.data.file_url || "";
if (!taskId || !uploadUrl) {
throw new Error(`免登录轻量接口响应缺少 task_id 或 file_url: ${submitBody}`);
}
const uploadHttp = sh(`/usr/bin/curl -s -X PUT -T ${shellQuote(inputFile)} ${shellQuote(uploadUrl)} -w '%{http_code}'`).trim();
if (uploadHttp !== "200" && uploadHttp !== "201") {
throw new Error(`免登录轻量接口文件上传失败 (HTTP ${uploadHttp})`);
}
log("已提交到免登录轻量接口,开始轮询结果...", 2);
const pollRespPath = `${workDir}/light_poll.json`;
let pollCount = 0;
let markdownUrl = "";
let finalPollResponse = null;
while (pollCount < options.pollMax && !markdownUrl) {
sh(`/bin/sleep ${options.pollSleep}`);
pollCount += 1;
const pollHttp = sh(`/usr/bin/curl -s ${shellQuote(LIGHT_API_BASE + "/parse/" + taskId)} -w '%{http_code}' -o ${shellQuote(pollRespPath)}`).trim();
const pollBody = sh(`/bin/cat ${shellQuote(pollRespPath)} 2>/dev/null || echo ""`);
const pollHint = normalizeLightFailure(skillRoot, pollHttp, pollBody, "");
if (pollHint) {
throw new Error(pollHint);
}
if (pollHttp !== "200" && pollHttp !== "201") {
throw new Error(`免登录轻量接口轮询失败 (HTTP ${pollHttp}): ${pollBody}`);
}
const pollResp = JSON.parse(pollBody);
finalPollResponse = pollResp;
if (pollResp.code !== 0 || !pollResp.data) {
const detail = pollResp.msg || pollResp.message || pollBody;
const normalized = normalizeLightFailure(skillRoot, pollHttp, pollBody, detail);
if (normalized) {
throw new Error(normalized);
}
throw new Error(`免登录轻量接口轮询失败: ${detail}`);
}
const state = pollResp.data.state || "";
if (state === "done" && pollResp.data.markdown_url) {
markdownUrl = pollResp.data.markdown_url;
break;
}
if (state === "failed") {
const detail = pollResp.data.err_msg || pollResp.data.msg || pollBody;
const normalized = normalizeLightFailure(skillRoot, pollHttp, pollBody, detail);
if (normalized) {
throw new Error(normalized);
}
throw new Error(`免登录轻量接口处理失败: ${detail}`);
}
if (pollCount % 10 === 0) {
log(`免登录轻量接口处理中 (${pollCount}/${options.pollMax})`, 2);
}
}
if (!markdownUrl) {
throw new Error(`免登录轻量接口处理超时,已尝试 ${pollCount} 次`);
}
const mdFile = `${workDir}/full.md`;
const mdHttp = sh(`/usr/bin/curl -s -L ${shellQuote(markdownUrl)} -w '%{http_code}' -o ${shellQuote(mdFile)}`).trim();
if (mdHttp !== "200" && mdHttp !== "201") {
throw new Error(`下载免登录轻量接口 Markdown 失败 (HTTP ${mdHttp})`);
}
if (finalPollResponse) {
writeJsonFile(sh, `${workDir}/light_result.json`, finalPollResponse);
}
return finalizeResult(sh, skillRoot, workDir, info, mdFile, {
mode: "light",
sourceFile: filePath,
detail: {
task_id: taskId,
markdown_url: markdownUrl
}
}, log);
} finally {
sh(`/bin/rm -rf ${shellQuote(workDir)}`);
}
}
function convertRemoteUrlWithLightApi(sh, skillRoot, sourceUrl, info, options, log) {
const LIGHT_API_BASE = "https://mineru.net/api/v1/agent";
const workDir = sh("/usr/bin/mktemp -d -t mineru_light_url_").trim();
try {
const submitReq = {
url: sourceUrl,
file_name: info.fileName,
language: options.languageCode,
is_ocr: !!options.enableOcr
};
const submitRespPath = `${workDir}/light_url_submit.json`;
const submitHttp = sh(`/usr/bin/curl -s -X POST ${shellQuote(LIGHT_API_BASE + "/parse/url")} ` +
`-H 'Content-Type: application/json' ` +
`--data-raw ${shellQuote(JSON.stringify(submitReq))} ` +
`-w '%{http_code}' -o ${shellQuote(submitRespPath)}`).trim();
const submitBody = sh(`/bin/cat ${shellQuote(submitRespPath)} 2>/dev/null || echo ""`);
const submitHint = normalizeLightFailure(skillRoot, submitHttp, submitBody, "");
if (submitHint) {
throw new Error(submitHint);
}
if (submitHttp !== "200" && submitHttp !== "201") {
throw new Error(`免登录轻量接口 URL 提交失败 (HTTP ${submitHttp}): ${submitBody}`);
}
const submitResp = JSON.parse(submitBody);
if (submitResp.code !== 0 || !submitResp.data || !submitResp.data.task_id) {
throw new Error(`免登录轻量接口 URL 提交失败: ${submitResp.msg || submitBody}`);
}
log("已提交远程文档 URL 到免登录轻量接口,开始轮询结果...", 2);
const pollRespPath = `${workDir}/light_url_poll.json`;
let pollCount = 0;
let markdownUrl = "";
let finalPollResponse = null;
while (pollCount < options.pollMax && !markdownUrl) {
sh(`/bin/sleep ${options.pollSleep}`);
pollCount += 1;
const pollHttp = sh(`/usr/bin/curl -s ${shellQuote(LIGHT_API_BASE + "/parse/" + submitResp.data.task_id)} -w '%{http_code}' -o ${shellQuote(pollRespPath)}`).trim();
const pollBody = sh(`/bin/cat ${shellQuote(pollRespPath)} 2>/dev/null || echo ""`);
const pollHint = normalizeLightFailure(skillRoot, pollHttp, pollBody, "");
if (pollHint) {
throw new Error(pollHint);
}
if (pollHttp !== "200" && pollHttp !== "201") {
throw new Error(`免登录轻量接口 URL 轮询失败 (HTTP ${pollHttp}): ${pollBody}`);
}
const pollResp = JSON.parse(pollBody);
finalPollResponse = pollResp;
if (pollResp.code !== 0 || !pollResp.data) {
throw new Error(`免登录轻量接口 URL 轮询失败: ${pollResp.msg || pollBody}`);
}
if (pollResp.data.state === "done" && pollResp.data.markdown_url) {
markdownUrl = pollResp.data.markdown_url;
break;
}
if (pollResp.data.state === "failed") {
const normalized = normalizeLightFailure(skillRoot, pollHttp, pollBody, pollResp.data.err_msg || "");
if (normalized) {
throw new Error(normalized);
}
throw new Error(`免登录轻量接口 URL 解析失败: ${pollResp.data.err_msg || "未知错误"}`);
}
if (pollCount % 10 === 0) {
log(`免登录轻量接口 URL 处理中 (${pollCount}/${options.pollMax})`, 2);
}
}
if (!markdownUrl) {
throw new Error(`免登录轻量接口 URL 处理超时,已尝试 ${pollCount} 次`);
}
const mdFile = `${workDir}/full.md`;
const mdHttp = sh(`/usr/bin/curl -s -L ${shellQuote(markdownUrl)} -w '%{http_code}' -o ${shellQuote(mdFile)}`).trim();
if (mdHttp !== "200" && mdHttp !== "201") {
throw new Error(`下载免登录轻量接口 URL Markdown 失败 (HTTP ${mdHttp})`);
}
if (finalPollResponse) {
writeJsonFile(sh, `${workDir}/light_url_result.json`, finalPollResponse);
}
return finalizeResult(sh, skillRoot, workDir, info, mdFile, {
mode: "light",
sourceFile: sourceUrl,
detail: {
task_id: submitResp.data.task_id,
markdown_url: markdownUrl,
source_type: info.sourceType
}
}, log);
} finally {
sh(`/bin/rm -rf ${shellQuote(workDir)}`);
}
}
function verifyToken(sh, skillRoot, config) {
const apiToken = resolveApiToken(sh, config);
if (!apiToken) {
return "当前未配置 MinerU Token。默认仍可使用免登录轻量接口。";
}
const apiBase = sanitizeConfigValue(config.MINERU_API_BASE) || "https://mineru.net/api/v4";
const probeTaskId = "00000000-0000-0000-0000-000000000000";
const probeRespPath = `/tmp/mineru-token-verify-${Date.now()}.json`;
const probeResult = runShellResult(`/usr/bin/curl -sS --connect-timeout 10 --max-time 20 ${shellQuote(apiBase + "/extract/task/" + probeTaskId)} ` +
`-H ${shellQuote("Authorization: Bearer " + apiToken)} ` +
`-w '%{http_code}' -o ${shellQuote(probeRespPath)}`);
const http = String(probeResult.stdout || "").trim();
const body = readTextFile(probeRespPath);
if (probeResult.exitCode !== 0) {
if (http === "401" || http === "403") {
throw new Error(buildExpiredTokenHelp(skillRoot, http));
}
if (http === "000" || !http) {
return `Token 已读取,但当前无法连接到 ${apiBase}。请检查网络连通性、代理设置或 MINERU_API_BASE 是否可达。` +
(probeResult.stderr ? ` 诊断信息:${probeResult.stderr}` : "");
}
return `Token 已读取,但自检请求未成功完成(curl 退出码 ${probeResult.exitCode},HTTP ${http || "unknown"})。` +
` 请检查 API 地址或网络连通性。` +
(probeResult.stderr ? ` 诊断信息:${probeResult.stderr}` : "");
}
if (http === "401" || http === "403") {
throw new Error(buildExpiredTokenHelp(skillRoot, http));
}
if (http !== "200" && http !== "404") {
return `Token 已读取,但自检接口返回 HTTP ${http}。请检查 API 地址或网络连通性。响应:${body}`;
}
return `Token 自检通过:已成功携带 Authorization 访问 ${apiBase}。当前脚本会优先使用标准 Token API。`;
}
function convert(source) {
const sh = runShell;
const skillRoot = getSkillRoot(sh);
const config = loadConfig(skillRoot);
const apiToken = resolveApiToken(sh, config);
const mode = apiToken ? "token" : "light";
const info = buildSourceInfo(sh, source, mode, skillRoot);
const options = {
apiBase: config.MINERU_API_BASE || "https://mineru.net/api/v4",
apiToken: apiToken,
enableOcr: parseBoolean(config.MINERU_ENABLE_OCR, true),
enableTable: parseBoolean(config.MINERU_ENABLE_TABLE, true),
enableFormula: parseBoolean(config.MINERU_ENABLE_FORMULA, false),
languageCode: config.MINERU_LANGUAGE_CODE || "ch",
modelVersion: resolveTokenModelVersion(config.MINERU_MODEL_VERSION, info.sourceType),
pageRanges: normalizePageRanges(config.MINERU_PAGE_RANGES),
pollMax: parseInteger(config.MINERU_POLL_MAX, 20),
pollSleep: parseInteger(config.MINERU_POLL_SLEEP, 10),
logLevel: config.MINERU_LOG_LEVEL || "medium"
};
const levelMap = { low: 1, medium: 2, high: 3 };
const currentLevel = levelMap[options.logLevel] || 2;
const log = (message, level) => {
if ((level || 1) <= currentLevel) {
console.log(message);
}
};
log(`开始转换: ${info.fileName}`, 2);
log(`转换模式: ${mode === "token" ? "标准 Token API" : "免登录轻量接口"}`, 2);
log(`输入类型: ${info.sourceType}`, 2);
if (mode === "token") {
if (info.sourceType === "local_file") {
return convertLocalFileWithTokenApi(sh, skillRoot, source, info, options, log);
}
return convertRemoteUrlWithTokenApi(sh, skillRoot, source, info, options, log);
}
if (info.sourceType === "local_file") {
return convertLocalFileWithLightApi(sh, skillRoot, source, info, options, log);
}
return convertRemoteUrlWithLightApi(sh, skillRoot, source, info, options, log);
}
// ===== CLI 入口 =====
function run(argv) {
try {
if (!argv || argv.length === 0) {
return "用法: osascript -l JavaScript convert.js <文件路径或URL>\n osascript -l JavaScript convert.js checktoken\n缺少文件路径或 URL 参数";
}
const command = argv[0];
if (command === "--verify-token" || command === "verify-token" || command === "checktoken") {
const sh = runShell;
const skillRoot = getSkillRoot(sh);
const config = loadConfig(skillRoot);
return verifyToken(sh, skillRoot, config);
}
const result = convert(command);
return result.message;
} catch (error) {
return `转换失败: ${error.message}`;
}
}
任务清单
已完成
- [x] 支持远程文档 URL 输入
- [x] 支持 Token 模式下的网页 URL 提取
- [x] 添加
checktokenToken 自检入口 - [x] 补充能力矩阵、官方 Crawl 与私有部署说明
- [x] 为精准模式增加模型版本与页码范围配置
- [x] 兼容读取官方 CLI 的
~/.mineru/config.yamlToken - [x] 引入 Auto 模式:未配置 Token 时默认走免登录轻量接口
- [x] 保留标准 Token API 与 archive 归档主链
- [x] 清理
MINERU_USER_TOKEN和未使用配置项 - [x] 创建 MinerU PDF 转 Markdown 技能基础框架
- [x] 实现 MinerU API 调用逻辑(scripts/convert.js)
- [x] 添加配置管理(config/.env)
- [x] 实现转换结果归档功能(archive/)
- [x] 编写 SKILL.md 使用说明
- [x] 添加 CHANGELOG.md 变更日志
- [x] 补充 DECISIONS.md 设计决策记录
- [x] 补充 TASKS.md 任务跟踪文档
待办事项
优化改进
- [ ] 添加跨平台支持(Windows、Linux)
- [ ] 优化错误处理和用户提示
- [ ] 添加转换进度显示
- [ ] 支持批量文件转换
- [ ] 添加配置验证功能(启动时检查 Token 有效性)
文档完善
- [ ] 添加更多故障排除案例
- [ ] 补充常见问题 FAQ
- [ ] 添加使用示例截图
功能增强
- [ ] 支持自定义输出格式(如保留原始图片)
- [ ] 添加转换质量选项
- [x] 支持从 URL 直接转换文档
- [ ] 添加转换历史记录管理
维护清理
- [ ] 实现 archive/ 目录自动清理(超过 N 天的归档)
- [ ] 添加归档存储空间告警
- [ ] 优化归档命名格式(更简洁)