ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

发票验真爬虫实战:Python自动化、验证码识别与反爬规避指南

发票验真爬虫实战:Python自动化、验证码识别与反爬规避指南

简介:这是基于Java爬虫技术对接增值税发票查验平台的完整实现项目,面向需要实现发票自动验真的Java开发者和企业财务系统设计人员。项目以Maven工程组织,核心Java源码共7个,覆盖页面表单分析、POST请求构造、验证码/异常处理与返回结果解析等关键环节。压缩包内共93个文件,包含pom.xml及IDEA配置等XML文件、sample样例数据、js辅助脚本,并附带完整Git仓库结构,方便直接查看历史提交与工程配置。整体仅1.61MB,轻量易部署,已有684人浏览学习。读者可基于该源码快速理解爬虫对接验真平台的全流程,复用请求构建、Jsoup/Gson解析及多线程优化思路,为企业发票核验场景提供可落地的参考实现。

1. 发票验真爬虫:为什么不用人工点,而要写代码

月底财务对账,手里摞着几百张增值税发票,一张张打开税局平台、输代码、输号码、输入开票日期和校验码,再对着验证码眯眼辨认——一套流程走完,半小时过去,眼也花了,错也来了。这个活儿如果只是偶尔三五张,人工点完全没问题;但一旦到了供应商对账、ERP 核票、报销单批量审核的场景,人工验真的成本就不再是“几分钟”,而是“一个下午”。所谓“爬虫通过增值税发票验真平台实现发票验真”,就是用 python 爬虫把这张发票的查验请求自动化:模拟浏览器访问税局发票查验平台,拿到验证码并识别,提交发票要素,再解析返回的真实状态。适合谁?适合有批量发票核验需求、又想省掉重复劳动的开发者和业务系统负责人。这篇笔记把完整的请求链路、最小可用代码、参数设置和踩坑记录都拆开讲。

2. 先拆平台:验真流程、请求链路与反爬边界

2.1 平台到底在验什么:发票要素与返回内容

增值税发票查验平台的核心逻辑并不复杂:你把发票上的一组关键要素提交上去,平台返回这张发票在税局系统里的真实登记状态。要提交的要素,就是票面上最显眼的那几项:

  • 发票代码(10位或12位数字,部分新发票已取消代码,用号码代替)
  • 发票号码(8位数字)
  • 开票日期(格式为 YYYY-MM-DD)
  • 校验码后六位(增值税专用发票等票种显示在票面右下角)
  • 不含税金额或价税合计(部分票种需要,用于二次核验)

平台返回的结果一般包括:发票状态(正常、作废、失控等)、销方名称、购方名称、开票金额、税额、货物或应税劳务名称清单。对做对账系统的人而言,最有价值的其实就是三样东西:状态是否正常、金额是否一致、购销方是否对得上。

这里有一个容易误判的地方:很多人以为验真平台的输入项是“发票代码 + 发票号码 + 金额”三件套,实际并不是所有票种都只用三个字段。你在写爬虫的时候,不要想当然地固定 payload 结构,要先拿一张样本票去平台页面手工查一次,留意它到底让你填了哪些字段。不同票种(专票、普票、电子票、通行费票)的必填项会有差别,这个差别最后会在你的解析逻辑里变成分支条件。

2.2 一次查询背后的请求链路:从首页 Cookie 到表单提交

把整条流程拆开看,一次验真请求大致走四个步骤:

  1. 访问平台首页,拿到会话 Cookie 和相关会话标识
  2. 获取图片验证码(部分时段是滑块验证码)
  3. 带上发票要素和验证码,向查验接口提交 POST 请求
  4. 接收返回的查验结果页面或 JSON 数据,解析出结构化结果

这个链路里,第一步容易被忽略,但恰恰最关键。平台对未带会话标识的请求,基本上会直接拒绝或要求重新验证。用 requests 写爬虫时,最常见的做法就是维护一个新的 Session 对象,把所有请求都走同一个会话,保证 Cookie 不丢失。

第二步的验证码,是整条链路里最动态的部分。平台有时是四位字符图片验证码,有时是滑块验证码。字符验证码走 OCR 识别,滑块验证码得用自动化浏览器模拟拖拽。写爬虫之前,先去页面上看当天的验证码形态,再决定技术选型,别一上来就把代码写成固定的图片识别流程。

2.3 反爬设计:知道边界在哪里,才知道怎么写代码

平台本身有一套反自动化机制,这几年肉眼可见地在“加防御”。热词里那些“关于前端的一些安全技术操作”“怎么在前端进行防止爬虫、防止查看页面源码”之类的话题,在这个平台上都能找到对应物。但说实话,这类前端防御手段在纯 requests 请求面前意义有限,真正影响爬虫的其实是下面几件事:

  • 验证码形态动态变化(字符→滑块切换,识别方式完全不同)
  • 会话有效期很短(Cookie 过期后请求直接失败,有时候只有几分钟)
  • 请求频率限制(高频请求会触发二次验证甚至 IP 临时封禁)
  • 结果数据不是纯静态 HTML,部分数据通过页面内嵌脚本渲染

做这类爬虫,你需要有一个认知:它不是那种“跑一次就永久可用”的脚本。平台的页面结构、参数名、验证码形态都可能调整,写代码时把会话、请求头、验证码识别、提交、解析这几个环节拆开封装,后续哪一块变了就只改哪一块。我就是按这个思路来写的,下面直接把最小可运行链路给你。

3. 用 requests 跑通最小验真链路:会话保持、验证码与结果解析

3.1 准备工作:构造一个带真实浏览器身份的 Session

先创建一个 requests.Session,把请求头统一设置好。很多第一次写这类爬虫的人,死在第 0 行:直接用了 requests.get() 裸请求,平台返回一个友好提示页,于是误判成“接口变了”。实际上平台判断的是请求头缺少浏览器特征。

import requests s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", }) # 第一次访问首页,让 Session 拿到平台的初始 Cookie home = s.get("https://inv-veri.chinatax.gov.cn/", timeout=10) print(home.status_code, s.cookies.get_dict())

逻辑说明:浏览器访问一个网站时,会先请求首页,服务器在下发页面内容的同时通过 Set-Cookie 写入会话标识。我们用同一个 Session 模拟这个过程,之后的验证码请求和查询提交都带着这套身份。

参数说明:timeout 设 10 秒足矣。不要设太长,否则某个请求卡住时整个脚本会原地等待。User-Agent 用新版 Chrome 的标识是惯用做法,平台对极老版本浏览器的 UA 有概率拒绝。首次拿到 Cookie 后打印一下字典,确认 s.cookies 不为空再往下走。

3.2 验证码识别:图片验证码的 OCR 方案与重试机制

下一步是请求验证码图片。老版本的平台是字符验证码,请求一个图片地址,返回 JPEG,需要识别。新版本平台有滑块形态,那套方案放到避坑章节单独讲,这里先把最常见的图片验证码流程写完。

import ddddocr # 1. 从平台获取验证码图片 captcha_url = "https://inv-veri.chinatax.gov.cn/verify/captcha" # 以实际抓包为准 resp = s.get(captcha_url, timeout=10) if resp.status_code == 200 and len(resp.content) > 1000: # 2. 用 ddddocr 做 OCR 识别 ocr = ddddocr.DdddOcr(show_ad=False) code = ocr.classification(resp.content) print("识别出的验证码:", code) else: # 3. 拿到的可能是一个错误页或反爬提示,重新获取 print("验证码获取失败,重置会话后重试") s.cookies.clear()

逻辑说明:先判断响应长度,能过滤掉大部分“反爬提示页”。ddddocr 是专门针对验证码场景的开源 OCR 库,对字母数字混合的字符验证码识别率尚可。识别完成后,将 code 作为字段值加入查询表单。

参数说明:len(resp.content) > 1000这个阈值是我踩出来的经验值,正常验证码图片通常有十几 KB,错误页反而体积小。若验证码图片是 WebP 格式,ddddocr 也支持直接输入字节流,不用手动转格式。识别出来的验证码偶尔会包含小写字母,平台一般忽略大小写,但保险起见可以统一转为大写。

3.3 提交验真表单:字段名、日期格式与校验码规则

拿到验证码后,构造查询参数并提交 POST 请求。字段名不要照着我的代码硬抄,以你实际从浏览器开发者工具里抓到的 payload 为准,我这里给出的是通用结构:

import time verify_url = "https://inv-veri.chinatax.gov.cn/verify/query" # 以实际抓包为准 data = { "fpdm": "发票代码", # 发票代码 "fphm": "发票号码", # 发票号码 "kprq": "2025-06-18", # 开票日期,必须是这种格式 "kjhj": "123456", # 校验码后六位,或金额,按票种要求 "yzm": code, # 上面 OCR 出来的验证码 } response = s.post(verify_url, data=data, timeout=15) print(response.status_code) time.sleep(2) # 查询间隔,避免连续请求触发限流

逻辑说明:POST 请求把发票要素和验证码一并提交,平台在服务端校验验证码与发票数据,返回结果页。开票日期格式必须严格为 YYYY-MM-DD,否则平台会返回日期格式错误提示。

参数说明:kjhj字段是“校验码后六位”的通用场景。注意,有些票种提交的是不含税金额而不是校验码,判断依据是平台表单页的输入框提示文字。建议在表单页源码里定位输入框的 name 和 placeholder,再映射到 payload 里。每次查询之间加 2 秒间隔,是批量场景下的底线节奏,后面并发章节会细说为什么。

3.4 解析返回结果:用 lxml 的 xpath 提取结构化字段

查询完成后返回的是一个 HTML 页面,里面嵌着查验结果。解析不推荐用正则硬抠,用 lxml 的 xpath 定位节点,这正是你在热词里看到的“python xpath爬虫 text函数”的套路。

from lxml import etree tree = etree.HTML(response.text) # 发票状态,通常在结果页某个 class 为 result-status 的标签里 status_nodes = tree.xpath('//div[contains(@class, "result-status")]//text()') status = "".join(status_nodes).strip() # 购方名称、销方名称、金额等字段,用表格行定位 buyer = tree.xpath('//td[contains(text(), "购方名称")]/following-sibling::td[1]/text()') seller = tree.xpath('//td[contains(text(), "销方名称")]/following-sibling::td[1]/text()') amount = tree.xpath('//td[contains(text(), "价税合计")]/following-sibling::td[1]/text()') print("状态:", status) print("购方:", "".join(buyer).strip()) print("销方:", "".join(seller).strip()) print("金额:", "".join(amount).strip())

逻辑说明:contains(@class, "result-status")是模糊匹配 class 属性,因为平台页面上 class 常有多个值,精确匹配容易漏。following-sibling::td[1]表示取当前 td 的右边相邻单元格,这是从“标签 + 值”型表格里取值最快的办法。

参数说明:如果提取出来是空列表,先不要怀疑 xpath 写错,去浏览器里看一下结果页的 HTML 结构。最可能的坑是结果数据通过 JavaScript 动态写入,原始 HTML 里只有空壳节点。这时候要么改用 Playwright 渲染页面再解析,要么找页面里内嵌的 JSON 数据对象,直接解析 JSON。

4. 参数配置与并发控制:让批量验真不翻车的三个刻度

4.1 必须调好的三个参数:超时、重试与查询间隔

爬虫写通之后,下一步就是批次跑。批量场景下,参数配置决定你是“几个小时跑完”还是“跑 10 分钟被平台拦截”。我一般会固定三组参数,写进配置文件而不是散落在代码里:

参数推荐值说明
timeout10s(页面)/ 15s(查询)请求超时时间,超过即放弃本次
retry2 次,指数退避验证码识别失败或网络抖动时重试
interval2~3 秒 / 张单线程下一次验真后的固定等待时间
concurrency2~3 个线程并发数,过高无意义,反而触发限制

超时时间要分场景设。请求首页和验证码图片,10 秒足够;查询接口因为服务端要查税库,15 秒比较稳妥。重试逻辑不能做成“死磕”,连续失败 2 次以上基本说明会话过期或平台结构有变,这时候应该停脚本、人工检查,而不是把请求量加大。

间隔时间和并发数是这套参数里最有玄学味道的部分。平台不是完全没有风控,连续高频请求会在某个临界点触发二次验证,一旦触发,你前面所有会话全部作废,需要重新走一遍验证流程。所以我在批量任务里默认同一个会话最多连续查询 20 张后主动重建会话。

4.2 并发控制:单机加线程池就够了,别急着上分布式

热词里挂着“分布式爬虫”“scrapy爬虫项目”,但在这个场景下,我要明确泼一盆冷水:发票验真平台是强服务端校验、低频查询型接口,瓶颈根本不在你的爬虫并发能力,而在平台的服务端处理能力和风控阈值。把并发从 1 提到 5,总耗时可能只缩短 20%,被拦截的概率却要翻几倍。我做这类任务,单机用 ThreadPoolExecutor 控住并发数就够了。

from concurrent.futures import ThreadPoolExecutor, as_completed import threading # 用信号量把并发压到 3,避免一瞬间打出去太多请求 semaphore = threading.Semaphore(3) def verify_one(invoice): with semaphore: code, cookies = fetch_captcha() result = submit_verify(invoice, code) return invoice["fpdm"], result.status invoices = load_invoice_list() # 从 Excel 读取的发票数据 with ThreadPoolExecutor(max_workers=3) as executor: futures = {executor.submit(verify_one, inv): inv for inv in invoices} for future in as_completed(futures): fpdm, status = future.result() print(fpdm, status)

逻辑说明:Semaphore 在这里是“限流闸门”,即使线程池有 3 个 worker,信号量保证真正在跑的请求不超过 3 个。as_completed让主线程每拿到一个结果就输出一条日志,跑批时能实时看到进度。

参数说明:max_workers=3和Semaphore(3)是配套的。你可以把线程数设为 5,但信号量锁在 3,效果是线程池有空闲、实际并发不会冲破阈值。这样做的目的是给“慢请求”留缓冲——某个请求卡在超时边缘时,其他请求可以及时顶上。

4.3 会话与 Cookie 管理:什么时候必须重建会话

批量查询跑了一段时间后,会遇到一个现象:前 20 张都是秒回,第 21 张开始连续报错,验证码识别也总是失败。这不是你的代码出了问题,是会话到期了。平台对会话有效期做了限制,加上每次验证码请求都在刷新会话状态,旧会话长时间使用后会进入半失效状态。

我习惯的做法是封装一个get_session()函数,每次调用时检查上次请求距现在的时间,超过 10 分钟就重建 Session 并重新请求首页。

import time last_session_time = 0 def get_session(): global last_session_time now = time.time() if s is None or now - last_session_time > 600: new_session = requests.Session() new_session.headers.update(headers) new_session.get("https://inv-veri.chinatax.gov.cn/", timeout=10) last_session_time = now return new_session return s

逻辑说明:重建会话的代价很小,无非是多一次首页请求。但每次重建后,旧的验证码自动失效,所以必须接着走“刷新验证码→识别→提交”的完整流程。我的代码里把会话对象做成模块级变量,就是方便 get_session() 和查询流程共享。

5. 避坑记录:验证码、会话失效与返回解析的五个实战坑

5.1 验证码识别明明正确,提交却被拒

现象:OCR 识别出来的验证码在页面上看起来是对的,但提交后返回“验证码错误”。

原因:平台下发的验证码图片可能带有时间戳绑定,从获取到提交超过一定时间就失效;另一个常见原因是 OCR 把易混淆字符识别错了——0 和 O、1 和 l、2 和 Z 在图片上极难区分。

解决:给验证码流程加一个“识别→重试”循环,最多尝试 3 次,每次都重新获取新图片。不要复用同一张验证码图片提交多次,平台对重复提交同一验证码会直接拒绝。另外,把 OCR 结果中的 O 统一替换成 0、I 统一替换成 1,这个规则改完识别成功率能提升不少。

5.2 查询成功,但 xpath 提取结果为空列表

现象:response.status_code 是 200,页面也有内容,但tree.xpath(...)返回空列表。

原因:平台查询结果可能是异步渲染的。初始 HTML 里包含查询框架,结果数据通过界面脚本在页面加载后动态填充。requests 拿到的是未渲染的源码,自然解析不到结果。

解决:最直接的办法是分析响应文本中的内嵌 JSON,通常结果数据就藏在某个 JavaScript 变量里。用正则或 json 解析把它抠出来。如果 JSON 也没有,就只能上 Playwright 无头浏览器渲染页面后解析。这个方案慢,但稳定。我通常优先找 JSON,只有找不到才上浏览器。

5.3 连续查询后突然跳转到安全验证页

现象:前 30 张发票验得好好的,第 31 张开始返回的页面变成了一个要求“拖拽滑块”或“点击重试”的安全确认页。

原因:平台检测到当前 IP 在短时间内发起了过多查询,触发了频率限制。这个阈值外人无法精确得知,但经验上连续查询超过 20~30 次就会触发。

解决:把单线程查询间隔从 2 秒拉长到 4 秒,并发降到 1,连续查询 20 张后主动等待 10 秒。如果滑块验证页已经出现,说明会话被标记了,最有效的处理是停止脚本,等 5 分钟以上再重新运行。不要试图自动过滑块,这个方向上投入产出比太低。

5.4 请求返回 403,且重置会话后依旧失败

现象:所有接口突然都返回 403 Forbidden,新建 Session、换 UA 都不管用。

原因:IP 被临时封禁了。一般是因为前面某轮查询频率过高,平台对网段做了短时间限制。

解决:这是唯一一个需要“等”的坑。临时封禁通常是 15~30 分钟,过了窗口自动恢复。我的做法是在脚本里加一个全局熔断器:连续出现 5 个 403 就停止全部任务,写日志后退出,不自动重试。等人工确认时间窗口后再手动运行恢复脚本。

5.5 校验码后六位从票面哪里取,总是取错

现象:用户反馈查询结果总是“发票不存在”,但发票确认是真实开具的。

原因:校验码在票面上的位置和位数,不同票种不一样。增值税专用发票显示在“校验码”一栏,共 6 位;电子普票有的显示 6 位,有的是“校验码后 6 位”需要自己截取。加上部分新发票已经取消发票代码,这时要传的是号码本身而不是代码,漏了这个差异,查询结果自然对不上。

解决:做票面字段标准化映射。拿到一张发票后,先标记票种,再按票种规则提取字段。我一般把规则写成一个字典,每种票种对应一组字段提取规则,这样批量跑不同类型发票时不会搞混。

6. 进阶:把验真结果接进业务系统的最后一公里

6.1 封装成批量验真脚本:Excel 进,Excel 出

单张验真能跑通后,落地到业务系统靠的是完整的输入输出闭环。我用 pandas 读 Excel,逐行取发票要素,调用验真流程,把返回结果写回 DataFrame,最后输出新文件。代码框架如下:

import pandas as pd df = pd.read_excel("invoices.xlsx") results = [] for _, row in df.iterrows(): res = verify_one( fpdm=row["发票代码"], fphm=row["发票号码"], kprq=row["开票日期"].strftime("%Y-%m-%d"), check_code=str(row["校验码后六位"]) ) results.append({ "发票代码": row["发票代码"], "发票号码": row["发票号码"], "验真状态": res.status, "购方名称": res.buyer, "价税合计": res.amount, }) pd.DataFrame(results).to_excel("verify_result.xlsx", index=False)

这段代码没有花哨技巧,但你在生产环境要盯住两件事:第一,发票数据可能有空值或格式混乱,读出来先做清洗;第二,网络异常时要单行重试而不是整个文件重跑。把失败行单独记到 error_list,跑完再统一补验,这个习惯能救你很多次。

6.2 验证脚本没出错:拿一张真票和一张作废票试跑

很多人写完后直接拿整月发票开跑,跑到一半才发现状态字段解析错了。我的经验是先做最小验证:手边拿一张真实报销过、确认正常的发票,再拿一张作废或红冲的发票,两个样例跑一遍,确认输出状态一个显示正常、一个显示作废或失控,脚本才算过了第一关。这一步 5 分钟能做完,但能避免批量脚本跑一整天才发现数据全错的惨剧。

6.3 给批量验真装上刹车

最后说一个习惯问题。我写这类脚本,默认都会加一个总任务数上限和强制间隔,比如单线程、每张之间 sleep 3 秒、满 50 张统计一次失败率。不是为了慢,是为了让脚本在平台规则内稳定跑完。我给这个方向做过不少次类似实现,最后都回归到一个原则:验真爬虫的价值在稳定批量、不在极限速度;真到了每天几千张的量,优先去走平台提供的批量查验服务接口,而不是继续压榨爬虫。这个边界想清楚了,你维护这套脚本的成本会低很多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表