使用Python从PDF提取发票数据

使用 Python 从 PDF 提取发票数据,你需要读取文件中的文本,然后从这些文本中拉取指定的字段。只需要两步,可能也就四十行代码,而且在你尝试的第一张发票上它能完美运行。然而,当第七个供应商把发票号码向上挪了三行时,你的解析器就会在凌晨两点开始返回 None

这第二部分才是本文真正的核心主题。读取数据很简单。保持数据的持续准确才是真正的工作。

要点总结

  • Python 只需几行代码即可提取发票文本。真正耗费成本的是在数百种供应商排版中保持提取的准确性。
  • PDF 不是一种数据格式。它是打印页面的排版描述,这就是为什么针对某一供应商排版编写的正则表达式会如此脆弱。
  • 正则表达式和针对单个供应商的模板会随你的供应商数量呈线性增长。视觉模型则不然,因为它们是在阅读页面,而不是读取字符串。
  • 无论是什么提取了数据,你自己的代码都必须检查其中的算术逻辑。如果明细行相加不等于小计金额,那将是你写过的成本最低廉的 Bug 探测器。
  • 让人放弃自行构建的往往不是提取环节本身。异常队列处理、供应商匹配和会计系统集成才是。

PDF格式

PDF 格式非常灵活,能够精准呈现如发票等纸质文档,并且对页面设计没有限制。 PDF 源自纸质打印领域,旨在作为打印页面的数字化等效物。 这种弹性提供了极大的自由,使 PDF 创建者可以自由发挥,并符合各种标准和法规要求。

然而,一旦数据被封装在 PDF 文件中,读取数据就成了挑战。 PDF 的自由形式和复杂特性,常常与企业需要结构化和统一处理大量数据的需求发生冲突。

A screen capture of PDF file format layers
PDF file format layers

PDF 会存储每个字符在页面上的位置。但它并不会保存“右下角的数字是总计金额”这一事实。这种关联存在于你的大脑中,而本文介绍的每种提取方法,都是在试图将这种关联编码。

从发票中提取数据的步骤有哪些?

发票通常以 PDF 格式呈现。 发票本质上是供应商与客户之间,为产品或服务的交易提供的文件。 要从这种文档中提取数据,需要以下步骤:

  1. 明确你要从发票中提取的数据结构(schema)
  2. 将发票从图片转换为文本
  3. 根据你的数据结构从发票文本中提取信息
  4. 收集已提取的数据

A screen capture of Invoice data extraction process
Invoice data extraction process

为你的发票数据定义结构(schema)

发票可能来自不同供应商,每个供应商喜欢自定义他们发票的样式。 尽管现实中样式多样,基本内容是一致的:需要包含供应商、客户、发票编号、日期,以及包含数量、描述和金额的项目列表。 定义发票格式的一个好方法是参考你的会计软件,因为最终你很可能会将提取的数据保存到那里。 如果你需要一个能涵盖所有场景的数据格式,推荐使用 schema.org 网站,其为许多场景(包括发票)定义了行业标准格式。 Parseur 定义了发票的默认数据结构,你也可以通过在发票邮箱中重命名字段自定义结构,详细见此处。 一旦数据格式确定好,你就可以将发票从图像转换为文本。

例如,使用 JSON Swagger 格式可以为你的发票定义以下字段:

{
    "InvoiceNumber": {
        "type": "string",
        "description": "发票号"
    },
    "InvoiceIssueDate": {
        "type": "string",
        "description": "开票日期"
    },
    "Items": {
        "type": "array",
        "description": "发票中的商品列表",
        "items": {
            "type": "object",
            "properties": {
                "quantity": {
                    "type": "number",
                    "description": "商品数量"
                },
                "description": {
                    "type": "string",
                    "description": "商品描述"
                },
                "unit_price": {
                    "type": "number",
                    "description": "商品单价"
                },
                "price": {
                    "type": "number",
                    "description": "商品总价"
                }
            }
        }
    }
}

在编写任何解析代码之前,请先写下这些内容。这是以下每种方法都必须满足的契约,也是你稍后将交给视觉模型的内容。

将你的发票从图片转换为文本

A screen capture of an invoice taken from a smartphone
Picture of an invoice taken from a smartphone

PDF 文件有时包含图片内容。例如,员工可能用手机拍发票照片,保存为 PDF 并发给财务部。 会计人员需要从这些发票中提取数据,并无误录入会计系统。 下一步是用光学字符识别(OCR)技术将图像转为文本。 Tesseract 是最流行的 OCR 方案之一。Tesseract 用 C 和 C++ 编写。要在 Python 中调用 Tesseract,可以用 PyTesseract 这样的绑定。绑定允许你用非原生语言(比如 Python)调用库(如 Tesseract)。 市面上相关系统众多,其效果取决于底层技术和文档扫描质量。 Parseur 会自动检测文档是否为图片并在后台自动将其转换为文本。一旦文档数据变为文本,就可以进行数据提取了。

根据数据结构从发票中提取文本

当你的 PDF 已转换为文本(或支持检索),你可以用 pdftotext Python 库将文本提取出来。 下面是一次将 PDF 提取为文本的代码示例:

import pdftotext

# 加载你的发票
with open("invoice.pdf", "rb") as file_handle:
    pdf = pdftotext.PDF(file_handle)

# 遍历所有页面
for page in pdf:
    print(page)

将该脚本命名为 convert_pdf_to_text.py 并运行,即可输出发票文本内容。 如果要重定向到文件,可运行:

$ python convert_pdf_to_text.py > invoice.txt

pdftotext 提供的是扁平的字符串,这对表头字段来说足够了,但对表格而言却毫无希望。当你需要提取明细行时,应该改用 pdfplumber,因为它保留了每个单词的坐标,并能尝试提取表格本身:

import pdfplumber

with pdfplumber.open("invoice.pdf") as pdf:
    page = pdf.pages[0]

    # 带页面坐标位置的单词
    for word in page.extract_words():
        print(word["text"], word["x0"], word["top"])

    # 尝试提取明细行表格
    table = page.extract_table()
    if table:
        for row in table:
            print(row)

在真实的供应商发票上运行这段代码,你会经常发现 tableNone。这不是 Bug。这是第一个明确的信号:这个问题比看起来要难,我们在下文中还会回到这一点。

获得文本后,可以采用以下任意组合方式从中提取所需数据:

  • 使用正则表达式提取数据。正则表达式功能强大但易碎,遇到格式变化就需调整,且应对表格提取较为困难。
  • 你可以利用可视化模板系统,理想情况下结合动态OCR区域OCR。这是一种更高级的文本数据提取方式。它比正则表达式更强大,但实现起来也更复杂。
  • 你可以将页面和数据结构(schema)一起交给视觉模型,让它像人类一样阅读文档。这是过去两年里发生变革的方法,下文会专门用一节来详细讨论。

示例:用 Python 的正则表达式 re 模块从发票中提取发票号码:

import re

# 加载发票文本
with open("invoice.txt", "r") as file_handle:
    invoice = file_handle.read()

# 提取发票号
invoice_number = re.search(r"Invoice number: (\w+)", invoice).group(1)
print(invoice_number)

保存脚本为 extract.py 并运行,将在终端输出发票号码:

$ python extract.py

输出结果类似:

INV-1234

为什么你的正则表达式发票解析器会失效

正则表达式匹配的是字符串。而发票是一张图片。所有出错的问题都源于这种不匹配,通常会以少数几种可预测的方式出错:

  • 标签位置移动。 你的匹配模式锚定在 Invoice number:,而新的模板可能写成了 Invoice #,或者把值放在了下一行而不是同一行。
  • extract_table 返回 None 表格提取器通常寻找边框线,但大多数供应商的发票只用空白对齐列,根本不画边框。
  • 文本顺序错乱。 当页面被展平为字符串时,双列排版和浮动的地址块会交织在一起,导致你的明细行数据顺序混乱。
  • 表格跨页。 第二至第九行在第一页,第十至第十四行在第二页,中间重复了一次列标题,还可能冒充明细行插进一个小计。
  • 多个数字看起来都像总计。 小计、总计、应付金额、结转余额等。在带有贷方余额的发票上,选取最大的那个数字往往是错误的。
  • 扫描件是照片。 OCR 会把一个模糊的 8 读作 3,而下游的任何程序都不会察觉,因为 3 是一个完全合法的数字。
  • 合并单元格与多行描述。 一个产品描述跨越了三行,你的拆行逻辑可能会把它变成三个没有价格的明细项。

这些都不是靠更好的正则表达式就能解决的。它们都是披着字符串问题外衣的排版问题。遇到这种情况,大多数人要么开始为每个供应商编写一个模板(这会无休止地增长),要么选择改变方法。

2026 年的方法 —— 视觉模型与数据结构(schema)

有用的转变在于,你不再需要在提取之前将页面展平为文本。视觉模型直接查看渲染后的发票,因此供应商移动其发票号不再是重大事件。你提供的不再是模式(pattern),而是你在本文开头编写的那个数据结构(schema)。

限制输出内容,以便每次都能获得相同的键。Pydantic 加上结构化输出模式,比如 OpenAI's structured outputs,可以为你做到这一点:

from typing import List
from pydantic import BaseModel

class LineItem(BaseModel):
    description: str
    quantity: float
    unit_price: float
    amount: float

class Invoice(BaseModel):
    vendor_name: str
    invoice_number: str
    invoice_date: str      # ISO 8601
    currency: str
    subtotal: float
    tax: float
    total: float
    line_items: List[LineItem]

# 将 PDF 页面渲染为图像并发送给视觉模型,
# 并要求响应必须符合 Invoice 数据结构。
# 模型只负责填入对应字段,不能自行发明数据形状。

这确实解决了大部分的数据提取问题,也是这种方法传播如此之快的原因。它还引入了一种正则表达式从未有过的新故障模式:找不到发票号的正则表达式会返回 None,而找不到发票号的模型有时会编造一个看似合理的数字。保障安全的规则很简单:模型负责提案,你的代码负责验证。

如果你不想自己运行模型,云服务商也将同样的功能作为托管服务出售,例如 Azure AI Document IntelligenceAmazon Textract's AnalyzeExpense,两者都能分别返回表头字段和明细行。我们在 AI vs 规则PDF解析器 中详细说明了底层方法与基于规则解析的区别,以及在 视觉AI发票处理 中专门探讨了它应用于发票时的表现。

你需要自己编写的验证层

无论是什么生成了你的 JSON,这些检查都应该写在你的代码里,而不是依赖提取器的置信度得分。它们成本低廉、确定性强,并且能捕获那些会导致资金损失的错误:

  1. 小计 + 税费 + 运费 - 折扣总计 对得上,误差在 1 分钱以内。
  2. 明细行金额之和等于小计。如果不等,说明你漏了一行或多算了一行。
  3. 每一行的 数量 * 单价 都等于它本身的 金额
  4. 日期能被成功解析,且不是未来日期。
  5. 这个发票号对于该供应商而言,尚未被支付过。重复付款是应付账款中最昂贵的 Bug。
  6. 供应商名称能在你的供应商主记录中匹配到。
  7. 汇款的银行信息与该供应商已登记在案的信息一致。此处的变更属于欺诈防范检查,而不仅仅是数据检查。
  8. 币种是你实际业务在使用的币种。
  9. 如果运行双向或三向匹配,采购订单号应存在,且其数量和价格应能对得上。
  10. 每个必填字段都存在且非空。

任何未通过验证的内容交由人工处理,而不是直接录入账本:

def validate(invoice):
    errors = []

    if abs(invoice.subtotal + invoice.tax - invoice.total) > 0.01:
        errors.append("totals_do_not_add_up")

    line_sum = sum(item.amount for item in invoice.line_items)
    if invoice.line_items and abs(line_sum - invoice.subtotal) > 0.01:
        errors.append("line_items_do_not_sum_to_subtotal")

    if not invoice.invoice_number:
        errors.append("missing_invoice_number")

    return errors

十行算术代码比任何程度的提示词(Prompt)微调都能捕获更多实际问题。

invoice2data 和其他库怎么样?

invoice2data 值得我们给予中肯的评价,因为它是许多人搜索到的第一个结果,且确实是一款非常优秀的软件。它是一个命令行工具和 Python 库,能将发票与你为每个供应商编写的 YAML 模板进行匹配,匹配规则存放在版本控制系统中而不是埋在代码里。如果你只有十几个排版永不改变的供应商,它能为你出色服务多年。

它的局限性在于设计本身,而不在于软件质量。每个供应商一个模板意味着你的维护工作将随着供应商列表的增长而增加,而且模板失效的原因与上文列举的一模一样。当活动排版数量达到20到30个之间时,维护模板的人付出的劳动,甚至可能超过当初手动重新录入发票的人。

相同的逻辑也适用于更广泛的开发库库。pdfplumber、PyMuPDF、pdftotext 和 pytesseract 在它们本身的工作——即从页面获取字符和坐标方面都非常出色。但它们从没被设计来知道哪个数字才是总额。我们在 最佳 PDF 解析器最佳数据提取 API 的综述中对比了更多广泛使用的工具。

收集已提取的数据

你可以用 Python 批量循环某个文件夹下发票,提取其中的数据。 假设我们提取发票号和总金额,并输出为 CSV 格式:

import os
import re

import pdftotext

# 遍历文件夹下所有 PDF 文件
for filename in os.listdir("invoices/"):
    if not filename.endswith(".pdf"):
        continue

    # 加载发票
    with open("invoices/" + filename, "rb") as file_handle:
        pdf = pdftotext.PDF(file_handle)

    # 打印CSV列标题
    print("InvoiceNumber,TotalAmount")

    # 遍历所有页面
    for page in pdf:
        # 提取发票号
        invoice_number = re.search(r"Invoice number: (\w+)", page).group(1)
        total_amount = re.search(r"Total amount: (\w+)", page).group(1)
        print(invoice_number, total_amount, sep=",")

将脚本命名为 extract_to_csv.py 并运行,即可将发票号和总金额输出到终端, 也可以将结果重定向为 CSV 文件,用 Excel 等表格软件查看:

$ python extract_to_csv.py > invoices.csv

大多数发票脚本在生成一堆 CSV 文件后就停止了,而这也是真正繁杂的会计工作的开始。依然需要有人去导入这些文件、匹配供应商、跟进被会计系统拒收的明细行,并弄清楚该如何处理凌晨2点验证失败的发票。这些工作不会出现在你的脚本中,也不会出现在你的 Token 账单里。

何时应该停止自行构建

这里是许多供应商常会跳过的内容,我们先坦诚讲。如果你只有少数固定合作的供应商,一名能时刻盯着数据管道的工程师,而且没有人急着等这些数据,那就自己写脚本。一个视觉模型加一个 schema 以及上面介绍的十行验证代码,就能以极低的成本带你走很长一段路,并且你会完全理解其中的每一部分。

自行构建的账单总在晚些时候到来,且绝不会在提取这一环节。它总是出现在那些无人去编写原型的部分:

  • 异常队列。 你的 AP 团队需要这样一个界面:点击某个字段时,能在发票上高亮显示它的位置,这样他们只需 4 秒钟就能完成修正,而不是花 40 秒。这是一个产品,而不是一个脚本。
  • 供应商匹配。 "ACME Ltd"、"Acme Limited" 和 "ACME LTD." 都是同一家供应商,但账本系统是不会接受建立三个不同记录的。
  • 重复检测。 同一张发票周二作为邮件附件收到一次,周五又作为对账单 PDF 再次收到。
  • 状态机控制。 处理队列、重试、部分失败,以及掌握昨晚的 300 张发票究竟有哪些真正处理成功了。
  • 审计追踪。 提取了什么,人工修改了什么,是谁批准的,何时批准的。财务部门总是会问的,通常是在审计期间。
  • JSON提取之后的所有工作。 映射到会计系统的字段、总账(GL)编码、采购订单(PO)匹配,以及处理被系统拒收的行。

以下这些明确的信号,意味着是时候该花钱购买而不是自行构建了。你的活跃供应商排版大致已超过20到30个。你需要提取明细行,而不仅仅是表头字段。你有相当数量的发票是扫描件。你需要由 AP 团队(而不是工程师团队)来修复错误。提取失败已经开始导致付款延误。或者最常见的情况是:负责维护该解析器的人,已经没有精力去交付其他任何东西了。

Parseur 是如何处理的

Parseur 是一款处理整个循环闭环(而不仅仅是提取步骤)的文档解析器。发票可以投递到专用的邮箱地址、通过 API 传入、或从受监控的文件夹中获取。视觉AI引擎 负责读取 PDF、扫描件和照片,而文本 AI 引擎负责读取电子邮件和文本文档。所有的字段在输出时都已经命名并设定好类型,明细也都会作为多行输出。你不需要编写任何模板,即便供应商重新设计了他们的发票,你也不需要进行任何维护。

你在获得 JSON 之外得到的,正是本文一直提醒你要小心的另一半工作。所有提取的数据都可以原地被审核和修正,AP 人员无需提交技术工单就能修复读取错误的金额。而且任何修正都能直接反馈到后续的提取中。最重要的是,数据能顺畅流向它需要去的地方——无论是通过 Webhook 直接集成MakeZapier 或是 Microsoft Power Automate 进行传输,亦或直接从 API 作为 JSON 拉取。

如果你想了解我们默认从发票中提取的数据字段集合,可以在我们的 发票OCR页面 找到文档,而更广泛的业务工作流则包含在 发票数据捕获 一文中。

注册您的免费账户
使用 Parseur 节省时间和精力。自动处理您的文档。

结论

使用 Python 从 PDF 提取发票数据,在处理约一百张发票时是个已经解决了的问题,但在处理一万张时依然是个未解难题。这两个量级之间发生改变的并不是代码,而是你默默承受并负责维护的供应商排版的数量,以及当其中某个排版发生变化时,谁会被半夜叫醒处理问题。

去写这个脚本吧。就算只是为了弄清楚前面提到的 7 种失效模式里,究竟是哪一种会最先找上你,这也是真正值得尝试一次的事情。但之后,请诚实地评估:你未来 6 个月的时间,是否真的值得花在修复第 8 个失效模式上?如果答案是否定的,请记住 Parseur 从 2016 年起就在解决这类问题,随时乐意接手这个棘手的文件夹。

最后更新于

深入了解

你可能还喜欢

立即开始

告别手动录入,
就从今天起。

几分钟免费上手,亲自体验Parseur如何融入您的工作流。

无需训练模型
为真实业务场景打造
操作足够简单,API足够强大

常见问题解答

当第一个发票脚本成功运行,而第二个供应商的发票却让它崩溃时,开发者们最常问的那些问题。

你可以使用像 pdfplumber 或 pdftotext 这样的库读取 PDF 中的文本,然后从这些文本中提取指定字段。对于原生的数字 PDF,这只需两步即可完成。但对于扫描件,你需要先进行一次 OCR 处理,将图像转换为文本。在生产环境中,决定它是否奏效的关键并非文本读取,而是当每个供应商的排版布局都各不相同时,你该如何将满屏的文本转化为准确的发票号、日期、供应商信息和明细行。

invoice2data 是一个开源的命令行工具和 Python 库。它使用你为各个供应商编写的 YAML 模板来从发票中提取字段。如果你拥有一批数量不大且格式稳定的供应商,并且希望将匹配逻辑存放在版本控制中而不是硬编码在代码里,那么它非常适合。但当你为每个供应商编写和维护模板的成本,开始高于人工重新录入的成本时,它就不再是好方案了。

请将明细行表格视为独立于表头字段的提取任务,并在解析前跨页重建整个表格。pdfplumber 的 extract_table 适用于有清晰划线的表格,但在大多数无边框的供应商发票上会返回空值。可靠的方法是:针对每种供应商的排版检测一次列边界,将这些边界延伸跨越分页符,去掉重复的表头行,然后检查你提取的行金额之和是否等于小计金额。如果不等,就说明你漏了一行。

必须依靠你代码中的确定性检查,永远不要仅凭提取器的置信度得分。核心检查项目包括算术计算和身份识别。确认“小计 + 税额 + 运费 - 折扣”等于总计金额;确认各明细行的总和等于小计;确认日期能正确解析且不是未来日期;确认该供应商的发票号尚未被支付过;确认供应商存在于你的主供应商记录中;确认币种是预期中的。任何未通过验证的发票都应交由人工处理,而不能直接入账。

当数据提取不再是你面临的最困难部分时。调用视觉模型的成本很低,也容易开发原型。因此如果你只有少数固定的供应商,且有人可以照看运行流程,那就自己构建。真正的高昂成本往往在后头:队列和重试逻辑、你的应付账款(AP)团队所需的异常审核界面、重复发票检测、供应商匹配、审计追踪以及与会计系统的集成。在比较单页价格之前,请先评估这些隐性成本。

可以,但在文本库能读取内容之前,扫描件需要先经过 OCR 处理。拍照或扫描的发票本质上是嵌套在 PDF 中的图像,因此 pdfplumber 和 pdftotext 对此会返回空字符串。常见的开源路线是在对图像进行倾斜校正和清理后,通过 pytesseract 绑定调用 Tesseract。而现代的视觉模型则完全跳过这一步,直接读取图片信息。

并不存在专门针对发票的库,只有 PDF 库加上你自己的逻辑。pdfplumber 通常是首选,因为它可以提取出带有坐标信息的单词,并且包含一个表格提取器。PyMuPDF 在大批量处理时速度更快。当你只需要纯文本时,pdftotext 是最轻量级的有效方案。pytesseract 则专用来处理扫描件。它们都能为你提供文本或边框坐标,但没有任何一个库能告诉你哪个数字才是总计金额。

因为正则表达式匹配的是字符串,而发票是一张图片。你的匹配模式通常是锚定在一个标签、一个换行符,或者是供应商在新模板中移动了的列位置。发票是半结构化的可视化文档,导致代码失效的往往是排版布局问题,而非字符串问题。比如跨页表格、重复的表头、合并的单元格,以及小计、总计和应付金额之间的混淆,这些都是无法通过改进正则表达式来修复的。

可以,并且这已经是目前从 PDF 到结构化 JSON 最短的路径,但前提是必须要有 schema 和验证器。视觉模型能像人类一样阅读发票,所以供应商改变排版不再是大问题。使用 JSON schema 限制输出格式,可以借助 OpenAI structured outputsPydantic 模型,这样你每次都能获得相同的键。之后,请务必亲自进行算术验证。因为在找不到发票号时,模型有时会自行编造出一个看似合理的号码。

准确率取决于具体的字段和扫描质量,而不是取决于产品。因此,请将任何单一的超高百分比数据视为营销话术。干净的原生数字 PDF 上打印的总计金额和发票号可以近乎完美地返回。但在拍照倾斜、对比度低的扫描件上,同样的字段却极易造成数字混淆,而一个错误的字符意味着真实的金钱损失。真正值得衡量的数据不是字符准确率,而是有多少张发票能做到在无人为干预的情况下顺利进入你的会计系统。

你要停止为每个供应商单独编写任何代码。任何需要为每个供应商设定模板、正则表达式组或坐标映射的方法,都会随着供应商列表的增长呈线性膨胀。当活动排版数量超过20到30种后,这种方式无法减轻任何人的工作量。能以视觉方式阅读文档的模型,在设计上就不依赖特定格式,这就解释了为何排版漂移不再成为一项维护负担。真正还需要你关注的是异常队列,但那是一个工作流问题,而不是提取问题。

编写 CSV 只需要几行 Python 代码,这算是简单的部分。困难的部分在于将数据接入实际付款的系统,这意味着要把你的字段名映射到它的数据结构中、将提取出的供应商匹配到现有的档案记录、处理系统拒收的行数据,以及对失败的调用进行重试。从一开始就应将重点规划在后半部分,因为如果只生成一个仍然需要人工手动导入的 CSV 文件夹,那根本无法为任何人节省一个下午的时间。