
简介面向Java开发者与财务信息化人员的发票验真爬虫项目源码包聚焦通过增值税发票验真平台自动完成发票真伪核验。项目以Java爬虫为主线从页面分析、请求构造、数据填充到结果解析给出完整实现覆盖Jsoup解析HTML、Gson/Jackson处理JSON、多线程批量验真、异常处理与日志记录等关键环节并强调合规调用与稳定性设计同时可结合配置文件定位发票字段与请求接口便于二次开发。压缩包共93个文件以XML配置、Java源码、JS脚本及Git元数据为主整体包体仅1.61MB轻量易用且保留.idea等工程配置可快速导入开发环境。已有681人学习浏览适合具备一定Java网络编程基础、希望落地发票自动化查验场景的读者参考。1. 发票验真这件事为什么值得用 Java 爬虫去做自动化财务部门每天经手的发票少则几十张、多则上百张每张都要上税务平台核对真伪操作流程又是复制发票代码又是抄校验码赶上月末对账一天下来光验票就能耗掉两三个小时。这个checkinvoice项目解决的就是这个痛点用 Java 爬虫模拟人手工查验的完整路径把发票代码、发票号码、开票日期、校验码后六位这些信息自动填进增值税发票查验平台的表单里提交后把结果抓回来解析成结构化数据。它适合两类人一类是企业内部做财务自动化的 Java 开发另一类是正在找爬虫项目案例练手的初学者。项目本身不复杂但网络请求、页面解析、异常处理、频率控制这套爬虫基本功都覆盖到了是一个可以完整跑通的发票验真爬虫案例。想拿它做二次开发或者只是想学习网页请求的完整链路都能直接动手。2. 先搞清查验平台的页面结构表单字段、请求参数与反爬边界很多新手拿到这种项目第一反应就是写代码实际上爬虫项目里最不该省的一步是先打开浏览器开发者工具把页面摸清楚。增值税发票查验平台从用户视角看是一个查询页加一个结果页从开发者视角看就是几个表单字段加一个提交接口但平台本身有登录态、验证码和频率限制不是单纯 Post 一个表单就能拿结果的。2.1 从 Network 面板里定位表单字段与真实请求 URL打开浏览器无痕窗口访问查验平台首页按 F12 进入开发者工具切到 Network 面板并勾选 Preserve log然后在页面里随便输入一组格式合法的测试数据点「查验」按钮。这时候 Network 面板里会出现一批请求重点看名字里带check、query、verify之类的 XHR/Fetch 请求点开它就能看到完整的请求 URL、Method、Request Headers 和 Form Data。增值税发票查验平台的查询请求一般是 POST表单字段固定为以下几类参数名含义说明fpdm发票代码10 位或 12 位数字fphm发票号码8 位数字kprq开票日期格式通常为 YYYY-MM-DDkjhj价税合计含小数部分平台需要jyzm校验码后六位部分平台只需要后 6 位部分要求全部yzm图形验证码每次请求都不同敲黑板这里有个区分点很多平台把「校验码」拆成两种口径一种是发票右上角的「校验码」全部数字另一种是「校验码后六位」。表单字段名可能叫jyzm也可能是checkCode绝不能凭猜必须实际抓包看真实的参数名。抓包时如果发现提交的数据里还有csrfToken、sign这类字段说明平台做了额外的防护后面要单独处理。2.2 看清会话状态Cookie 与验证码的双重约束把请求头和 Form Data 对照着看大概率会发现这个平台的请求依赖两部分上下文。第一部分是 Cookie打开页面时服务器会下发一个会话标识后续每一次查询请求都要带这个 Cookie 才能过身份校验所以代码里第一步必须是先 GET 首页把 Cookie 拿下来存好再带着 Cookie 去 POST 查询。第二部分是验证码。增值税发票查验平台基本都接了图形验证码验证码图片一般是一个独立的 GET 请求每次刷新都会换一张新的。这里的关键是验证码图片请求的 Cookie 必须和查询请求的 Cookie 保持一致否则你提交的验证码永远校验不过。用 OkHttp 的话就是同一个OkHttpClient实例共享同一个 CookieJar而不是每次请求都 new 一个 client。提示如果你发现验证码接口的响应头里有Set-Cookie而查询接口的请求头里也有同样的 Cookie 名说明验证码的会话和查询的会话绑定了一定要用同一个 HTTP Client 实例。反爬边界也要在这个阶段摸一遍连续查询几次以后注意看响应是正常结果还是弹出验证码滑块、要求重新登录等提示。实操里最常见的反爬手段是「查询超过 N 次后强制要求重新输入验证码」和「同 IP 短时间查询超过阈值后临时封禁」这两个边界直接决定你的批量程序到底能开几线程、能跑多快。3. 用 OkHttp 构造验真请求Cookie 管理、参数拼装与多线程批量查验项目里pom.xml和src目录已经搭好了 Maven 骨架实际开发时我习惯用 OkHttp 做 HTTP 客户端配合 Gson 做序列化。选 OkHttp 而不是HttpURLConnection主要是因为它内置了连接池、超时管理和 Cookie 持久化机制写批量任务的时候省掉很多底层细节。3.1 Maven 依赖与 HttpClient 选型先把项目的依赖补全pom.xml里至少要有以下几项dependencies !-- HTTP 客户端 -- dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency !-- HTML 解析应对返回 HTML 的场景 -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency !-- JSON 解析应对返回 JSON 的场景 -- dependency groupIdcom.google.code.gson/groupId artifactIdgson/artifactId version2.10.1/version /dependency !-- 日志 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version1.7.36/version /dependency /dependencies用 OkHttp 而不是 Apache HttpClient理由其实很直接OkHttp 的 API 设计更贴近现代 JavaOkHttpClient是线程安全的可以复用内部连接池在批量场景下性能更好。而HttpURLConnection虽然 JDK 自带但 Cookie 管理要手动从响应头提取然后拼到下一个请求里代码非常啰嗦而且它不支持 HTTP/2连接复用也差一些。选型时记住一个原则爬虫的 HTTP 客户端要同时满足「会话保持」「连接复用」「超时可控」三个条件OkHttp 在三者之间平衡得最好。3.2 单个发票的验真请求从拿 Cookie 到提交查询单个发票的查询流程拆成四步先 GET 首页拿 Cookie再 GET 验证码图片并识别然后 POST 提交发票参数最后解析响应。核心代码骨架如下public class InvoiceChecker { // 同一个 client 实例保证 Cookie 自动带上 private final OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .cookieJar(new MyCookieJar()) // 内存版 Cookie 管理 .addInterceptor(new UserAgentInterceptor()) .build(); private final String baseUrl https://example-tax.gov.cn; // 实际换成平台地址 /** * 第一步访问首页让服务器下发会话 Cookie */ public void initSession() throws IOException { Request request new Request.Builder() .url(baseUrl /) .build(); try (Response resp client.newCall(request).execute()) { // 这里不需要处理 body重点是触发 Set-Cookie resp.body().close(); } } /** * 第二步下载验证码图片返回图片字节数组 */ public byte[] fetchCaptcha() throws IOException { Request request new Request.Builder() .url(baseUrl /captcha?t System.currentTimeMillis()) .build(); try (Response resp client.newCall(request).execute()) { return resp.body().bytes(); } } /** * 第三步提交发票信息做验真 */ public String checkInvoice(String fpdm, String fphm, String kprq, String jyzm, String captcha) throws IOException { FormBody formBody new FormBody.Builder() .add(fpdm, fpdm) .add(fphm, fphm) .add(kprq, kprq) .add(jyzm, jyzm) .add(yzm, captcha) .build(); Request request new Request.Builder() .url(baseUrl /check) .post(formBody) .addHeader(Referer, baseUrl /) .addHeader(X-Requested-With, XMLHttpRequest) .build(); try (Response resp client.newCall(request).execute()) { return resp.body().string(); } } }代码里有几个参数要单独说明。t时间戳参数是防止浏览器缓存验证码图片用的每次 GET 验证码都要随机生成一个新的如果你固定同一个时间戳可能拿到的图片是缓存里的旧图。Referer头用于模拟浏览器访问来源部分平台会校验这个字段少了它直接返回 403。X-Requested-With: XMLHttpRequest是告诉服务器这是一个 AJAX 请求部分平台区分普通表单提交和 AJAX 提交两者返回的数据格式不一样。三步走完checkInvoice方法返回的字符串就是平台的响应内容。这里有个细节要留意平台返回的内容可能是 JSON也可能是一段 HTML甚至可能是带着一层壳的 HTML 里嵌着 JSON。不要一上来就写死用 Gson 去解析先用日志把原始响应打出来看一眼再决定解析策略。3.3 多线程批量查验与频率控制单个发票能查通了批量就是加线程的事。但批量不是简单把单线程循环换成线程池就完事需要同时考虑频率限制和错误重试。我一般会用固定线程池配合请求间隔控制public class BatchInvoiceChecker { private final InvoiceChecker checker new InvoiceChecker(); // 核心线程数不要太大实测 3~5 个足够避免触发平台限流 private final ExecutorService pool Executors.newFixedThreadPool(3); public void batchCheck(ListInvoiceInfo invoices) { ListCompletableFutureCheckResult futures invoices.stream() .map(invoice - CompletableFuture.supplyAsync(() - { try { // 每个请求前固定 sleep整体拉低请求频率 Thread.sleep(ThreadLocalRandom.current().nextLong(1500, 3000)); return doCheckWithRetry(invoice, 2); } catch (Exception e) { return CheckResult.fail(invoice, e.getMessage()); } }, pool)) .collect(Collectors.toList()); // 统一等待所有任务完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } private CheckResult doCheckWithRetry(InvoiceInfo inv, int retryCount) throws Exception { for (int i 0; i retryCount; i) { try { byte[] captcha checker.fetchCaptcha(); String code CaptchaRecognizer.recognize(captcha); String resp checker.checkInvoice(inv.getFpdm(), inv.getFphm(), inv.getKprq(), inv.getJyzm(), code); return parseResult(inv, resp); } catch (IOException e) { if (i retryCount) throw e; // 重试前退避防止雪崩 Thread.sleep(1000L * (i 1)); } } throw new RuntimeException(unreachable); } }线程数设 3 而不是 10 是有原因的。像这种税务类平台单 IP 的查询频率阈值往往低得超出预期同一 IP 每秒超过 2 次请求就可能触发临时封禁。线程数开大了不仅提升不了吞吐反而会让所有请求都被拒。真正稳妥的做法是横向扩容——多台机器、多个 IP 分摊请求量而不是单机开高并发。4. 结果解析与状态判定HTML 和 JSON 两条解析路径都要留验真平台返回的响应格式不是每家都一样同一个平台也可能因为请求头里是否带了X-Requested-With而返回不同格式。解析这块最忌讳只写一种解析逻辑代码里最好把 HTML 解析和 JSON 解析都实现出来运行时按响应特征自动分流。4.1 用 Jsoup 解析 HTML 响应如果响应内容是 HTML查验结果一般嵌在表格里结构通常是「发票代码、发票号码、开票日期、价税合计、校验码、查验结果」几列结果那一列的文字是「一致」「不一致」或者「查无此票」。public CheckResult parseHtml(InvoiceInfo inv, String html) { Document doc Jsoup.parse(html); // 结果列表通常在 id 为 result 的表格里按实际页面调整选择器 Element table doc.selectFirst(table#result); if (table null) { return CheckResult.fail(inv, result table not found); } Elements rows table.select(tr); MapString, String rowData new HashMap(); for (Element row : rows) { Elements tds row.select(td); if (tds.size() 2) { rowData.put(tds.get(0).text().trim(), tds.get(1).text().trim()); } } String statusText rowData.getOrDefault(查验结果, ); String statusCode normalizeStatus(statusText); return new CheckResult(inv, statusCode, rowData); }这里有个实际踩过的坑Jsoup 解析时很多人一上来就doc.body().text()一把梭拿全文然后拿字符串去 contains。这在大批量处理时极容易出错因为结果页除了表格还可能有页头页脚、公告信息这些文本里如果包含「不一致」字样判断就废了。正确做法是先定位到结果表格这个节点再在节点内取文本。另外税务平台返回的 HTML 里经常会混入「温馨提示本查询结果仅供参考」之类的免责声明不要把这行字当成查验结论去解析一定要锚定表格行。4.2 用 Gson 解析 JSON 响应如果平台侧是 AJAX 异步返回响应体就是 JSON结构一般长这样{ code: 200, message: success, data: { fpdm: 发票代码, fphm: 发票号码, status: 一致, kprq: 2024-06-18, kjjg: 价税合计 } }对应的 Gson 解析代码public CheckResult parseJson(InvoiceInfo inv, String json) { JsonObject root JsonParser.parseString(json).getAsJsonObject(); String code root.get(code).getAsString(); if (!200.equals(code)) { return CheckResult.fail(inv, root.get(message).getAsString()); } JsonObject data root.getAsJsonObject(data); String status data.get(status).getAsString(); return new CheckResult(inv, normalizeStatus(status), data); }解析 JSON 时要注意 Gson 的JsonParser对数字精度敏感发票金额这类字段如果平台返回的是浮点数直接用getAsDouble()再转字符串会有精度损失。稳妥做法是统一按字符串类型读取也就是data.get(kjjg).getAsString()避免中间过了 Double 转换。4.3 验真结论的状态归一化不管是 HTML 还是 JSON 解析出来的状态文本最后都要归一化成统一枚举否则后面写业务逻辑时到处都是字符串比较原始文本归一化状态含义一致 / 验真一致 / 结果为一致MATCHED发票真实且信息一致不一致 / 验真不一致MISMATCH发票真实但信息对不上查无此票 / 未查到该发票NOT_FOUND平台里没有这张票超过查验次数 / 重复查验LIMITED该发票当日查验次数超限状态归一化要放在单独一个方法里因为平台的文案不是固定不变的今天写「一致」明天可能改成「查无此票」加个「的」统一收敛到一个方法以后改起来容易。我习惯把原始文本和归一化结果同时存进日志出问题回溯时能看清原始响应到底是什么样的。5. 避坑手册发票验真爬虫的常见问题与排查记录这类政务平台是反爬策略的重灾区开发过程中踩坑在所难免。下面五条是实际项目里出现频率最高的每一条都按现象、原因、解决三段来说明。5.1 验证码识别成功率低请求频繁被拒现象程序里接了 OCR 识别验证码识别后提交查询平台提示「验证码错误」连续重试多次依然失败日志里全是验证码校验不通过。原因验证码图片本身有两种常见情况。一是图片里除了数字还夹杂了干扰线、噪点OCR 直接识别确实容易错二是 Cookie 管理不当下载验证码的请求和提交查询的请求用了两个 HttpClient 实例服务器端的验证码会话和查询会话对不上导致你提交的验证码永远落后服务器一拍即使识别对了也报错。解决先用 Postman 或 curl 手动测试验证码流程排除 Cookie 问题后再谈识别率。确认是同一个会话后再考虑识别算法的优化。识别准确率死活上不去的时候不要硬刚改成半自动方案批量程序把验证码图片存到本地目录人工看图输入验证码程序轮询目录读取识别结果。试过一次就知道人工处理一万张验证码也不现实但处理三五百张应急完全没问题。5.2 查询频率过高被临时封禁 IP现象程序刚跑起来的前几分钟一切正常跑了十几个请求以后突然所有请求都返回 403 或者「操作过于频繁」的提示有些平台更直接返回一个空白页。原因触发频率限制是这类平台最常见也是最难绕的坎。税务平台对单个 IP 的查询频率限制往往严格到你想象不到可能每分钟只允许 5 到 10 次查询超过就封禁一段时间。封禁时长从几分钟到几小时不等没有统一标准。解决控制请求频率是唯一的解决方案。每个请求之间 sleep 一个随机间隔取值范围建议在 2 到 5 秒之间随机而不是固定 sleep 3 秒。固定间隔会被识别为机器行为随机间隔更像人工操作的节奏。另一个办法是给请求加一个「人性化」的随机停顿每查完 5 张发票停 60 秒左右再继续模拟人工查几张歇一会儿的行为。单机每小时查询量控制在两三百笔以内才相对安全。5.3 连接池耗尽与请求超时现象批量任务跑到一半线程池里的线程开始大量抛出SocketTimeoutException或者ConnectionPoolTimeoutException再往后整个程序卡住不动了。原因OkHttp 的默认连接池有最大空闲连接数和存活时间限制在高并发场景下如果每个请求的响应时间过长连接池里的连接会被占满新请求拿不到可用连接就会排队等待等了超过设定时间就抛异常。另外平台响应慢时读超时设置太短也会误伤正常请求。解决一是调大超时时间建议读超时设为 20 到 30 秒税务平台的响应速度波动很大某些时刻加验证码校验逻辑要跑好几秒。二是给 OkHttp 配置连接池参数ConnectionPool(5, 30, TimeUnit.SECONDS)表示最大保持 5 个空闲连接、空闲 30 秒后回收这个参数能避免连接无限堆积。三是在批量线程里加一个Semaphore限制最大并发请求数不要把所有发票一股脑全丢进线程池。5.4 SSL 握手失败与证书校验问题现象程序在本地环境跑得好好的部署到服务器上以后请求验真平台突然抛SSLHandshakeException或者CertificateException。原因这台服务器上的 JDK 版本较老内置的 CA 证书列表里没有税务平台使用的新版 SSL 证书链。或者平台 SSL 证书配置不完整缺少中间证书公共信任库校验失败。解决先用curl -v https://平台地址在服务器上测一下证书链路看是服务器 JDK 的问题还是平台证书的问题。如果是 JDK 证书库太旧使用keytool -importcert把平台证书导入 JDK 的cacerts这是生产环境里最稳妥的方案。严禁直接写一个TrustAllCerts类把 SSL 校验全局关掉那会把所有请求的安全边界全部打开在爬虫项目里属于高风险操作如果账号信息要经过代理转发截获风险会非常大。5.5 响应内容被压缩后解析乱码现象接口正常返回但解析出来的响应内容全是乱码或者 Jsoup 解析出来的 HTML 文本缺字、跳行。原因平台启用了 Gzip 压缩传输响应头里带了Content-Encoding: gzip。OkHttp 默认情况下会自动处理 Gzip 解压但如果你手动加了Accept-Encoding: gzip请求头OkHttp 就不会再自动解压了拿到的就是原始压缩字节流。另一种情况是页面的字符集是 GBK 或 GB2312而 Jsoup 默认按 UTF-8 解析。解决先从日志里打印原始响应体的字节数组转成十六进制字符串看前几个字节如果是1f 8b开头说明是 Gzip 压缩流。OkHttp 场景下去掉手动设置的Accept-Encoding头让它自己处理Jsoup 场景下用Jsoup.parse(html, GBK)指定字符集解析。字符集问题很隐蔽页面里明明写了meta charsetgbkJsoup 有时候还是会识别失败显式指定最靠谱。6. 把项目跑起来的最后一公里配置项、降级策略与合规建议到了这一步项目已经从「能不能查」推进到了「能不能稳定查」的阶段。checkinvoice项目的代码框架里已经包含了 Maven 工程、验真逻辑、测试目录真正落地时还需要做三件事把参数拎到配置文件里、把验证码流程做成可降级、想清楚合规边界。配置项是项目上线后改动最频繁的部分验真平台的地址可能会调整请求参数的名称可能变化频率阈值也可能被运营策略调整。我一般会把可变参数全部外置到 properties 文件里# 验真平台基础地址 invoice.check.base-urlhttps://example-tax.gov.cn # 首页路径用于初始化会话 invoice.check.index-path/ # 验证码图片接口路径 invoice.check.captcha-path/captcha # 查询接口路径 invoice.check.check-path/check # 请求间隔范围毫秒 invoice.check.interval-min2000 invoice.check.interval-max5000 # 单线程重试次数 invoice.check.retry-count2对应读取配置的类用一个简单的Properties加载即可不要引入 Spring 或者繁重的配置框架保持项目轻量。参数改动的频率远高于代码改动的频率把 URL 和间隔这些抽出来以后平台调整接口路径时只需要改配置文件重新启动代码一行不用动。实际维护中这类平台每年都会调整两三次页面结构配置文件设计得好能省掉很多重复发布的事。验证码降级策略必须提前设计好。全自动 OCR 识别在验证码简单时尚可一战遇到复杂背景加干扰线的验证码识别率会肉眼可见地下降。我实际采用的是「两级降级」方案第一级是 OCR 全自动识别识别置信度达到阈值就直接提交第二级是把验证码图片保存到本地一个pending目录程序挂起等待人工在answer.txt文件里输入验证码每 1 秒轮询一次这个文件读到内容就继续执行。这套方案把批量任务从「必须全程人工盯屏」降级成「只在验证码变得复杂时需要人工介入」实际操作中大部分验证码第一级就能过人工介入的频率大概每百张发票一次左右。注意关于合规边界这是这个项目里最重要的一条。发票查验平台是政务公共服务系统开发这类爬虫项目时需求里说的「遵守平台使用条款和法律法规」绝对不是套话。企业内部做批量验真最安全的做法是优先使用税务官方提供的发票查验 API 或电子发票服务平台只有官方途径不可用或成本过高时才考虑页面自动化方案。即使使用页面方案也要将请求频率控制在一个非常克制的范围内以不对平台造成压力为前提。这个项目定位为技术学习与内部自动化验证工具是合理的但不要扩散成面向公众的批量查询服务。这个项目让我第一次真正体会到「爬虫的难点不在爬而在被限流之后怎么把程序稳住」。从那以后我每次给这种对接政务平台的爬虫收尾都强制走一遍流程核对配置文件里的参数与抓包结果一致检查 Cookie 复用逻辑手动测试三次验证码流程再压一遍极限频率——四步全过了才敢交付。希望这个项目的拆解思路能帮你在做类似发票验真、单据核验场景时少走点弯路。本文还有配套的精品资源点击获取