ARTICLE DETAIL

资讯详情

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

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

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 cache,Application 面板里把该站点的 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 type="hidden">里,或者藏在页面内联 JS 的变量里。硬编码这些值是新手最常踩的坑——今天能跑,明天服务端换个算法就全挂。

正确做法是每次登录前先 GET 登录页,把表单里所有带name的控件原样收集起来,再把账号密码覆盖进去。Jsoup 干这个很顺手:

// 拉登录页并收集表单字段:隐藏域一个都不能漏 Document doc = Jsoup.connect(LOGIN_PAGE) .userAgent(UA) // 与后续提交保持一致,别这里一个那里一个 .timeout(10_000) .get(); Map<String, 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> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <!-- HTML 解析:抽隐藏域、校验登录态都靠它 --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency> <!-- 有的站点登录返回 JSON,有的返回 HTML,两种都要能处理 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.0</version> </dependency> </dependencies>

工程结构建议按职责切三层:http层放客户端构建和 CookieJar,parser层放字段抽取和登录态判定,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 Map<String, List<Cookie>> store = new ConcurrentHashMap<>(); private final Path dumpFile; public FileCookieJar(Path dumpFile) { this.dumpFile = dumpFile; } @Override public void saveFromResponse(HttpUrl url, List<Cookie> cookies) { if (cookies.isEmpty()) return; store.computeIfAbsent(url.host(), k -> new CopyOnWriteArrayList<>()).addAll(cookies); flush(); // 立即落盘:进程被 kill 也能续上,调试时尤其重要 } @Override public List<Cookie> loadForRequest(HttpUrl url) { List<Cookie> 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); Map<String, 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建议表单提交用默认值即可,不要手写过于具体的 MIME
Accept-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 解码字节流,解完再交给 Jsoup,Jsoup 拿到的已经是坏字符串,此时再设编码也没用。

解决:不要用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 返回 302,Location指向登录页,跟过去看是全新的登录页,没有错误提示。

原因有四种,按出现频率排:一是第一跳的会话 Cookie 没被保存(CookieJar 是 NO_COOKIES,或者自定义实现里saveFromResponse没被调用);二是 Cookie 保存了但 Path 不匹配,请求时没带上;三是隐藏 token 取到了但提交时被FormBody重新编码,值里的+、=变了形;四是服务端要求Referer,缺了。

解决:按顺序排查。先打日志看loadForRequest返回了什么,为空就是第一种;不为空但请求头里没有Cookie,检查 Path;Cookie 有、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,保持页面出现顺序;遇到同名多值,把Map<String, String>换成Map<String, List<String>>,提交时用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。希望帮到你。

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

返回列表