ARTICLE DETAIL

资讯详情

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

Java发票验真爬虫实战:POST请求、会话维持与结果解析全攻略

Java发票验真爬虫实战:POST请求、会话维持与结果解析全攻略 简介这套采用Java语言开发的爬虫工程围绕增值税发票验真平台实现了从发票信息录入、请求提交到结果校验的全流程自动化适合企业财务人员、开发工程师以及爬虫技术学习者理解真实的税务数据交互场景。资源压缩包共计九十三个文件体积约一点六一兆字节核心是七个源代码文件分别负责页面表单字段分析、网络请求构造、浏览器行为模拟、响应内容解析以及异常情况兜底另有二十五个配置文档用于管理项目依赖与结构十一个样例数据文件用于快速验证流程八个脚本文件辅助处理页面动态逻辑并且保留了完整的版本控制仓库信息便于直接导入集成开发环境运行。已有六百八十三人学习下载。通过研读这份工程读者能够掌握如何构造网络请求并携带发票参数如何解析返回的超文本或结构化数据如何应对验证码和网络超时等异常还能够将单条验真扩展为多线程批量任务最终获得一套合法合规、稳定高效、可复用的发票查验解决方案。1. 发票验真爬虫为什么核心是 POST 与解析而不是反爬月底财务对账几十张增值税发票等着验真逐张打开查验平台填代码、号码、日期、金额、校验码再等页面刷新对比结果一上午就耗进去了。这个 checkinvoice 项目解决的就是这件事。用 Java 爬虫把发票信息拼成 POST 请求提交到增值税发票验真平台再自动抓取“查验通过 / 查无此票 / 不一致”的返回结果。它不涉及复杂的反爬对抗核心是页面分析、请求构造和响应解析。适合财务系统后台开发、需要做批量验真的运维同学以及想用自动化替代重复录入的 Java 从业者。下面把从抓包、请求构造到批量并发的完整链路和踩过的坑一次性讲透。2. 页面分析与请求识别从浏览器抓包到 Jsoup 预请求2.1 手工验真一张票把请求链路完整录下来写表单型网络爬虫最忌讳上来就写代码。我一般先开浏览器开发者工具Network 面板勾选 Preserve log然后手工验真一张发票把这个过程完整录下来。这个操作会一次性暴露四类关键信息提交 URL、请求方法POST 还是 GET、Form Data 里的字段名、响应中结果出现在哪个节点。多数验真平台的表单字段按拼音缩写命名常见的一组见下表但不同接入页面命名不一定相同务必以浏览器里抓到的为准常见参数名含义格式示例fpdm发票代码12 位数字fphm发票号码8 位或 20 位数字kprq开票日期yyyy-MM-ddjshj价税合计两位小数如 1234.56jym校验码后六位数字或字母串这份表只是让你有个心理预期真正起作用的是你抓包拿到的字段名和格式。我见过同一平台不同年份的接入参数从 fpdm 改成 fp_dm 的情况所以别迷信任何第二手资料。抓包时顺手把请求头也记下来重点看三个值User-Agent、Referer、Cookie。UA 要模拟浏览器Referer 一般指向上一次 GET 的验真页面Cookie 是会话凭证三者缺一请求就可能被拒。Cookie 尤其关键有些平台首次访问种的是匿名会话提交时才下发真正的业务 cookie抓包时把两次请求的 Set-Cookie 都看一遍。还要额外确认一件事提交后返回的是 HTML 还是 JSON。老式服务端渲染页面返回 HTML结果藏在表格或带特定 class 的节点里新一点的页面走 AJAX 接口返回 JSON。这个判断决定后面用 Jsoup 还是 Jackson 解析建议在抓包阶段就把响应体和响应头保存成文件作为后续开发的对照样本。2.2 用 Jsoup 预请求页面先拿会话 Cookie 和隐藏字段验真平台基本都有会话机制直接 POST 往往被当作无会话请求拒掉。常见做法是先 GET 一次验真页面拿到服务端下发的 Cookie再带着这批 Cookie 提交。部分平台还会在页面里埋隐藏字段比如 ASP.NET 的 __VIEWSTATE或者一次性 token提交时必须原样带回。下面这段是预请求的标准写法// 预请求先 GET 验真页面拿到会话 Cookie 和隐藏字段 Connection.Response pre Jsoup.connect(CHECK_PAGE_URL) .method(Connection.Method.GET) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .timeout(10000) .execute(); MapString, String cookies pre.cookies(); Document page pre.parse(); // 打印表单里所有隐藏字段确认是否要随 POST 携带 Elements hiddenInputs page.select(form input[typehidden]); for (Element input : hiddenInputs) { System.out.println(input.attr(name) input.attr(value)); }这段代码有两处值得说明。pre.cookies()拿到的是服务端通过 Set-Cookie 下发的会话标识稍后要原样交给 HttpClient 的 CookieStoreselect(form input[typehidden])是探测动作不是每个平台都有隐藏字段但存在且不携带时POST 会被直接拒绝。timeout(10000)是连接超时公共服务类页面响应不稳定10 秒是合理下限再短容易误报超时。拿到 Cookie 后把 cookies 和隐藏字段封装成一个 SessionContext 对象后续 POST 构造时直接从这个对象取值比散落在 Map 里清晰。同时把预请求响应保存成 HTML 文件用编辑器搜一下页面里有没有 JavaScript 动态生成 token 的逻辑。如果 token 是 JS 算出来的就得考虑 Selenium 兜底——但我的经验是大多数增值税发票验真平台是传统表单提交没复杂到要上浏览器自动化。真遇到动态参数的先确认是否只是时间戳拼接别一上来就上重型工具。2.3 把抓到的请求另存 curl写代码前先手动跑通浏览器 Network 面板里右键任意请求可以直接 Copy as cURL包含完整 URL、请求头、Form Data。我先在命令行跑一遍这条 curl确认服务端返回“查验通过”或对应结果再动手写 Java 代码。这一步能把“平台侧请求有问题”和“Java 代码写错了”两类问题隔离开。curl 命令里要注意引号包裹字段值。发票号码、校验码是纯数字串还好金额带小数点的某些 shell 会做算术扩展稳妥做法是给整个 data 段加单引号curl -i -X POST https://example-verify-platform/invoice-check \ -H User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ -H Cookie: sessionidxxxxxx \ --data fpdm044001900111fphm12345678kprq2024-01-15jshj1234.56jym123456-i会把响应头打印出来方便看 Content-Type 是 text/html 还是 application/json以及状态码和 Set-Cookie 的更新。Cookie 值直接从浏览器抓包结果复制第一次跑通后Java 代码的目标就很明确等价重放这个请求再解析响应。这条 curl 建议存成项目里的一个 .sh 脚本它既是复现依据也是以后页面改版时的第一手排查样本。跑通后顺手把响应保存成 sample-pass.html——它就是第 6 章测试夹具的原材料这一步做掉后面能省不少事。3. HttpClient 构造 POST 请求会话维持、参数拼装与响应解析3.1 Maven 依赖HttpClient、Jsoup、Jackson 各管一段如果你写过 python 的 requests 爬虫Java 这边的对应关系是Apache HttpClient 负责发请求Jsoup 负责解析 HTMLJackson 负责解析 JSONSlf4j 负责日志。各管一段职责清楚。pom.xml 里加这四个就够起步dependencies !-- HTTP 请求 -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.13/version /dependency !-- HTML 解析 -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.15.4/version /dependency !-- JSON 解析兼容接口返回 JSON 的场景 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.13.4/version /dependency !-- 日志 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version1.7.36/version /dependency /dependencies依赖选择上HttpClient 4.5.13 是产线上用得最多的稳定版本5.x API 变化不小老项目迁移成本高这里按 4.5.x 写。Jsoup 1.15.4 对 CSS 选择器支持完整解析表格足够。Jackson 只在你抓包发现响应是 JSON 时才真正用上但提前放进依赖很便宜免得后面接口改版再补。版本号取自当前主流组合。如果你公司有统一依赖管理比如 parent BOM以公司规范为准别硬顶版本否则私服拉包失败会浪费一下午。3.2 构造表单参数并发请求参数顺序、Cookie 与超时请求构造的关键点有三个参数顺序、Cookie 复用、超时设置。参数顺序有些平台不敏感有些则会对顺序做签名校验所以一律用 List 按抓包顺序拼装不要用 HashMap——HashMap 不保证顺序偶发失败会让你排查到怀疑人生。// 全局只建一个 CookieStore所有请求共用会话不断 BasicCookieStore cookieStore new BasicCookieStore(); // 将从 Jsoup 预请求拿到的 cookies 写入 store for (Map.EntryString, String entry : sessionCookies.entrySet()) { cookieStore.addCookie(new BasicClientCookie(entry.getKey(), entry.getValue())); } CloseableHttpClient client HttpClients.custom() .setDefaultCookieStore(cookieStore) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(15000) .setConnectionRequestTimeout(5000) .build()) .build(); ListNameValuePair form new ArrayList(); form.add(new BasicNameValuePair(fpdm, invoice.getFpdm())); form.add(new BasicNameValuePair(fphm, invoice.getFphm())); form.add(new BasicNameValuePair(kprq, invoice.getKprq())); form.add(new BasicNameValuePair(jshj, invoice.getJshj())); // 隐藏字段如果有继续 add 进来 // form.add(new BasicNameValuePair(__VIEWSTATE, ctx.getViewState())); HttpPost post new HttpPost(CHECK_URL); post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); post.setHeader(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36); post.setHeader(Referer, CHECK_PAGE_URL);这段代码里BasicCookieStore配合setDefaultCookieStore会让 HttpClient 自动维护服务端下发的新 Cookie后续遇到 Set-Cookie 更新也不用手工处理。三个超时分别管建连、读响应、从连接池取连接读响应 15 秒是给公共服务类页面的富余。UrlEncodedFormEntity会自动做 URL 编码中文和特殊字符不用自己转义。执行请求时我习惯把状态码和响应体的前 200 字符先打出来try (CloseableHttpResponse resp client.execute(post)) { int status resp.getStatusLine().getStatusCode(); String body EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); log.info(invoice {} response status{}, preview{}, invoice.getFphm(), status, body.length() 200 ? body.substring(0, 200) : body); if (status 302 || status 401) { log.warn(会话可能过期需要重新预请求刷新 Cookie); } }302 在这里基本等于是会话失效的信号看到它就别继续批量先回到第 2 章预请求流程刷新 Cookie 再重试否则后面全是无效请求。3.3 响应解析HTML 表格和 JSON 两套模板验真平台返回的响应格式抓包时就定下来了。如果是 HTML用 Jsoup 定位结果节点。多数平台会把查验结果放在一个固定 class 的节点里或者结果表格的某一行Document resultDoc Jsoup.parse(responseBody); Elements stateNodes resultDoc.select(.result-state, .verify-result, .check-result); String state stateNodes.isEmpty() ? 未知 : stateNodes.first().text().trim(); if (state.contains(查无此票) || state.contains(不一致)) { log.warn(发票未通过查验: {}, invoice.getFphm()); } else if (state.contains(查验通过)) { log.info(发票查验通过: {}, invoice.getFphm()); } else { log.error(无法识别的结果节点: {}, state); } // 提取发票明细遍历结果表格 Elements rows resultDoc.select(table.result-table tbody tr); for (Element tr : rows) { Elements tds tr.select(td); if (tds.size() 2) { System.out.println(tds.get(0).text() : tds.get(1).text()); } }注意select里的 class 名必须来自你实际抓包的页面不同平台差很远。第一次对接时先把响应 HTML 落盘一次人工确认选择器能选中结果再写进代码别凭感觉猜节点。如果抓包发现响应是 JSON改用 JacksonObjectMapper mapper new ObjectMapper(); JsonNode root mapper.readTree(responseBody); JsonNode data root.path(data); String state data.path(state).asText(); // 具体字段名以接口返回为准 String message data.path(message).asText(); boolean passed 1.equals(data.path(checkResult).asText());JSON 字段名的可塑性比 HTML 选择器还强没有统一标准。我的习惯是先用readTree把整个响应打印成树状结构看一遍再决定取哪几个节点别照着网上某篇文章的字段名硬写。4. 批量验真与并发控制CompletableFuture、限速与重试4.1 先封装模型Invoice 与 VerifyResult批量之前先把单张验真封装成一个方法入参出参都定义成对象而不是散落的 Map。这样后面接 Excel、接消息队列都能复用同一套逻辑。checkinvoice 里的核心模型大致是这样Data Builder public class Invoice { private String fpdm; // 发票代码 private String fphm; // 发票号码 private String kprq; // 开票日期 yyyy-MM-dd private String jshj; // 价税合计金额 private String jym; // 校验码后六位 } Data Builder public class VerifyResult { private String fphm; private boolean passed; // 是否查验通过 private String state; // 原始状态文案查验通过/查无此票/不一致 private String rawResponse; // 保留原文排查时用 }数据源这块常见做法是用 EasyExcel 或 Apache POI 从 Excel 读待验发票清单逐行映射成 Invoice 对象。字段映射要显式做别用反射猜列名发票代码和发票号码这种数字串如果用数值列读入会丢前导零必须按文本类型读。金额字段在模型里直接用 String 承载计算比较时再用 BigDecimal 工具类转换——Excel 读到的数字、JSON 返回的金额、拼接参数时的字符串三种形态互转最容易出精度问题。4.2 CompletableFuture 并发线程数克制请求之间加随机间隔批量验真的瓶颈从来不在 CPU在网络和服务端承受能力。爬虫异步化在这个场景下的正确用法是少量线程并发 每个请求之间随机 Sleep防止请求落在同一秒形成脉冲。我一般只开 2 到 4 个线程这种公共服务对并发很敏感快那几十秒不值得换来封 IP。ExecutorService pool Executors.newFixedThreadPool(3); ListInvoice invoices loadInvoices(xlsxPath); // 从 Excel 读到的列表 ListCompletableFutureVerifyResult futures invoices.stream() .map(inv - CompletableFuture.supplyAsync(() - { // 每次请求前随机停顿 300-800ms把请求摊开 Thread.sleep(ThreadLocalRandom.current().nextLong(300, 800)); return verifyOne(inv); }, pool)) .collect(Collectors.toList()); ListVerifyResult results futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());supplyAsync把每个验真任务丢进线程池join阻塞等待全部完成。随机 Sleep 是给请求加抖动避免 N 个线程在同一毫秒内同时发出请求——服务端如果做每秒请求数统计这种脉冲最容易触发限流。想把吞吐调高优先扩大 Sleep 的随机范围而不是加线程数。这类验真平台是公共服务调用要克制。只验真业务上真实需要的发票别拿批量接口当压测靶场超大并发对自己、对平台都是麻烦。4.3 重试要分场景网络错误重试业务结果不重试重试是批量任务里最容易写错的地方之一。判断标准很简单只有 IOException、超时这类网络层异常值得重试“查无此票”是业务结果重试一万次也不会变反而会把请求量放大。下面这个带退避的重试写法我一直在用public VerifyResult verifyWithRetry(Invoice inv, int maxRetry) { int attempt 0; while (true) { try { return verifyOne(inv); } catch (IOException e) { attempt; if (attempt maxRetry) { throw new RuntimeException(发票验真重试耗尽: inv.getFphm(), e); } // 指数退避1s、4s、9s给服务端喘息时间 long backoff 1000L * attempt * attempt; log.warn(第 {} 次重试等待 {}ms, attempt, backoff); Thread.sleep(backoff); } } }attempt * attempt是指数退避的简化写法第三次等待 9 秒比固定间隔好用得多也比退避库少一个依赖。业务异常比如解析不到结果节点不在这层处理直接向上抛由主流程记录失败清单结束后人工重跑失败项。日志这块给每次请求打一行结构化日志就够了发票号码、HTTP 状态码、耗时、结果状态。批量结束后把失败清单写入一个 CSV 或 Excel方便财务同事对照处理。日志字段固定下来出问题时 grep 发票号码就能把整条链路拉出来。5. 发票验真爬虫避坑记录五个翻车现场与排查思路这章是血泪经验。前四个坑都是我在真实项目里踩过的第五个是同行项目里见的通病每一条都按现象、原因、解决的顺序写方便直接对照排查。5.1 全部返回“查无此票”日期格式和金额口径不一致现象同一张发票在浏览器手工验真通过程序提交返回全是“查无此票”。原因两处最容易错。一是日期格式平台要求 yyyy-MM-dd程序里拼成了 yyyyMMdd服务端解析不到日期直接查无此票二是金额口径有些平台按“价税合计”校验有些按“不含税金额”校验还有的要带负号或不带负号错一位数结果就完全不一样。这种问题几乎是验真爬虫的玄学重灾区看起来像平台抽风实际是参数没对齐。解决在 2.1 抓包时逐字段比对浏览器实际提交的值保存一份“标准参数样例”。金额用 BigDecimal 处理不要用 double 直接拼接——double 的浮点误差在验真场景里会造成偶发失败。日期字段从 Excel 读取时很可能被自动转成序列号读出来要格式化回 yyyy-MM-dd。提交前把拼好的参数打一行日志和浏览器抓到的数据逐项对一遍肉眼确认再上量。5.2 批量中途全部 302会话过期没有自动刷新现象跑前 50 张正常第 60 张开始所有请求返回 302 或跳回首页失败清单越来越长。原因验真平台会话有有效期或者按请求次数限定了会话时长。HttpClient 复用的是最初的 CookieStore服务端 session 过期后没有新的 Set-Cookie 下发后续请求全部带着旧 Cookie 被拒。解决在响应处理里显式判断 302/401遇到后暂停批量重新执行一次第 2 章的 Jsoup 预请求刷新 Cookie更新 CookieStore 后再把当前请求重发一次。刷新的动作要包进重试逻辑别在循环体外只刷一次——批量跑半小时后过期的情况单次刷新救不回来。5.3 验证码一出现整个爬虫全灭现象连续请求到一定量页面开始要求输入图形验证码程序没有识别通道后续请求全军覆没。原因这是平台的反爬虫风控通常按 IP 维度的请求频率触发。请求一密验证码就是必经之路。响应里会多出验证码图片节点表单字段也随之多一个 yzm 或者 captcha 之类的参数程序不认识自然全灭。解决最稳的方案是降低频率——单线程 3 秒一张已经是比较激进的节奏我一般把线程降到 1 到 2间隔拉长到 2 到 5 秒随机。如果业务量确实大再考虑接入 OCR 识别或人工兜底通道。这里不建议挖逆向绕验证码验真场景讲究的是长期稳定频率克制比技术对抗省心得多。代码里发现多出验证码字段时直接停止批量并告警别硬着头皮继续空跑。5.4 响应编码 GBK 乱码解析出来全是问号现象请求正常返回 200但 Jsoup 解析出来的结果全是“”或乱码文本匹配永远失败日志里一片问号。原因平台响应是 GBK 编码代码里用EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8)强制按 UTF-8 解码中文字符全被拆坏。这类老平台用 GBK 的不少用错编码后解析逻辑再正确也没意义。解决不要盲目指定编码。先看响应头 Content-Type 里的 charset没有的话再看页面meta charset声明。稳妥写法是先用ContentType.getOrDefault(resp.getEntity())取编码取不到再按 UTF-8 兜底。抓包保存样本 HTML 时也要保留原始字节别在保存环节就用错误的编码转掉否则夹具本身就是坏的。5.5 页面改版选择器失效解析器静默返回空现象前一天跑得好好的今天开始大量票据被标记为“未知”没有人第一时间发现等对账时才知道出了批量问题。原因平台前端改了 class 名或 DOM 结构select(.result-state)选不到任何节点代码又没有对空结果做告警直接把空值当结果写库。这种静默失败最坑没有后悔药可吃只能靠机制兜住。解决解析层对“结果节点为空”做显式告警宁可报错也不要静默当失败。批量跑的时候每 100 张统计一次“未知”比例超过阈值自动停止并通知。更系统的做法是把解析逻辑做成可回归的测试——用抓包保存的样本 HTML 跑 JUnit 断言这就是下一章要落地的内容。页面改版后第一反应不是去猜新选择器而是重新跑一遍抓包加 curl 的流程拿到新样本再更新测试。6. 用 JUnit 断言锁死解析逻辑页面改版也不怕解析逻辑是整个爬虫里最脆弱的部分因为它依赖平台的 DOM 结构而 DOM 改版是早晚的事。我的习惯是把抓包得到的样本响应固化成测试夹具每次改动解析代码都回归一遍。这个习惯救过我两次一次是平台改版一次是我自己重构两次都是测试先红了批量任务才没跟着遭殃。做法是在 src/test/resources 下建一个 fixtures 目录把 2.1 保存的样本 HTML 和 JSON 放进去再写一个针对解析器的 JUnit 测试public class VerifyResultParserTest { Test public void shouldParsePassedStateFromHtml() throws Exception { String html Files.readString( Paths.get(src/test/resources/fixtures/sample-pass.html)); VerifyResult result VerifyResultParser.parseHtml(html, 12345678); assertEquals(查验通过, result.getState()); assertTrue(result.isPassed()); } Test public void shouldParseNotFoundState() throws Exception { String html Files.readString( Paths.get(src/test/resources/fixtures/sample-notfound.html)); VerifyResult result VerifyResultParser.parseHtml(html, 12345679); assertEquals(查无此票, result.getState()); assertFalse(result.isPassed()); } }这两个用例把“解析样本 HTML 并识别状态”这个契约固定下来。平台改版时测试会直接失败第一时间告诉你解析器坏了而不是等批量任务跑完、财务对账时才发现一批结果不对。parseHtml方法内部只做一件事接收原始 HTML 字符串返回 VerifyResult 对象不牵扯 HTTP 和网络测试才稳定可跑。filter 的关键是 sample-pass.html 和 sample-notfound.html 这两个夹具要原样保存包括编码和原始字节。页面改版后重新抓一份新样本更新夹具再调整选择器直到测试通过——整个过程跑完不超过十分钟。测试覆盖两个分支就行通过和未通过不一致的回报可以并进未通过分支的断言里。这个项目的 Maven 结构里src 下是主逻辑test 下就是这类回归用例拿回去把页面地址和参数名换成你们实际对接的平台就能跑。从那以后我每次改动解析逻辑都强制走一遍“手工验真抓包 → 落盘样本 → 更新 JUnit 用例 → 单张跑通 → 批量限速跑”这条流程宁可慢十分钟也不让爬虫在半夜批量里静默翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表