Skip to content

feat(docparser): 以 Go 进程内方式集成 anydoc 解析引擎,并收敛引擎注册表 - #58

Draft
lyingbug wants to merge 7 commits into
mainfrom
cursor/integrate-anydoc-go-parser-7c44
Draft

feat(docparser): 以 Go 进程内方式集成 anydoc 解析引擎,并收敛引擎注册表#58
lyingbug wants to merge 7 commits into
mainfrom
cursor/integrate-anydoc-go-parser-7c44

Conversation

@lyingbug

@lyingbug lyingbug commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Description

firecrawl/anydoc(Rust 文档转换库)以 Go 进程内库的方式集成为一个新的解析引擎 anydoc:docx/doc/pptx/ppt/xlsx/xls/odf/rtf/epub/csv/pdf 直接在 Go 侧转成 Markdown,不经过 Python docreader,也不 exec 任何二进制。

集成对象选择的是 anydoc 整体而不是底层的 pdf-inspector:WeKnora 的短板是 office 格式(builtin 甚至不支持 pptx),而 PDF 侧已有 pypdfium2 + OCR 路由,单接 pdf-inspector 只会重复现有能力并继承其风险。

绑定方式采用 firecrawl/anydoc#30(cgo + Rust 静态库)。该 PR 尚未合入、也没有发布 go/vX.Y.Z 或归档产物,因此把它 rebase 到上游最新 v0.1.8 后 vendored 到 third_party/anydoc-go/go.modreplace 指过去;上游发布后删目录、去 replace 即可,仅一个文件 import 它。

关键设计

  • 默认不链接:静态库需要 Rust 工具链,因此走 anydoc 构建标签。未启用时 backend_stub.go 让引擎在引擎列表里显示为「未构建进本二进制」,其余引擎不受影响,go build ./... 无需 Rust。
  • 依赖锁定 + 审计Cargo.lock 提交进仓库,构建脚本带 --locked,CI 跑 cargo audit。解析不可信上传的那层依赖必须可复现、有告警。
  • panic 隔离:vendored 的 C ABI shim 中三个转换入口包了 catch_unwind(已实测有效,见下)。

对 PR30 的审计与修复

审计发现两个实现问题,已在 vendored 副本中修复(值得回报上游):

  1. 并发下错误详情丢失/串号。错误消息由 Rust 侧 thread-local 暂存、再由第二次 cgo 调用 anydoc_last_error 读取;而 Go 允许 goroutine 在两次 cgo 调用之间迁移到别的 OS 线程。4000 次并发转换稳定复现(约 1/1500 丢失详情,原理上也会读到同线程上另一份文档的错误)。修复:整个 ABI 调用期间 runtime.LockOSThread,并加回归测试 TestErrorDetailSurvivesConcurrency(去掉修复即失败)。
  2. 解码器无界预分配make([]Block, 0, n)n 直接取自缓冲区,Rust 编码器与 Go 解码器版本错位时会先 OOM 掉进程,再谈越界检查。修复:按剩余字节数封顶(capFor),并让 need 拒绝负长度。

其余审计结论:编码器对模型是穷尽匹配(上游改模型会编译失败而非静默丢数据);资产长度前缀 u32 与上游 128 MiB 资产上限相容;40000 次转换 RSS 平稳无泄漏;-race 干净;20000 个变异 office/PDF 文件无崩溃。

上游落后提交的影响

main 领先 PR30 六个提交(v0.1.7、v0.1.8 两次发版 + 三个解析器/渲染器修复)。逐个核对后,没有一个需要对绑定增删改:公共 API(lib.rs 导出、model/ConvertErrorFormat)零改动,唯一的可见变化是 detect::from_bytes 降为 pub(crate) 并把 PDF 嗅探移到容器判定之后(内嵌 PDF 的 docx 现在识别为 docx),绑定通过 Format::from_bytes 调用不受影响。Node/Python 绑定在这些提交里也只改了版本号。

顺带的解析器重构

  • 引擎「元数据 + Reader 工厂」合并到 EngineRegistration,目录集中在新的 engines.goknowledge_process.go 里那段字符串 switch 收敛成一次 docparser.NewReader 调用。
  • 聊天附件与临时文档两条链路此前把引擎名原样透传给 Python docreader,Go 原生引擎(MinerU、WeKnora Cloud,以及现在的 anydoc)实际不会生效;改为同样走注册表,注册表构造不出 Reader 时仍回落 docreader,保持原有的宽松行为。
  • 新增 doc.go 说明包内分层。

Type of Change

  • ✨ New feature
  • 🐛 Bug fix
  • 🎨 Refactor
  • 📚 Documentation update
  • 🔧 Configuration / Build / CI

Related Issue

Testing

单元 / 回归测试internal/infrastructure/docparser/anydoc(格式映射、可用性、扩展名与绑定的一致性、真实转换:CSV / 含内嵌图片的 docx / 内容嗅探 / 畸形输入 / PDF、并发错误详情、深度嵌套 PDF 存活)、anydoc_reader_test.go(图片标签与引用组装)、engine_registry_reader_test.go(引擎路由)。CI 新增 job:cargo audit → 构建静态库 → -tags anydoc 跑 docparser 全量测试。

安全验证(对照实验)

### PR30 原样的依赖树(lopdf 0.41)
ID: RUSTSEC-2026-0187  Severity: 7.5 (high)  Solution: Upgrade to >=0.42.0
同一份 10 万字节深度嵌套 PDF → signal: segmentation fault(进程被打死)

### 本仓库 vendored 依赖树(anydoc =0.1.8 + 提交的 Cargo.lock → lopdf 0.42)
cargo audit exit=0(仅 1 条 allowed warning:ttf-parser 不再维护)
同一份 PDF → 返回普通错误,进程存活(TestDeeplyNestedPDFFailsWithoutKillingTheProcess PASS)

### panic 隔离(向 shim 注入 panic)
有 guard:err=anydoc: malformed: ... panic while converting in anydoc_to_markdown_bytes,进程存活
去掉 guard:进程收到致命信号,测试二进制崩溃

### 变异模糊测试:20000 个被随机破坏的 docx/pptx/xlsx/pdf
converted=9132 errored=10868 contained-panics=0,无崩溃

### FFI 内存(MALLOC_ARENA_MAX=1,40000 次转换)
rss 16512 KB → 17144 KB,无线性增长

端到端(真实文件走 docparser.NewReaderAnydocReader → cgo → Rust):docx 的标题/表格/内嵌图片、pptx 的分页标题与要点、xlsx 表格、仓库内中文合并单元格 docx 均正确;37 KB docx 约 9 ms/篇,pptx 约 1 ms,xlsx 约 0.1 ms。

完整日志:/opt/cursor/artifacts/anydoc_audit.log/opt/cursor/artifacts/anydoc_end_to_end.log/opt/cursor/artifacts/anydoc_tests.log

其它已执行:go vet + go test(全仓库,排除 /docreader/)、go test -race -tags anydocgolangci-lint run --new-from-rev=origin/main ./internal/...(0 issues)、前端 npx tsx --test src/i18n/localeKeyAudit.test.ts(11 pass)。

Checklist

  • git diff --check origin/main...HEAD passes
  • Changed source files are formatted
  • Targeted tests for the changed packages/components pass
  • Diff-scoped lint passes where applicable
  • Full-repository checks were run
  • Self-reviewed the code
  • Added/updated tests covering the change
  • Updated related documentation

已知边界(已写进文档)

  • 扫描件 PDF 无文字层,anydoc 直接报错,仍需 builtin / mineru / paddleocr_vl
  • anydoc 的 Markdown 渲染不输出内嵌图片,而其渲染函数 document_to_markdown 是 crate 私有的,外部无法「改写成链接再渲染」。因此图片引用被追加在正文末尾,标签带上文档模型里的原始 alt 与所在章节(![出货趋势 · 季度经营简报](images/image-1.png)),但正文中的精确位置无法恢复。彻底解决需要上游导出渲染函数或提供资产链接选项。
  • 纵向合并单元格只在起始行输出值,不像 docreader 那样逐行回填。
  • 多线程转换会让 glibc 按线程扩 malloc arena,RSS 可能比单线程高十几 MB —— 是分配器行为,不是泄漏。
Open in Web Open in Cursor 

lyingbug and others added 7 commits August 12, 2026 14:52
vendored firecrawl/anydoc#30 的 Go 绑定(已 rebase 到 anydoc v0.1.8),
通过 cgo 链接 Rust 静态库,在 Go 侧直接把 docx/pptx/xlsx/odf/rtf/epub/csv/pdf
转成 Markdown,不再依赖 Python docreader,也不调用外部二进制。

默认构建不链接该库(无需 Rust 工具链),引擎在列表中显示为不可用;
使用 make build-anydoc / -tags anydoc 构建即可启用。

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
同时为 app CI 增加一个 job:构建 anydoc 静态库并以 -tags anydoc 跑 docparser 测试,
避免 vendored 绑定与构建脚本在无人使用时腐化。

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
这两条链路此前把引擎名原样透传给 Python docreader,Go 原生引擎(anydoc、
MinerU 等)实际不会生效。改为统一经 docparser.NewReader 解析,注册表构造不出
Reader 时仍回落到 docreader,保持原有的宽松行为。

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
审计 PR30 实现时发现两个问题:

1. 错误详情由 Rust 侧 thread-local 暂存、再由第二次 cgo 调用读取,而 Go 允许
   goroutine 在两次 cgo 调用之间换到别的 OS 线程——并发下会丢失详情,甚至读到
   同线程上另一份文档的错误。4000 次并发转换稳定复现(约 1/1500)。改为在整个
   ABI 调用期间 LockOSThread,并补回归测试。
2. 解码器按缓冲区里的计数直接预分配(make([]Block, 0, n)),Rust 编码器与 Go
   解码器版本错位时会先 OOM 掉进程再谈越界检查。改为按剩余字节数封顶。

同时给图片补上文档模型里的原始 alt 与所在章节,追加的图片引用不再只有文件名。

Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Co-authored-by: lyingbug <lyingbug@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant