上个月帮人做一个课表同步的小工具,需求简单到一句话就能说完:每天早上把教务系统里的课表拉一遍,有变动就提醒一下。真正动手写代码才发现,卡住整个项目的不是 HTML 解析,也不是定时任务,而是最开始那一次登录。教务系统那套登录流程看着就是两个输入框加一个按钮,可你一旦脱离浏览器,用 Java HttpClient 从零发一遍请求,就会发现里面藏着隐藏字段、会话 Cookie、密码密文、验证码绑定、跳转链路,甚至还有服务器时间戳参与运算。这篇文章就把我自己走过一遍的完整链路摊开讲:从抓包拆解登录流程,到 HttpClient 的会话管理,到口令加密参数的复现,到"怎么判断真的登录成功了"这个最容易被忽略的问题,再到登录之后的保活和限速。适合已经会写 Java、但第一次碰表单登录和 Cookie 会话的开发者看;也适合做校园工具、企业内部系统自动化、报表抓取这类事情的同行参考。下文统一把目标系统叫"教务系统",域名一律用示例域名,代码结构和参数顺序可以直接拿去改。
1. 登录流程拆解:一次点击背后其实有四五次往返
1.1 用开发者工具把整条链路看清楚
很多人上来就写代码,直接构造一个 POST 打到登录接口,然后拿到一堆看不懂的响应开始怀疑人生。正确顺序是先在浏览器里把整条链路录下来。打开开发者工具的 Network 面板,勾上 Preserve log(否则一次 302 跳转就把前面的记录冲掉了),清空记录,然后老老实实输入账号密码点登录。你会看到大致这样一串请求:先是 GET 登录页,返回 HTML;然后是 GET 验证码图片;然后是 POST 登录接口;接着是一串 302;最后落到首页或者框架页。
关键在于,第一跳 GET 登录页的响应头里通常有Set-Cookie,里面装着会话标识;登录页 HTML 里通常还有若干<input type="hidden">字段,值在每次访问时都不同;验证码那次 GET 之所以重要,是因为它把"这张图"和"这个会话"绑定了。你要是漏掉其中任何一环,后面的 POST 必然失败,而且失败信息往往极其含糊,只告诉你"用户名或密码错误",让你在错误的方向上排查半天。
我的习惯是,把整条链路里每一次请求的方法、URL、请求头、请求体的原始内容,逐条抄到一个文本文件里。别嫌麻烦,后面写代码时这个文件就是你的施工图纸,哪一步对不上就回来比对。
1.2 登录页里那些不显眼的隐藏字段
教务系统这类老牌管理系统,很多是十几年前的框架堆出来的,表单里除了username、password之外,常常还有两三个隐藏字段。常见的几种:一个是所谓"加密开关"标记,值可能是1或者空串;一个是密码的密文载体,字段名可能是encoded、pwdCipher之类;还有一个是时间戳或者随机串,服务器会拿它和会话里的值做比对,超时或对不上就直接拒绝。
处理方法很直接:GET 登录页拿到 HTML 之后,用正则或者 Jsoup 把隐藏字段全部抽出来,塞进一个Map<String, String>,然后提交表单时原样带上。这里有个细节,很多人只抽type="hidden"的,实际上有些字段是可见但被 CSS 藏起来的,或者type="text"但设了readonly,最稳妥的做法是把所有带name属性的input、select、textarea全抽出来,然后按抓包结果筛选。
抽完之后建议打印一遍,和浏览器抓包里提交的字段逐个对照。少一个字段、多一个字段、值的格式差一个前缀,都可能是失败的原因。我踩过一次,是因为某个字段值末尾有个空格,浏览器提交时编码成+,我手动拼接时把它 trim 掉了,结果服务器校验不过,排查了整整一下午。
1.3 为什么不建议直接复制 curl 命令
有些人图省事,从开发者工具里"Copy as cURL",然后丢给工具转成 Java 代码。这条路能跑通一次,但没法长期用,原因有三个。第一,curl 里带着当时的 Cookie,而 Cookie 是有时效的,过期了代码就废了。第二,curl 里带的是那次会话的隐藏字段值,服务器换了新会话就会拒绝。第三,curl 里带的是完整请求头,包括一些浏览器专属的头,直接照搬有时候反而会触发额外的校验。
正确的心态是:curl 只用来确认"服务器接受什么样的请求形态",具体值必须由代码在运行时动态获取。你写的应该是一套流程,而不是一串常量。
提醒:动手之前先确认这件事是允许的。只对自己有账号的系统做自动化,只抓自己的数据,频率控制住,别影响别人正常使用教务系统,这是底线。
2. HttpClient 的会话模型:Cookie 不是自动送上门的
2.1 为什么选 HttpClient 而不是老的 URLConnection
Java 11 之后内置的java.net.http.HttpClient已经足够好用,异步、连接池、超时、Cookie 处理都有,不用引第三方依赖。和老的HttpURLConnection比,它最大的好处是 API 干净、线程安全、可以复用同一个实例,不像HttpURLConnection那样每次都要手动管连接。和 OkHttp、Apache HttpClient 比,它功能少一些,但对这种场景完全够用,而且省掉一个依赖,在一些受限环境里很实用。
需要注意的是,HttpClient实例本身是不可变的、线程安全的,应该全局复用一个。但CookieManager是有状态的,如果你要同时维护多个不同账号的会话,就得每个账号一个CookieManager、一个HttpClient实例,别混用。
2.2 CookieManager 的配置,以及三个典型误区
会话能维持住,全靠CookieManager。基本配置是这样:
CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL); HttpClient client = HttpClient.newBuilder() .cookieHandler(cookieManager) .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NEVER) .version(HttpClient.Version.HTTP_1_1) .build();CookiePolicy.ACCEPT_ALL的意思是全收,包括那些HttpOnly、Secure标记的。教务系统常用的JSESSIONID一般就是HttpOnly的,ACCEPT_ORIGINAL_SERVER策略下也能收,但用ACCEPT_ALL最省心。
第一个误区是以为 Cookie 会自动跨域共享。CookieManager是按域严格匹配的,如果你的登录接口和业务接口不在同一个域名下(有些系统登录走统一认证域名,业务在另一个子域),Cookie 就不会自动带过去,需要手动从CookieStore里取出来加到请求头。这种情况在单点登录体系里很常见。
第二个误区是没意识到 Cookie 的路径匹配。有些系统会给 Cookie 设置Path=/jw这样的路径,然后你请求/api/xxx时它就不带了。解决办法也是手动取。
第三个误区是忽略Set-Cookie的覆盖顺序。同一个名字的 Cookie,服务器可能先后下发两次,后一次带不同的Path,浏览器会存两条。CookieManager也是这么处理的,但如果你手动读CookieStore再拼Cookie头,要注意别把两条都拼上去。
2.3 followRedirects 设成 NEVER 之后要自己接住跳转
把重定向设成NEVER是我强烈建议的做法。原因很简单:登录成功与否,最重要的信号就在那串 302 的Location里。如果让 HttpClient 自动跟随,你就看不到中间的跳转过程,只能看到最后一页的 HTML,判断起来非常麻烦。
手动处理跳转的代码大概长这样:
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString(charset)); int code = resp.statusCode(); if (code == 301 || code == 302 || code == 303 || code == 307) { String location = resp.headers().firstValue("location").orElse(""); // location 可能是相对路径,要自己拼成绝对 URL URI next = baseUri.resolve(location); // 继续发下一次请求 }这里要注意两点。一是Location的值可能是相对路径,比如/menu/simple.jsp,甚至是../main.jsp,必须用URI.resolve拼接,不能直接字符串相加。二是跳转次数要设上限,比如最多跟 10 次,防止服务器配置异常导致死循环。
还有一个细节:302 之后跟过去的那次请求,方法要变成 GET,请求体要丢掉。这一点在 HTTP 规范里明确规定,但自己手写的时候很容易忘。
3. 口令参数:定位加密逻辑的实操路径
3.1 先确认密码到底是不是明文提交
不是所有教务系统都加密密码。有些就是明文 POST,有些做了一次简单编码,有些做了真正的加密。判断方法很简单:看抓包里password字段的值。如果和你输入的完全一致,就是明文;如果是一串固定长度的大写十六进制、Base64 串或者带固定前缀的字符串,那就是处理过了。
这一步非常重要,因为方向错了会浪费大量时间。我见过有人对着一个 Base64 编码的字段研究了一整天加密算法,结果那只是Base64(username + ":" + password)这种极简拼接。
3.2 在混淆过的 JS 里找加密入口
确认需要复现加密逻辑之后,就得去登录页引用的 JS 文件里找。这类系统的前端代码通常被压缩过,变量名全是单字母,直接搜password往往搜不到。比较有效的几个技巧:在登录按钮的onclick上打断点,然后点登录,看调用栈;或者在浏览器里搜索encrypt、des、md5、hex_md5这类关键词;再或者直接搜那个隐藏字段的名字,看它在哪里被赋值。
找到之后,一般能看出用的是哪套算法。老系统里 DES、3DES、MD5 加盐都很常见,JS 版的加密库特征很明显,比如会有一大堆常量数组。看到那些常量数组,基本可以确定是某个知名 JS 加密库,Java 端有对应的标准实现。
3.3 用 Java 标准库复现加密逻辑
如果确认是 DES 类的对称加密,Java 端用javax.crypto就能复现,关键是把密钥、模式、填充方式、输出编码这几个参数对准确:
public static String encryptDes(String plain, String key) throws Exception { DESKeySpec spec = new DESKeySpec(key.getBytes(StandardCharsets.UTF_8)); SecretKeyFactory factory = SecretKeyFactory.getInstance("DES"); SecretKey secretKey = factory.generateSecret(spec); Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, secretKey); byte[] out = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : out) { sb.append(String.format("%02X", b)); } return sb.toString(); }这段代码里的每一个参数都可能是错的原因。模式是ECB还是CBC?如果是CBC,IV 是固定的还是随机的?填充是PKCS5Padding还是NoPadding然后自己补零?输出是十六进制大写、小写还是 Base64?这五个维度里任何一个不对,加密结果就完全不同,服务器就会告诉你密码错误。
我的排查方法是:在浏览器里对同一个密码、同一个密钥跑一次 JS 加密,把结果记下来;然后在本地写一段 Java 单元测试,反复调参数直到输出完全一致。字节级对齐了再去发请求,能省掉大量无效请求。
3.4 动态盐值和服务器时间戳的处理
有些系统会在加密密钥里拼一个时间戳,比如key = baseKey + yyyyMMddHHmmss,然后把这个时间戳一起提交,服务器校验时间差不能超过若干分钟。这种情况下,本地时钟不准就会导致登录失败,而且失败原因看起来和密码错误一模一样。
解决办法是用服务器的Date响应头来校准。第一次 GET 登录页的时候,读取响应头里的Date,算出本机时间和服务器时间的偏移量,后续生成时间戳时用校正后的时间:
long serverEpochMillis = resp.headers().firstValue("date") .map(s -> ZonedDateTime.parse(s, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()) .orElse(System.currentTimeMillis()); long offset = serverEpochMillis - System.currentTimeMillis(); // 后续生成时间戳时:System.currentTimeMillis() + offset这个技巧解决过我遇到的一个"本地跑得好好的、放到服务器上就登录失败"的问题,那台服务器的时间慢了两分多钟。
4. 验证码:从人工输入到自动识别的现实取舍
4.1 验证码和会话的绑定关系必须保持
教务系统的验证码绝大多数是 4 位数字,偶尔带字母。它的工作方式是把识别码存在服务端会话里,和当前JSESSIONID绑定,你提交的验证码字段和会话里存的做比对。所以取验证码图片的请求,必须和后面的登录 POST 用同一个HttpClient、同一个CookieManager,否则会话对不上,必然失败。
还有个细节:有些系统取一次验证码就会刷新一次,也就是你取了两遍图,第一张就作废了。调试的时候别乱刷新。另外验证码一般有过期时间,三十秒到两分钟不等,隔太久再提交也会失败。
4.2 三种方案的取舍
| 方案 | 实现成本 | 稳定性 | 适用场景 |
|---|---|---|---|
| 保存图片人工输入 | 极低 | 100% | 一次性调试、低频手动运行 |
| 简单二值化加字符切分加模板匹配 | 中等 | 60% 到 85% | 纯数字、字体固定的老式验证码 |
| 接入第三方识别服务 | 低 | 90% 左右 | 有预算、调用频率不高 |
我自己做课表同步那次,最后选的是第一种加缓存:第一次运行的时候把验证码图片存到本地,弹窗让人工输入,输入对了就把账密和这个会话的 Cookie 一起序列化缓存下来,之后每次运行先复用会话,会话过期了才重新走一次人工。这样平均一个月才需要人工介入一两次,性价比最高。
如果要走自动识别,最简单的是纯数字四位验证码:转灰度、二值化、按列投影找切分点、归一化成固定尺寸,然后和预先标注好的字符模板做像素比对。这套东西写下来两百行左右,识别率能到八成以上,配合失败重试基本够用。
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); BufferedImage gray = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_BYTE_GRAY); Graphics g = gray.getGraphics(); g.drawImage(img, 0, 0, null); g.dispose(); // 之后做阈值二值化、列投影切分4.3 失败重试的节奏必须控制住
验证码识别必然会失败,失败就要重试,但重试节奏非常关键。有些系统对同一账号连续失败超过五次就锁定十分钟,有些会把验证码从四位数字升级成干扰线严重的图形码,越试越难。
我的做法是:单次运行最多重试三次,每次之间隔开一到两秒,三次都失败就终止并记录日志,不要连续轰炸。另外要区分"验证码错误"和"密码错误",这两种失败的响应内容通常不一样,前者会提示重新输入验证码,后者会提示账号问题。混在一起判断会导致你拿正确的密码反复重试,最后把账号锁了。
5. 怎么判断真的登录成功了:比状态码靠谱的信号
5.1 状态码几乎说明不了问题
这是新手最容易掉进去的坑。教务系统的登录失败,绝大多数情况下返回的是 HTTP 200,页面内容是登录页本身,再加上一段alert脚本提示错误。也就是说,你只看状态码,200 和 200 之间看不出任何区别。
必须看内容。常见的几种判断依据:
| 响应特征 | 含义 | 处理方式 |
|---|---|---|
| 302 且 Location 指向菜单页或框架页 | 大概率成功 | 跟随跳转后再验证一次 |
| 200 且 HTML 里含"密码错误""用户名不存在" | 明确失败 | 记录原因,不要重试 |
200 且 HTML 里含<script>alert( | 需要提取提示文字 | 用正则把提示文案抓出来 |
| 200 且页面结构仍是登录表单 | 静默失败 | 检查字段、Cookie、编码 |
| 403 或 500 | 请求形态有问题 | 检查请求头、参数长度 |
把提示文案提取出来这一步很重要,因为不同失败原因的处理策略完全不同。写个简单的正则:
Pattern p = Pattern.compile("alert\\(['\"]([^'\"]+)['\"]\\)"); Matcher m = p.matcher(body); if (m.find()) { String msg = m.group(1); // 根据 msg 决定是重试、换验证码还是直接报错 }5.2 用探针请求确认会话真的有效
我现在的习惯是,登录流程走完之后,立刻发一个"探针请求",去打一个需要登录才能访问的轻量页面,比如获取当前用户信息的接口,或者一个只有登录态才返回特定内容的小页面。拿到响应后,检查里面是不是包含用户名、学号或者某个登录后才有的标记。
这一步的价值在于,302 跳到菜单页并不等于会话一定可用。有些系统在登录成功后还会有一次服务端初始化,初始化失败会静默跳回登录页;有些系统会做二次校验,比如检查 UA 是否变化、IP 是否变化。只有探针请求真的拿到了登录后的数据,才能对外宣称"登录成功"。
探针请求要选轻的,别一上来就拉整个学期的成绩单。一是慢,二是容易触发频率限制。
5.3 编码问题导致的"假失败"
这个坑我印象很深。有些老系统的页面编码是 GBK,而HttpClient默认按 UTF-8 解码响应体(如果响应头没有明确指定charset),结果是中文全是乱码。你拿着乱码去匹配"密码错误"这几个字,永远匹配不上,于是代码认为登录成功,继续往下走,然后在解析课表的时候拿到一堆乱码字段,报一个完全不相干的异常。
正确做法是明确指定字符集:
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString(Charset.forName("GBK")));字符集怎么定?看响应头的Content-Type。如果没有,就看 HTML 里的<meta charset>,或者干脆两种都试一次,看哪种解出来是正常中文。表单提交那一侧也要注意,很多老系统只认 GBK 编码的表单,这时候URLEncoder.encode(value, "GBK")是必须的,用 UTF-8 编出来服务器解不对。
6. 登录之后:保活、限速和一堆琐碎但致命的细节
6.1 会话过期的识别与自动重建
教务系统的会话有效期通常在二十分钟到半小时之间。你的程序如果跑得久,中间必然会过期。过期的表现是:请求返回 302 跳回登录页,或者返回一段包含登录表单的 HTML。识别逻辑和登录成功的判断逻辑其实是同一套,只是判断方向反过来。
我的处理方式是把"发请求"封装成一个带重试的方法:每次请求拿到响应后,先判断是不是登录态失效,如果是,就触发一次重新登录,然后重放原来的请求,最多重放一次。这样上层业务代码不用关心会话状态。
保活的话,可以在空闲时每隔十分钟左右发一个极轻量的请求,比如取一下首页。但别设得太频繁,一分钟一次那种属于给自己找麻烦,也给别人添负担。
6.2 并发和请求间隔
真心建议串行。教务系统这类系统的承载能力通常一般,你并发开五个线程去拉数据,轻则响应变慢,重则被临时限制。而且它的很多接口是有状态的,并发请求之间可能互相干扰。
如果确实需要提高速度,我的经验是:并发度不超过 2,每次请求之间加 800 到 2000 毫秒的随机间隔。随机是关键的,固定间隔的请求序列特征太明显,随机抖动更像人的操作节奏。另外,大批量任务最好安排在没有课的时间段,这个纯粹是出于对别人使用体验的考虑。
6.3 请求头、表单空值和 URL 编码
有几个看似无关紧要但经常致命的细节。
User-Agent不要用 Java 默认的。HttpClient默认不发User-Agent,有些系统看到空 UA 直接拒绝。设一个正常的浏览器 UA 就行。Referer有些系统也校验,尤其是登录 POST,一般要求是登录页的地址。
表单里的空值字段不能省略。这个特别反直觉:你觉得某个字段值为空,那就不传了。但服务器代码可能是request.getParameter("xxx").trim(),返回 null 直接空指针,然后给你一个 500。正确做法是把抓包里出现过的所有字段都带上,值为空就传空字符串。
URLEncoder会把空格编码成+,而有些系统的后端只认%20。如果发现带空格的参数总是出问题,可以加一句value.replace("+", "%20")。
最后是超时。连接超时和读取超时都要设,别用默认值。教务系统在网络不佳的时候响应可能很慢,读取超时给 15 到 20 秒比较合适,太短会频繁误判失败,太长会导致程序卡死。
7. 我在这条路上踩过的几个坑
第一个坑是 Cookie 存了但没用上。当时我用了CookieManager,但同时也手动往请求头里塞了一个Cookie,结果两个来源冲突,服务器收到的 Cookie 顺序不对,会话一直建立不起来。加了CookieManager之后就不要再手动加Cookie头了,除非你明确知道自己在做什么。
第二个坑是重定向跟随和 Cookie 写入的时机。早期的实现里我把followRedirects设成了NORMAL,结果 302 过程中的Set-Cookie被 HttpClient 自动处理了,我却在外面手动又发了一遍请求,导致会话被刷新了两次,验证码绑定的会话失效。改成NEVER手动跟之后就正常了。
第三个坑是调试日志泄露密码。为了排查问题,我把每次请求的完整参数都打进日志,包括明文密码。后来想想这个习惯很危险,尤其是日志会落到共享目录的时候。现在的做法是打印之前先把密码字段替换成固定长度的星号。
第四个坑是没处理好 HTTP 和 HTTPS 混用。有些系统登录页是 HTTP,登录后跳 HTTPS,或者反过来。CookieManager对Secure标记的 Cookie 有额外要求,混用的时候会出现"登录成功但跳转后立刻又回登录页"的现象。遇到这种情况,先检查跳转链路上协议有没有变化。
第五个坑是 TLS 证书。极个别老系统的证书链不完整,Java 默认的信任库会报PKIX path building failed。网上一堆教程教你把证书校验全局关掉,我不建议这么做,那等于把自己的连接暴露给任何中间人。正确做法是把服务器的证书导出,加到一个自定义的SSLContext里,只信任这一个。
最后再强调一次节奏问题。这类工具的价值在于"稳定地少跑几次",而不是"高频地多跑几次"。把间隔留足,把重试次数压住,把异常日志记清楚,比任何优化都管用。