ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java发票验真爬虫工程拆解:自动化核验流程与并发控制实践

Java发票验真爬虫工程拆解:自动化核验流程与并发控制实践 简介面向企业财务与Java开发人员的发票验真爬虫实现包围绕增值税发票验真平台演示如何用Java自动提交发票信息并解析验证结果。资源共93个文件包含Java源码、Maven配置、xml与properties等工程文件压缩包仅1.61MB结构紧凑适合需要快速上手爬虫开发或财务发票核验场景的读者。已有684人学习下载。从内容预览看内含完整Maven工程、测试与main目录、.git版本库以及IDE配置打开即可阅读与二次开发结合描述资源提炼了页面分析、请求构造、结果解析、异常处理等关键环节并给出合规性注意事项能帮助开发者避开常见坑位高效实现批量发票验真需求。具体工程中封装了常见请求客户端与解析库的典型用法可作为项目模板直接迁移到实际业务中去同时包内附带版本控制信息便于追踪项目演进。1. 把发票验真从网页搬进 Java 工程这是怎样一份爬虫资源财务月末最磨人的不是算账是对着增值税发票验真平台一张张录入发票代码、号码、开票日期、校验码。这份 checkinvoice 工程就是冲着这个场景来的用 Java 写的发票验真爬虫通过模拟浏览器请求把人工录入变成程序 POST再把平台的返回结果抓回来自动落库。它不是做假票识别算法的也不是绕过风控的黑盒子而是把验真链路拆成可复现的代码流程。适合谁财务系统开发、对接税票数据的后端工程师以及想给手工对账环节留一只自动脚本当备选方案的读者。接下来我把工程目录、请求构造、参数解析和踩坑记录完整拆一遍照着跑通之后再聊怎么维护。2. 工程拆包与验真链路先看懂平台参数再谈写代码2.1 解压 checkinvoice.zip 后你拿到的是哪几块东西先别急着跑mvn compile把这几个目录和文件的作用分清后面排错才不至于瞎猜。路径 / 文件在工程里的作用pom.xmlMaven 工程描述文件声明依赖版本和打包方式src/main主流程源码目录爬虫的请求、解析、入口逻辑都在这下面src/test回归测试目录对于这种会话强相关的爬虫单测是改代码的后悔药scaninvoice.imlIntelliJ IDEA 的模块描述文件开 IDE 时用它识别项目结构.git/Git 历史记录出问题可以 diff 每次改动方便回溯.idea/IDEA 的本地配置含数据源、Maven 等杂项不用提交到生产这是一份标准的 Maven IntelliJ 工程。src/test和src/main并列说明作者在写主流程时把回归验证也考虑进去了这一点比很多临时爬虫脚本强。scaninvoice.iml名字对应工程名同样是“scan invoice”的缩写核心业务一眼能看出来。初次导入 IDE 时我一般直接选pom.xml作为 Maven Project等依赖拉完先跑一次测试确认环境没坏再动主流程。pom.xml里如果没有指定仓库注意国内网络环境下部分依赖可能拉得慢可以临时加阿里云镜像属于常规操作但不用写进代码。2.2 验真平台的链路说到底就一条 POST用浏览器手动验真时你看到的是网页实际上发生的是一次 HTTP POST页面把发票代码、发票号码、开票日期、校验码后 6 位组装成表单参数提交给税务平台的后端接口后端拿这些参数去和发票库比对再把“一致”“查无此票”等结果返回到页面。爬虫要做的事就是把第三步“组装表单参数”变成代码。工程里真正需要盯住的字段映射关系大致如下页面里的录入项常见请求参数名说明发票代码fpdm10 位或 12 位数字发票号码fphm8 位数字开票日期kprq通常为yyyy-MM-dd校验码后 6 位jym部分发票类型是必填开票金额不含税je部分专用发票验真时需要不同的平台版本字段名会有差异但你只需要用浏览器开发者工具打开“网络”面板勾选“保留日志”手动提交一张发票就能看到这次请求的真实 URL、请求头和 Form Data。工程里的参数名如果和页面不一致直接改FormBody.Builder里的.add(fpdm, ...)这一段即可核心逻辑不用动。2.3 为什么绕不开 Cookie 与会话管理不少人第一次跑这种爬虫会翻车是因为只发了 POST没先建会话。增值税发票验真平台这类政务接口通常会在你打开页面时下发一个会话标记后续每一次查询都要求带上这个标记。直接裸 POST平台要么把请求重定向回首页要么返回一串类似“未授权”的提示而你不会立刻意识到是 Cookie 的问题。工程里处理这一点的方式是“先 GET 首页再 POST 验真”。手动在代码里做就是第一次请求时把响应头Set-Cookie的值抓出来放到并发容器里之后每次 POST 都带同一个Cookie头。如果用的是 OkHttp更省事的做法是用自带的CookieJar实现把会话管理从业务代码里摘出去这样调度线程再多也不会出现“这个线程的 Cookie 和另外一锅混了”这种玄学问题。3. 请求构造与并发控制Java 爬虫收发票核验闭环的核心代码3.1 先写一个最小可用的 POST 提交方法拿到这份工程之后我建议你最先看的不是入口类而是这个doVerify方法。它把发票信息和验真平台之间的交互收敛到一个方法里后面换接口、加日志、加多线程都在这一层做文章。// 单张发票验真OkHttp 版本 4.x public InvoiceCheckResult doVerify(Invoice input) throws IOException { // 组装表单字段名以页面 Network 里抓到的 Form Data 为准 FormBody body new FormBody.Builder() .add(fpdm, input.getFpdm()) // 发票代码 .add(fphm, input.getFphm()) // 发票号码 .add(kprq, input.getKprq()) // 开票日期如 2025-02-14 .add(jym, input.getJym()) // 校验码后 6 位 .build(); Request request new Request.Builder() .url(this.targetUrl) // 从浏览器 Network 里复制的请求地址 .header(User-Agent, this.userAgent) // 用一个常规浏览器的 UA别用默认 Java 标识 .header(Referer, this.refererUrl) // 部分平台会校验来源页 .post(body) .build(); try (Response resp this.client.newCall(request).execute()) { String bodyStr resp.body() ! null ? resp.body().string() : ; // 交给解析模块解析逻辑见第 4 章 return this.parser.parse(bodyStr); } }逻辑说明整个方法就三个环节——构造表单、发 POST、取响应体。targetUrl和userAgent建议从配置文件读不要硬编码在类里因为平台改地址是迟早的事。resp.body().string()读取响应体时注意OkHttp 的ResponseBody只能消费一次不要先转成 byte 再转 String 后又想去读原始流那会拿到空串。参数说明FormBody.Builder只适合结构简单的表单接口。如果目标平台要求 JSON 格式请求体这里就要换成RequestBody.create(jsonStr, MediaType.parse(application/json))。判断标准只有一个——浏览器 Network 面板里请求负载是application/x-www-form-urlencoded还是application/json别凭经验猜。3.2 连接池、超时和重试先配好别等跑挂了再排查很多爬虫跑着跑着突然抛Connection reset不是代码逻辑错了是 HttpClient 的连接池默认行为和生产环境不匹配。这份工程如果用的是 OkHttp建议在初始化客户端时不偷懒把超时和连接池参数显式写出来。OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) // 建立连接的超时给 10 秒算宽容 .readTimeout(15, TimeUnit.SECONDS) // 等待平台返回结果的超时 .writeTimeout(15, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .connectionPool(new ConnectionPool(4, 5, TimeUnit.MINUTES)) .cookieJar(new JavaNetCookieJar(new CookieManager(null, CookiePolicy.ACCEPT_ALL))) .build();逻辑说明connectTimeout解决的是“平台机器不可达”的场景readTimeout解决的是“平台已经返回请求头但迟迟不吐数据”的场景两种超时对爬虫的意义完全不同只配前者会在平台卡顿 Post 请求时无限等下去。connectionPool(4, 5, TimeUnit.MINUTES)表示最多 4 个空闲连接空闲超过 5 分钟回收。批量验真时这个值要稍微调大但别调到几十否则平台那边看到的就是一个短时间内大量建连的异常来源。retryOnConnectionFailure(true)建议打开但不能只依赖它。工程里应再加一层业务重试单张验真失败时最多重试 2 次重试前 sleep 1 秒。超过重试次数就把这张票标记为待人工处理别死循环别无限重试。3.3 多线程验真用 CompletableFuture 也要给并发上把锁批量验真几百张发票时串行确实慢我见过有人一张票跑一次接口300 张票跑了 20 分钟。多线程能提速但税务平台的频率限制不是摆设并发一高返回的就不是验真结果而是验证码或者限流提示了。常规做法是控制工作线程数不超过 2再用信号量把瞬时并发压住。ExecutorService pool Executors.newFixedThreadPool(2); // 工作线程固定 2 个 Semaphore semaphore new Semaphore(2); // 同时只允许 2 个请求在途 ListCompletableFutureInvoiceCheckResult futures invoices.stream() .map(invoice - CompletableFuture.supplyAsync(() - { try { semaphore.acquire(); return client.doVerify(invoice); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return InvoiceCheckResult.timeout(invoice); } catch (IOException e) { // 网络异常要单独记录不要直接吞掉 return InvoiceCheckResult.networkError(invoice, e.getMessage()); } finally { semaphore.release(); } }, pool)) .toList(); futures.forEach(CompletableFuture::join); pool.shutdown();逻辑说明newFixedThreadPool(2)限制的是并发线程数Semaphore(2)限制的是同时进入接口调用的请求数。两层限制叠加即使后面有人改动代码把线程池扩到 8信号量仍然能守住“同时最多 2 个请求在途”这条底线。CompletableFuture.supplyAsync的返回值是带结果的 Futurejoin()会阻塞等待全部完成拿到所有结果再统一落库。参数说明为什么用 2 而不是 5这不是拍脑袋。政务类验真平台通常按 IP 维度统计请求频率合法业务场景下单张验真往往是几秒一笔2 并发已经能覆盖“财务对账几百张票”的绝大多数需求。追求吞吐而把并发拉到 10换来的是验证码频出、IP 临时受限最后反而更慢。4. 响应解析与日志落库把验真结果从 HTML/JSON 变成结构化数据4.1 返回可能是 JSON也可能是 HTML解析要两套都准备验真平台升级积分制版本后很多页面前端是动态渲染的直接抓页面 HTML 看到的往往是空壳。这个工程里建议同时保留两套解析逻辑优先判断响应体首字符是不是{是就按 JSON 走否则按 HTML 交给 Jsoup 解析。public InvoiceCheckResult parse(String content) { String trimmed content.trim(); if (trimmed.startsWith({)) { // JSON 分支用 Gson 抽关键字段字段名以平台实际返回为准 JsonObject obj JsonParser.parseString(trimmed).getAsJsonObject(); String state getStringSafely(obj, state, desc); return InvoiceCheckResult.fromJson(state, trimmed); } // HTML 分支用 Jsoup 按文本命中判断 Document doc Jsoup.parse(content); Elements hit doc.select(span.result-success, .verify-result); if (hit.text().contains(一致)) { return InvoiceCheckResult.valid(content); } if (hit.text().contains(查无此票)) { return InvoiceCheckResult.notFound(content); } return InvoiceCheckResult.unknown(content); }逻辑说明JSON 分支里我没有把toJsonObject().get(state).getAsString()写成硬编码而是封装了一个getStringSafely原因是平台升过一次版本后字段路径变过直接强转容易抛NullPointerException。HTML 分支的 selector 要按实际页面微调hit.text().contains(一致)这种写法比精确匹配稳妥因为页面里常有“发票信息一致”和“查询结果一致”两种文案contains 能同时命中。4.2 验真状态要做业务映射别只存“返回原文”平台返回的结果文案不同版本可能表述不同但业务上无外乎这么几类真票、查无此票、状态异常、请求失败。工程里建议定义一个状态枚举把结果先归一化再落库业务方拿到的永远是同一套枚举而不是满屏的中文差异。public enum CheckStatus { VALID(1), // 发票信息一致 INVALID(2), // 查无此票或票面信息不匹配 UNKNOWN(3), // 平台返回了未能识别的状态 ERROR(4); // 网络异常、超时、验证码拦截等 }落库代码用PreparedStatement批量写入比一条条 insert 快得多String sql INSERT INTO invoice_check_log (fpdm, fphm, kprq, status, raw_message, elapsed_ms, checked_at) VALUES (?, ?, ?, ?, ?, ?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (InvoiceCheckResult r : results) { ps.setString(1, r.getFpdm()); ps.setString(2, r.getFphm()); ps.setString(3, r.getKprq()); ps.setInt(4, r.getStatus().getCode()); ps.setString(5, r.getRawMessage()); ps.setLong(6, r.getElapsedMs()); ps.setTimestamp(7, new Timestamp(r.getCheckedAt().getTime())); ps.addBatch(); } ps.executeBatch(); }逻辑说明raw_message字段建议截断到 500 字符因为平台返回的 HTML 里可能夹杂脚本标记存全量既占空间又没意义。elapsed_ms别漏掉它是你后续判断“要不要调低并发”的最直接数据——如果单票耗时普遍超过 5 秒说明平台侧已经出现排队再怎么加线程都是徒劳。4.3 日志与脱敏跑批程序最容易犯的错就是把发票号全量打出来做这件事的都知道发票号码和校验码属于敏感票面信息。工程里如果打日志时把完整发票信息都输出了一旦日志文件被截图或者同步到第三方日志平台风险不小。常规做法是打日志时对发票号码中间四位打码只保留前四位和后四位既能定位问题又不会泄露完整信息。private String maskInvoiceNo(String no) { if (no null || no.length() 8) { return ****; } return no.substring(0, 4) **** no.substring(no.length() - 4); }日志样例也建议统一格式方便 grepCHECK_INVOICE fpdm048001**** fpdmNo**** statusVALID elapsed872ms。排障时先按状态码过滤再按时间窗口看耗时分布能快速分清问题是集中在某一类票还是集中在某一个时间段。5. 发票验真爬虫的五个常见坑现象、原因、解法一次说清5.1 连续验真后平台突然开始要图形验证码现象单线程跑前 10 张票都正常跑到第 20 张左右返回里不再有验真结果而是提示页面上出现图形验证码。原因平台 controller 层的防爬策略被触发最常见的是按 IP 和 User-Agent 统计请求频率短时间请求次数超过阈值后进入验证码拦截。解决把并发压回 1两次验真之间加Thread.sleep(800)以上的固定间隔如果业务允许再把请求时间随机化到 8001500 毫秒之间。图形验证码本身不应去破解程序识别到验证码特征后应立即停止本批次把剩余票放入待人工清单。合规和稳定这两件事在频率控制面前是同一件事。5.2 返回结果中文全是乱码但手动在浏览器里验真正常现象程序解析出的验真结果文案出现??或锟斤拷这类乱码状态码却没问题。原因平台返回的 Content-Type 里声明了charsetGBK而 OkHttp 的body().string()默认按 UTF-8 解码导致中文全部变乱码。旧版页面尤其常见新版多数切到了 UTF-8。解决抓取响应前先看Content-Type动态判断字符集再解码。最直接的做法是用resp.body().source().readByteString()拿到原始字节然后根据响应头里的 charset 参数手动new String(bytes, charsetName)。别在表单提交时强行把平台改成 UTF-8那是自欺欺人。5.3 每次验真结果都和上次一模一样像被缓存住了现象换了一批发票号进去返回结果却和上一批完全一致连elapsed_ms都相近。原因多半是 Cookie 没带对平台端把每一次验真都绑定到了同一个会话上而你的 CookieJar 没有正确更新会话请求命中了平台端的重复查询拦截。解决先跑一次 GET 首页拿到新的会话 Cookie再发 POST 验真。用 OkHttp 的CookieJar实现时建议自测一下saveFromResponse是否被回调。某些版本的 OkHttp 对 302 响应的 Cookie 保存不积极遇到这种情况就要手动解析Set-Cookie写入一个全局 Map下一次请求时通过addHeader(Cookie, ...)显式带上。5.4 跑着跑着突然大量Connection reset现象批处理前 50 张票一切正常第 51 张开始连续抛Connection reset随后又自动恢复。原因服务端主动关闭了空闲连接而 OkHttp 连接池里的旧连接没有被及时淘汰复用了一个已被服务端关闭的 socket。解决第一层是设置更合理的连接池参数把keepAlive从默认的 5 分钟降到 1 分钟降低复用失效连接的概率。第二层是重试逻辑遇到IOException时先丢弃当前主机连接再重试一次。OkHttp 里可以捕获异常后对同一个 client 再次发请求大部分情况下第二次会新建连接成功。5.5 页面加了 js 渲染拿到手的 HTML 没有验真结果现象用 Jsoup 解析时代码里 select 的节点不存在页面 HTML 里只看到一个空div。原因平台前端采用动态渲染验真结果是页面加载后由 JavaScript 二次请求接口填充的初始 HTML 里压根没有结果数据。解决这时候甄别真正值得请求的 URL。开发者工具里筛 XHR 请求找到返回验真结果的那条 JSON 或 XML 请求把爬虫目标从“页面 URL”切到“接口 URL”。这份工程里解析策略保留 JSON 分支的原因就在这——很多平台升级后表单还是那个表单但响应已经从 HTML 变成了 JSON。6. 让工程能长期维护用回归测试锁住验真行为拿到一份能跑通的爬虫工程最怕的就是“今天能出结果下周平台改版后全体罢工”。平台接口不可控我们能控制的只有回归验证。我自己拿到这种工程后的第一个动作不是去改并发而是先写一组参数化测试把手工能确认的样例票固化成用例。ParameterizedTest CsvSource({ 048001900111, 12345678, 2025-02-14, ABCD, VALID, 048001900111, 87654321, 2025-02-14, WXYZ, NOT_FOUND }) void checkSampleInvoice(String fpdm, String fphm, String date, String jym, String expected) { InvoiceCheckResult result client.doVerify(prepareInvoice(fpdm, fphm, date, jym)); assertEquals(expected, result.getStatus().name()); }这套用例的价值不在跑通一次而在于每次平台行为变化后测试会先于业务反馈暴露差异。真票样例要选能稳定返回“一致”的假票样例要选能稳定返回“查无此票”的两条用例分别锁住最核心的两条分支。平台改版后如果第一个用例挂了而第二个还能过通常说明是字段映射变了而不是被限流。长期跑这个工程我还有个习惯每次上线新批次前先挑 5 张票跑一次“冒烟批次”确认VALID/INVALID比例和预期一致再放全量。冒烟批次里故意放一张预期为NOT_FOUND的票如果平台返回VALID先停掉任务去查会话和参数映射不要盲目重试。从那以后我每次接这种官方查询平台的自动化都强制走一遍“最小工程跑通—单测锁行为—冒烟验证—再放量”的流程翻车率直接降了一截。希望这次拆包记录对你也同样有用先把工程跑起来再按第 5 章的坑逐条对照能省下不少对接时间。本文还有配套的精品资源点击获取
返回列表