公司动态
Python 批量解析电子发票(PDF/OFD/XML)导出 Excel 台账:8 个真实踩坑记录
背景需求很简单一个文件夹几百张电子发票要变成带发票号码、日期、购销双方、金额、税额的 Excel 台账。一开始我以为这是个半小时的活儿pdfplumber抽文本 正则匹配。**结果做了两周。**下面是 8 个把我按在地上的真实问题每个都来自真票调试。如果你也要写这个能省不少时间。先明确一件事电子发票不需要 OCR这是最重要的认知。电子发票的 PDF有文字层字是文本不是图片。数电票的 XML 更是结构化原件。所以正确路线是.xml → 结构化直读 [最可靠] .ofd → 本质是 ZIP解压取内嵌 XML [最可靠] .pdf 有文字层 → 坐标 文本解析 [主力] .pdf 无文字层/图片 → OCR 兜底 [不可靠必须人工复核]把电子 PDF 拿去 OCR是人为把结构化数据降级成图片再猜回来。准确率、速度、成本三输。判断有没有文字层import pdfplumber with pdfplumber.open(path) as pdf: words pdf.pages[0].extract_words() has_text_layer len(words) 20 # 阈值经验值纯扫描件通常为 0坑 1必须先做 NFKC 归一化否则正则全部失配有些发票我遇到的是某出行平台的票文字层里用的是康熙部首区的变体字⼦ ⽅ ⾦ ⼈。这些字长得和常规汉字一模一样你肉眼看是金额比较是 False正则金额匹配不到。调试的时候会出现我明明看见这两个字了但就是抓不到的灵异现象。加上全角冒号、全角括号、全角的问题解决方案是统一归一化import unicodedata def norm_text(s: str) - str: 康熙部首区变体字→常规汉字全角标点→半角 if not s: return return unicodedata.normalize(NFKC, s)所有文本进正则之前先过这一层。这是整个项目里性价比最高的三行代码。坑 2标签和值是分离乱序的文本块不能按行取文本page.extract_text()出来的字符串顺序是按 PDF 内部绘制顺序来的不等于视觉顺序。某运营商的发票名称:和它对应的公司名在文本流里隔了十几个 token。所以必须用extract_words()拿到带坐标的词自己做行聚类def cluster_rows(words, tol_ratio0.6): 按词的垂直中心聚类成行容差按词高自适应 items [] for w in words: cy (w[top] w[bottom]) / 2 h w[bottom] - w[top] items.append((cy, h, w)) items.sort(keylambda t: t[0]) rows, cur, cur_cy [], [], None for cy, h, w in items: tol h * tol_ratio # 关键容差跟着字号走 if cur_cy is None or abs(cy - cur_cy) tol: cur.append(w) cur_cy cy if cur_cy is None else (cur_cy cy) / 2 else: rows.append(sorted(cur, keylambda x: x[x0])) cur, cur_cy [w], cy if cur: rows.append(sorted(cur, keylambda x: x[x0])) return rows两个细节用垂直中心而不是 top同一行里字号不同比如金额比标签大top 会差出半行。容差按词高自适应不要写死PDF 坐标是 pt 尺度一行约 10ptOCR 出来的词框是像素尺度一行可能 30px。写死tol3的话换成 OCR 输入立刻全乱。这个坑在后面接 OCR 兜底时才会爆提前处理。坑 3行聚类兜不住时用标签锚点法有的票标签和值垂直错位我遇到最恶劣的是错 3.5pt正好卡在容差边界聚类时而对时而错。解法不聚类了直接找锚点。def value_right_of_label(words, label): 找裸标签词取其右侧、垂直区间有交叠的最近词 anchors [w for w in words if norm_text(w[text]).strip( :) label] if not anchors: return None a anchors[0] cands [ w for w in words if w[x0] a[x1] # 在右边 and min(w[bottom], a[bottom]) - max(w[top], a[top]) 0 # 垂直有交叠 ] return min(cands, keylambda w: w[x0] - a[x1], defaultNone)垂直区间有交叠比中心距离小于阈值稳得多因为它不依赖阈值。坑 4老式发票和数电票的版面布局完全不同购销双方会搞反这个坑最贵因为它不报错只是把数据搞错。老式增值税发票购买方信息块在货物表格上方销售方信息块在价税合计下方。数电票全电发票购买方和销售方左右分栏并列。如果你只写从上往下第一个公司名当购买方遇到数电票就会稳定出错。我的处理if is_digital_invoice(fields): # 数电票按销字的 x 坐标切左右栏 x_split find_word_x(words, 销) buyer_words [w for w in words if w[x1] x_split] seller_words [w for w in words if w[x0] x_split] else: # 老式票按货物表格 y 坐标切上下 y_table find_goods_table_top(words) buyer_words [w for w in words if w[bottom] y_table] seller_words [w for w in words if w[top] y_total_amount]另外老式票右侧有密码区/备注区里面的文字会污染字段匹配按x 0.55 * page_width裁掉。坑 5二维码里的金额三种票的语义不一样发票二维码是校验金额有没有被改的最硬手段——票面文字能用编辑器改二维码里的数据改不了。但别直接拿二维码金额和价税合计比票种二维码内容金额字段的语义老式增值税发票01/04/10逗号分隔串不含税金额数电票31/32逗号分隔串价税合计深圳区块链发票一个 URLtotal_amount单位是分票面校验码 hash 末 5 位拿不含税金额去比价税合计好票会被误判成改过的票。我一开始就是这么误伤了三张真票查了半天。解码用pyzbar或opencv都行PDF 里的二维码得先渲染成图import fitz # PyMuPDF page fitz.open(path)[0] pix page.get_pixmap(dpi200) # dpi 低于 150 二维码解不出来坑 6OFD 就是个 ZIP不用找什么专用库import zipfile with zipfile.ZipFile(ofd_path) as z: names z.namelist() # OFD.xml, Doc_0/Document.xml, ... # 数电票 OFD 通常内嵌一份原始 XML 发票数据优先找它找到内嵌的发票 XML 后按国税 eInvoice 规范取字段比解析版面文本可靠得多。如果内嵌 XML 找不到再退回解析版面 XML 里的文本块不同开票服务商的 OFD 结构有差异得两条路都准备。坑 7Excel 会毁掉你的 20 位发票号码写 Excel 时如果按数字写入20 位发票号码会变成科学计数法2.53E19末几位归零。你的查重从此静默失效。# openpyxl ws.cell(rowr, column1, valuestr(invoice_no)) ws.cell(rowr, column1).number_format # 文本格式必须显式设坑 8税号校验位GB 32100可以本地验18 位统一社会信用代码有校验位能本地算用来发现打错的税号CHARS 0123456789ABCDEFGHJKLMNPQRTUWXY W [1,3,9,27,19,26,16,17,20,29,25,13,8,24,10,30,28] def check_uscc(code: str) - bool: if len(code) ! 18: return False try: s sum(CHARS.index(c) * w for c, w in zip(code[:17], W)) except ValueError: return False c 31 - s % 31 return CHARS[c % 31] code[17]顺带说一下解析完了才是一半拿到字段之后真正有用的是校验我做了这几层勾稽金额 税额 价税合计浮点比较留 0.01 容差大写金额互验把贰佰捌拾玖元贰角捌分转成 289.28 和小写比。中文大写转数字是个独立小坑零的处理、整结尾、角分二维码互验见坑 5两级查重文件 SHA256 发票号码入库唯一索引以及一条原则OCR 来源的结果一律标待复核不允许直接进账。OCR 认错一位金额的代价比多花两分钟人工看一眼大得多。完整实现上面这些我最后做成了一个 Windows 桌面工具「发票大管家」PySide6 pdfplumber RapidOCR 兜底全离线不联网除了解析还做了台账、查重、税局对账 Excel 自动匹配、按规范批量重命名、合规归档包。自己写脚本的话上面 8 条够你少走两周弯路。不想自己写的闲鱼搜「发票大管家」免费版每月 50 张。有其他没列到的坑欢迎评论区补充——尤其是真实的 OFD/XML 票样本变体这块的公开资料很少。