资讯详情

Java模拟登录实战:OkHttp+CookieJar处理跳转与验证码

📅 2026/11/2 15:46:51 | 华诺云谱 👁 阅读
Java模拟登录实战:OkHttp+CookieJar处理跳转与验证码
简介面向需要接入人人网校内登录流程的 Java 开发者这份代码示例填补了网上旧版校内登录程序无法直接使用的空白。作者以简易实现为主仅完成账号登录这一核心环节适合作为二次开发的起点或教学演示素材。压缩包共 4 个文件含 1 个 java 源码与 3 个 zip 依赖库commons-httpclient、commons-codec、commons-logging 等整体约 4.31MB解压后需自行添加库文件再编译运行。资源已有 43239 人学习。读者可从中了解基于 HttpClient 的模拟表单提交与 Cookie 保持思路、第三方库的引入方式以及登录后接口调用的衔接位置代码量小、结构直白便于快速定位关键请求参数并按需扩展验证码处理、会话维持等功能也适合作为爬虫与自动化测试入门的参考骨架。1. 从「两行代码就登录」到跑通一个 Java 自动登录代码包我最初以为校园社交站点的自动登录无非是填两个表单字段、发一次 POST真跑起来才发现老站的登录链路远比这复杂登录页带隐藏 token提交后 302 跳两次Cookie 分属不同 Path验证码还会按访问频率动态出现。这份 Java 自动登录代码包处理的正是这些环节打开登录页、抽取隐藏域、提交表单、跟随跳转、校验登录态、保存 Cookie 到磁盘串成一条能重复执行的链路OkHttp 管连接自写 CookieJar 管会话Jsoup 管字段解析。适合两类人手上有只能靠网页登录才拿得到的数据、想用 Java 定时取的开发者以及想搞懂模拟登录难在哪、拿一个能跑的骨架改到自己目标站点上的同学。下面按我实际拆包的顺序讲。2. 拆登录链路一次 POST 背后其实有四跳这章先把链路讲清楚代码放到下一章。之所以先讲链路是因为九成跑不通的自动登录都错在这一步照着别人的教程写POST /login参数一个不差返回 200然后拿到的页面又是登录页。你会怀疑密码加密错了、怀疑验证码、怀疑风控实际上是提交时少了第一跳带下来的会话 Cookie或者跳转链中间那一跳的Set-Cookie被客户端丢掉了。把这些摸清后面写代码只是填空。2.1 用 Network 面板抓一条「可重放」的链路抓包的第一步不是点登录而是先清空 Cookie。开发者工具的 Network 面板勾上Preserve log保留日志和Disable cacheApplication 面板里把该站点的 Cookie 全删掉再走一次完整登录。为什么要清因为带着旧会话时服务端可能直接跳过预检接口你抓到的是一条「捷径链路」换个干净环境就跑不通。一条典型的链路长这样四跳缺一不可序号方法与落点关键响应头这一跳在干什么1GET 登录页Set-Cookie: JSESSIONID...下发会话 ID页面里埋隐藏域2GET 预检/心跳接口部分站点有Set-Cookie: 另一个名字下发风控指纹或设备标识3POST 提交302 Location: /home或200 JSON真正校验账号密码4GET 跳转落点200页面含昵称节点确认登录态已生效第 2 跳是最容易被忽略的。老站常见的做法是登录页加载完成后前端 JS 再打一个接口拿「设备指纹」「时间戳」「加密盐」然后把这些值塞进第 3 跳的表单。你只复制第 3 跳的参数看起来齐全但那个盐是一次性的或者和 Cookie 里的会话绑定重放必失败。抓的时候还有两个细节。一是把请求按时间排序后逐条看Form Data和Query String注意有些参数藏在 URL 上而不是 body 里二是右键任意一条请求选「Copy as cURL」粘到文本编辑器里对照cURL 会把-H头和--data体完整展开比在面板里点开逐项看省事得多。提示抓包阶段不要一边改代码一边抓。先把完整链路复制成一份静态文本存档后面所有字段都以这份存档为准避免「改着改着不知道哪一版是对的」。2.2 从登录页 HTML 里挖隐藏字段第 3 跳的表单字段分两类一类是你知道的账号、密码另一类是你不知道的token、时间戳、随机串、来源标记。第二类全部藏在登录页的input typehidden里或者藏在页面内联 JS 的变量里。硬编码这些值是新手最常踩的坑——今天能跑明天服务端换个算法就全挂。正确做法是每次登录前先 GET 登录页把表单里所有带name的控件原样收集起来再把账号密码覆盖进去。Jsoup 干这个很顺手// 拉登录页并收集表单字段隐藏域一个都不能漏 Document doc Jsoup.connect(LOGIN_PAGE) .userAgent(UA) // 与后续提交保持一致别这里一个那里一个 .timeout(10_000) .get(); MapString, String form new LinkedHashMap(); for (Element input : doc.select(form input[name])) { String name input.attr(name); // 隐藏域通常没有 value 属性attr 返回空串直接塞进去即可 form.put(name, input.attr(value)); } // 顺便把 textarea 和部分 select 也收进来有的站点把 token 放在隐藏 textarea 里 for (Element ta : doc.select(form textarea[name])) { form.put(ta.attr(name), ta.text()); }这段代码的逻辑是「以页面为准而不是以记忆为准」。三个参数值得说userAgent必须和后面提交请求用同一个常量很多站点会把 UA 的哈希算进 token两处不一致直接判为非法timeout给 10 秒老站点偶尔响应慢设 3 秒会误判成网络故障select的范围限定在form内因为页面上常有搜索框、评论框也带name全页扫描会把无关字段一起提交上去。收集完之后不要急着用。打印一遍form的 keySet对照抓包存档核一遍抓包里提交了 8 个字段你这里只有 5 个说明有的字段是 JS 动态生成的得回去翻 JS你这里有 12 个说明多收了多提交的字段有的站点会直接拒绝。2.3 密码字段到底传什么三种形态与判定顺序密码是最容易想当然的地方。同一个站点PC 端和移动端可能用两套加密同一个端改版前后也可能换算法。我一般按下面的顺序判定从便宜到贵形态判定方法实现代价备注明文直传抓包里密码字段肉眼可读极低老站点仍不少见前端摘要MD5/SHA 系列抓包值是固定长度十六进制且同一密码多次提交结果一致低注意有没有加盐盐可能是用户名或固定串前端加盐摘要同一密码多次提交结果不同中盐通常来自 2.1 里那一跳非对称加密抓包值是 Base64 长串长度固定且远大于摘要中高公钥往往硬编码在前端 JS 里判定不要靠猜。最省事的办法是在浏览器开发者工具的 Sources 面板里全局搜password、encrypt、md5、publicKey这几个关键字找到提交前那次赋值看它调了什么函数。搜不到就用断点在提交按钮的点击事件上打断点单步跟进去把加密函数的入参和出参都打出来。还有一个反向技巧如果抓包显示同一密码两次提交的值不一样但长度一样那基本可以确定是「加盐摘要」或者「带时间戳的摘要」。这类算法你复现不了也不算致命——很多实现里服务端会同时接受「摘要值」和「原始值 正确盐」只是没人告诉你。所以先按最简单的方式试一次能过就别折腾。2.4 把抓到的链路固化成一份「登录契约」链路摸清后别急着写 Java。先在一张纸上或者一个 Markdown 文件里写清楚五件事登录页 URL、提交 URL、必须的请求头清单、字段来源哪些来自页面、哪些来自用户输入、哪些是常量、成功判据。这份东西我习惯叫登录契约它是后面所有代码的唯一依据。契约里「成功判据」这一项最容易被漏掉也最影响调试体验。很多人用「HTTP 200 就算成功」结果服务端把错误信息渲染在 200 的页面里代码一路往下跑直到后面取数据时才报空指针。正确判据在 3.4 节展开这里只需要记住一句话状态码只说明传输成功不说明业务成功。3. Java 侧落地OkHttp 加自管 CookieJar 把会话留住链路的每一步都需要客户端记住上一跳的状态所以自动登录的成败八成取决于会话管理而不是加密算法。这章按工程顺序走先搭骨架再把 Cookie 管住然后写登录方法最后补上成功判据。3.1 依赖与工程骨架依赖不用多三个足够HTTP 客户端、HTML 解析、JSON 处理。用 Maven 的话长这样dependencies !-- HTTP 客户端连接池、拦截器、CookieJar 都是现成的 -- dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency !-- HTML 解析抽隐藏域、校验登录态都靠它 -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency !-- 有的站点登录返回 JSON有的返回 HTML两种都要能处理 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.0/version /dependency /dependencies工程结构建议按职责切三层http层放客户端构建和 CookieJarparser层放字段抽取和登录态判定flow层放登录、探活、重登这些业务流程。三层分开的好处是换目标站点时只改 parser换 HTTP 库时只改 http 层。全塞一个类里也能跑但第二次改需求时会很痛苦。客户端构建有个关键开关OkHttpClient client new OkHttpClient.Builder() .cookieJar(new FileCookieJar(cookieFile)) // 见 3.2 .followRedirects(false) // 手动跟跳才能看清每一跳的 Set-Cookie .followSslRedirects(false) .connectTimeout(Duration.ofSeconds(8)) .readTimeout(Duration.ofSeconds(15)) .build();followRedirects(false)是这份代码和网上大多数示例最大的差别。自动跟跳看着省事但你会在两个地方吃亏一是跳转中间那一跳的Set-Cookie可能因为 Path 不匹配被丢掉二是 POST 被 302 之后按规范浏览器会转成 GET而某些客户端实现里会保留 body导致服务端收到一个畸形的 GET 请求。手动跟跳多写十几行但每一跳的响应头都在你手里。3.2 自己实现 CookieJar内存加磁盘双写OkHttp 默认的 CookieJar 是CookieJar.NO_COOKIES也就是什么都不存。很多人第一次用 OkHttp 写模拟登录卡就卡在这——第一跳拿到的会话 ID 根本没被保存第二跳提交时自然是未登录状态。一个够用的实现要满足三件事按 host 隔离、过滤过期项、落盘可续用。public class FileCookieJar implements CookieJar { private final MapString, ListCookie store new ConcurrentHashMap(); private final Path dumpFile; public FileCookieJar(Path dumpFile) { this.dumpFile dumpFile; } Override public void saveFromResponse(HttpUrl url, ListCookie cookies) { if (cookies.isEmpty()) return; store.computeIfAbsent(url.host(), k - new CopyOnWriteArrayList()).addAll(cookies); flush(); // 立即落盘进程被 kill 也能续上调试时尤其重要 } Override public ListCookie loadForRequest(HttpUrl url) { ListCookie list store.get(url.host()); if (list null) return Collections.emptyList(); long now System.currentTimeMillis(); // 过期项必须剔除带着死 Cookie 请求只会换来无尽的 302 return list.stream().filter(c - c.expiresAt() now).collect(Collectors.toList()); } }三个参数值得展开。dumpFile建议放在项目工作目录下而不是临时目录临时目录会被系统清理第二天跑起来发现会话没了排查半天。expiresAt()的判断用的是服务端下发的过期时间注意区分「会话 Cookie」——这类 Cookie 没有Expires/Max-Age客户端通常给一个很远的未来时间落盘时要单独标记否则重启后会被当成有效 Cookie 带去请求服务端不认又是一次 302。computeIfAbsent加CopyOnWriteArrayList是为了并发安全单线程跑用ArrayList也行但定时任务场景下经常是多线程共享一个 client。还有一个细节按 host 隔离意味着a.example.com和b.example.com的 Cookie 不会串。真实站点常有跨子域登录服务端在Set-Cookie里写Domain.example.com这种情况要按 Domain 属性归并不能只看url.host()。写死按 host 存的实现遇到跨子域站点会表现为「登录成功但访问子域仍是未登录」。判断方法很简单看Set-Cookie里有没有domain属性有就按它归并。3.3 一次登录方法的完整时序把 2.1 的四跳翻译成方法结构就清楚了。下面这段是骨架省略了异常包装public boolean login(String account, String rawPassword) throws IOException { // 第 1 跳GET 登录页顺手把会话 Cookie 收进 jar String html get(LOGIN_PAGE); MapString, String form parseFormFields(html); // 见 2.2 // 第 2 跳如果契约里写了预检接口在这里补一次 GET if (PRECHECK_URL ! null) { get(PRECHECK_URL); // 目的只是让 jar 收 Cookie } // 第 3 跳填字段。用户名/密码的字段名从 form 的 key 里模糊匹配别写死 String userKey matchKey(form, user, account, email, loginname); String passKey matchKey(form, pass, pwd, password); form.put(userKey, account); form.put(passKey, encodePassword(rawPassword, form)); // 见 2.3 FormBody.Builder fb new FormBody.Builder(); form.forEach(fb::add); // 隐藏域原样带回顺序保持 Request req new Request.Builder() .url(SUBMIT_URL) .post(fb.build()) .header(Referer, LOGIN_PAGE) // 缺这行部分站点直接 403 .header(Origin, BASE_URL) .header(User-Agent, UA) .build(); try (Response resp client.newCall(req).execute()) { String location resp.header(Location); if (location null) { // 有的站点是 200 页面内跳转交给 3.4 的判据处理 return isLoggedIn(html); } // 第 4 跳手动跟一次确认落点不是登录页 String landed get(location); return !landed.contains(loginPageMarker) isLoggedIn(landed); } }几个参数的作用需要说明清楚。matchKey里的候选词是按常见命名习惯给的不要一次写十个三到四个够了多了容易误匹配到「用户名提示」这类字段。form.forEach(fb::add)用的是LinkedHashMap的遍历顺序服务端如果对参数顺序敏感老站点真有顺序错也会失败所以收集字段时一定要用有序 Map。Referer和Origin两个头不是可选项它们是服务端判断「请求是否来自自己的登录页」的常用依据缺了会被当成 CSRF 攻击直接拒绝。encodePassword这个函数是最容易被写死的地方。我的做法是把它做成一个策略接口按 2.3 的判定结果选实现明文策略直接返回原值摘要策略拼上盐再算哈希非对称策略用内置公钥加密。好处是同一个登录流程可以复用到多个目标站点坏处是多了一层抽象调试时要点进去看。第一次跑通前建议先写死跑通后再抽。3.4 登录成功的判定信号判定信号按可靠性排序能用就尽量用靠前的判据可靠性说明落点 URL 是否跳出登录页中有的站点登录失败也跳首页只是首页不含用户信息页面里能否解析到用户昵称节点高依赖选择器改版会失效但失效时报错很明确用户信息接口返回 200 且带 uid最高多一次请求但最稳响应头里是否下发了新的会话 Cookie中可作为补充信号不能单独用HTTP 状态码低登录失败常见返回 200我一般同时用「昵称节点」和「用户信息接口」两个前者省一次请求用于快速判断后者用于兜底。代码里把这两个判据写成独立方法登录流程只调结果不掺进去这样改版时只改一处。还有一点登录成功后立刻把 Cookie 落盘并在日志里打印会话 Cookie 的数量和过期时间。这一步在调试期能省掉大量「为什么第二次跑还是未登录」的困惑——多数时候答案就是 Cookie 没存下来或者存下来了但过期时间是 1970。4. 验证码与风控把不确定性交给可控处理验证码不是一个「识别一下就行」的问题它是一整套触发机制。你在本机跑十次都不出放到服务器上跑第三次就弹出来了然后代码整个流程崩在「找不到验证码输入框」上。这章讲的是怎么把这个不确定性变成可控分支。4.1 验证码出现的触发条件先把触发条件摸清才知道该防哪一头。常见的有四类访问频次同一 IP 短时间多次打开登录页、账号维度同一账号连续失败、环境异常UA 与 Cookie 不匹配、缺少某些请求头、以及首次登录新设备。前三类是可以通过代码规范的第四类防不住只能处理。探测方法很简单写个循环连续 GET 登录页每次解析页面里有没有验证码图片或验证码输入框把出现的那次记下来。跑二十次你大概能知道阈值在哪个区间。这个数字不要写死在代码里当常量记录下来当参考就行因为阈值会变。提示探测频次时把间隔拉到 30 秒以上用同一个账号反复快速请求容易触发账号维度的临时限制恢复起来比 IP 维度麻烦得多。4.2 三档处理策略与统一接口验证码处理我一般给三档人工介入、会话复用、外部识别。三档按代价从低到高排优先用前面的。人工介入就是弹出图片让使用者输入适合跑批频率低、无人值守要求不高的场景会话复用是把登录后的 Cookie 落盘长期复用把「登录次数」压到最低这是最有效的一招很多场景根本不需要每次都登录外部识别是把图片交给第三方服务代价是引入外部依赖和成本。不管用哪档接口先统一下来上层不用关心底层是什么public interface CaptchaSolver { /** * param image 验证码图片字节服务端返回什么就传什么不做转码 * param scene 场景标记方便同一套实现区分不同站点 * return 识别结果返回 null 表示放弃本次登录走重试 */ String solve(byte[] image, String scene); } // 人工实现适合本地调试和低频任务 public class ConsoleCaptchaSolver implements CaptchaSolver { Override public String solve(byte[] image, String scene) { Path tmp Paths.get(captcha- System.currentTimeMillis() .png); try { Files.write(tmp, image); // 先落盘用系统看图工具打开 System.out.println(验证码已保存: tmp.toAbsolutePath()); System.out.print(请输入: ); return new Scanner(System.in).nextLine().trim(); } catch (IOException e) { return null; } } }scene参数看着多余实际很有用一个工程里可能要处理多个站点甚至同一站点登录页和二次验证页的验证码尺寸都不一样带上场景标记可以在实现类里分流。返回null而不是抛异常是为了让上层统一按「本次失败N 秒后重试」处理不用为验证码单独写一套异常分支。4.3 请求头的最小可信集合风控看的是「像不像浏览器」而不是「有没有某个头」。与其堆十几个头不如把关键的几个做对请求头是否必须说明User-Agent必须全流程统一包括登录页 GET、提交 POST、后续接口Referer必须提交时必须指向登录页后续接口指向对应页面Origin提交时必须有与站点协议 域名严格一致多一个斜杠都可能被拒Accept-Language建议与 UA 里的地区保持一致别 UA 是中文环境、语言头是英文Accept建议表单提交用默认值即可不要手写过于具体的 MIMEAccept-Encoding建议用客户端默认别手动写gzip又忘了解压最后一行是血泪经验手动设了Accept-Encoding: gzip服务端乖乖压缩客户端没配解压拦截器拿到的 body 是乱码然后你开始怀疑编码、怀疑加密查两小时最后发现是压缩。OkHttp 默认会自己处理 gzip只要你不手动覆盖这个头。4.4 重试与退避别写成死循环验证码失败、网络抖动、临时限流这几种失败都需要重试但重试方式决定了你是「稳定跑一个月」还是「十分钟后被封」。三条规矩限定次数、指数退避、失败分类。for (int attempt 1; attempt MAX_ATTEMPT; attempt) { try { LoginResult r doLoginOnce(); if (r.isSuccess()) return r; // 业务失败密码错、验证码错不重试重试只会加速触发风控 if (r.isFatal()) throw new IllegalStateException(登录失败: r.getReason()); } catch (IOException e) { // 网络类异常才重试 if (attempt MAX_ATTEMPT) throw e; } long backoff (long) (BASE_MS * Math.pow(2, attempt - 1)); // 1s, 2s, 4s... backoff ThreadLocalRandom.current().nextLong(0, 500); // 加抖动避免固定节奏 Thread.sleep(backoff); }MAX_ATTEMPT我一般设 3。再多意义不大因为失败三次基本说明不是偶发问题。BASE_MS给 1000 到 2000 之间不要给 100 毫秒——退避的意义就是让服务端看到请求间隔在拉长。抖动那 500 毫秒不是凑数固定节奏的请求在日志里一眼就能被识别成机器行为。isFatal的划分很关键密码错误、账号被锁、需要二次验证这三类属于「重试也没用」必须立刻抛出让人来处理验证码识别错误属于「可能下次就对」可以重试超时、连接重置属于网络类必须重试。把这三类混在一起就是死循环的来源。5. 避坑记录五个把登录打回原形的细节这章是我自己踩过的、也见过别人踩的记录。每一条都按现象、原因、解决写能对号入座就别走弯路。5.1 中文乱码不是编码问题是解码时机问题现象登录能成但页面里的中文昵称全是问号或方块Jsoup 解析出来的文本是乱码。原因老站点大量使用 GBK 系列编码而 HTTP 响应头里的Content-Type经常不带charset或者带了一个错的。客户端默认按 UTF-8 解码字节流解完再交给 JsoupJsoup 拿到的已经是坏字符串此时再设编码也没用。解决不要用response.body().string()直接取字符串先取字节再手动解码byte[] raw resp.body().bytes(); String charset UTF-8; MediaType ct resp.body().contentType(); if (ct ! null ct.charset() ! null) { charset ct.charset().name(); } else { // 响应头没给从前 2KB 的 meta 里找找不到再按 GBK 兜底 String head new String(raw, 0, Math.min(raw.length, 2048), StandardCharsets.ISO_8859_1); Matcher m Pattern.compile(charset[\]?([\\w-]), Pattern.CASE_INSENSITIVE).matcher(head); if (m.find()) charset m.group(1); else charset GBK; } String html new String(raw, Charset.forName(charset));关键在「先字节后解码」这个顺序。另外Jsoup.connect(...).get()会自动处理编码但它是按响应头猜的猜错的时候你没法干预所以登录流程我一般用 OkHttp 自己发请求只把 Jsoup 当解析器用。5.2 提交后 302 回登录页四个可能的原因现象POST 返回 302Location指向登录页跟过去看是全新的登录页没有错误提示。原因有四种按出现频率排一是第一跳的会话 Cookie 没被保存CookieJar 是 NO_COOKIES或者自定义实现里saveFromResponse没被调用二是 Cookie 保存了但 Path 不匹配请求时没带上三是隐藏 token 取到了但提交时被FormBody重新编码值里的、变了形四是服务端要求Referer缺了。解决按顺序排查。先打日志看loadForRequest返回了什么为空就是第一种不为空但请求头里没有Cookie检查 PathCookie 有、token 有就对比你提交的 body 和抓包里的原始 body逐字符比重点看、%2B、空格这几处最后补Referer。5.3 自动跟跳把 POST 变成 GET现象开了followRedirects(true)登录有时成功有时失败失败时服务端日志显示收到一个奇怪的 GET 请求。原因302 的语义是「用 GET 重新请求 Location」客户端自动跟跳时会把原请求的方法改掉但 body 有时候还留着取决于实现服务端收到一个带 body 的 GET直接判为异常请求。解决关掉自动跟跳手动读Location再发一次 GET见 3.1 的配置。多写的这十几行换来的是每一跳都可观测出问题时能立刻定位在哪一跳断的。5.4 表单参数顺序与重复 key现象参数一个不差抓包对比也一致但就是失败。原因部分老站点用「按顺序拼接参数再算签名」的方式校验顺序错签名就错另一种情况是表单里有同名多值字段比如多选用 Map 收集会把前面的值覆盖掉。解决收集字段一律用LinkedHashMap保持页面出现顺序遇到同名多值把MapString, String换成MapString, ListString提交时用FormBody.Builder.add()逐个加不要用addEncoded拼字符串。5.5 高频请求触发临时限制现象本地跑得好好的部署到服务器后前两次成功第三次开始全部失败返回一个提示页而不是登录页。原因服务器出口 IP 固定短时间内多次打开登录页 提交触发了频次限制另一种是 UA 和 Cookie 的组合在多次请求间不一致被判为异常环境。解决把登录频率压到最低能复用会话就别重登所有请求共用一个OkHttpClient实例连接池复用也保证 Cookie 一致UA 定义成一个常量全流程引用同一个退避策略按 4.4 配。已经触发了就停一段时间别用缩短间隔的方式硬刚越刚恢复越慢。6. 进阶把一次性脚本收敛成可复用的登录模块脚本跑通只是起点。真正在项目里用的时候登录只占一行调用剩下的全在「会话会不会中途失效」这件事上。最后这章讲怎么把登录做成一个能长期跑的模块以及怎么验证它真的在长期正确地工作。6.1 会话探活与静默重登登录成功后拿到的会话不会永久有效服务端可能因为超时、异地登录、主动踢下线等原因让它失效。判断标准不是「Cookie 还在不在」而是「用一个轻量接口试一下还能不能拿到数据」。我一般选一个返回体很小、鉴权要求完整的接口作为探活点。public synchronized Session ensureSession() throws IOException { // 1. 有本地会话就先探活探活成本远低于重登 if (session ! null probe(session)) return session; // 2. 探活失败用落盘的 Cookie 再试一次进程重启后的常见路径 cookieJar.reload(); if (probe(null)) return session; // 3. 都不行才真正重登并且只重登一次 return login(account, password); } private boolean probe(Session ignored) { try (Response r client.newCall(new Request.Builder().url(PROBE_URL).build()).execute()) { if (r.code() ! 200) return false; String body r.body().string(); // 探活接口返回登录页说明会话已失效这是最常见的失效信号 return !body.contains(loginPageMarker) body.contains(\uid\); } catch (IOException e) { return false; // 网络问题不重登交给上层重试避免风暴 } }三个设计点。synchronized是必须的多个线程同时发现会话失效会并发触发三次登录直接把账号送进风控名单。探活优先于重登一次探活是一百多字节的响应一次重登要下验证码、要算加密代价差两个数量级。网络异常返回 false 但不触发重登网络抖动时重登只会让情况更糟应该让上层按退避重试。cookieJar.reload()这一步是给「进程重启」准备的。定时任务每天跑一次进程每次都是新的登录态只存在于磁盘上的 Cookie 文件里不 reload 就等于每次都重登。6.2 一套可复用的验证清单模块写完怎么确认它在不同环境下都靠得住我固定跑这五项每项都能在五分钟内出结果检查项做法期望结果冷启动登录删掉 Cookie 文件后直接跑一次登录成功文件被重新写入热启动复用不删文件再跑一次不触发提交请求直接探活通过会话失效恢复手动把 Cookie 文件里的值改坏探活失败后自动重登只重登一次编码健壮性用含中文昵称的账号跑昵称解析正常无乱码异常终止登录过程中 kill 进程下次启动不因半截 Cookie 文件崩溃第四项容易被忽略。很多实现只验证「登录成没成」不验证「解析出来的用户信息对不对」结果中文全乱码登录判据却返回 true一路跑下去数据全是脏的。第五项考验的是落盘的原子性写文件时先写临时文件再改名比直接覆盖安全否则 kill 在写入中途会留下一个半截文件下次启动 JSON 解析直接抛异常。那张「登录契约」我到现在还留着模板每接一个新目标站点先花半小时填它填完再动手写代码。早期我不这么干习惯打开 IDE 就开始敲结果经常是写到一半发现字段来源没摸清回头重新抓包一晚上过去代码还是跑不通。从那以后我每次接新的登录目标都强制先把四跳链路、字段来源、成功判据三样写完整再写第一行 Java。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑