编程 anydoc 深度拆解:当文档解析决定「不上 AI」——从 OLE 复合文档到 GFM 序列化,一条 4.4ms 中位数的纯 Rust 管线如何把 14 种格式收敛成一个模型

2026-08-10 04:52:49 +0800 CST views 37

anydoc 深度拆解:当文档解析决定「不上 AI」——从 OLE 复合文档到 GFM 序列化,一条 4.4ms 中位数的纯 Rust 管线如何把 14 种格式收敛成一个模型

2026 年,你想让一个 Agent「读一下这个附件」,主流答案已经变成了:丢给多模态模型。DeepSeek-OCR 之后是百度的 Unlimited-OCR,一张图进去,Markdown 出来,端到端,省心。

然后你上了生产,账单来了。

一份 200 页的合同扫描件,按页切图、按视觉 token 计费、跑一遍 VLM,几毛到几块钱不等,延迟以秒甚至分钟计。更要命的是——这份文档根本不是扫描件,它是一个规规矩矩的 .docx,里面的每一个字符、每一个样式 ID、每一个表格的合并信息,都以结构化的形式明明白白躺在 XML 里。你花钱让一个 700 亿参数的模型,去「看」一张由这些结构化数据渲染出来的图片,再把它「猜」回结构化数据。

这就是 firecrawl/anydoc 出现的语境。它的立场极其鲜明:能确定性解析的,绝不交给概率模型。纯 Rust、零 ML、零外部服务,14 种格式全覆盖,单文档转换中位数 4.4 毫秒

仓库创建于 2026-08-03,四天后 star 数冲到 1.2 万+、fork 626,MIT 协议,整个仓库 275 个文件。这篇文章我们把它彻底拆开:它的架构为什么能「修一次 bug 全格式受益」,Word 97 的二进制地狱它是怎么趟过去的,那张 4.4ms 的性能表有多少水分,以及最重要的——在 2026 年,你的文档管线到底应该怎么分层


一、背景:文档解析是 AI 工程里最脏、最被低估的一段路

1.1 「读文件」这件事,从来没有被真正解决过

做过 RAG 的人都知道一个残酷的事实:检索质量的天花板,在切块之前就被文档解析定死了

你的向量库里存的如果是这样的东西:

第一季度营收
2026
Q1
Q2
Q3
1,200
1,450
1,380
增长率
12%
8%
-5%

那不管你用多好的 embedding 模型、多花哨的 rerank,检索出来的都是一堆语义粉尘。原文本来是一张三行四列的表格,解析器把它拍平成了一列,行列关系全丢。用户问「Q2 的增长率是多少」,模型只能瞎猜。

反过来,如果解析出来的是:

## 第一季度营收

| 2026 | Q1 | Q2 | Q3 |
| --- | --- | --- | --- |
| 营收 | 1,200 | 1,450 | 1,380 |
| 增长率 | 12% | 8% | -5% |

同一个 embedding 模型,同一个 chunk 大小,检索准确率能差出一个数量级。结构就是语义

1.2 现有工具的三种失败模式

在 anydoc 之前,Python 生态的主力选手大致分三类,每一类都有结构性缺陷:

第一类:包壳子的 LibreOffice。subprocess 起一个 soffice --headless --convert-to,等它启动 JVM 级别的庞然大物,转完再读结果。功能是全的,但代价是:进程启动 + 文档转换,中位数 1129.5 毫秒。一份文档一秒多,一万份文档三个多小时。而且 LibreOffice 的 Markdown/HTML 导出是给人看的,不是给机器吃的——满屏 <span style="...">,清洁度得分只有 24 分(满分 100)。

第二类:per-format 的 Python 库拼盘。 markitdown 是这个流派的代表:docx 走 mammoth,xlsx 走 openpyxl,pptx 走 python-pptx,PDF 走 pdfminer。问题在于每个格式的输出风格完全不一致。docx 的表格用一套转义规则,xlsx 的表格用另一套;docx 的标题带 anchor,pptx 的标题不带。你在下游写的清洗逻辑,得为每种格式写一遍。更糟的是,修一个 bug 只能修一个格式——docx 的表格里出现 | 没转义导致 Markdown 表格错位,你修好了 docx,rtf 还是错的。

第三类:ML 优先。 doclingunstructured 这类工具引入了版面分析模型。理论上更强,实际上:装依赖要下 GB 级模型权重,冷启动几十秒,中位数 513.6ms / 572.9ms,还得有 GPU 才能跑得动。对于一个「用户上传了个 .docx,我要在 HTTP 请求里同步转换」的场景,这是完全不可接受的。

1.3 anydoc 的赌注

anydoc 的判断是:上面三类工具解决的是同一个问题的不同侧面,而这个问题本身其实是确定性的。

.docx 是 ZIP + XML,规范是公开的 ECMA-376。.doc 是 OLE 复合文档,规范是公开的 MS-DOC。.rtf 是纯文本控制字语法,规范公开。.odt 是 OASIS ODF,规范公开。除了扫描件 PDF,其他所有格式的文档结构都是可以被精确读出来的,不需要任何猜测。

既然如此,为什么不写一个真正的解析器?

答案很简单:因为太累了。MS-DOC 规范有 600 多页,光是 SPRM(Single Property Modifier)就有几百个操作码。ECMA-376 更是上万页。写这么一个东西,是那种「明知道正确但没人愿意干」的活。

anydoc 干了。而且用 Rust 干的。


二、项目概览:一个 275 文件的 Rust 单体,五种分发形态

2.1 基本盘

项目
仓库firecrawl/anydoc
语言Rust(100%,无 ML 依赖)
协议MIT
创建时间2026-08-03
Star / Fork12,655 / 626(截至 2026-08-07)
仓库文件数275
支持格式14 种扩展名,8 大格式族
中位转换耗时4.4ms

出品方是 Firecrawl——做网页抓取转 Markdown 那家。这个背景很重要:anydoc 是他们 Firecrawl Parse 商业产品的开源内核。这意味着两件事,一是这代码是被真实流量喂过的,不是玩具;二是开源部分故意留了一个缺口——扫描件 OCR 在托管 API 里,本地版没有。

2.2 支持矩阵

格式族扩展名
Word.doc .docx .docm
PowerPoint.ppt .pps .pot .pptx .pptm .ppsx .ppsm
Excel.xls .xlsx .xlsm .xlsb
OpenDocument.odt .ods .odp
RTF.rtf
EPUB.epub
CSV.csv
PDF.pdf(文本型)

注意 .doc .ppt .xls .xlsb 这几个——这是二进制格式,不是 ZIP+XML。绝大多数现代工具直接放弃了它们(markitdown、pandoc、docling 在基准里对 .doc 一栏全是横杠)。anydoc 把它们全啃了,而且 .doc 拿了 87 分,是所有格式里第二高的。

对于国内团队,这一点的意义比看上去大得多:政企、金融、法务场景里,2003 版 .doc.xls 的存量大得惊人。一个不支持 .doc 的文档管线,在这些场景里就是不能用。

2.3 五种分发形态

这是 anydoc 工程化上做得最舒服的地方——一份 Rust 内核,五个入口:

# 1. CLI(npx 首次运行会下载对应平台预编译二进制)
npx @firecrawl/anydoc report.docx                # 输出到 stdout
npx @firecrawl/anydoc slides.pptx -o slides.md   # 输出到文件
npx @firecrawl/anydoc - --format csv < data.csv  # 从 stdin 读
# 2. Rust crate
cargo add anydoc
# 3. Node.js(napi 绑定)
npm install @firecrawl/anydoc
# 4. Python(maturin / PyO3 轮子)
pip install firecrawl-anydoc
# 5. 浏览器(WebAssembly)
npm install @firecrawl/anydoc-wasm

外加第六种,很 2026 的一种:

# 6. Agent Skill
npx skills add firecrawl/anydoc

装完之后,Claude Code / Codex / Cursor / OpenCode 这类 Agent 就直接会用 anydoc CLI 读文档了。仓库里 skills/convert-documents-to-markdown/SKILL.md 就是那份技能定义。

这个分发策略值得所有做基础库的人抄一遍。 内核用系统语言写一次,然后 FFI 到脚本语言、编译到 WASM、包一层 CLI、再写一份 Agent Skill。四条路覆盖了从「浏览器里前端本地转换」到「Python 数据管线」到「AI Agent 自主调用」的全部消费场景,维护成本却只有一份解析逻辑。


三、架构分析:一个模型,一个序列化器,十个解析器

3.1 核心管线

README 里给出的管线图,是理解整个项目的钥匙:

document bytes
  │
  ├─► format detection      → 看内容标记,不看扩展名
  │
  ├─► format parser          → 每种格式一个(doc, docx, ppt, pptx, xls,
  │                            xlsx, odt/ods/odp, rtf, epub, csv)
  │         │
  │         └─► Document     → 共享模型:blocks, inlines, tables,
  │                            footnotes, assets
  │               │
  │               └─► GFM serializer → Markdown
  │
  └─► PDF → pdf-inspector    → 直接产出 Markdown

这个「沙漏形」架构是 anydoc 相对于 markitdown 那一类工具的根本性优势。 十个解析器在上面,一个 Document 模型在腰,一个 GFM 序列化器在下面。

它带来的直接后果,README 里说得很直白:

一个 docx 的表格转义修复,自动就是 rtf、odt 以及所有其他格式的表格转义修复。

这在软件工程上叫「收敛点」。markitdown 那种架构,N 种格式就有 N 条输出路径,bug 修复是 O(N) 的;anydoc 是 N 条输入路径汇聚到 1 条输出路径,输出层的 bug 修复是 O(1) 的。

而在文档转换这个领域,绝大多数难缠的 bug 恰恰都在输出层:Markdown 转义、表格对齐、列表嵌套编号、链接锚点、脚注编号。把这些集中到一个地方修,是巨大的杠杆。

3.2 目录结构反推架构

让我们把 src/ 摊开看。这是从仓库 git tree 拿到的真实文件清单:

src/
├── lib.rs
├── error.rs
├── formats/
│   ├── detect.rs                 # 内容嗅探
│   ├── mod.rs
│   ├── csv.rs
│   ├── pdf.rs                    # 旁路:转发给 pdf-inspector
│   ├── doc/
│   │   ├── mod.rs
│   │   ├── sprm.rs               # Single Property Modifier 解码
│   │   ├── stsh.rs               # 样式表(Style Sheet)
│   │   └── lists.rs
│   ├── docx/
│   │   ├── mod.rs
│   │   ├── content.rs
│   │   ├── numbering.rs
│   │   └── styles.rs
│   ├── ppt/
│   │   ├── mod.rs
│   │   └── styletext.rs
│   ├── pptx/
│   │   ├── mod.rs
│   │   └── cascade.rs            # 占位符样式级联
│   ├── sheet/
│   │   └── mod.rs                # xls / xlsx 共用
│   ├── odf/
│   │   ├── mod.rs
│   │   ├── styles.rs
│   │   ├── table.rs
│   │   └── text.rs
│   ├── rtf/
│   │   ├── mod.rs
│   │   ├── lexer.rs              # 控制字词法分析
│   │   ├── table.rs
│   │   └── tables.rs
│   └── epub/
│       └── mod.rs
├── model/
│   ├── mod.rs
│   ├── block.rs                  # 块级:段落、标题、列表、表格、引用
│   ├── inline.rs                 # 行内:粗斜体、代码、链接
│   ├── table.rs
│   ├── list.rs
│   ├── link.rs
│   ├── style.rs
│   └── asset.rs                  # 嵌入资源
├── package/
│   ├── mod.rs
│   ├── archive.rs                # ZIP 容器
│   ├── limits.rs                 # 资源上限(防炸弹)
│   ├── path.rs                   # 包内路径规范化
│   ├── relationships.rs          # OPC .rels 关系解析
│   └── xml.rs
├── render/
│   └── mod.rs                    # GFM 序列化器
└── shared/
    ├── binary.rs        blockstyle.rs   chain.rs      delta.rs
    ├── drawingml.rs     fields.rs       grid.rs       header.rs
    ├── html.rs          list.rs         mc.rs         numbering.rs
    ├── officeart.rs     text.rs         uri.rs

这个结构里有几个模块,只要你写过 Office 文档解析器就会会心一笑:

package/——OPC 容器抽象。 docx / pptx / xlsx / odt / epub 本质上都是 ZIP 包,都有 [Content_Types].xmlmimetype,都有一套包内相对路径和关系文件。anydoc 把这一层抽出来共用,而不是让每个格式各自 unzip 一遍。relationships.rs 处理的是 OOXML 的 .rels 文件——图片、超链接、外部引用全靠它把 rId3 这种符号引用解析成真实目标。

shared/mc.rs——Markup Compatibility。 这个文件的存在说明 anydoc 处理了 OOXML 的 mc:AlternateContent。这是个坑:Office 在写文件时,同一块内容会写两份,一份是新版渲染器用的 mc:Choice,一份是老版兼容用的 mc:Fallback。天真的解析器会把两份都读出来,导致内容重复。这个 bug 在 python-docx 生态里被踩了无数次。

shared/grid.rs——表格网格。 OOXML 的表格合并单元格不是用 rowspan/colspan 表达的,而是用 gridSpan(横向)+ vMerge(纵向,restart/continue 状态机)。要把它还原成一个逻辑网格,你得自己维护一个二维占位表。这个文件就是干这个的。

shared/numbering.rs + shared/list.rs + shared/chain.rs——列表编号地狱。 Word 的列表编号是三层间接:段落引用 numIdnum 引用 abstractNumIdabstractNum 定义 9 级 lvl,每级有自己的 numFmtlvlText(形如 %1.%2.)、start 和重启规则。还要处理 lvlOverridechain.rs 大概率就是处理这条引用链的。

shared/fields.rs——域代码。 Word 里的超链接、交叉引用、页码、目录,很多时候不是标签,而是 HYPERLINK "http://..." 这样的域代码字符串,夹在 fldChar begin/separate/end 之间。不解析域代码,链接就全丢了。

shared/drawingml.rs + shared/officeart.rs——两代图形格式。 DrawingML 是 OOXML 的,OfficeArt(Escher)是 97-2003 二进制格式的。anydoc 两代都实现了。

formats/doc/sprm.rs + stsh.rs——Word 97 二进制的核心。 这两个文件是整个项目最硬核的地方。简单说一下 .doc 有多变态:

  • 文件是 OLE 复合文档(CFB),本质是一个「文件系统中的文件系统」,有 FAT、有目录项、有 512 字节扇区
  • 正文在 WordDocument 流里,但文本不是连续存放的——你得先读 FIB(File Information Block),从里面拿到 CLX,解析出 piece table,才知道哪段字符在哪个偏移,以及那段是 CP1252 单字节还是 UTF-16 双字节
  • 格式属性不是内联的,而是用 SPRM 编码的差分指令流,存在 1Table/0Table 流里的 PLCF 结构中,通过字符位置区间应用到文本上
  • 样式继承在 STSH(Style Sheet)里,同样是 SPRM 编码

这套设计的每一步都在告诉你:它是为 1997 年的内存和磁盘优化的,不是为了让你读的。 anydoc 把它啃下来并拿到 87 分,这是真功夫。

3.3 PDF:架构上的那个「例外」

注意管线图里 PDF 是一条独立支线,不进 Document 模型,由 pdf-inspector(同为 Firecrawl 出品的独立 crate)直接产出 Markdown。

这是一处清醒的架构妥协,但也是整个设计里最脆弱的一环。

原因很实际:PDF 根本不是文档格式,它是打印机指令格式。PDF 里没有「段落」这个概念,只有「在坐标 (x, y) 用字体 F 画字符串 S」。要从中还原出段落、标题、表格,本质上是一个版面重建问题——你得做行聚类、列检测、阅读顺序推断,这跟解析 XML 是完全不同的问题域。

把它塞进 Document 模型只会污染模型。所以 anydoc 选择让它走旁路。

代价是:PDF 的输出不享受统一序列化器带来的一致性红利。表格转义、列表编号这些在 GFM serializer 里修的 bug,PDF 路径吃不到。如果你的语料以 PDF 为主,anydoc 的架构优势就被削掉一大半。

而且 README 说得很明白,图片型 PDF(扫描件)直接返回 Unsupported。这不是 bug,这是产品设计:扫描件 OCR 是 Firecrawl 托管 API 的付费功能。

3.4 格式检测:不信扩展名

Format::from_bytes(&bytes);              // Some(Format::Docx) 或 None
Format::from_extension("pptm");          // Some(Format::Pptx)
Format::from_path(Path::new("report.odt")); // Some(Format::Odt)

anydoc 默认走 from_bytes,读的是规范指定的内容标记:

格式标记
PDF文件头 %PDF-
RTF起始控制组 {\rtf
doc/ppt/xlsOLE 复合文档签名 + 内部流名(WordDocument / PowerPoint Document / Workbook
docx/pptx/xlsxZIP 包内 [Content_Types].xml
odt/ods/odp/epubZIP 包内 mimetype 条目
CSV无标记,只能靠扩展名或显式指定

注意 OLE 那一行:.doc.ppt.xls 文件头是完全一样的D0 CF 11 E0 A1 B1 1A E1)。光看 magic number 区分不了,必须打开复合文档的目录,看里面有哪个流。这是很多简单 sniff 库会错的地方。

为什么这个设计重要?因为真实世界的文件扩展名满嘴跑火车。用户上传的 report.pdf 可能是个 docx,data.xlsx 可能是个 CSV,邮件附件的扩展名经常在转发过程中被吃掉。内容嗅探是唯一可靠的路。

3.5 错误分类:可操作的错误

这是 anydoc 一个被低估的设计。它的 ConvertError 不是一个笼统的字符串,而是六个语义明确的变体:

变体含义你该怎么办
Unsupported未知格式,或无法转换(图片型 PDF)转 OCR 管线
Malformed结构损坏,提不出有意义内容记录,人工介入
Encrypted加密或密码保护找用户要密码
ResourceLimit触发安全上限(解压比、嵌套深度、节点数)可能是攻击,告警
MissingPart缺少必需的包内部件文件不完整,重新上传
Io文件读不出来基础设施问题

README 里给的用法示例,一眼就能看出它的设计意图:

match anydoc::to_markdown(path) {
    Ok(markdown) => Some(markdown),
    // 这两种情况文档本身就出不来内容,记一笔,处理下一个
    Err(error @ (ConvertError::Encrypted | ConvertError::Unsupported(_))) => {
        unconverted.push((path, error));
        None
    }
    Err(error) => return Err(error),
}

「批量转换时哪些错误可以跳过、哪些必须中断」——这个区分是所有批处理管线的核心决策,anydoc 把它编码进了类型系统。 Node 和 WASM 把变体名放在 error.code 上,Python 给每个变体一个异常子类,这就让上层语言也能做同样的分流。

ResourceLimit 这个变体尤其值得点名。src/package/limits.rs 的存在意味着 anydoc 认真对待了这几类攻击:

  • ZIP 炸弹:一个 42KB 的 zip 解压出 4.5PB。限制解压比和绝对大小。
  • XML 实体炸弹(billion laughs):递归实体引用指数级膨胀。限制实体展开。
  • 深度嵌套:一万层嵌套表格把递归下降解析器的栈干爆。限制嵌套深度。
  • 路径穿越(zip slip):包内条目名写成 ../../etc/passwdsrc/package/path.rs 负责规范化。

如果你的服务接受用户上传的文档,这一层不是「加分项」,是上线前置条件。很多 Python 解析库在这方面是裸奔的。


四、代码实战:把 anydoc 塞进真实管线

理论说完,上代码。以下都是可以直接跑的。

4.1 最小可用:三行

// Cargo.toml: anydoc = "*"
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let markdown = anydoc::to_markdown("report.docx")?;
    println!("{markdown}");
    Ok(())
}

从字节流转(HTTP 上传场景):

use anydoc::{to_markdown_bytes, to_document, Format};

// 内容嗅探,格式自动识别
let md = to_markdown_bytes(&bytes, None)?;

// CSV 没有内容标记,必须显式告知
let md = to_markdown_bytes(&bytes, Format::Csv)?;

// 停在文档模型这一层,可以拿到嵌入资源
let doc = to_document(&bytes, None)?;

to_document 这个 API 很关键。它让你在 Markdown 序列化之前拿到结构化模型,可以自己决定怎么渲染。想输出 JSON?想只提取表格?想把图片单独抽出来传对象存储?都从这里下手。

4.2 批量转换:rayon + 错误分流

真实场景很少是单文档。下面这个是我认为的标准写法——并行、错误分类、可观测:

use anydoc::{to_markdown, ConvertError};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

#[derive(Debug, Default)]
struct Stats {
    ok: AtomicUsize,
    need_ocr: AtomicUsize,
    encrypted: AtomicUsize,
    malformed: AtomicUsize,
    suspicious: AtomicUsize,
}

enum Outcome {
    Converted(PathBuf, String),
    NeedsOcr(PathBuf),
    Skipped(PathBuf, String),
}

fn convert_all(paths: &[PathBuf], stats: &Stats) -> Vec<Outcome> {
    paths
        .par_iter()
        .map(|p| match to_markdown(p) {
            Ok(md) => {
                stats.ok.fetch_add(1, Ordering::Relaxed);
                Outcome::Converted(p.clone(), md)
            }
            // 图片型 PDF / 未知格式 → 降级到 OCR 慢路径
            Err(ConvertError::Unsupported(_)) => {
                stats.need_ocr.fetch_add(1, Ordering::Relaxed);
                Outcome::NeedsOcr(p.clone())
            }
            Err(ConvertError::Encrypted) => {
                stats.encrypted.fetch_add(1, Ordering::Relaxed);
                Outcome::Skipped(p.clone(), "encrypted".into())
            }
            // 触发资源上限:可能是恶意文件,单独告警
            Err(ConvertError::ResourceLimit) => {
                stats.suspicious.fetch_add(1, Ordering::Relaxed);
                tracing::warn!(path = %p.display(), "resource limit tripped");
                Outcome::Skipped(p.clone(), "resource-limit".into())
            }
            Err(e) => {
                stats.malformed.fetch_add(1, Ordering::Relaxed);
                Outcome::Skipped(p.clone(), format!("{e:?}"))
            }
        })
        .collect()
}

关键点:

  1. par_iter() 直接并行。 anydoc 是纯 CPU 计算、无全局状态、无外部进程,天然线程安全,并行扩展是线性的。这一点跟包 LibreOffice 的方案形成鲜明对比——后者你并行 8 个进程,内存直接爆炸。
  2. Unsupported 不是失败,是路由信号。 它告诉你「这个文件需要走 OCR」。
  3. ResourceLimit 单独告警。 正常业务文档几乎不可能触发,一旦触发就该查。

在一台 16 核机器上,按 4.4ms 中位数算,理论吞吐大约是每秒 3600 份文档。实际会低一些(IO、大文件长尾),但这个量级意味着你可以在 HTTP 请求生命周期内同步转换,不需要队列。这是一个架构级别的简化。

4.3 Node.js:不阻塞事件循环

import { toMarkdown, toMarkdownBytes, toDocument } from '@firecrawl/anydoc';
import express from 'express';
import multer from 'multer';

const app = express();
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 50 * 1024 * 1024 },
});

app.post('/parse', upload.single('file'), async (req, res) => {
  try {
    // 转换跑在 libuv 线程池,不阻塞 event loop
    const markdown = await toMarkdownBytes(req.file.buffer);
    res.json({ ok: true, markdown });
  } catch (err) {
    // 变体名在 error.code 上
    switch (err.code) {
      case 'Unsupported':
        return res.status(422).json({ ok: false, reason: 'needs_ocr' });
      case 'Encrypted':
        return res.status(422).json({ ok: false, reason: 'password_protected' });
      case 'ResourceLimit':
        return res.status(400).json({ ok: false, reason: 'file_rejected' });
      default:
        return res.status(500).json({ ok: false, reason: 'parse_failed' });
    }
  }
});

app.listen(3000);

README 里那句「Node.js conversion runs on the libuv thread pool and never blocks the event loop」是有分量的。

对比一下:如果你用 markitdown,你得起一个 Python 子进程,序列化文件过去,等结果回来——一次转换至少多 100ms 的进程开销,还得管进程池。如果你用纯 JS 的 mammoth,它只支持 docx,而且解析是同步的,一个 50MB 的文档能把 event loop 卡死好几百毫秒。

napi 绑定 + libuv 线程池,是这个问题的正确解。

注意一个运维细节: libuv 默认线程池只有 4 个线程。如果你的服务是文档转换密集型,记得调:

UV_THREADPOOL_SIZE=16 node server.js

不调的话,你的 16 核机器只会用 4 个核。

4.4 Python:GIL 真的被释放了

import anydoc
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

def convert(path: Path) -> tuple[Path, str | None, str | None]:
    try:
        return path, anydoc.to_markdown(str(path)), None
    except anydoc.Unsupported:
        return path, None, "needs_ocr"
    except anydoc.Encrypted:
        return path, None, "encrypted"
    except anydoc.ConvertError as e:
        return path, None, type(e).__name__
    except OSError as e:
        return path, None, f"io:{e}"

paths = list(Path("corpus").rglob("*"))

# 线程池,不是进程池——因为 anydoc 在转换期间释放 GIL
with ThreadPoolExecutor(max_workers=16) as pool:
    for path, md, err in pool.map(convert, paths):
        if md:
            (Path("out") / f"{path.stem}.md").write_text(md, encoding="utf-8")
        else:
            print(f"skip {path}: {err}")

ThreadPoolExecutor 而不是 ProcessPoolExecutor——这个区别在 Python 里是天大的事。

因为 anydoc 的转换全程在 Rust 侧,PyO3 绑定里用 py.allow_threads() 释放了 GIL,所以多线程是真并行。你不需要付进程池那份代价:不用 pickle 序列化数据、不用每个进程重新 import 一遍依赖、内存不用翻 N 倍。

这在 Python 3.15 自由线程还没完全普及的过渡期,是一条相当实用的路子:把 CPU 密集的部分推到 Rust 扩展里,然后用普通线程池就能吃满 CPU

4.5 浏览器 WASM:文件不出本地

这是我觉得最有想象力的一个用法。

import init, { toMarkdownBytes, formatFromBytes } from '@firecrawl/anydoc-wasm';

await init();

document.querySelector('#file').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const bytes = new Uint8Array(await file.arrayBuffer());

  const fmt = formatFromBytes(bytes);
  if (!fmt) {
    alert('无法识别的格式');
    return;
  }

  const t0 = performance.now();
  const markdown = toMarkdownBytes(bytes);
  console.log(`${fmt} 转换耗时 ${(performance.now() - t0).toFixed(1)}ms`);

  document.querySelector('#out').textContent = markdown;
});

文件一个字节都没离开浏览器。

想想这个能力值多少钱:法务、医疗、金融场景下,用户要把合同/病历/财报转成 Markdown 喂给 AI,但公司合规不允许原始文件上传到第三方。传统方案要么部署私有化、要么走复杂的脱敏流程。WASM 方案里,你的服务器只收到用户已经确认过的 Markdown 文本,原始文件从来没上过网。

这不是「性能优化」,这是合规边界的重新划分

4.6 接进 RAG:结构化切块

前面说了「结构就是语义」。有了干净的 Markdown,切块策略立刻可以从「按字符数硬切」升级到「按语义边界切」:

import re
import anydoc
from dataclasses import dataclass, field

@dataclass
class Chunk:
    heading_path: list[str]   # ["第三章", "3.2 违约责任"]
    text: str
    kind: str = "prose"       # prose | table | code

    @property
    def contextualized(self) -> str:
        """把标题路径拼进正文,让 embedding 带上上下文"""
        prefix = " > ".join(self.heading_path)
        return f"[{prefix}]\n{self.text}" if prefix else self.text


HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")

def chunk_markdown(md: str, max_chars: int = 1200) -> list[Chunk]:
    chunks: list[Chunk] = []
    stack: list[str] = []
    buf: list[str] = []
    in_table = False

    def flush(kind="prose"):
        nonlocal buf
        text = "\n".join(buf).strip()
        if text:
            chunks.append(Chunk(heading_path=list(stack), text=text, kind=kind))
        buf = []

    for line in md.splitlines():
        if m := HEADING.match(line):
            flush()
            level = len(m.group(1))
            stack[:] = stack[: level - 1]
            while len(stack) < level - 1:
                stack.append("")
            stack.append(m.group(2).strip())
            continue

        is_row = bool(TABLE_ROW.match(line))
        # 表格整块保留,绝不从中间切开
        if is_row and not in_table:
            flush()
            in_table = True
        elif not is_row and in_table:
            flush("table")
            in_table = False

        buf.append(line)
        if not in_table and sum(len(x) for x in buf) > max_chars:
            flush()

    flush("table" if in_table else "prose")
    return chunks


md = anydoc.to_markdown("contract.docx")
for c in chunk_markdown(md):
    embed(c.contextualized)   # 你的 embedding 调用

三个要点:

  1. heading_path 注入 embedding。 一个 chunk 写着「乙方应在 30 日内支付」,单看毫无信息量;带上 [第三章 > 3.2 违约责任] 之后,检索命中率完全不是一个量级。这个信息只有在解析器正确还原了标题层级时才存在——这就是解析质量直接换算成检索质量的地方
  2. 表格绝不从中间切。 一张表被切成两半,两半都是垃圾。上面的状态机在检测到表格行时先 flush 前面的内容,把整张表作为一个 chunk。
  3. 超长表格要特殊处理。 上面的代码对超过 max_chars 的表格是「原样放行」,实际生产中你应该给大表做「表头 + N 行」的滑窗切分,每个窗口都重复表头。

4.7 双层架构:快路径 + OCR 慢路径

这是我认为 2026 年文档管线的正确形态

import anydoc
from pathlib import Path

def parse_document(path: Path) -> tuple[str, str]:
    """返回 (markdown, 使用的路径)"""
    try:
        # 快路径:确定性解析,4.4ms,成本 ≈ 0
        return anydoc.to_markdown(str(path)), "deterministic"
    except anydoc.Unsupported:
        # 慢路径:扫描件 / 图片型 PDF → VLM OCR
        return ocr_pipeline(path), "vlm-ocr"
    except anydoc.Encrypted:
        raise
    except anydoc.ConvertError:
        # 结构损坏的文件,有时候渲染成图再 OCR 反而能救回来
        return ocr_pipeline(path), "vlm-ocr-fallback"


def ocr_pipeline(path: Path) -> str:
    """把页面渲染成图,走 VLM。
    可以是 Unlimited-OCR / DeepSeek-OCR 自建,也可以是托管 API。"""
    import fitz  # PyMuPDF
    doc = fitz.open(path)
    pages = []
    mat = fitz.Matrix(300 / 72, 300 / 72)   # 300 DPI
    for page in doc:
        pages.append(page.get_pixmap(matrix=mat).tobytes("png"))
    return vlm_parse(pages)

为什么这个分层是对的?

因为真实语料的分布是长尾的。在大多数企业场景里,70%~90% 的文档是原生电子格式(Office 文档、文本型 PDF、网页导出),剩下的才是扫描件和拍照件。

  • 如果你全走 VLM:90% 的文档在为 10% 的困难情况买单,成本和延迟都是无谓损失,而且原生文档过 VLM 反而更容易出错——模型会「看错」表格边界,会漏掉浅色文字,会把页眉页脚混进正文。
  • 如果你全走确定性解析:那 10% 直接挂了。
  • 分层之后:90% 的路径 4.4ms、零成本、可复现、可审计;10% 的路径慢但可用。综合成本降一个数量级,P50 延迟降三个数量级。

而且分层还有一个隐藏收益:可审计性。确定性解析的输出是可复现的——同样的字节进去,永远是同样的 Markdown 出来。VLM 不是,temperature 设成 0 也不是(batch 组合、kernel 非确定性都会导致漂移)。在金融、法务这类需要留痕的场景,「同一份合同两次解析结果不一样」是个严重的合规问题。


五、性能与基准:4.4ms 是怎么来的,又有多少水分

5.1 数字本身

工具支持格式中位耗时总分完整性结构格式清洁度
anydoc14/144.4ms8187797881
libreoffice12/141129.5ms4059424024
unstructured8/14572.9ms6376595163
markitdown6/14134.8ms6578666052
pandoc5/14102.1ms5674575638
docling4/14513.6ms5760605751
mammoth1/1452.5ms7084717551

速度差距:

  • vs LibreOffice:256 倍
  • vs unstructured:130 倍
  • vs docling:117 倍
  • vs markitdown:31 倍
  • vs mammoth(只支持 docx 的最快对手):12 倍

5.2 快在哪里——四个来源

第一,没有进程启动。 LibreOffice、pandoc 这类是 CLI 工具,每次转换都要付一次进程创建 + 动态链接 + 初始化的成本。LibreOffice 尤其重,它初始化的是一整套办公套件运行时。README 里明确说了:Python 库和 anydoc 的计时排除了进程启动,CLI 工具包含了——因为那才是它们真实的使用方式。这个测法是公允的。

第二,没有 ML 模型。 docling / unstructured 的时间大头在版面分析模型的前向推理。没有模型,就没有这部分开销,也没有 GB 级权重下载和显存占用。

第三,Rust 的零成本抽象。 这里最关键的是避免不必要的字符串分配。文档解析的本质是「从字节流里切出无数小片段」,Python 的做法是每切一片就 str 一个新对象,一份 docx 转换下来产生几十万个临时字符串对象,GC 压力巨大。Rust 可以用 &str 借用原始 buffer,只在真正需要拥有权时才分配。

第四,一遍流式遍历。 从模块结构看(shared/delta.rs 这种名字暗示了增量/差分处理),anydoc 大概率是单遍 SAX 式遍历 XML,边走边构建 Document,而不是先 DOM 化再遍历。DOM 化一个 50MB 的 document.xml 光内存分配就够喝一壶。

5.3 质量分是怎么评的——以及它的问题

README 对评测方法交代得非常坦诚,这点值得表扬:

一个 LLM 评审(Claude Sonnet 5)盲测对比两个工具的输出,对照 ground truth:该文档前六页由 LibreOffice 渲染成的图片。每份输出按完整性、结构、格式、清洁度打分。每一对都判两次、输出位置对调以消除位置偏差,总共 482 次判定。

以及:

每个工具的 score 是它支持的那些格式的分数平均,所以某种格式占比过高的语料不会歪曲结果。这也意味着每一行平均的是不同的格式集合(mammoth 的分数只来自 docx,anydoc 的横跨全部十四种),所以按格式对比的那张表才是公平的比较

但这套方法有三个必须指出的问题:

问题一:ground truth 是 LibreOffice 渲染的。

这里有个尴尬的循环:LibreOffice 既是被评测对象之一,又是 ground truth 的生产者。如果 LibreOffice 对某个 .doc 的渲染本身就是错的(这在老格式上完全可能),那么「正确」解析出真实内容的工具反而会被判低分,因为它跟错误的参考答案不一致。

不过反过来说,LibreOffice 自己在这个基准里只拿了 40 分,说明这个循环并没有帮到它——它的渲染可能是对的,但它的 Markdown/HTML 导出很差。所以这个偏差主要影响的是「解析正确性」的判定,而不是给 LibreOffice 送分。

问题二:只看前六页。

对于长文档,前六页往往是封面、目录、前言——结构相对简单。真正难的东西(跨页表格、复杂嵌套列表、大量交叉引用)通常在正文深处。这个采样策略会系统性地低估长文档的解析难度。

问题三:LLM 评审的固有偏好。

LLM 评审倾向于喜欢「看起来整洁」的输出。一个工具如果激进地丢弃它不确定的内容,输出会显得干净,清洁度得分高;另一个工具保守地把所有内容都保留下来,可能显得杂乱,但信息更完整。这两种策略在「完整性」和「清洁度」两个维度上是对冲的,而总分是它们的平均——一个激进丢弃的工具可能在总分上不吃亏

判断这一点的方法是看 anydoc 的分数分布:完整性 87 / 结构 79 / 格式 78 / 清洁度 81。完整性是最高分,说明它没有靠丢内容来刷清洁度。这个分布是健康的。

另外,语料不可再分发,不在仓库里。 这意味着这套基准不可独立复现。你只能自己拿自己的文档跑。README 把 harness 放在 bench/ 下(convert.pyjudge.pymetrics.pyrender_truth.pyreport.py),代码是开的,语料是闭的。

所以我的建议是:把这张表当作「量级参考」而不是「精确排名」。 速度那一列的量级差距(256 倍 / 31 倍)是结构性的,方法学问题动摇不了;质量那一列,你应该拿自己的真实语料重跑一遍。

5.4 工程质量:测试策略

一个解析库的可靠性,看它的测试策略就够了。anydoc 这块相当扎实:

tests/
├── common/mod.rs
├── snapshots.rs        # 快照测试:固定语料 → 固定输出
├── robustness.rs       # 变异测试:对每个 fixture 做随机字节变异
└── fixture-src/book.md

fuzz/
└── fuzz_targets/
    ├── csv.rs   doc.rs   docx.rs   epub.rs
    ├── odf.rs   pdf.rs   ppt.rs    rtf.rs   xlsx.rs

九个 cargo-fuzz 目标,每种格式一个。 这是解析器该有的样子——解析器面对的是不可信输入,模糊测试不是可选项。

robustness.rs 的变异测试尤其对路:拿正常文件随机翻转字节,然后要求解析器「要么正确处理,要么优雅报错,绝不 panic、绝不无限循环、绝不 OOM」。Rust 在这里的价值就体现出来了——内存安全是编译期保证的,剩下要防的只有逻辑层面的 panic(数组越界、unwrap、整数溢出)和资源耗尽,而后者由 package/limits.rs 兜着。

写过 C/C++ 文档解析器的人应该都懂,同样的东西用 C 写,光是 .doc 那个 piece table 的偏移计算就能写出十几个可利用的堆溢出。

5.5 一处工程债

README 自己承认了:版本号在三个地方,发版时要一起改。

  • Cargo.toml(crate)
  • node/package.json(npm 包)
  • python/Cargo.toml(wheel,pyproject.toml 从这里读)

这是多语言分发的经典问题。三处手动同步,迟早有一次会漏。合理的做法是加一个 cargo-release 的 pre-release hook,或者干脆写个小脚本:

#!/usr/bin/env bash
# bump.sh <version>
set -euo pipefail
V="$1"
sed -i.bak "0,/^version = /s//version = \"$V\"/" Cargo.toml
sed -i.bak "0,/^version = /s//version = \"$V\"/" python/Cargo.toml
node -e "
  const fs=require('fs'), p='node/package.json';
  const j=JSON.parse(fs.readFileSync(p));
  j.version='$V';
  fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
"
find . -name '*.bak' -delete
git add -A && git commit -m "chore: release v$V" && git tag "v$V"
echo "tagged v$V"

小事,但这种小事在多语言仓库里会一直咬人。


六、局限性:它做不到什么

夸完了,说说边界。这一节比前面几节都重要,因为选型的关键从来不是「它能干什么」,而是「它不能干什么,而我恰好需要」。

6.1 扫描件:彻底不行

图片型 PDF 直接 Unsupported。没有内置 OCR,也不会有——这是 Firecrawl 的商业分界线。

如果你的语料主要是扫描件、拍照件、传真件,anydoc 对你的价值接近于零。你需要的是 Unlimited-OCR / DeepSeek-OCR 这类 VLM 方案,或者 PaddleOCR 这类传统 OCR 管线。

6.2 Markdown 是有损的——尤其是表格

这是格式本身的天花板,不是 anydoc 的问题,但你必须知道。

GFM 表格语法不支持合并单元格。而真实的 Office 文档里,合并单元格随处可见——尤其是中文文档,多级表头几乎是标配:

┌─────────┬───────────────────┬───────────────────┐
│         │      2025 年       │      2026 年       │
│  项目    ├─────────┬─────────┼─────────┬─────────┤
│         │  上半年  │  下半年  │  上半年  │  下半年  │
├─────────┼─────────┼─────────┼─────────┼─────────┤
│  营收    │  1,200  │  1,450  │  1,380  │  1,600  │
└─────────┴───────────────────┴───────────────────┘

这张表在 GFM 里没有任何办法无损表达。anydoc 内部的 shared/grid.rs 肯定是正确还原了逻辑网格的(否则拿不到那个分数),但序列化到 GFM 时必然降级——通常是把合并单元格的值复制到每个被覆盖的格子,或者留空。

这意味着:如果你的下游需要精确的表格结构,不要用 to_markdown,用 to_document,自己从 Document 模型里拿表格,序列化成 HTML <table>(支持 rowspan/colspan)或者 JSON。 这也是 to_document 这个 API 存在的最大意义。

同类的有损项还有:公式(OMML/MathML → 无处安放)、图表(chart part → 只剩标题)、批注和修订(trackchanges → 通常直接丢)、文本框和艺术字的定位关系、页眉页脚(shared/header.rs 说明处理了,但 Markdown 里没有对应概念)。

6.3 中文编码:需要实测

.doc.rtf 都是有 codepage 概念的格式。中文 Word 97 文档里的文本,可能是 CP936(GBK)单字节存储,也可能是 UTF-16。RTF 里则是 \ansicpg936 加上 \'d6\'d0 这样的十六进制转义。

anydoc 的 shared/text.rs 应该处理了编码转换,formats/rtf/lexer.rs 也必然要处理 \'hh 转义。但基准语料的构成没有公开,我们不知道里面有多少中文文档

如果你的场景是中文,上生产前务必自己跑一批测试,重点测:

  1. 中文 .doc(Word 97-2003 简体中文版生成的)
  2. 中文 .rtf(尤其是从 WPS 导出的)
  3. GB18030 四字节字符和生僻字
  4. 繁体 / Big5 编码的老文档
  5. 中英文混排时的空格处理

我建议的验证脚本:

import anydoc
from pathlib import Path
import unicodedata

def audit(path: Path):
    md = anydoc.to_markdown(str(path))
    total = len(md)
    # 替换字符 = 编码解码失败的信号
    replacement = md.count("\ufffd")
    cjk = sum(1 for c in md if "\u4e00" <= c <= "\u9fff")
    # 控制字符残留 = 解析器漏处理的信号
    ctrl = sum(1 for c in md if unicodedata.category(c) == "Cc" and c not in "\n\t")
    print(f"{path.name:40s} len={total:7d} cjk={cjk:6d} "
          f"replacement={replacement:4d} ctrl={ctrl:4d}")
    if replacement > 0:
        print(f"  ⚠ 存在 {replacement} 个替换字符,编码可能有问题")
    if ctrl > 0:
        print(f"  ⚠ 存在 {ctrl} 个控制字符残留")

for p in Path("cn-corpus").rglob("*"):
    if p.is_file():
        audit(p)

\ufffd(U+FFFD REPLACEMENT CHARACTER)的数量是最直接的编码健康度指标。

6.4 PDF 支线的短板

前面说过了,再强调一次:PDF 走 pdf-inspector 旁路,不进统一模型。这意味着:

  • PDF 的输出一致性不如其他格式
  • 复杂版面(多栏、图文混排、脚注密集)的阅读顺序还原,纯几何方法一定不如版面分析模型
  • 你不能对 PDF 用 to_document 拿结构化模型(至少不能拿到跟其他格式一致的模型)

如果你的语料以复杂版面 PDF 为主,docling 那类带版面模型的工具在质量上仍有优势,代价是慢 100 倍。

6.5 项目太新

2026-08-03 创建,写这篇文章时刚满四天。1.2 万 star 说明关注度极高,但也意味着:

  • 生产验证时间短
  • API 可能还会变(虽然核心 API 看起来已经很收敛了)
  • 长尾格式 bug 还没被充分暴露

建议:锁死版本号,自己建一套回归语料,升级前跑一遍 diff。 对于解析器这类「输出会直接影响下游一切」的组件,这个纪律是必须的。


七、选型决策树

把上面所有讨论压缩成一个可执行的决策流程:

你的语料里扫描件占比 > 50%?
├─ 是 → 走 VLM OCR 为主(Unlimited-OCR / DeepSeek-OCR),
│       anydoc 只做原生格式的快路径分流
└─ 否 ↓

你需要精确的表格结构(合并单元格、多级表头)?
├─ 是 → anydoc + to_document,自己序列化成 HTML/JSON,
│       不要用 to_markdown
└─ 否 ↓

你的语料以复杂版面 PDF 为主?
├─ 是 → anydoc 优势不明显,考虑 docling 或托管 Parse API
└─ 否 ↓

你需要在 HTTP 请求里同步转换 / 需要浏览器端转换 / 
需要每天处理十万级文档?
├─ 是 → anydoc 是目前唯一合理选择(4.4ms + WASM + 线性并行)
└─ 否 ↓

你已经在用 markitdown 且只处理 docx?
├─ 是 → 收益主要是速度和格式覆盖,可以观望
└─ 否 → 上 anydoc,架构上是净收益

再给一条实操建议:不要 all-in 替换,先并行跑

import anydoc
from markitdown import MarkItDown
import difflib

md_tool = MarkItDown()

def compare(path):
    try:
        a = anydoc.to_markdown(str(path))
    except Exception as e:
        a = f"<<ERROR {type(e).__name__}>>"
    try:
        b = md_tool.convert(str(path)).text_content
    except Exception as e:
        b = f"<<ERROR {type(e).__name__}>>"

    ratio = difflib.SequenceMatcher(None, a, b).quick_ratio()
    # 长度差异是最粗但最有效的信号:谁丢内容了?
    print(f"{path.name:40s} anydoc={len(a):7d} markitdown={len(b):7d} "
          f"sim={ratio:.2f}")
    if ratio < 0.6:
        # 差异大的样本,人工看
        Path(f"diff/{path.stem}.a.md").write_text(a)
        Path(f"diff/{path.stem}.b.md").write_text(b)

跑一周,把差异大的样本人工过一遍。这一周的成本,远小于「换了解析器结果 RAG 效果掉了但不知道为什么」的成本。


八、总结:确定性回归,不是复古,是分层

anydoc 这个项目最有价值的地方,其实不是那 4.4 毫秒,也不是那 14 种格式。

是它在一个所有人都往 AI 上堆的时刻,提出了一个非常朴素的反问:这个问题真的需要智能吗?

.docx 里的标题层级,是一个明确的 w:pStyle 值。表格的合并信息,是明确的 gridSpanvMerge 状态。列表的编号规则,是明确的 numFmtlvlText 模板。这些不是需要"理解"的东西,这些是需要"读取"的东西。 用概率模型去读确定性数据,不只是浪费,而且是主动引入了本来不存在的错误率。

这个判断可以推广到 AI 工程的很多角落:

  • 结构化数据的抽取,先问问有没有 schema
  • 格式转换,先问问有没有规范
  • 分类任务,先问问有没有确定的规则能覆盖 80%
  • 任何时候,先把确定性的部分榨干,再让模型处理剩下的不确定性

这不是反 AI,恰恰相反——这是让 AI 用在刀刃上。你省下的那 90% 的调用额度和延迟预算,可以拿去做真正需要智能的事:让模型处理那 10% 的扫描件,或者把省下的延迟花在更好的 reranking 上。

2026 年的文档管线,正确的形态是双层的:

                  ┌─────────────────────┐
   文档字节  ───► │  格式检测(内容嗅探)  │
                  └──────────┬──────────┘
                             │
              ┌──────────────┴──────────────┐
              │                             │
     ┌────────▼────────┐          ┌─────────▼─────────┐
     │  确定性快路径     │          │   VLM 慢路径       │
     │  anydoc          │          │   OCR / 版面模型    │
     │  4.4ms · ¥0      │          │   数秒 · 按 token   │
     │  可复现·可审计    │          │   概率性·不可复现   │
     │  覆盖 ~90%       │          │   覆盖剩余 ~10%    │
     └────────┬────────┘          └─────────┬─────────┘
              │                             │
              └──────────────┬──────────────┘
                             │
                  ┌──────────▼──────────┐
                  │  统一 Markdown 输出   │
                  │  → 结构化切块 → RAG   │
                  └─────────────────────┘

anydoc 把左边那条路径做到了目前的工程上限。右边那条路径,交给 VLM。

至于这个项目本身能走多远,我的判断是:它的天花板取决于 Firecrawl 愿意开源多少。目前的开源/商业分界画在 OCR 上,这条线是合理的。如果未来这条线往上挪(比如把表格结构还原也做成付费功能),社区会 fork;如果保持现状,anydoc 有机会成为文档解析领域的 libxml2——一个所有人都在用、但没人需要关心的基础设施。

对基础库来说,「没人需要关心」就是最高的赞美。


附:五分钟上手清单

# 1. 命令行试一下,不用装任何东西
npx @firecrawl/anydoc 你的文档.docx | head -50

# 2. 装到项目里
cargo add anydoc                      # Rust
npm install @firecrawl/anydoc         # Node
pip install firecrawl-anydoc          # Python
npm install @firecrawl/anydoc-wasm    # 浏览器

# 3. 给你的 AI Agent 装上读文档的能力
npx skills add firecrawl/anydoc

# 4. Node 服务记得调线程池
export UV_THREADPOOL_SIZE=16

# 5. 拿自己的语料跑一遍质量审计(编码 + 控制字符)
python audit.py cn-corpus/

项目地址: https://github.com/firecrawl/anydoc · MIT · Rust

基准 harness: 仓库 bench/ 目录(语料不可再分发,需自备)

推荐文章

Linux 常用进程命令介绍
2024-11-19 05:06:44 +0800 CST
Graphene:一个无敌的 Python 库!
2024-11-19 04:32:49 +0800 CST
Vue3中的JSX有什么不同?
2024-11-18 16:18:49 +0800 CST
html折叠登陆表单
2024-11-18 19:51:14 +0800 CST
如何实现生产环境代码加密
2024-11-18 14:19:35 +0800 CST
html一个包含iPhoneX和MacBook模拟器
2024-11-19 08:03:47 +0800 CST
Redis函数在PHP中的使用方法
2024-11-19 04:42:21 +0800 CST
程序员茄子在线接单