
Mineru Ocr
- 101 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Convert local documents, remote document URLs, or web pages to Markdown via MinerU, auto-switching to the standard API when a token is set.
About
Converts local documents, remote document URLs, and web pages to Markdown using MinerU, defaulting to a login-free lightweight endpoint and auto-switching to the standard API when a token is configured. A developer uses it for PDF-to-Markdown, OCR, table and formula recognition, and remote document conversion.
- Auto mode: login-free by default, token-based standard API
- Table, formula, and web-page extraction to Markdown
Mineru Ocr by the numbers
- 101 all-time installs (skills.sh)
- Ranked #329 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill mineru-ocrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 101 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Convert local documents, remote document URLs, or web pages to Markdown via MinerU, auto-switching to the standard API when a token is set.
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
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);
}
// 支持多个路径传入:遍历 argv 中所有参数,分别转换
const results = [];
for (let i = 0; i < argv.length; i++) {
const source = argv[i];
if (!source || source.trim() === "") {
continue;
}
const result = convert(source);
results.push(result);
}
if (results.length === 0) {
return "没有有效的文件路径或 URL";
}
if (results.length === 1) {
return results[0].message;
}
// 多个结果汇总
const successCount = results.filter(r => r.success).length;
const messages = results.map((r, idx) => `(${idx + 1}) ${r.message}`).join("\n");
return `\n========== 批量转换结果 ==========\n${messages}\n==================================\n成功: ${successCount}/${results.length}`;
} catch (error) {
return `转换失败: ${error.message}`;
}
}