
Legal Ocr
- 41 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
OCR PDFs, images, Office files, and URLs into Markdown, auto-routing between PaddleOCR and MinerU, with conservative legal-term post-processing.
About
A general OCR entry that converts PDFs, images, Office documents, and URLs into editable Markdown, auto-routing between PaddleOCR and MinerU by configuration, and applying conservative legal post-processing when legal material is detected. A developer or lawyer uses it to turn scanned or document input into Markdown.
- Config-first auto-routing between PaddleOCR and MinerU
- Conservative legal-term and structure post-processing
Legal Ocr by the numbers
- 41 all-time installs (skills.sh)
- Ranked #389 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 legal-ocrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
OCR PDFs, images, Office files, and URLs into Markdown, auto-routing between PaddleOCR and MinerU, with conservative legal-term post-processing.
Files
Legal OCR
本技能用于 OCR、扫描识别、图片文字识别、文档识别,以及把 PDF、图片、Office 文档和 URL 转换为可继续编辑、分析和归档的 Markdown。它首先是通用 OCR 入口;当结果被识别为法律材料时,再自动启用保守型法律后处理。默认使用配置优先的自动路由:
- 只配置 PaddleOCR:PaddleOCR 支持的 PDF / 图片优先走 PaddleOCR;超出能力边界时再提示或改走 MinerU 支持链路。
- 只配置 MinerU Token:所有 MinerU 支持的输入统一走 MinerU,包含 PDF、图片、Office、远程文档 URL 和网页 URL。
- 同时配置两套 API:本地 PDF / 图片优先 PaddleOCR,Office / 网页 URL 优先 MinerU;首选后端出现额度、频率、鉴权、网络或服务失败时自动尝试候选后端。
- 两套 API 都未配置:使用 MinerU 轻量接口处理小文件,并在超限时提示补充 Token。
旧的 paddle-ocr 和 mineru-ocr 保持可用;本技能是新的统一入口,目标是覆盖两者的常用 OCR/转换场景。
何时使用
在以下场景使用本技能:
- 用户明确需要 OCR、扫描识别、图片文字识别或文档识别。
- 输入可能是 PDF、图片、Office 文档、远程文档 URL 或网页 URL,并希望转成 Markdown。
- 希望保留 archive,便于复核原文件、后端结果、Markdown 和图片资源。
- 需要自动选择 OCR 后端,而不是手动判断该用 PaddleOCR 还是 MinerU。
- 希望非法律材料保持通用 OCR 输出,法律材料再做术语和文书结构优化。
不优先使用本技能的场景:
- 只需要快速读取一小段清晰文本,且不需要 Markdown 文件和归档。
- 需要基于上下文改写事实、补充缺失信息、印章深度标注或图表语义分析;这些能力仍在后续计划中。
依赖
系统依赖
| 依赖 | 安装方式 |
|---|---|
python3 | macOS 通常已内置 |
uv | macOS: brew install uv |
Python 包
脚本使用 uv run 执行,依赖写在脚本头部;推荐直接使用 uv run scripts/convert.py,无需单独维护 requirements.txt。
| 包名 | 用途 | 安装命令 |
|---|---|---|
httpx | 调用 PaddleOCR 与 MinerU API | pip install httpx |
pypdfium2 | 读取 PDF 页数与拆分页码范围 | pip install pypdfium2 |
如直接用 python scripts/convert.py 运行且缺少依赖,脚本会给出安装提示。
首次配置
复制配置模板:
cd legal-ocr/config
cp .env.example .env
nano .env可选配置:
- PaddleOCR:填写
PADDLEOCR_DOC_PARSING_API_URL和PADDLEOCR_ACCESS_TOKEN。 - MinerU Token:填写
MINERU_API_TOKEN;不填时小文件默认走 MinerU 轻量接口。 - 自动路由:保持
LEGAL_OCR_BACKEND=auto。 - 法律术语优化:保持
LEGAL_OCR_LEGAL_TERMS=auto,只在检测到法律材料时启用;如需强制启用可设为true,如需关闭可设为false。 - 通用硬换行优化:保持
LEGAL_OCR_LINE_MERGE=true;如需加载自定义法律术语,设置LEGAL_OCR_CUSTOM_TERMS_PATH。
本技能也会尝试读取环境变量和 ~/.mineru/config.yaml 中的 MinerU Token。 PaddleOCR 也兼容 pdf-processor 使用的 PADDLE_OCR_API_ENDPOINT / PADDLE_OCR_API_KEY,并支持 /api/v2/ocr/jobs 异步任务接口。
常用命令
在技能根目录运行:
uv run scripts/convert.py "/path/to/file.pdf"
uv run scripts/convert.py "/path/to/file.pdf" --pages "1-20"
uv run scripts/convert.py "/path/to/file.pdf" --backend paddle
uv run scripts/convert.py "/path/to/file.pdf" --backend paddle --paddle-model PaddleOCR-VL-1.5
uv run scripts/convert.py "https://example.com/document.pdf" --backend auto
uv run scripts/convert.py "https://example.com/article" --backend mineru
uv run scripts/convert.py "/path/to/judgment.pdf" --legal-terms always
uv run scripts/convert.py checktoken兼容 JXA 入口:
/usr/bin/osascript -l JavaScript scripts/convert.js "/path/to/file.pdf"可选参数:
| 参数 | 说明 |
|---|---|
| `--backend auto | paddle |
--output <path> | 输出 Markdown 路径或目录 |
--pages <spec> | 页码范围,如 1-20、1-5,8,10-12 |
--archive-name <name> | 自定义 archive 目录名 |
--no-archive | 不写入 archive |
--no-post-process | 跳过全部后处理 |
--no-legal-terms | 跳过法律术语优化 |
| `--legal-terms auto | always |
--no-line-merge | 跳过 OCR 硬换行整理 |
| `--model pipeline | vlm` |
| `--paddle-model PP-OCRv5 | PaddleOCR-VL-1.5` |
| `--paddle-api-protocol auto | sync |
--paddle-api-extra-json <path> | 合并额外 PaddleOCR optionalPayload |
PaddleOCR 同步接口会校验后端实际返回页数。若返回页数少于本地 PDF 批次页数,转换会失败并提示降低 PADDLEOCR_BATCH_PAGES 或使用 --pages 重跑,避免缺页结果被误当作成功。
自动分流
auto会先看用户实际配置了哪些 API;只配置一套时尽量统一走这一套,减少用户判断成本。- 两套 API 都配置时,按材料类型选择首选后端,并把另一个可用后端作为候选。
- 如果后端返回 429、额度不足、余额不足、频率限制、鉴权失败、网络超时或服务失败,会在
result.json和metadata.json的route.attempts中记录失败类别;存在候选后端时自动继续转换。 - 当前没有接入独立额度预检接口;额度判断来自 API 响应码和错误信息。若服务商提供稳定 quota endpoint,再加入转换前检查。
瞬态错误自动重试
- 范围:所有 HTTP 调用(同步提交、异步提交、异步轮询、MinerU 上传/轮询/下载、Token 自检)都会被瞬态错误分类与重试包装。
- 瞬态定义:当前为
httpx.RequestError(DNS 解析失败、连接失败、连接/读取超时、远端关闭连接、协议错误)。HTTP 4xx 仍立即抛出(鉴权、配额、参数错误),HTTP 5xx 和 429 在轮询路径下会被同样的重试包装覆盖。 - 默认参数:3 次尝试(含首次),首次重试前 1.0 秒,单次重试等待上限 30.0 秒(指数退避 1 → 2 → 4 → 8 → ...)。
- 配置项:统一用
LEGAL_OCR_RETRY_ATTEMPTS/LEGAL_OCR_RETRY_BASE_DELAY/LEGAL_OCR_RETRY_MAX_DELAY;可用PADDLEOCR_RETRY_*与MINERU_RETRY_*覆盖单后端。设置为 1 等于关闭重试。 - 重试前会向 stderr 输出一行
PaddleOCR/MinerU 瞬态错误 …日志,便于排查真实网络问题。
OCR 与法律增强
- 非法律材料默认只做通用 Markdown 清理、空行整理和硬换行整理。
- 法律增强默认处于
auto模式:先扫描 OCR 原始文本和文件名,只有命中法院、案号、当事人标签、判决/裁定结构等足够信号时,才运行法律术语优化。 - 检测结果会写入
result.json和metadata.json的postprocess.legal_context/legal_context字段。 - 如用户确认输入一定是法律材料,可使用
--legal-terms always或LEGAL_OCR_LEGAL_TERMS=true强制启用。 - 如处理非法律材料且希望完全关闭法律替换,可使用
--legal-terms never、--no-legal-terms或LEGAL_OCR_LEGAL_TERMS=false。
法律术语优化
- 默认仅在检测到法律材料时启用保守型法律术语后处理,只处理高置信 OCR 断字和常见误识别,不做事实补全或语义改写。
- 默认覆盖文书名称、主体标签、诉讼程序、证据材料、法院文书结构词等常见词。
- 默认整理明显的 OCR 硬换行,只合并同一中文段落内的物理换行;标题、当事人标签、编号、表格、引用和 Markdown 结构会保留。
- 每次替换会写入
postprocess_log.json,并保留result_raw.md供复核。 - 自定义术语格式见
references/legal_terms.md。
输出
- Markdown 默认保存在源文件同目录;远程 URL 默认保存在当前目录。
- 图片资源默认保存在 Markdown 同目录的
<文件名>_images/。 - archive 默认保存在
legal-ocr/archive/时间戳_文件名/。
输入/输出
输入
- 必需:本地文件路径、远程 URL,或
checktoken。 - 可选:
--backend、--output、--pages、--archive-name、--model和 PaddleOCR 相关参数。
输出
- Markdown 主文件:转换后的可编辑文本。
- 图片目录:后端返回或 Markdown 引用的图片资源。
- Archive:默认保存原始结果、最终结果、后端响应、路由记录和后处理日志;输入文件的
path/sha256/size_bytes(本地)或原始 URL(远程)通过metadata.json的source字段记录,不再单独复制输入副本。
archive 内包含:
output/result.mdoutput/result_raw.mdoutput/result.jsonbackend_result/metadata.json(输入文件的path/sha256/size_bytes或远程 URL 通过source字段记录;不单独保存输入副本)- 必要时包含图片资源和
postprocess_log.json
详细结构见 references/output_schema.md。
故障排除
| 问题 | 解决方式 |
|---|---|
| PaddleOCR 未配置 | 补充 PADDLEOCR_DOC_PARSING_API_URL 与 PADDLEOCR_ACCESS_TOKEN,或显式使用 --backend mineru |
| MinerU 轻量接口超限 | 配置 MINERU_API_TOKEN 后重试 |
| 一个 API 额度用尽 | 同时配置另一套 API,并保持 --backend auto;转换时会自动尝试候选后端 |
| 网页 URL 失败 | 网页 URL 需要 MinerU Token,不支持轻量模式 |
| DOCX/PPTX 走 PaddleOCR 失败 | Office 文档只能走 MinerU,使用 --backend auto 或 --backend mineru |
| PaddleOCR 返回页数不足 | 降低 PADDLEOCR_BATCH_PAGES 或使用 --pages 按较小范围重跑;当前云端接口实测单次稳定返回上限约 100 页 |
| 转换质量需复核 | 查看 archive 中的 result_raw.md、result.json 和 backend_result/ |
维护
修改本技能后,同步更新本目录下的 TASKS.md、DECISIONS.md 和 CHANGELOG.md。
变更记录
[1.4.3] - 2026-06-14
🔥 真修:httpx.Client(..., trust_env=False) 全 5 处加固 — cron 死循环 root cause 治本
经过 1.4.1(3 处 except 探针)+ 1.4.2(入口兜底)+ book-ocr-manager 0.6.9(信道扩宽)三层诊断铺路,用户第七次手测拿到完整 traceback,锁定真因:**httpx 默认 trust_env=True,会从环境变量HTTP_PROXY/HTTPS_PROXY/ALL_PROXY读 proxy URL 构建 mounts**。
cron / Agent 沙箱子进程下,proxy env 可能含畸形值(用户 ~/.zshrc 写:HTTPS_PROXY=http://127.0.0.1:$_opencode_proxy_port,变量未定义时展开为http://127.0.0.1:),
httpx 在_urlparse.py:411 normalize_port抛InvalidURL: Invalid port: ':1]',
100% 阻塞 OCR 调用。
修复
5 处 httpx.Client(...) / httpx.get(...) 全部加 `trust_env=False`:
paddle_ocr.py:413_make_request._post(同步提交)paddle_ocr.py:526_submit_async_job._post(异步提交)paddle_ocr.py:558_poll_async_job客户端(轮询 + JSONL 下载)mineru_ocr.py:218_self_test直调httpx.get(token 自检)mineru_ocr.py:245_client()(MinerU 所有请求公用客户端)
为什么 trust_env=False 合适
- legal-ocr 调的是 公网 API(
paddleocr.aistudio-app.com/mineru.net),不应该走本机代理 - 代理对内网 / 翻墙服务才有意义;公网 API 走 socks/http proxy 只会带来污染风险
trust_env=False是 httpx 推荐的"程序化 client"标准做法- 不影响手动跑(手动跑用户已无 proxy env 时本来就 OK);只在 cron / Agent 沙箱下提供保护
决定性验证(本地复现)
# 修复前:必抛
$ HTTPS_PROXY="http://127.0.0.1:1]" python3 -c "import httpx; httpx.Client(timeout=30)"
InvalidURL: Invalid port: '1]'
# 修复后:exit=0,2 个 backend 自检通过
$ HTTPS_PROXY="http://127.0.0.1:1]" uv run --script scripts/convert.py checktoken
legal-ocr 配置自检
===============================================
MinerU: Token 自检通过…
PaddleOCR: 已检测到必要配置。建议用户做的清理
修复后,之前所有被 `Invalid port: ':1]'` 击败的 segments(SQLite segments.status='failed' AND last_error LIKE '%Invalid port%')都不再是真失败,可以批量重置为 planned(本机统计约 7264 段 / 1501 本书)。详见 book-ocr-manager 同步 bump 到 0.6.10 的 CHANGELOG。
关联
- TASKS.md(book-ocr-manager)5 号问题第 127-153 行:用户第七次手测的 traceback 是真因锁定的关键证据
- 1.4.1 / 1.4.2 的探针都保留(覆盖未来其他类型错误);本版加
trust_env=False是定向治本 - DEC-025(待写):本版决策矩阵 + 复现剧本 + 7264 段重置建议
[1.4.2] - 2026-06-14
入口级兜底 catch — 1.4.1 探针的补完
背景:1.4.1 在 3 处 httpx 调用点装的 except httpx.InvalidURL 在 cron 跑批时全没触发。说明真因更早(可能在PaddleOCRBackend.__init__/MinerUBackend.__init__/
route.choose_backend 等阶段就抛了),绕过了内部 catch。- `convert.py:__main__` —
raise SystemExit(main())改为try ... except BaseException: traceback.print_exc(file=sys.stderr); raise SystemExit(1),入口层兜底:不管哪行抛错,完整 stack trace 必进 stderr。 - 与 1.4.1 的 traceback dump 行为重叠时不冲突(SystemExit 直接 re-raise,不进 catch 块;真正异常都被入口 catch 兜底)。
- 配合下游
book-ocr-manager 0.6.9的run_ocr.py:326信道扩宽(从只抓 stderr 最后一行改为抓后 30 行),下次 cron 触发的events.message必含完整 traceback。
用户感知
下次 OCR 失败时,SQLite events.message 不再是 24 字符短文案,而是含完整 stack trace,例如:
convert.py 入口异常:InvalidURL: Invalid port: ':1]'
Traceback (most recent call last):
File ".../convert.py", line ..., in <module>
...
File ".../paddle_ocr.py", line ..., in __init__
...
httpx.InvalidURL: Invalid port: ':1]'直接锁定真正抛错的文件 + 行号 + 完整调用栈。
关联
- 1.4.1 加的 3 处 except 仍保留(覆盖典型场景),本版只在入口加兜底
book-ocr-manager同步 bump 到0.6.9,加配套信道扩宽- 烟雾测试:
convert.py --help/convert.py checktoken(本机 + 模拟空 PATH 两种 env) 均正常 exit=0
[1.4.1] - 2026-06-14
诊断性 logging 增强(不改业务逻辑)
背景:book-ocr-managercron 大批 OCR 失败,所有events.message都是 24 字符短文案"最后错误:Invalid port: ':1]'",
无法定位是 PaddleOCR / MinerU 哪个 backend、哪一行 httpx 调用、哪个 url 抛的。
根因是convert.py把str(last_error)dump 到 stderr,抹平了 type 和 traceback。
- `convert.py`:
print(f"最后错误:{last_error}")改为先打印完整traceback.format_exception(...),末行保留旧前缀但补type(last_error).__name__(让上游run_ocr.py:326抓 stderr 最后一行时能看到异常类,如httpx.InvalidURL) - `paddle_ocr.py:_poll_async_job`:
client.get(jsonl_url)增加except httpx.InvalidURL,失败时raise RuntimeError(f"... jsonUrl={jsonl_url!r} ...")—jsonl_url来自 PaddleOCR 服务端响应data.resultUrl.jsonUrl字段,是可疑的畸形 URL 来源之一 - `mineru_ocr.py:_download_zip_and_extract`:
client.get(result_url)增加except httpx.InvalidURL,失败时带full_zip_url真值 raise - `mineru_ocr.py:_upload_via_ticket`:
client.put(upload_url, ...)增加except httpx.InvalidURL,失败时带file_urls[0]原值 +normalize 后同时输出 —_normalize_upload_url不做 URL 校验,是 最高嫌疑点
用户感知
下次 Invalid port 复现时,events.message 末尾会变成类似:
最后错误:RuntimeError: MinerU 上传 URL 非法:file_urls[0]='...' normalized='...' (httpx.InvalidURL: Invalid port: ':1]')或 PaddleOCR 路径:
最后错误:RuntimeError: PaddleOCR JSONL 下载 URL 非法:jsonUrl='...' (httpx.InvalidURL: Invalid port: ':1]')直接锁定后端、字段、真实 URL,无需再追代码。
关联
- 触发分析:
book-ocr-managerTASKS.md 第 83-106 行(2026-06-14 cron 第四次跑批 1154 段 100% Invalid port) - 此版本只增强 logging、不改业务路径;真修复(URL 校验 /
_normalize_upload_url加 httpx.URL 检查)等下次 cron 触发拿到真 URL 后定向修 - 烟雾测试:
convert.py --help/convert.py checktoken均正常,语法 + import 链路无破坏
[1.4.0] - 2026-06-06
改进
- 新增瞬态网络错误自动重试:所有 HTTP 调用(同步提交、异步提交、异步轮询、MinerU 上传/轮询/下载、Token 自检)通过统一的
retry_with_backoff包装;DNS 失败、连接失败、读取超时等httpx.RequestError会被指数退避重试 2-3 次。 - 退避参数通过环境变量控制:
LEGAL_OCR_RETRY_ATTEMPTS(默认 3)、LEGAL_OCR_RETRY_BASE_DELAY(默认 1.0s)、LEGAL_OCR_RETRY_MAX_DELAY(默认 30.0s);PADDLEOCR_RETRY_*与MINERU_RETRY_*可覆盖单后端。 - 重试前向 stderr 输出一行
PaddleOCR/MinerU 瞬态错误 …日志,便于排查真实网络问题。
文档完善
- SKILL.md 新增「瞬态错误自动重试」章节,说明范围、瞬态定义、默认参数和配置项。
.env.example顶部与各后端小节均补充 RETRY_* 变量。
[1.3.3] - 2026-06-05
修复
- PaddleOCR 同步接口新增返回页数校验:当
result.dataInfo.numPages或result.dataInfo.pages长度少于本地 PDF 批次页数时,转换直接失败并提示降低PADDLEOCR_BATCH_PAGES或使用--pages重跑。
改进
- PaddleOCR 批次元数据新增
expected_pages和returned_pages,便于排查大 PDF 缺页、服务端单次返回上限等问题。
文档完善
- 在
SKILL.md故障排除中补充“PaddleOCR 返回页数不足”的处理方式。
[1.3.2] - 2026-06-03
优化
- archive 不再单独保存输入副本:移除
archive/<时间戳>_<名称>/input/目录及对应的shutil.copy2/source_url.txt写入逻辑。 - 输入文件元信息继续保留在
metadata.json的source字段:本地文件记录path/sha256/size_bytes;远程 URL 记录原始字符串。 - 同步更新
SKILL.md与references/output_schema.md的 archive 结构说明。
Reason
- 现状:
skills/legal-ocr/archive/累计 649 MB;6 个 archive 平均 100 MB+,主要来自input/下的原 PDF 副本。 - 风险:随着 OCR 任务增多本地磁盘会持续膨胀;archive 已在
.gitignore内,不会被 push,但本地占用无法控制。 - 取舍:放弃 archive 内"可重放原文件"的能力,换取固定占用;如需重新转换,使用
metadata.json.source.path(本地)或metadata.json.source.raw(URL)重跑即可。
[1.3.1] - 2026-05-20
文档完善
- 精简 SKILL.md frontmatter description,仅保留 OCR/扫描识别/文档识别等功能触发条件和必要边界,不再描述后端路由等实现细节。
- 同步 README 与 marketplace 中的
legal-ocr简介。
[1.3.0] - 2026-05-20
改进
- 将技能定位调整为“通用 OCR + 法律材料自动增强”:用户需要 OCR、扫描识别、图片文字识别或文档识别时可直接调用,非法律材料默认保持通用 OCR 输出。
LEGAL_OCR_LEGAL_TERMS默认改为auto,仅在检测到法院文书、案号、当事人标签、判决/裁定结构等法律信号时启用法律术语优化。- 新增
--legal-terms auto|always|never参数,支持单次自动、强制或关闭法律术语优化。 result.json与metadata.json新增法律上下文检测记录,便于复核为什么启用或跳过法律增强。
文档完善
- 更新
SKILL.md触发条件,明确可替代普通 OCR 场景,并说明法律增强的自动检测机制。 - 更新
.env.example、references/output_schema.md和references/legal_terms.md,同步auto模式说明。
[1.2.2] - 2026-05-20
技术优化
- 按
skill-lint规范扁平化scripts/目录,移除scripts/backends/与scripts/postprocess/子目录。 - 优化脚本依赖防护,缺少
httpx或pypdfium2时给出清晰安装提示。 - 优化
SKILL.mdfrontmatter 描述,明确触发场景和不适用边界。
文档完善
- 补充 Python 包依赖表和输入/输出说明。
[1.2.1] - 2026-05-20
改进
- 法律术语断字合并支持跨单个换行,处理
本院认\n为、人\n民\n法\n院等 OCR 断行场景。 - 新增保守型 OCR 硬换行整理,合并明显属于同一中文段落的物理换行,同时保留标题、当事人标签、编号、表格和 Markdown 结构。
- 扩充常见法律词表,补充审理经过、争议焦点、执行标的、案件受理费、迟延履行期间等常见文书词。
- 新增
LEGAL_OCR_LINE_MERGE与--no-line-merge,可关闭硬换行整理。
文档完善
- 补充换行整理边界说明,强调不做事实改写和过度纠错。
[1.2.0] - 2026-05-20
新增
- 新增保守型法律术语优化后处理,默认修正常见 OCR 断字、异体字和法律文书标签格式。
- 新增自定义术语文件支持,可通过
LEGAL_OCR_CUSTOM_TERMS_PATH加载 JSON 替换表。 - 新增
--no-legal-terms参数,可单次跳过法律术语优化。 - 新增
references/legal_terms.md与config/legal_terms.example.json,说明默认处理范围和自定义格式。
改进
- 后处理顺序调整为先做法律术语优化,再做标题和条文结构整理,提升
本院认为、判决如下等文书结构识别稳定性。 - 法律术语替换记录写入
postprocess_log.json,并保留result_raw.md便于人工复核。
[1.1.1] - 2026-05-20
改进
- 自动路由调整为配置优先:只配置 PaddleOCR 时优先使用 PaddleOCR,只配置 MinerU Token 时所有支持输入统一走 MinerU,两套 API 都配置时再按材料类型选择最优后端。
- 两套 API 都配置时保留候选后端;首选后端失败后可自动尝试下一后端。
- 转换失败记录新增错误分类,支持识别额度/频率限制、鉴权失败、轻量接口超限、不支持类型、超时和网络问题。
文档完善
- 补充自动分流说明,明确当前额度判断来自 API 响应码和错误信息,暂未接入独立额度预检接口。
[1.1.0] - 2026-05-20
新增
- 复制旧
paddle-ocr与mineru-ocr的本地.env配置到legal-ocr/config/.env,保留原 Token 和后端设置;该文件继续被 Git 忽略。 - PaddleOCR 后端新增
pdf-processor同源能力:兼容PADDLE_OCR_API_ENDPOINT/PADDLE_OCR_API_KEY/API_URL/TOKEN变量别名。 - PaddleOCR 后端新增异步任务协议支持,可调用
/api/v2/ocr/jobs并轮询 JSONL 结果。 - 新增
PP-OCRv5与PaddleOCR-VL-1.5模型选择,支持--paddle-model参数和PADDLEOCR_MODEL配置。 - 新增 PaddleOCR optionalPayload 扩展:支持 VL 版面检测、图表识别、方向/去畸变、文本行方向、可视化和额外 JSON payload。
改进
checktoken/ smoke test 可识别 PaddleOCR 旧变量和pdf-processor变量别名。- PaddleOCR archive 中记录 API 协议、模型、轮询参数和 payload 配置。
[1.0.0] - 2026-05-20
新增
- 新增
legal-ocr统一 OCR Skill,整合 PaddleOCR 与 MinerU 双后端。 - 新增自动路由:本地 PDF/图片默认走 PaddleOCR,Office 文档、远程文档 URL 和网页 URL 默认走 MinerU。
- 新增统一 Python 主入口
scripts/convert.py,支持--backend、--output、--pages、--archive-name、--no-archive、--no-post-process、--model。 - 新增 MinerU Python 后端,覆盖 local/token、local/light、remote/token、remote/light 四条转换链路。
- 新增 PaddleOCR Python 后端,保留本地 PDF 自动分批、图片提取和 Markdown 输出能力。
- 新增统一 archive 结构,保留输入、最终 Markdown、原始 Markdown、结构化 JSON、后端原始结果和元数据。
- 新增基础法律后处理,支持空行清理和简单法律标题结构识别。
- 新增
scripts/smoke_test.py和 JXA 兼容入口scripts/convert.js。
文档完善
- 新增
SKILL.md、references/output_schema.md、TASKS.md、DECISIONS.md和LICENSE.txt。 - 新增统一
.env.example,保留PADDLEOCR_*与MINERU_*变量名并补充LEGAL_OCR_*设置。
待办事项
- 增加真实法律 PDF、病历、票据和网页 URL 的回归样本。
- 评估复杂法律词典纠错、印章标注和图表标注规则。
# ===== 统一设置 =====
LEGAL_OCR_BACKEND=auto
LEGAL_OCR_POST_PROCESS=true
# auto=检测到法律材料才启用;true/always=强制启用;false/never=关闭
LEGAL_OCR_LEGAL_TERMS=auto
LEGAL_OCR_LINE_MERGE=true
LEGAL_OCR_CUSTOM_TERMS_PATH=
LEGAL_OCR_LOG_LEVEL=medium
# 瞬态网络错误自动重试:尝试次数(含首次)/ 首次重试前秒数 / 单次重试等待上限秒数
LEGAL_OCR_RETRY_ATTEMPTS=3
LEGAL_OCR_RETRY_BASE_DELAY=1.0
LEGAL_OCR_RETRY_MAX_DELAY=30.0
# ===== PaddleOCR 后端 =====
# 从 https://www.paddleocr.com 对应模型 API 页面复制完整 layout-parsing 端点和 Access Token。
PADDLEOCR_DOC_PARSING_API_URL=https://your-endpoint.example.com/layout-parsing
PADDLEOCR_ACCESS_TOKEN=your_access_token_here
PADDLEOCR_API_PROTOCOL=auto
PADDLEOCR_MODEL=PaddleOCR-VL-1.5
PADDLEOCR_DOC_ORIENTATION=false
PADDLEOCR_DOC_UNWARP=false
PADDLEOCR_TEXTLINE_ORIENTATION=false
PADDLEOCR_CHART_RECOG=false
PADDLEOCR_VISUALIZE=false
PADDLEOCR_VL_LAYOUT_DETECTION=true
PADDLEOCR_VL_LAYOUT_SHAPE_MODE=rect
PADDLEOCR_API_EXTRA_JSON=
PADDLEOCR_DOC_PARSING_TIMEOUT=600
PADDLEOCR_POLL_INTERVAL=5
PADDLEOCR_POLL_TIMEOUT=1800
PADDLEOCR_BATCH_PAGES=40
PADDLEOCR_MAX_BASE64_MB=20
PADDLEOCR_LOG_LEVEL=medium
# 单后端覆盖;不设则取 LEGAL_OCR_RETRY_*
# PADDLEOCR_RETRY_ATTEMPTS=3
# PADDLEOCR_RETRY_BASE_DELAY=1.0
# PADDLEOCR_RETRY_MAX_DELAY=30.0
# PaddleOCR API 兼容别名(如来自 pdf-processor,可二选一使用)
# PADDLE_OCR_API_ENDPOINT=https://your-aistudio-app.example.com/api/v2/ocr/jobs
# PADDLE_OCR_API_KEY=your_paddle_token_here
# API_URL=https://your-aistudio-app.example.com/api/v2/ocr/jobs
# TOKEN=your_paddle_token_here
# ===== MinerU 后端 =====
# 不填 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
# 单后端覆盖;不设则取 LEGAL_OCR_RETRY_*
# MINERU_RETRY_ATTEMPTS=3
# MINERU_RETRY_BASE_DELAY=1.0
# MINERU_RETRY_MAX_DELAY=30.0
{
"replacements": [
{
"source": "某OCR误词",
"replacement": "正确法律词",
"description": "示例:客户名称、专有术语或高频误识别"
},
{
"pattern": "管[辖輖]权",
"replacement": "管辖权",
"description": "示例:使用正则修正常见 OCR 误识别"
}
]
}
MIT License
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
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.
法律术语后处理说明
legal-ocr 的法律术语优化是保守型后处理,用于修正常见 OCR 断字和高置信误识别。它不是法律语义改写,也不会补充原文没有的信息。
默认模式是 auto:非法律材料只做通用 OCR 清理;只有检测到法律文书信号时,才启用本页所述法律术语优化。
默认处理范围
- 合并常见法律术语断字/断行:如
人 民 法 院、委 托 诉 讼 代 理 人、本 院 认 为、本院认\n为。 - 修正常见 OCR 异体字或混淆词:如
民亊、刑亊、入民法院。 - 统一文书标签冒号:如
原告;、被告:、案号:。 - 合并明显属于同一中文段落的 OCR 硬换行,但保留标题、当事人标签、编号、表格和 Markdown 结构。
默认词表覆盖:
- 文书名称:民事判决书、民事裁定书、民事调解书、刑事判决书、行政判决书、执行裁定书、民事起诉状、民事上诉状。
- 主体标签:原告、被告、第三人、上诉人、被上诉人、申请人、被申请人、再审申请人、申请执行人、被执行人。
- 程序与证据词:诉讼请求、事实和理由、管辖权异议、举证期限、证据目录、庭审笔录、开庭传票、执行依据。
- 文书结构词:本院查明、本院认为、判决如下、裁定如下、审判长、审判员、人民陪审员、书记员。
配置
.env 中默认使用自动检测:
LEGAL_OCR_LEGAL_TERMS=auto
LEGAL_OCR_LINE_MERGE=true
LEGAL_OCR_CUSTOM_TERMS_PATH=可选值:
auto:默认值,检测到法律材料才启用法律术语优化。true/always:强制启用,适合用户确认输入就是法律材料。false/never:关闭,适合不希望做任何法律词替换的非法律材料。
如需关闭法律术语优化:
LEGAL_OCR_LEGAL_TERMS=false单次命令关闭:
uv run scripts/convert.py "/path/to/file.pdf" --no-legal-terms如需强制启用法律术语优化:
uv run scripts/convert.py "/path/to/file.pdf" --legal-terms always如需单次关闭硬换行整理:
uv run scripts/convert.py "/path/to/file.pdf" --no-line-merge--no-post-process 会关闭全部后处理,包括法律术语优化、硬换行整理和标题整理。
换行整理边界
默认只合并这类硬换行:
被告提交的证据能够证明
其已经履行付款义务,
但不能证明原告同意解除合同。处理后:
被告提交的证据能够证明其已经履行付款义务,但不能证明原告同意解除合同。以下内容不会主动合并:
原告:、被告:、案号:等主体或文书信息标签。一、、(一)、1.、第十条等编号。- Markdown 标题、引用、表格、图片和代码块。
- 已经以明显句末标点结束的两行。
自定义术语
可通过 LEGAL_OCR_CUSTOM_TERMS_PATH 指向 JSON 文件。支持对象映射:
{
"某OCR误词": "正确法律词",
"某公司常见误识别": "某公司标准名称"
}也支持列表格式:
{
"replacements": [
{
"source": "某OCR误词",
"replacement": "正确法律词",
"description": "客户名称常见误识别"
},
{
"pattern": "管[辖輖]权",
"replacement": "管辖权",
"description": "正则修正"
}
]
}建议只加入高置信替换。涉及人名、公司名、金额、案号等事实信息时,应保留 result_raw.md 并人工复核。
输出结构说明
legal-ocr 有三类输出:
1. Markdown 主文件:给用户继续编辑、分析或入库。非法律材料保持通用 OCR 输出;法律材料会按检测结果启用保守增强。 2. 图片资源目录:保存后端返回或 Markdown 引用的图片资源。 3. archive:保存可追溯的完整转换记录。
主输出
默认输出位置:
- 本地文件:源文件同目录,文件名与源文件同名,扩展名为
.md - 远程 URL:当前执行目录
- 显式
--output:按指定文件或目录输出
如有图片资源,默认保存到:
<markdown_stem>_images/Archive 结构
archive/
└── 20260520_153000_某案卷宗/
├── output/
│ ├── result.md
│ ├── result_raw.md
│ ├── result.json
│ └── images/
├── batches/
│ └── batch_001_1-40.json
├── backend_result/
│ ├── result.zip
│ ├── token_poll.json
│ └── batch_001_all-pages.json
├── postprocess_log.json
└── metadata.json说明:
output/result.md:最终 Markdown,可能经过基础后处理。output/result_raw.md:后端原始 Markdown,未经过基础后处理。output/result.json:统一结构化摘要。batches/:PaddleOCR 分批结果;仅在适用时存在。backend_result/:后端原始响应或结果包。metadata.json:路由、后端、输出路径和处理配置;输入文件信息(本地路径、sha256、size_bytes;或远程 URL)通过source字段记录,不再单独保存输入副本。postprocess_log.json:基础标题识别和清理记录;仅在命中时存在。
output/result.json
成功时:
{
"ok": true,
"source": {
"raw": "/path/to/file.pdf",
"type": "local_file",
"name": "file.pdf",
"suffix": ".pdf",
"page_count": 35,
"sha256": "..."
},
"backend": {
"name": "paddle",
"mode": "api",
"provider": "PaddleOCR Document Parsing API"
},
"route": {
"preferred": "paddle",
"candidates": ["paddle", "mineru"],
"reason": "已检测到 PaddleOCR 与 MinerU 两套 API,按输入类型选择最优后端,并保留失败回退",
"attempts": [
{
"backend": "paddle",
"status": "failed",
"category": "quota_or_rate_limit",
"fallback": "next_backend"
},
{"backend": "mineru", "status": "success"}
]
},
"processing": {
"mode": "single",
"batch_count": 1
},
"text": "最终 Markdown",
"raw_text": "后端原始 Markdown",
"images": [],
"batches": [],
"postprocess": {
"enabled": true,
"log_count": 3,
"legal_context": {
"mode": "auto",
"enabled": true,
"detection": {
"is_legal": true,
"score": 12,
"strong_signal_count": 2,
"hits": [
{"label": "人民法院", "count": 1, "weight": 3},
{"label": "本院认为", "count": 1, "weight": 5}
],
"filename_hits": [],
"threshold": "score>=6 or strong_signal_count>=2"
}
}
}
}postprocess_log.json
后处理命中时会保存日志。普通材料在 auto 模式下通常只会记录法律增强跳过信息和通用换行整理;法律术语优化的日志示例:
[
{
"action": "legal_term_replace",
"category": "spaced_legal_term",
"pattern": "本(?:[ \\t]+|[ \\t]*\\n[ \\t]*)院...",
"replacement": "本院认为",
"count": "1",
"description": "合并法律术语断字/断行:本院认为"
},
{
"action": "line_merge",
"category": "hard_wrap",
"count": "2",
"description": "合并明显属于同一中文段落的 OCR 硬换行"
}
]result_raw.md 始终保留后端原始文本,便于对照法律术语优化是否合适。
法律上下文检测
LEGAL_OCR_LEGAL_TERMS=auto 时,脚本会先对 OCR 原始文本和文件名做保守检测:
- 命中法院、检察院、判决书、裁定书、起诉状、案号、当事人标签、本院认为、判决如下等信号时,才启用法律术语优化。
- 非法律材料不会因为包含少量“法律”“权利”“义务”等泛化词而触发法律替换。
- 检测结果写入
result.json的postprocess.legal_context,并复制到metadata.json的legal_context。 mode=always表示用户强制启用;mode=never或--no-legal-terms表示跳过法律术语优化。
后端差异
路由与回退
auto先看已配置 API:只配置 PaddleOCR 时优先 PaddleOCR,只配置 MinerU Token 时统一 MinerU,两者都配置时按材料类型选择最优顺序。- 失败尝试会记录在
route.attempts中;category可能是quota_or_rate_limit、auth、light_limit、unsupported、timeout、network或error。 - 当前没有独立额度预检结果字段;额度/频率判断来自后端响应码和错误信息。
PaddleOCR
- 支持本地 PDF、图片,以及 PDF/图片类远程文档 URL。
- 本地 PDF 支持
--pages与自动分批。 backend_result/中保存每个批次的原始 JSON envelope。- 支持两类协议:
sync:/layout-parsingJSON 接口,适合沿用paddle-ocr的旧配置。async:/api/v2/ocr/jobs异步任务接口,适合沿用pdf-processor的 PaddleOCR API 配置。- 异步模式支持
PP-OCRv5和PaddleOCR-VL-1.5。Markdown 输出优先使用PaddleOCR-VL-1.5,因为它返回版面块和 Markdown 文本。
MinerU
- 支持本地 PDF、图片、Office 文档、远程文档 URL。
- 配置 Token 后支持网页 URL。
- 无 Token 时使用轻量接口,受 10 MB、20 页和频率限制影响。
- Token API 的结果包保存为
backend_result/result.zip。
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol
from common import SourceInfo
@dataclass
class ConvertOptions:
pages: str | None = None
model: str | None = None
paddle_model: str | None = None
log_level: str = "medium"
@dataclass
class BackendResult:
backend: str
mode: str
provider: str
markdown: str
images: list[dict[str, str]] = field(default_factory=list)
batches: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
backend_result_dir: Path | None = None
class OCRBackend(Protocol):
name: str
def convert(
self,
source: SourceInfo,
options: ConvertOptions,
work_dir: Path,
assets_dir: Path,
) -> BackendResult:
...
from __future__ import annotations
import re
from dataclasses import dataclass, field
@dataclass
class PostprocessResult:
text: str
log: list[dict[str, str]] = field(default_factory=list)
CHAPTER_RE = re.compile(r"^(第[一二三四五六七八九十百千万\d]+章\s*.+)$")
SECTION_RE = re.compile(r"^(第[一二三四五六七八九十百千万\d]+节\s*.+)$")
ARTICLE_RE = re.compile(r"^(第[一二三四五六七八九十百千万\d]+条\s*.+)$")
COURT_HEADING_RE = re.compile(
r"^(原告诉称|被告辩称|第三人述称|上诉人上诉称|被上诉人辩称|申请人称|被申请人辩称|"
r"诉讼请求|事实和理由|审理经过|本院查明|经审理查明|本院认为|裁判结果|判决如下|"
r"裁定如下|审理查明|法院认为)[::]?$"
)
def _normalize_heading(line: str) -> tuple[str, str | None]:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
return stripped, None
if CHAPTER_RE.match(stripped):
return f"## {stripped}", "chapter"
if SECTION_RE.match(stripped):
return f"### {stripped}", "section"
if COURT_HEADING_RE.match(stripped):
return f"## {stripped}", "court_heading"
if ARTICLE_RE.match(stripped) and not stripped.startswith("**"):
return f"**{stripped}**", "article"
return stripped, None
def run_basic_postprocess(markdown: str) -> PostprocessResult:
text = markdown.replace("\r\n", "\n").replace("\r", "\n")
log: list[dict[str, str]] = []
normalized_lines: list[str] = []
for line_no, raw_line in enumerate(text.split("\n"), start=1):
line = raw_line.rstrip()
normalized, action = _normalize_heading(line)
if action:
log.append({"line": str(line_no), "action": action, "text": normalized})
normalized_lines.append(normalized)
text = "\n".join(normalized_lines).strip()
text = re.sub(r"\n{3,}", "\n\n", text)
return PostprocessResult(text=text, log=log)
from __future__ import annotations
import hashlib
import math
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, TypeVar
from urllib.parse import unquote, urlparse
try:
import httpx
except ImportError: # pragma: no cover - httpx is required for OCR but optional for type-checkers
httpx = None # type: ignore[assignment]
SUPPORTED_IMAGE_SUFFIXES = (
".png",
".jpg",
".jpeg",
".bmp",
".tiff",
".tif",
".webp",
".gif",
".jp2",
)
PADDLE_LOCAL_SUFFIXES = (".pdf",) + SUPPORTED_IMAGE_SUFFIXES
MINERU_LOCAL_SUFFIXES = (
".pdf",
".doc",
".docx",
".ppt",
".pptx",
".png",
".jpg",
".jpeg",
".jp2",
".webp",
".gif",
".bmp",
".xls",
".xlsx",
)
DOCUMENT_URL_SUFFIXES = MINERU_LOCAL_SUFFIXES
@dataclass(frozen=True)
class SourceInfo:
raw: str
is_url: bool
source_type: str
suffix: str
file_name: str
base_name: str
path: Path | None = None
size_bytes: int | None = None
page_count: int | None = None
def get_skill_root() -> Path:
return Path(__file__).resolve().parent.parent
def get_config_path() -> Path:
return get_skill_root() / "config" / ".env"
def read_env_file(path: Path) -> dict[str, str]:
if not path.exists():
return {}
env: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
def load_env() -> dict[str, str]:
file_env = read_env_file(get_config_path())
return {**file_env, **os.environ}
def first_non_empty(env: dict[str, str], *keys: str) -> str:
for key in keys:
value = env.get(key, "").strip()
if value:
return value
return ""
def parse_bool(value: str | bool | None, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if not normalized:
return default
return normalized in {"1", "true", "yes", "on"}
def parse_positive_int(value: str | None, default: int) -> int:
if not value or not str(value).strip():
return default
try:
parsed = int(str(value).strip())
except ValueError as error:
raise ValueError(f"整数配置无效:{value}") from error
if parsed <= 0:
raise ValueError(f"整数配置必须大于 0:{value}")
return parsed
def parse_positive_float(value: str | None, default: float) -> float:
if not value or not str(value).strip():
return default
try:
parsed = float(str(value).strip())
except ValueError as error:
raise ValueError(f"数字配置无效:{value}") from error
if not math.isfinite(parsed) or parsed <= 0:
raise ValueError(f"数字配置必须大于 0:{value}")
return parsed
def sanitize_name(value: str) -> str:
sanitized = re.sub(r"[^\w\u4e00-\u9fff.-]+", "_", value, flags=re.UNICODE)
sanitized = sanitized.strip("._")
return sanitized or "document"
def is_http_url(value: str) -> bool:
return value.lower().startswith(("http://", "https://"))
def suffix_from_url(url: str) -> str:
path = unquote(urlparse(url).path)
return Path(path).suffix.lower()
def derive_name_from_url(url: str) -> str:
parsed_path = unquote(urlparse(url).path).rstrip("/")
filename = Path(parsed_path).name if parsed_path else ""
if not filename:
host = urlparse(url).hostname or "remote-document"
filename = sanitize_name(host)
return sanitize_name(filename)
def is_html_like_url(url: str) -> bool:
suffix = suffix_from_url(url)
return not suffix or suffix not in DOCUMENT_URL_SUFFIXES
def estimate_base64_mb(path: Path) -> float:
return (path.stat().st_size * 4 / 3) / 1024 / 1024
def sha256_of_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def resolve_output_markdown_path(source: SourceInfo, output_arg: str | None) -> Path:
if output_arg:
output_path = Path(output_arg).expanduser()
if output_path.suffix.lower() == ".md":
return output_path.resolve()
if output_arg.endswith("/") or output_path.is_dir() or not output_path.suffix:
return (output_path / f"{source.base_name}.md").resolve()
return output_path.resolve()
if source.path:
return source.path.with_suffix(".md").resolve()
return (Path.cwd() / f"{source.base_name}.md").resolve()
def resolve_images_dir(markdown_path: Path) -> Path:
return markdown_path.with_name(f"{markdown_path.stem}_images")
def build_source_info(raw_source: str, page_count: int | None = None) -> SourceInfo:
raw = raw_source.strip()
if is_http_url(raw):
file_name = derive_name_from_url(raw)
suffix = suffix_from_url(raw)
base_name = Path(file_name).stem or "remote-document"
source_type = "remote_html_url" if is_html_like_url(raw) else "remote_doc_url"
return SourceInfo(
raw=raw,
is_url=True,
source_type=source_type,
suffix=suffix,
file_name=file_name,
base_name=sanitize_name(base_name),
page_count=page_count,
)
path = Path(raw).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"文件不存在:{path}")
if not path.is_file():
raise ValueError(f"不是普通文件:{path}")
if path.stat().st_size == 0:
raise ValueError(f"文件为空:{path}")
return SourceInfo(
raw=str(path),
is_url=False,
source_type="local_file",
suffix=path.suffix.lower(),
file_name=path.name,
base_name=sanitize_name(path.stem),
path=path,
size_bytes=path.stat().st_size,
page_count=page_count,
)
def read_official_mineru_token() -> str:
yaml_path = Path.home() / ".mineru" / "config.yaml"
if not yaml_path.exists():
return ""
try:
content = yaml_path.read_text(encoding="utf-8")
except OSError:
return ""
patterns = [
r"^\s*token\s*:\s*[\"']?([^\"'#\r\n]+)[\"']?\s*$",
r"^\s*api_token\s*:\s*[\"']?([^\"'#\r\n]+)[\"']?\s*$",
r"^\s*mineru_token\s*:\s*[\"']?([^\"'#\r\n]+)[\"']?\s*$",
]
for pattern in patterns:
match = re.search(pattern, content, flags=re.MULTILINE)
if match:
return sanitize_config_value(match.group(1))
return ""
def sanitize_config_value(value: str | None) -> str:
text = str(value or "").strip()
if not text:
return ""
lowered = text.lower()
placeholders = {
"your_token_here",
"your_mineru_api_token_here",
"your_access_token_here",
"your_paddle_token_here",
"your-endpoint.example.com",
}
if lowered in placeholders:
return ""
return text
def resolve_mineru_token(env: dict[str, str]) -> str:
return (
sanitize_config_value(first_non_empty(env, "MINERU_API_TOKEN"))
or sanitize_config_value(os.environ.get("MINERU_API_TOKEN"))
or sanitize_config_value(os.environ.get("MINERU_TOKEN"))
or read_official_mineru_token()
)
def is_transient_httpx_error(exc: BaseException) -> bool:
"""判断一个异常是否属于可安全重试的瞬态网络错误。
覆盖:
- DNS 解析失败([Errno 8] nodename nor servname provided)
- TCP 连接失败、连接超时、读取超时
- 远端在写入响应中途关闭连接
- 协议层错误
"""
if httpx is None:
return False
return isinstance(exc, httpx.RequestError)
def is_transient_http_status(status_code: int) -> bool:
"""判断 HTTP 状态码是否属于可重试的瞬态错误。"""
if status_code == 429:
return True
if 500 <= status_code < 600:
return True
return False
class PaddleOCRRateLimited(Exception):
"""PaddleOCR 服务端瞬态限流(HTTP 400 + code:10010 任务提交队列已满)。
与普通 RuntimeError 的区别:这是可安全退避重试的瞬态错误,不应立即把段标 failed。
retry_with_backoff 用 is_paddle_rate_limited 接住它。
"""
# PaddleOCR 业务码:任务提交队列已满(瞬态,可重试)
RATE_LIMITED_CODES = {"10010"}
def __init__(self, message: str, *, trace_id: str | None = None) -> None:
super().__init__(message)
self.trace_id = trace_id
def is_paddle_rate_limited_response(status_code: int, body: str) -> bool:
"""判断一个 PaddleOCR 响应是否属于 code:10010 服务端限流。
响应体形如:{"traceId":"...","code":10010,"msg":"任务提交队列已满,请稍后重试"}
"""
if status_code not in {400, 429, 503}:
return False
for code in PaddleOCRRateLimited.RATE_LIMITED_CODES:
if f'"code":{code}' in body or f'"code": {code}' in body:
return True
return False
def is_paddle_rate_limited(exc: BaseException) -> bool:
"""retry_with_backoff 的 is_transient 钩子:识别 PaddleOCRRateLimited。"""
return isinstance(exc, PaddleOCRRateLimited)
T = TypeVar("T")
def retry_with_backoff(
func: Callable[..., T],
*args: Any,
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
is_transient: Callable[[BaseException], bool] | None = None,
on_retry: Callable[[int, BaseException, float], None] | None = None,
**kwargs: Any,
) -> T:
"""以指数退避重试 ``func(*args, **kwargs)``,仅在 ``is_transient`` 判为瞬态时重试。
参数:
- ``max_attempts``: 最多尝试次数(含首次),默认 3。
- ``base_delay``: 首次重试前的等待秒数,默认 1.0。
- ``max_delay``: 单次重试等待上限,默认 30.0。
- ``is_transient``: 自定义瞬态判定;默认 ``is_transient_httpx_error``。
- ``on_retry``: 重试前回调,签名 ``(attempt, exc, delay) -> None``,用于记录日志。
非瞬态异常或达到最大次数后透传给调用方;首参后的 ``max_attempts`` 计数从 1 开始。
"""
if max_attempts < 1:
raise ValueError("max_attempts 必须 >= 1")
transient = is_transient or is_transient_httpx_error
last_exc: BaseException | None = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except BaseException as exc: # noqa: BLE001
if not transient(exc) or attempt >= max_attempts:
raise
last_exc = exc
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
if on_retry:
on_retry(attempt, exc, delay)
time.sleep(delay)
if last_exc is not None:
raise last_exc
raise RuntimeError("retry_with_backoff: 不可达分支")
def has_paddle_config(env: dict[str, str]) -> bool:
return bool(
sanitize_config_value(
first_non_empty(
env,
"PADDLEOCR_DOC_PARSING_API_URL",
"PADDLE_OCR_API_ENDPOINT",
"API_URL",
)
)
and sanitize_config_value(
first_non_empty(
env,
"PADDLEOCR_ACCESS_TOKEN",
"PADDLE_OCR_API_KEY",
"TOKEN",
)
)
)
ObjC.import("Foundation");
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function getScriptPath() {
const args = $.NSProcessInfo.processInfo.arguments;
if (args.count < 4) {
throw new Error("无法定位当前脚本路径");
}
return ObjC.unwrap(args.objectAtIndex(3));
}
function run(argv) {
const app = Application.currentApplication();
app.includeStandardAdditions = true;
const sh = (cmd) => app.doShellScript(cmd);
try {
const scriptPath = getScriptPath();
const scriptDir = sh(`/usr/bin/dirname ${shellQuote(scriptPath)}`).trim();
const pythonScript = `${scriptDir}/convert.py`;
const skillRoot = sh(`/usr/bin/dirname ${shellQuote(scriptDir)}`).trim();
const uvPath = sh(`/bin/zsh -lc 'command -v uv || true'`).trim();
const runner = uvPath ? `${shellQuote(uvPath)} run` : "/usr/bin/python3";
const argList = (argv || []).map(shellQuote).join(" ");
const command =
`cd ${shellQuote(skillRoot)} && ${runner} ${shellQuote(pythonScript)}` +
(argList ? ` ${argList}` : "");
const output = sh(command);
console.log(output);
return output;
} catch (error) {
const message =
"legal-ocr 转换失败\n" +
"===============================================\n" +
`${error.message}\n\n` +
"建议:\n" +
"1. 先运行 `uv run scripts/smoke_test.py --skip-api-test`\n" +
"2. 检查 `config/.env` 是否已配置\n" +
"3. 直接运行 `uv run scripts/convert.py \"文件路径或URL\"` 查看详细日志";
console.log(message);
return message;
}
}
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "httpx>=0.27.0",
# "pypdfium2>=4.30.0",
# ]
# ///
"""legal-ocr 统一入口:通用 OCR 自动路由到 PaddleOCR 或 MinerU,输出 Markdown 与 archive。"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import tempfile
import traceback
from dataclasses import replace
from datetime import datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from base import BackendResult, ConvertOptions # noqa: E402
from mineru_ocr import MinerUBackend # noqa: E402
from paddle_ocr import PaddleOCRBackend # noqa: E402
from common import ( # noqa: E402
PADDLE_LOCAL_SUFFIXES,
SourceInfo,
build_source_info,
first_non_empty,
get_skill_root,
load_env,
parse_bool,
resolve_images_dir,
resolve_output_markdown_path,
sanitize_name,
sha256_of_file,
)
from pdf_tools import get_pdf_page_count # noqa: E402
from basic_postprocess import run_basic_postprocess # noqa: E402
from legal_terms import detect_legal_context, run_legal_terms_postprocess # noqa: E402
from linebreaks import run_linebreak_postprocess # noqa: E402
from router import RouteDecision, choose_backend # noqa: E402
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="将 PDF、图片、Office 文档或 URL 转换为 Markdown,并在法律材料中自动启用保守增强"
)
parser.add_argument("input", help='本地文件、远程 URL,或 "checktoken"')
parser.add_argument("--backend", choices=["auto", "paddle", "mineru"], default=None)
parser.add_argument("--output", help="输出 Markdown 路径;如果不是 .md,则按目录处理")
parser.add_argument("--pages", help='页码范围,例如 "1-20" 或 "1-5,8,10-12"')
parser.add_argument("--archive-name", help="自定义 archive 目录名后缀,默认使用输入文件名")
parser.add_argument("--no-archive", action="store_true", help="不写入 archive,仅输出 Markdown")
parser.add_argument("--no-post-process", action="store_true", help="跳过全部后处理")
parser.add_argument("--no-legal-terms", action="store_true", help="跳过法律术语优化")
parser.add_argument(
"--legal-terms",
choices=["auto", "always", "never"],
help="法律术语优化模式;auto 仅在检测到法律材料时启用",
)
parser.add_argument("--no-line-merge", action="store_true", help="跳过 OCR 硬换行整理")
parser.add_argument("--model", choices=["pipeline", "vlm"], help="MinerU Token API 模型")
parser.add_argument(
"--paddle-model",
choices=["PP-OCRv5", "PaddleOCR-VL-1.5"],
help="PaddleOCR 异步任务模型;Markdown 输出建议 PaddleOCR-VL-1.5",
)
parser.add_argument(
"--paddle-api-protocol",
choices=["auto", "sync", "async"],
help="PaddleOCR API 协议;layout-parsing 用 sync,/api/v2/ocr/jobs 用 async",
)
parser.add_argument("--paddle-api-extra-json", help="额外 PaddleOCR optionalPayload JSON 文件")
return parser.parse_args(argv)
def write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def copytree_if_exists(source: Path | None, target: Path) -> None:
if source and source.exists():
shutil.copytree(source, target, dirs_exist_ok=True)
def build_source(raw_input: str) -> SourceInfo:
source = build_source_info(raw_input)
if source.path and source.suffix == ".pdf":
source = replace(source, page_count=get_pdf_page_count(source.path))
return source
def should_postprocess(env: dict[str, str], no_post_process: bool) -> bool:
if no_post_process:
return False
return parse_bool(env.get("LEGAL_OCR_POST_PROCESS"), default=True)
def legal_terms_mode(env: dict[str, str], no_legal_terms: bool, cli_value: str | None) -> str:
if no_legal_terms:
return "never"
configured = (cli_value or env.get("LEGAL_OCR_LEGAL_TERMS") or "auto").strip().lower()
aliases = {
"true": "always",
"1": "always",
"yes": "always",
"on": "always",
"always": "always",
"force": "always",
"false": "never",
"0": "never",
"no": "never",
"off": "never",
"never": "never",
"auto": "auto",
}
if configured not in aliases:
raise ValueError("LEGAL_OCR_LEGAL_TERMS 仅支持 auto/true/false,或命令行 auto/always/never")
return aliases[configured]
def should_line_merge(env: dict[str, str], no_line_merge: bool) -> bool:
if no_line_merge:
return False
return parse_bool(env.get("LEGAL_OCR_LINE_MERGE"), default=True)
def make_backend(name: str, env: dict[str, str]) -> Any:
if name == "paddle":
return PaddleOCRBackend(env)
if name == "mineru":
return MinerUBackend(env)
raise ValueError(f"未知后端:{name}")
def source_json(source: SourceInfo) -> dict[str, Any]:
payload: dict[str, Any] = {
"raw": source.raw,
"type": source.source_type,
"name": source.file_name,
"suffix": source.suffix,
"page_count": source.page_count,
}
if source.path:
payload["path"] = str(source.path)
payload["sha256"] = sha256_of_file(source.path)
payload["size_bytes"] = source.size_bytes
return payload
def build_archive(
*,
source: SourceInfo,
archive_name: str,
output_md_path: Path,
output_images_dir: Path,
raw_markdown: str,
result_json: dict[str, Any],
metadata: dict[str, Any],
backend_result_dir: Path | None,
) -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_root = get_skill_root() / "archive" / f"{timestamp}_{sanitize_name(archive_name)}"
output_dir = archive_root / "output"
batches_dir = archive_root / "batches"
backend_dir = archive_root / "backend_result"
output_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(output_md_path, output_dir / "result.md")
write_text(output_dir / "result_raw.md", raw_markdown)
write_json(output_dir / "result.json", result_json)
if output_images_dir.exists():
shutil.copytree(output_images_dir, output_dir / "images", dirs_exist_ok=True)
if backend_result_dir and backend_result_dir.exists():
shutil.copytree(backend_result_dir, backend_dir, dirs_exist_ok=True)
batch_files = sorted(backend_result_dir.glob("batch_*.json"))
if batch_files:
batches_dir.mkdir(parents=True, exist_ok=True)
for batch_file in batch_files:
shutil.copy2(batch_file, batches_dir / batch_file.name)
write_json(archive_root / "metadata.json", metadata)
if metadata.get("postprocess_log"):
write_json(archive_root / "postprocess_log.json", metadata["postprocess_log"])
return archive_root
def copy_attempt_assets(attempt_assets_dir: Path, output_images_dir: Path) -> bool:
if not attempt_assets_dir.exists() or not any(attempt_assets_dir.iterdir()):
return False
if output_images_dir.exists():
shutil.rmtree(output_images_dir)
shutil.copytree(attempt_assets_dir, output_images_dir)
return True
def update_image_paths(images: list[dict[str, str]], output_images_dir: Path) -> None:
for image in images:
filename = image.get("filename")
if filename:
image["path"] = str(output_images_dir / filename)
def build_result_payload(
*,
source: SourceInfo,
backend_result: BackendResult,
route: RouteDecision,
attempts: list[dict[str, str]],
raw_markdown: str,
final_markdown: str,
postprocess_log: list[dict[str, str]],
postprocess_enabled: bool,
legal_context: dict[str, Any],
) -> dict[str, Any]:
metadata = backend_result.metadata or {}
return {
"ok": True,
"source": source_json(source),
"backend": {
"name": backend_result.backend,
"mode": backend_result.mode,
"provider": backend_result.provider,
},
"route": {
"preferred": route.preferred,
"candidates": route.candidates,
"reason": route.reason,
"notes": route.notes,
"attempts": attempts,
},
"processing": metadata.get("processing", {}),
"text": final_markdown,
"raw_text": raw_markdown,
"images": backend_result.images,
"batches": backend_result.batches,
"postprocess": {
"enabled": postprocess_enabled,
"log_count": len(postprocess_log),
"legal_context": legal_context,
},
}
def classify_backend_error(error: Exception) -> str:
message = str(error)
lowered = message.lower()
quota_terms = (
"429",
"quota",
"rate limit",
"rate-limit",
"rate_limited",
"too many requests",
"limit exceeded",
"insufficient",
"配额",
"额度",
"余额",
"用量",
"频率",
"资源包",
"点数",
)
if any(term in lowered or term in message for term in quota_terms):
return "quota_or_rate_limit"
auth_terms = ("401", "403", "unauthorized", "forbidden", "access denied", "鉴权", "认证失败")
if any(term in lowered or term in message for term in auth_terms):
return "auth"
if "免登录轻量接口限制" in message:
return "light_limit"
unsupported_terms = ("unsupported", "不支持", "仅由", "只能走", "暂不支持")
if any(term in lowered or term in message for term in unsupported_terms):
return "unsupported"
timeout_terms = ("timeout", "timed out", "超时")
if any(term in lowered or term in message for term in timeout_terms):
return "timeout"
network_terms = ("network", "connection", "connect", "网络", "连接")
if any(term in lowered or term in message for term in network_terms):
return "network"
return "error"
def run_checktoken(env: dict[str, str]) -> int:
print("legal-ocr 配置自检")
print("===============================================")
try:
mineru = MinerUBackend(env)
print(f"MinerU: {mineru.verify_token()}")
except Exception as error: # noqa: BLE001
print(f"MinerU: {error}")
try:
PaddleOCRBackend(env)
print("PaddleOCR: 已检测到必要配置。")
except Exception as error: # noqa: BLE001
print(f"PaddleOCR: {error}")
return 0
def convert_once(
*,
backend_name: str,
env: dict[str, str],
source: SourceInfo,
options: ConvertOptions,
temp_root: Path,
) -> tuple[BackendResult, Path]:
backend = make_backend(backend_name, env)
work_dir = temp_root / backend_name
assets_dir = temp_root / f"{backend_name}_assets"
work_dir.mkdir(parents=True, exist_ok=True)
return backend.convert(source, options, work_dir, assets_dir), assets_dir
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
env = load_env()
if args.paddle_model:
env["PADDLEOCR_MODEL"] = args.paddle_model
if args.paddle_api_protocol:
env["PADDLEOCR_API_PROTOCOL"] = args.paddle_api_protocol
if args.paddle_api_extra_json:
env["PADDLEOCR_API_EXTRA_JSON"] = args.paddle_api_extra_json
if args.input in {"checktoken", "verify-token", "--verify-token"}:
return run_checktoken(env)
source = build_source(args.input)
configured_backend = args.backend or env.get("LEGAL_OCR_BACKEND", "auto")
route = choose_backend(source, env, configured_backend)
output_md_path = resolve_output_markdown_path(source, args.output)
output_images_dir = resolve_images_dir(output_md_path)
archive_name = args.archive_name or source.base_name
options = ConvertOptions(
pages=args.pages,
model=args.model,
paddle_model=args.paddle_model,
log_level=first_non_empty(env, "LEGAL_OCR_LOG_LEVEL", "PADDLEOCR_LOG_LEVEL", "MINERU_LOG_LEVEL") or "medium",
)
try:
postprocess_enabled = should_postprocess(env, args.no_post_process)
configured_legal_terms_mode = (
legal_terms_mode(env, args.no_legal_terms, args.legal_terms) if postprocess_enabled else "disabled"
)
except ValueError as error:
print(error, file=sys.stderr)
return 2
temp_root = Path(tempfile.mkdtemp(prefix="legal_ocr_"))
attempts: list[dict[str, str]] = []
try:
last_error: Exception | None = None
for attempt_index, backend_name in enumerate(route.candidates):
try:
backend_result, attempt_assets_dir = convert_once(
backend_name=backend_name,
env=env,
source=source,
options=options,
temp_root=temp_root,
)
attempts.append({"backend": backend_name, "status": "success"})
raw_markdown = backend_result.markdown.strip()
if postprocess_enabled:
working_markdown = raw_markdown
postprocess_log: list[dict[str, str]] = []
mode = configured_legal_terms_mode
legal_context = {
"mode": mode,
"enabled": False,
"detection": None,
}
if mode == "auto":
detection = detect_legal_context(working_markdown, source_name=source.file_name)
legal_context["detection"] = detection
legal_context["enabled"] = bool(detection["is_legal"])
if not detection["is_legal"]:
postprocess_log.append(
{
"action": "legal_terms_skip",
"category": "legal_context_auto",
"description": "未检测到足够法律文书信号,跳过法律术语优化",
"score": str(detection["score"]),
}
)
elif mode == "always":
legal_context["enabled"] = True
if legal_context["enabled"]:
legal_terms_result = run_legal_terms_postprocess(
working_markdown,
custom_terms_path=env.get("LEGAL_OCR_CUSTOM_TERMS_PATH"),
)
working_markdown = legal_terms_result.text
postprocess_log.extend(legal_terms_result.log)
if should_line_merge(env, args.no_line_merge):
linebreak_result = run_linebreak_postprocess(working_markdown)
working_markdown = linebreak_result.text
postprocess_log.extend(linebreak_result.log)
postprocessed = run_basic_postprocess(working_markdown)
final_markdown = postprocessed.text
postprocess_log.extend(postprocessed.log)
else:
final_markdown = raw_markdown
postprocess_log = []
legal_context = {
"mode": "disabled",
"enabled": False,
"detection": None,
}
write_text(output_md_path, final_markdown)
if copy_attempt_assets(attempt_assets_dir, output_images_dir):
update_image_paths(backend_result.images, output_images_dir)
result_json = build_result_payload(
source=source,
backend_result=backend_result,
route=route,
attempts=attempts,
raw_markdown=raw_markdown,
final_markdown=final_markdown,
postprocess_log=postprocess_log,
postprocess_enabled=postprocess_enabled,
legal_context=legal_context,
)
metadata = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"source": source_json(source),
"backend": result_json["backend"],
"route": result_json["route"],
"output_markdown": str(output_md_path),
"image_output_dir": str(output_images_dir) if output_images_dir.exists() else None,
"postprocess_enabled": postprocess_enabled,
"legal_context": result_json["postprocess"]["legal_context"],
"postprocess_log": postprocess_log,
"backend_metadata": backend_result.metadata,
}
archive_path: Path | None = None
if not args.no_archive:
archive_path = build_archive(
source=source,
archive_name=archive_name,
output_md_path=output_md_path,
output_images_dir=output_images_dir,
raw_markdown=raw_markdown,
result_json=result_json,
metadata=metadata,
backend_result_dir=backend_result.backend_result_dir,
)
print("转换完成")
print(f"Markdown: {output_md_path}")
if output_images_dir.exists():
print(f"图片目录: {output_images_dir}")
if archive_path:
print(f"Archive: {archive_path}")
print(f"后端: {backend_result.backend} ({backend_result.mode})")
# 输出实际使用的模型名,供上游 run_ocr.py 解析
_model = (backend_result.metadata or {}).get("config", {}).get("model")
if _model:
print(f"OCR_MODEL: {_model}")
return 0
except Exception as error: # noqa: BLE001
category = classify_backend_error(error)
has_next_backend = attempt_index < len(route.candidates) - 1
attempt: dict[str, str] = {
"backend": backend_name,
"status": "failed",
"category": category,
"error": str(error),
}
if route.fallback_allowed and has_next_backend:
attempt["fallback"] = "next_backend"
attempts.append(attempt)
last_error = error
if not route.fallback_allowed or not has_next_backend:
break
print("转换失败", file=sys.stderr)
for attempt in attempts:
detail = f"- {attempt['backend']}: {attempt['status']}"
if "error" in attempt:
detail += f" - {attempt['error']}"
print(detail, file=sys.stderr)
if last_error:
# 2026-06-14 加诊断性 logging:
# 旧实现 `print(f"最后错误:{last_error}")` 把异常 str() 化掉,丢了 type 和 traceback;
# 上游 run_ocr.py:326 只抓 stderr 最后一行,导致 segments.last_error 短得无法定位真因
# (例:`Invalid port: ':1]'` 是 httpx.InvalidURL 的 message,但看不出哪个 url 哪个 client 抛的)。
# 现在最后一行带 type 名,traceback 在 stderr 全文里供 archive / cron 日志回溯。
tb_lines = traceback.format_exception(type(last_error), last_error, last_error.__traceback__)
print("最后错误 traceback:", file=sys.stderr)
for ln in tb_lines:
print(ln, file=sys.stderr, end="")
# 末行(保留旧前缀让上游 splitlines()[-1] 兼容,但补上 type 名定位异常类)
print(f"最后错误:{type(last_error).__name__}: {last_error}", file=sys.stderr)
return 1
finally:
shutil.rmtree(temp_root, ignore_errors=True)
if __name__ == "__main__":
# 2026-06-14 入口级 catch:之前 1.4.1 加的 try/except 在 main() 内 backend 循环里,
# 但有些异常更早(如 PaddleOCRBackend.__init__ / MinerUBackend.__init__ 调
# httpx.URL / httpx.Client 校验,或 route 解析阶段)就抛出,绕过内部 catch。
# 在入口层兜底 catch BaseException + 立即 traceback.print_exc 到 stderr,
# 保证不管哪行抛错,完整 stack 必进 stderr,上游 run_ocr.py 扩信道后能完整读到。
try:
raise SystemExit(main())
except SystemExit:
raise
except BaseException as _entry_exc:
print(f"convert.py 入口异常:{type(_entry_exc).__name__}: {_entry_exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
raise SystemExit(1)
#!/usr/bin/env python3
"""Fix broken image src paths in legal-ocr markdown output.
When the PaddleOCR backend returns images, the markdown text keeps the
original `imgs/img_in_image_box_*.jpg` references, but the script saves
images with batch-based names like `1-40_001.jpg`. This script reads the
result.json from the archive, builds a source→filename map, and replaces
the broken references in the markdown.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
def build_map(result_json_path: Path) -> dict[str, str]:
data = json.loads(result_json_path.read_text(encoding="utf-8"))
mapping: dict[str, str] = {}
for image in data.get("images", []):
source = image.get("source")
filename = image.get("filename")
if source and filename:
mapping[source] = filename
return mapping
def fix_markdown(md_path: Path, mapping: dict[str, str]) -> tuple[int, int]:
text = md_path.read_text(encoding="utf-8")
pattern = re.compile(r"imgs/[A-Za-z0-9_\-]+\.(?:jpg|jpeg|png|gif|webp)")
replaced = 0
missing = 0
def sub(match: re.Match[str]) -> str:
nonlocal replaced, missing
key = match.group(0)
if key in mapping:
replaced += 1
return mapping[key]
missing += 1
return key
new_text = pattern.sub(sub, text)
if new_text != text:
md_path.write_text(new_text, encoding="utf-8")
return replaced, missing
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("markdown", type=Path, help="Path to the markdown file to fix")
parser.add_argument(
"--result-json",
type=Path,
required=True,
help="Path to result.json from the legal-ocr archive",
)
args = parser.parse_args()
if not args.markdown.exists():
print(f"Markdown not found: {args.markdown}", file=sys.stderr)
return 1
if not args.result_json.exists():
print(f"result.json not found: {args.result_json}", file=sys.stderr)
return 1
mapping = build_map(args.result_json)
if not mapping:
print("No image mapping found in result.json", file=sys.stderr)
return 1
replaced, missing = fix_markdown(args.markdown, mapping)
print(f"Image references replaced: {replaced}")
if missing:
print(f"Unresolved references: {missing}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from basic_postprocess import PostprocessResult
@dataclass(frozen=True)
class LegalTermRule:
pattern: str
replacement: str
category: str
description: str
flags: int = re.MULTILINE
@dataclass(frozen=True)
class LegalContextSignal:
pattern: str
label: str
weight: int
flags: int = re.MULTILINE
def _spaced_term_rule(term: str, category: str = "spaced_legal_term") -> LegalTermRule:
chars = [re.escape(char) for char in term]
optional_gap = r"(?:[ \t]*\n[ \t]*|[ \t]*)"
required_gap = r"(?:[ \t]+|[ \t]*\n[ \t]*)"
variants: list[str] = []
for required_index in range(len(chars) - 1):
parts: list[str] = []
for index, char in enumerate(chars):
parts.append(char)
if index < len(chars) - 1:
parts.append(required_gap if index == required_index else optional_gap)
variants.append("".join(parts))
pattern = "(?:" + "|".join(variants) + ")"
return LegalTermRule(
pattern=pattern,
replacement=term,
category=category,
description=f"合并法律术语断字/断行:{term}",
)
def _loose_term_pattern(term: str) -> str:
chars = [re.escape(char) for char in term]
return r"[ \t\n]*".join(chars)
LITERAL_CONFUSIONS = [
("亊", "事", "ocr_variant", "将 OCR 识别出的异体字“亊”规范为“事”"),
("入民法院", "人民法院", "ocr_confusion", "修正常见 OCR 误识别:入民法院"),
("入民检察院", "人民检察院", "ocr_confusion", "修正常见 OCR 误识别:入民检察院"),
("入民陪审员", "人民陪审员", "ocr_confusion", "修正常见 OCR 误识别:入民陪审员"),
("民亊", "民事", "ocr_variant", "修正常见 OCR 误识别:民亊"),
("刑亊", "刑事", "ocr_variant", "修正常见 OCR 误识别:刑亊"),
]
SPACED_TERMS = [
"人民法院",
"人民检察院",
"人民陪审员",
"民事判决书",
"民事裁定书",
"民事调解书",
"刑事判决书",
"行政判决书",
"执行裁定书",
"民事起诉状",
"民事上诉状",
"答辩状",
"代理意见",
"质证意见",
"法定代表人",
"委托诉讼代理人",
"委托代理人",
"诉讼请求",
"事实和理由",
"审理经过",
"审理查明",
"经审理查明",
"基本事实",
"争议焦点",
"本院查明",
"本院认为",
"裁判结果",
"判决如下",
"裁定如下",
"审判长",
"审判员",
"书记员",
"原告",
"被告",
"第三人",
"上诉人",
"被上诉人",
"申请人",
"被申请人",
"再审申请人",
"申请执行人",
"被执行人",
"执行依据",
"执行标的",
"管辖权异议",
"举证期限",
"证据目录",
"庭审笔录",
"开庭传票",
"送达地址确认书",
"营业执照",
"统一社会信用代码",
"身份证号码",
"案件受理费",
"保全费",
"公告费",
"鉴定费",
"律师费",
"违约金",
"滞纳金",
"迟延履行期间",
"加倍支付迟延履行期间的债务利息",
]
LABEL_RULES = [
LegalTermRule(
pattern=r"^(\s*)(原告|被告|第三人|上诉人|被上诉人|申请人|被申请人|再审申请人|申请执行人|被执行人|法定代表人|委托诉讼代理人|委托代理人)\s*[;;::]\s*",
replacement=r"\1\2:",
category="legal_label",
description="统一常见法律主体标签冒号",
),
LegalTermRule(
pattern=r"^(\s*)(案号|审理法院|裁判日期|书记员|审判长|审判员|人民陪审员)\s*[;;::]\s*",
replacement=r"\1\2:",
category="legal_label",
description="统一常见文书信息标签冒号",
),
]
DEFAULT_RULES = [
*[
LegalTermRule(
pattern=re.escape(source),
replacement=target,
category=category,
description=description,
)
for source, target, category, description in LITERAL_CONFUSIONS
],
*[_spaced_term_rule(term) for term in SPACED_TERMS],
*LABEL_RULES,
]
LEGAL_CONTEXT_SIGNALS = [
*[
LegalContextSignal(
pattern=_loose_term_pattern(term),
label=term,
weight=weight,
)
for term, weight in [
("人民法院", 3),
("人民检察院", 3),
("民事判决书", 5),
("刑事判决书", 5),
("行政判决书", 5),
("民事裁定书", 5),
("执行裁定书", 5),
("民事起诉状", 4),
("民事上诉状", 4),
("委托诉讼代理人", 3),
("法定代表人", 2),
("诉讼请求", 3),
("事实和理由", 2),
("本院查明", 4),
("本院认为", 5),
("判决如下", 5),
("裁定如下", 5),
("审判长", 3),
("书记员", 3),
("证据目录", 3),
]
],
LegalContextSignal(
pattern=r"^\s*(原告|被告|第三人|上诉人|被上诉人|申请人|被申请人|申请执行人|被执行人)\s*[;;::]",
label="法律主体标签",
weight=4,
),
LegalContextSignal(pattern=r"^\s*(案号|审理法院|裁判日期)\s*[;;::]", label="文书信息标签", weight=3),
LegalContextSignal(pattern=r"[((]\d{4}[))][\u4e00-\u9fffA-Za-z0-9()()第\-]+号", label="裁判文书案号", weight=4),
]
LEGAL_FILENAME_TERMS = [
"判决书",
"裁定书",
"调解书",
"起诉状",
"上诉状",
"答辩状",
"证据目录",
"庭审笔录",
"法院",
"卷宗",
"案卷",
]
def detect_legal_context(markdown: str, *, source_name: str | None = None) -> dict[str, Any]:
"""Conservatively detect whether OCR text looks like a legal document."""
sample = markdown[:50000]
hits: list[dict[str, Any]] = []
score = 0
strong_signal_count = 0
for signal in LEGAL_CONTEXT_SIGNALS:
matches = re.findall(signal.pattern, sample, flags=signal.flags)
if not matches:
continue
count = len(matches)
capped_count = min(count, 3)
score += signal.weight * capped_count
if signal.weight >= 4:
strong_signal_count += 1
hits.append(
{
"label": signal.label,
"count": count,
"weight": signal.weight,
}
)
filename_hits: list[str] = []
if source_name:
filename_hits = [term for term in LEGAL_FILENAME_TERMS if term in source_name]
if filename_hits:
score += min(len(filename_hits), 2) * 4
is_legal = score >= 6 or strong_signal_count >= 2
if filename_hits and score >= 4:
is_legal = True
return {
"is_legal": is_legal,
"score": score,
"strong_signal_count": strong_signal_count,
"hits": hits[:20],
"filename_hits": filename_hits,
"threshold": "score>=6 or strong_signal_count>=2",
}
def _load_custom_rules(path_value: str | None) -> list[LegalTermRule]:
if not path_value or not str(path_value).strip():
return []
path = Path(str(path_value).strip()).expanduser()
if not path.exists():
raise FileNotFoundError(f"LEGAL_OCR_CUSTOM_TERMS_PATH 不存在:{path}")
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except ValueError as error:
raise ValueError(f"LEGAL_OCR_CUSTOM_TERMS_PATH 不是合法 JSON:{path}") from error
raw_rules: Any
if isinstance(payload, dict) and isinstance(payload.get("replacements"), list):
raw_rules = payload["replacements"]
elif isinstance(payload, dict):
raw_rules = [{"source": key, "replacement": value} for key, value in payload.items()]
elif isinstance(payload, list):
raw_rules = payload
else:
raise ValueError("LEGAL_OCR_CUSTOM_TERMS_PATH 顶层必须是对象、数组或包含 replacements 的对象")
rules: list[LegalTermRule] = []
for index, item in enumerate(raw_rules, start=1):
if not isinstance(item, dict):
raise ValueError(f"自定义术语第 {index} 项必须是对象")
replacement = str(item.get("replacement") or item.get("target") or "").strip()
if not replacement:
raise ValueError(f"自定义术语第 {index} 项缺少 replacement")
if item.get("pattern"):
pattern = str(item["pattern"])
else:
source = str(item.get("source") or item.get("text") or "").strip()
if not source:
raise ValueError(f"自定义术语第 {index} 项缺少 source 或 pattern")
pattern = re.escape(source)
rules.append(
LegalTermRule(
pattern=pattern,
replacement=replacement,
category=str(item.get("category") or "custom_legal_term"),
description=str(item.get("description") or "自定义法律术语替换"),
)
)
return rules
def run_legal_terms_postprocess(
markdown: str,
*,
custom_terms_path: str | None = None,
) -> PostprocessResult:
text = markdown
log: list[dict[str, str]] = []
rules = [*DEFAULT_RULES, *_load_custom_rules(custom_terms_path)]
for rule in rules:
text, count = re.subn(rule.pattern, rule.replacement, text, flags=rule.flags)
if count:
log.append(
{
"action": "legal_term_replace",
"category": rule.category,
"pattern": rule.pattern,
"replacement": rule.replacement,
"count": str(count),
"description": rule.description,
}
)
return PostprocessResult(text=text, log=log)
from __future__ import annotations
import re
from basic_postprocess import PostprocessResult
STRUCTURAL_RE = re.compile(
r"^\s*("
r"#{1,6}\s+|"
r"[-*+]\s+|"
r"\d+[.)、.]\s*|"
r"[一二三四五六七八九十]+[、..]\s*|"
r"[((][一二三四五六七八九十\d]+[))]\s*|"
r"第[一二三四五六七八九十百千万\d]+[章节条]\s*"
r")"
)
LEGAL_LABEL_RE = re.compile(
r"^\s*("
r"原告|被告|第三人|上诉人|被上诉人|申请人|被申请人|"
r"再审申请人|申请执行人|被执行人|法定代表人|"
r"委托诉讼代理人|委托代理人|案号|审理法院|裁判日期|"
r"审判长|审判员|人民陪审员|书记员"
r")\s*[;;::]"
)
COURT_HEADING_RE = re.compile(
r"^(原告诉称|被告辩称|第三人述称|上诉人上诉称|被上诉人辩称|"
r"申请人称|被申请人辩称|诉讼请求|事实和理由|审理经过|"
r"本院查明|经审理查明|本院认为|裁判结果|判决如下|裁定如下|"
r"审理查明|法院认为)[::]?$"
)
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
CONTINUATION_START_RE = re.compile(r"^[,。;:、,.!?!?)】》”’]")
STRONG_END_RE = re.compile(r"[。!?!?)】》”’]$")
WEAK_END_RE = re.compile(r"[,、;;::]$")
def _cjk_count(text: str) -> int:
return len(CJK_RE.findall(text))
def _is_table_line(text: str) -> bool:
stripped = text.strip()
return stripped.startswith("|") and stripped.endswith("|")
def _is_markdown_boundary(text: str) -> bool:
stripped = text.strip()
if not stripped:
return True
if stripped.startswith(("```", "~~~", ">", "![", "<")):
return True
if stripped in {"---", "***", "___"}:
return True
if _is_table_line(stripped):
return True
return False
def _is_structural_line(text: str) -> bool:
stripped = text.strip()
if _is_markdown_boundary(stripped):
return True
if COURT_HEADING_RE.match(stripped):
return True
if LEGAL_LABEL_RE.match(stripped):
return True
if STRUCTURAL_RE.match(stripped):
return True
if re.match(r"^\d{4}年\d{1,2}月\d{1,2}日$", stripped):
return True
return False
def _join_text(left: str, right: str) -> str:
right = right.strip()
if CONTINUATION_START_RE.match(right):
return left.rstrip() + right
if re.search(r"[A-Za-z0-9]$", left) and re.match(r"^[A-Za-z0-9]", right):
return left.rstrip() + " " + right
return left.rstrip() + right
def _should_merge(left: str, right: str) -> bool:
left = left.strip()
right = right.strip()
if not left or not right:
return False
if _is_structural_line(left) or _is_structural_line(right):
return False
if _cjk_count(left) < 6 or _cjk_count(right) < 2:
return False
if CONTINUATION_START_RE.match(right):
return True
if WEAK_END_RE.search(left):
return True
if not STRONG_END_RE.search(left):
return True
return False
def run_linebreak_postprocess(markdown: str) -> PostprocessResult:
text = markdown.replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
output: list[str] = []
current: str | None = None
merge_count = 0
in_fence = False
def flush_current() -> None:
nonlocal current
if current is not None:
output.append(current)
current = None
for raw_line in lines:
line = raw_line.rstrip()
stripped = line.strip()
if stripped.startswith(("```", "~~~")):
flush_current()
output.append(line)
in_fence = not in_fence
continue
if in_fence or _is_markdown_boundary(line) or _is_structural_line(line):
flush_current()
output.append(line)
continue
if current is None:
current = stripped
continue
if _should_merge(current, stripped):
current = _join_text(current, stripped)
merge_count += 1
else:
flush_current()
current = stripped
flush_current()
result = "\n".join(output).strip()
log = []
if merge_count:
log.append(
{
"action": "line_merge",
"category": "hard_wrap",
"count": str(merge_count),
"description": "合并明显属于同一中文段落的 OCR 硬换行",
}
)
return PostprocessResult(text=result, log=log)
from __future__ import annotations
from pathlib import Path
try:
import pypdfium2 as pdfium
except ImportError as error:
print("缺少依赖: pypdfium2")
print("请使用: uv run scripts/convert.py <input>")
print("或安装: pip install pypdfium2")
raise SystemExit(1) from error
def get_pdf_page_count(input_path: Path) -> int:
document = pdfium.PdfDocument(str(input_path))
try:
return len(document)
finally:
document.close()
def parse_pages_spec(pages_spec: str, total_pages: int) -> list[int]:
if not pages_spec or not pages_spec.strip():
raise ValueError("页码范围不能为空")
selected: list[int] = []
seen: set[int] = set()
def add_page(page_number: int) -> None:
if page_number < 1 or page_number > total_pages:
raise ValueError(f"页码超出范围:{page_number},有效范围是 1-{total_pages}")
index = page_number - 1
if index not in seen:
seen.add(index)
selected.append(index)
for token in [part.strip() for part in pages_spec.split(",") if part.strip()]:
if "-" in token:
start_text, end_text = token.split("-", 1)
if not start_text.isdigit() or not end_text.isdigit():
raise ValueError(f"非法页码范围:{token}")
start_page, end_page = int(start_text), int(end_text)
if start_page > end_page:
raise ValueError(f"起始页不能大于结束页:{token}")
for page_number in range(start_page, end_page + 1):
add_page(page_number)
else:
if not token.isdigit():
raise ValueError(f"非法页码:{token}")
add_page(int(token))
if not selected:
raise ValueError("没有解析出有效页码")
return selected
def format_pages_compact(page_indices: list[int]) -> str:
if not page_indices:
return "none"
pages = sorted(index + 1 for index in page_indices)
ranges: list[str] = []
start = prev = pages[0]
for page in pages[1:]:
if page == prev + 1:
prev = page
continue
ranges.append(f"{start}-{prev}" if start != prev else str(start))
start = prev = page
ranges.append(f"{start}-{prev}" if start != prev else str(start))
return ",".join(ranges)
def extract_pages_to_pdf(input_path: Path, output_path: Path, page_indices: list[int]) -> None:
source_pdf = pdfium.PdfDocument(str(input_path))
try:
output_pdf = pdfium.PdfDocument.new()
try:
output_pdf.import_pages(source_pdf, page_indices)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_pdf.save(str(output_path))
finally:
output_pdf.close()
finally:
source_pdf.close()
def split_pdf_by_batch_size(
*,
input_path: Path,
output_dir: Path,
batch_size: int,
page_indices: list[int] | None = None,
) -> list[dict[str, Path | str]]:
total_pages = get_pdf_page_count(input_path)
selected_pages = page_indices or list(range(total_pages))
if batch_size <= 0:
raise ValueError("batch_size 必须大于 0")
output_dir.mkdir(parents=True, exist_ok=True)
batches: list[dict[str, Path | str]] = []
for batch_index in range(0, len(selected_pages), batch_size):
chunk = selected_pages[batch_index : batch_index + batch_size]
label = format_pages_compact(chunk)
filename = f"batch_{batch_index // batch_size + 1:03d}_{label.replace(',', '_')}.pdf"
output_path = output_dir / filename
extract_pages_to_pdf(input_path, output_path, chunk)
batches.append({"label": label, "path": output_path})
return batches
from __future__ import annotations
from dataclasses import dataclass, field
from urllib.parse import urlparse
from common import (
MINERU_LOCAL_SUFFIXES,
PADDLE_LOCAL_SUFFIXES,
SourceInfo,
first_non_empty,
has_paddle_config,
resolve_mineru_token,
sanitize_config_value,
)
OFFICE_SUFFIXES = {".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"}
@dataclass
class RouteDecision:
preferred: str
candidates: list[str]
reason: str
fallback_allowed: bool = True
notes: list[str] = field(default_factory=list)
def _mineru_supports(source: SourceInfo) -> bool:
if source.is_url:
return True
return source.suffix in MINERU_LOCAL_SUFFIXES
def _paddle_protocol(env: dict[str, str]) -> str:
configured = first_non_empty(env, "PADDLEOCR_API_PROTOCOL", "PADDLE_API_PROTOCOL").lower()
if configured in {"sync", "async"}:
return configured
api_url = sanitize_config_value(
first_non_empty(
env,
"PADDLEOCR_DOC_PARSING_API_URL",
"PADDLE_OCR_API_ENDPOINT",
"API_URL",
)
)
if api_url and "://" not in api_url:
api_url = f"https://{api_url}"
return "sync" if urlparse(api_url).path.rstrip("/").endswith("/layout-parsing") else "async"
def _paddle_supports(source: SourceInfo, env: dict[str, str]) -> bool:
if source.is_url:
return (
source.source_type == "remote_doc_url"
and source.suffix in PADDLE_LOCAL_SUFFIXES
and _paddle_protocol(env) == "sync"
)
return source.suffix in PADDLE_LOCAL_SUFFIXES
def _append_unique(candidates: list[str], backend: str) -> None:
if backend not in candidates:
candidates.append(backend)
def _light_note(source: SourceInfo) -> str:
if source.source_type == "remote_html_url":
return "网页 URL 需要 MinerU Token;未配置时会失败并提示补充 Token"
return "未检测到 MinerU Token 时会使用轻量接口;大文件、长 PDF 或高频请求可能受限"
def _optimal_candidates(
source: SourceInfo,
env: dict[str, str],
*,
paddle_ready: bool,
mineru_ready: bool,
notes: list[str],
) -> list[str]:
candidates: list[str] = []
paddle_supports = paddle_ready and _paddle_supports(source, env)
mineru_supports = _mineru_supports(source)
if source.source_type == "remote_html_url":
_append_unique(candidates, "mineru")
return candidates
if source.suffix in OFFICE_SUFFIXES:
_append_unique(candidates, "mineru")
return candidates
if source.is_url:
if mineru_supports:
_append_unique(candidates, "mineru")
if paddle_supports:
_append_unique(candidates, "paddle")
elif paddle_ready and source.suffix in PADDLE_LOCAL_SUFFIXES:
notes.append("PaddleOCR 异步任务接口不能直接提交远程 URL,远程文档优先使用 MinerU")
return candidates
if source.suffix in PADDLE_LOCAL_SUFFIXES:
if paddle_supports:
_append_unique(candidates, "paddle")
if mineru_supports and mineru_ready:
_append_unique(candidates, "mineru")
return candidates
if mineru_supports:
_append_unique(candidates, "mineru")
return candidates
def choose_backend(
source: SourceInfo,
env: dict[str, str],
explicit_backend: str = "auto",
) -> RouteDecision:
explicit_backend = (explicit_backend or "auto").lower()
paddle_ready = has_paddle_config(env)
mineru_ready = bool(resolve_mineru_token(env))
if explicit_backend in {"paddle", "mineru"}:
return RouteDecision(
preferred=explicit_backend,
candidates=[explicit_backend],
reason=f"用户显式指定 {explicit_backend} 后端",
fallback_allowed=False,
)
notes: list[str] = []
if paddle_ready and mineru_ready:
candidates = _optimal_candidates(
source,
env,
paddle_ready=paddle_ready,
mineru_ready=mineru_ready,
notes=notes,
)
if not candidates:
raise ValueError(f"不支持的输入类型:{source.suffix or source.raw}")
return RouteDecision(
preferred=candidates[0],
candidates=candidates,
reason="已检测到 PaddleOCR 与 MinerU 两套 API,按输入类型选择最优后端,并保留失败回退",
fallback_allowed=len(candidates) > 1,
notes=notes,
)
if paddle_ready and not mineru_ready:
candidates: list[str] = []
if _paddle_supports(source, env):
_append_unique(candidates, "paddle")
elif _mineru_supports(source):
_append_unique(candidates, "mineru")
notes.append("已检测到 PaddleOCR 配置,但该输入类型不适合 PaddleOCR,改用 MinerU 支持链路")
notes.append(_light_note(source))
else:
raise ValueError(f"不支持的输入类型:{source.suffix or source.raw}")
return RouteDecision(
preferred=candidates[0],
candidates=candidates,
reason="仅检测到 PaddleOCR API 配置,优先使用 PaddleOCR;超出 PaddleOCR 能力时才改用 MinerU",
fallback_allowed=False,
notes=notes,
)
if mineru_ready and not paddle_ready:
if not _mineru_supports(source):
raise ValueError(f"不支持的输入类型:{source.suffix or source.raw}")
return RouteDecision(
preferred="mineru",
candidates=["mineru"],
reason="仅检测到 MinerU Token/API 配置,所有支持的输入统一使用 MinerU",
fallback_allowed=False,
notes=notes,
)
if _mineru_supports(source):
notes.append(_light_note(source))
return RouteDecision(
preferred="mineru",
candidates=["mineru"],
reason="未检测到可用 OCR API 配置,使用 MinerU 轻量接口作为开箱即用回退",
fallback_allowed=False,
notes=notes,
)
raise ValueError(f"不支持的输入类型:{source.suffix or source.raw}")
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "httpx>=0.27.0",
# "pypdfium2>=4.30.0",
# ]
# ///
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
try:
import httpx # noqa: F401
import pypdfium2 # noqa: F401
except ImportError as error:
missing = getattr(error, "name", str(error))
print(f"[FAIL] 缺少依赖: {missing}")
print("请使用: uv run scripts/smoke_test.py --skip-api-test")
print("或安装: pip install httpx pypdfium2")
raise SystemExit(1) from error
from mineru_ocr import MinerUBackend # noqa: E402
from paddle_ocr import PaddleOCRBackend # noqa: E402
from common import get_config_path, get_skill_root, has_paddle_config, load_env, resolve_mineru_token # noqa: E402
def check_path(path: Path, label: str) -> bool:
if path.exists():
print(f"[OK] {label}: {path}")
return True
print(f"[FAIL] {label}: {path}")
return False
def main() -> int:
parser = argparse.ArgumentParser(description="legal-ocr 配置与结构自检")
parser.add_argument("--skip-api-test", action="store_true", help="跳过外部 API 连通性检查")
args = parser.parse_args()
root = get_skill_root()
checks = [
check_path(root / "SKILL.md", "SKILL.md"),
check_path(root / "config" / ".env.example", ".env.example"),
check_path(root / "references" / "output_schema.md", "output_schema.md"),
check_path(root / "archive" / ".gitkeep", "archive/.gitkeep"),
check_path(root / "scripts" / "convert.py", "convert.py"),
check_path(root / "scripts" / "paddle_ocr.py", "paddle backend"),
check_path(root / "scripts" / "mineru_ocr.py", "mineru backend"),
]
env = load_env()
print("")
print("配置状态")
print("===============================================")
print(f"配置文件: {get_config_path()} ({'存在' if get_config_path().exists() else '未创建,可从 .env.example 复制'})")
print(f"PaddleOCR: {'已配置' if has_paddle_config(env) else '未配置'}")
print(f"MinerU Token: {'已检测到' if resolve_mineru_token(env) else '未检测到,将使用轻量模式'}")
print(f"法律术语优化: {env.get('LEGAL_OCR_LEGAL_TERMS') or 'auto'}")
if not args.skip_api_test:
print("")
print("API 自检")
print("===============================================")
try:
print(f"MinerU: {MinerUBackend(env).verify_token()}")
except Exception as error: # noqa: BLE001
print(f"MinerU: {error}")
try:
PaddleOCRBackend(env)
print("PaddleOCR: 配置字段可读取。")
except Exception as error: # noqa: BLE001
print(f"PaddleOCR: {error}")
return 0 if all(checks) else 1
if __name__ == "__main__":
raise SystemExit(main())