
做学生成绩聚合、课表提醒、学期绩点趋势这类小工具绕不开的第一道坎就是教务系统的登录。很多人第一次接触正方教务系统模拟登录都会卡在同一个地方浏览器里点一下“登录”就能进换成 Java HttpClient 发同样的表单回来的不是“用户名或密码不正确”就是干脆一个空白的 200 页面。我前后跟着几个不同版本的教务系统打过交道有的还是明文 MD5有的已经上了前端 RSA 加密加 csrftoken 校验说实话踩的坑比写的代码多得多。这篇东西讲的是用 Java HttpClient 完成正方教务系统的模拟登录从抓包定基线、会话 Cookie 管理、RSA 密码加密、表单字段拼装一直到登录结果判定和登录后的数据拉取。它解决的核心问题只有一个把“浏览器能过、代码过不去”这件事彻底捋清楚。适合已经会写基本 HTTP 请求、但一碰到带加密参数的登录就发懵的开发者也适合做校园数据小工具、需要长期稳定拉取自己数据的同学参考。下面的内容都是围绕单账号、自己本人数据查询这个场景展开的不涉及任何越权操作。1. 先想清楚要复刻什么整体设计与技术选型1.1 一次登录在浏览器里到底发生了什么模拟登录这件事本质上不是“写一个请求”而是“把浏览器的一连串动作按顺序复刻一遍”。你打开登录页的那一刻服务端就已经在 Set-Cookie 里塞了一个会话标识页面 HTML 里还埋着 csrftoken 这样的隐藏域你输入密码点提交前端脚本会用页面里带下来的 RSA 公钥把密码加密拼成表单 POST 出去服务端校验通过返回 302跳转到内部首页同时把会话状态标记为“已登录”。这三个环节里任何一环缺失结果都是失败。我见过不少人一上来就直接 POST 账号密码连登录页都不请求结果自然是 100% 失败。原因很朴素没有会话 Cookie服务端压根不知道你是谁没有 csrftoken服务端认为是跨站伪造请求。所以后面所有的代码设计都是围绕“维持一个会话对象”来展开的——登录页请求、公钥请求、表单提交、后续数据拉取必须共用同一个 Cookie 容器。这一点想明白了整个方案就立起来了。1.2 三套 HTTP 客户端的取舍Java 圈子里能发 HTTP 请求的库太多了我实际用下来做教务系统登录这类带会话、带重定向、带 Cookie 自动管理的场景主流就三个选择各自脾气不太一样方案版本示例Cookie 管理重定向控制上手成本我的使用场景Apache HttpClient 4.x4.5.14自带 CookieStore自动回填可关闭、可自定义策略低首选绝大多数登录场景Apache HttpClient 5.x5.2.x自带 CookieStoreAPI 更现代可关闭中新项目、需要 HTTP/2JDK 自带 HttpClientJava 11无需自己实现 CookieHandler可配置低但繁琐不想引第三方依赖时我个人的默认选择是 Apache HttpClient 4.5.x。理由很实际它的BasicCookieStore和HttpClientContext配合得太顺了你只要在构建 client 的时候把 CookieStore 挂上去后面所有的请求、重定向、Set-Cookie 回写全部自动完成不用写一行解析 Set-Cookie 的代码。教务系统的登录链路里Cookie 会在登录页、公钥接口、登录提交、跳转首页之间来回变好几次手工维护极易出错。JDK 11 的java.net.http.HttpClient也能干这活但它默认不维护 Cookie你得自己实现一个CookieManager或者手动从响应头里抠Set-Cookie再往下一个请求塞。写一次两次还行写多了就是纯体力活而且一旦服务端返回多个同名 Cookie教务系统里很常见比如ASP.NET_SessionId和自定义的会话标识并存手写解析很容易漏。至于 HttpClient 5.x功能上完全够用API 也更清晰比如PoolingHttpClientConnectionManagerBuilder的链式配置读起来比 4.x 舒服。但如果你参考的旧代码、旧博客大多是 4.x 的写法混着看容易晕。我的建议是手上有 4.x 的代码基础就继续用 4.x新起项目且团队统一用 5.x 就上 5.x别为了追新把简单的事搞复杂。1.3 为什么不上 Selenium为什么手写表单更稳每次聊这个话题总有人问直接用 Selenium 打开浏览器点一下不香吗香但分场景。Selenium 的优势是能执行 JSRSA 加密、验证码渲染这些前端逻辑它天然就能跑通你只需要定位元素、填值、点按钮。缺点是资源占用大、启动慢、在无图形界面的服务器上还得配虚拟显示长期跑的定时任务用它就有点杀鸡用牛刀。手写表单的优势在于可控和轻量。你知道每一个参数从哪来、为什么这么传出问题的时候日志里能看清请求和响应的每一个字节。缺点也很明显前端的加密逻辑你得自己用 Java 复刻一遍一旦教务系统改版换了加密方式你的代码就得跟着改。我自己的取舍是登录链路用 HttpClient 手写把加密逻辑单独抽成一个方法这样即使将来加密方式变了改动范围也就那一个方法不至于推倒重来。还有一点值得说手写表单的过程中你会被迫搞清楚每个字段的含义比如yhm是用户名、mm是加密后的密码、csrftoken是防伪令牌。这份理解在排查问题时价值极高——当服务端返回“参数错误”时你能立刻判断是哪个字段没对上而不是对着 Selenium 的黑盒抓瞎。2. 抓包定基线核心细节解析2.1 登录链路上的四个关键请求在动手写代码之前我一定先用浏览器开发者工具的 Network 面板完整走一遍登录把请求顺序和每个请求的请求头、表单体、响应头全部记下来。正方教务系统虽然版本多但登录链路的骨架高度一致一般是这么四步GET 登录页拿到会话 Cookie从 HTML 里提取 csrftoken 和隐藏域。GET 公钥接口通常是/xtgl/login_getPublicKey.html返回一个 JSON里面有 base64 编码的modulus模数和exponent指数。POST 登录提交把用户名、RSA 加密后的密码、csrftoken 一起提交到/xtgl/login_slogin.html。302 跳转成功时 Location 指向内部菜单页失败时跳回登录页并在 URL 带上错误提示。抓包时有个细节必须看清楚登录页请求和公钥请求是不是同一个会话。有些版本的教务系统公钥接口也会 Set-Cookie如果你在代码里用两个不同的 HttpClient 实例去请求Cookie 对不上后面提交必然失败。所以我在抓包阶段就会顺手确认一遍所有请求的 Cookie 是不是在同一个会话里累加的。另外要注意老版本的正方教务那种页面顶部有蓝色导航条、地址栏是default2.aspx的走的是另一套逻辑隐藏域是__VIEWSTATE和__EVENTVALIDATION密码可能是明文、MD5 或者别的哈希提交地址是default2.aspx。这两种版本的差异我在后面会单独做一张对照表别拿新版的方法去套老版会浪费很多时间。2.2 密码加密RSA 公钥从哪来、怎么用新版正方教务的密码加密流程说白了就是把前端 JSEncrypt 干的事用 Java 重做一遍。服务端下发一对 RSA 公钥参数前端用它对密码做RSA/ECB/PKCS1Padding加密结果转 base64塞进mm字段。服务端持有私钥收到后解密比对。整个过程是标准的非对称加密没有任何“黑科技”只是很多人不知道 Java 里怎么从 modulus 和 exponent 还原出一个PublicKey。关键就在RSAPublicKeySpec这个类上。modulus和exponent都是 base64 字符串解码后是字节数组用new BigInteger(1, bytes)转成大整数注意第一个参数必须传 1 表示正数传 0 或省略在遇到高位为 1 的字节时会得到负数这是个经典坑然后构造 spec、交给KeyFactory.getInstance(RSA).generatePublic(spec)公钥对象就到手了。这一步我必须强调几个容易翻车的点。第一填充模式必须和前端一致。JSEncrypt 默认用的是 PKCS#1 v1.5 填充对应 Java 的RSA/ECB/PKCS1Padding。你要是随手写了RSA/ECB/NoPadding加密出来的密文服务端解不开报的还是“密码错误”排查起来极其痛苦。第二base64 的变体。有些系统返回的 modulus 带换行符或者用了 URL-safe 的 base64直接Base64.getDecoder().decode()会抛异常得先清洗一下字符串。第三长密码要分段。2048 位密钥的 PKCS1 填充最多只能加密 245 字节普通密码远远够用但如果你哪天要加密别的长文本就得按块切分每块不超过keyByteLen - 11字节。2.3 Cookie、csrftoken 与隐藏域的三方配合这三个东西是模拟登录失败率最高的地方我把它们的关系理一下。Cookie 是服务端识别“你是谁”的凭证由服务端通过 Set-Cookie 下发客户端后续请求自动带上csrftoken 是服务端塞在登录页 HTML 里的一个随机串提交时必须原样带回用来证明这个请求确实来自它自己发的页面隐藏域则是老版本系统用来维持页面状态的机制__VIEWSTATE里编码了整个页面的控件树状态你不带或者带错服务端直接拒绝。三者的共同点是都必须在“同一个会话”里产生和消费。这也是为什么我前面反复强调要共用 CookieStore。举个我实际遇到过的例子有次我为了省事把登录页和公钥接口写成两个独立的方法每个方法内部各自 new 了一个 HttpClient单测跑起来都没问题一串联就报参数错误。后来打印日志才发现登录页那次请求拿到的 csrftoken在提交时用的是另一个会话的 Cookie服务端一比对直接判定为非法请求。正确的做法是把 client 和 cookieStore 作为成员变量或者通过构造注入整个登录流程从头到尾只用这一个 client 实例。代码上大概是这样组织的初始化 client 的时候挂上BasicCookieStore然后所有请求都通过这个 client 发出去Cookie 的读写完全交给它。这样你写业务代码的时候脑子里根本不需要想 Cookie 的事只需要关心“这一步该提取什么字段、下一步该带什么参数”。2.4 验证码那一关怎么处理验证码是绕不过去的话题我先把话说在前面只针对自己本人的账号做自动化验证码的处理方式应该以不增加服务端负担、不干扰他人为前提。正方教务系统不同版本、不同学校配置下验证码策略差别很大有的登录页默认不显示验证码连续输错几次密码才弹出来有的一开始就要地址是CheckCode.aspx返回一张带干扰线的图片。处理思路上我一般按场景分三档。第一档能人工就人工如果是偶尔跑一次的脚本直接把验证码图片下载到本地打开人工看一眼输入代码里留一个控制台输入的入口最省事也最稳妥。第二档模板匹配或 OCR针对那种字体固定、干扰线少的简单验证码用 Tesseract 或者自己写个简单的字符切割加模板比对识别率能到七八成识别失败就刷新重试配合有限次重试基本能过。第三档压根不需要很多新版正方在正常登录流程里是不弹验证码的只要你不频繁失败这个环节可以忽略。我实际的经验是先按“不需要验证码”的路径把整个流程跑通等真的遇到验证码再补。因为验证码这一环会显著增加代码复杂度如果一开始就陷进去很容易还没摸清登录主链路就先被 OCR 劝退。另外无论用哪种方式都要给请求加上合理的间隔别一秒发十个请求去试验证码那既不明智也不礼貌。3. 代码落地从零到登录成功3.1 依赖引入与连接参数先上依赖。除了 HttpClient 本身我还会引入 Jsoup 用来解析 HTML比正则稳得多尤其是教务系统那种嵌套很深的表格结构。dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.14/version /dependency dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency连接参数这块很多人直接HttpClients.createDefault()就完事了单账号低频访问确实也够用。但我还是建议显式配一下超时因为教务系统的服务器在选课季或者成绩发布那几天响应慢是常态不设超时的话线程会一直挂在那里定时任务就卡死了。RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) // 建立连接超时5 秒够了 .setConnectionRequestTimeout(3000) // 从连接池取连接的超时 .setSocketTimeout(15000) // 读取数据超时教务系统慢给宽一点 .setRedirectsEnabled(false) // 关键关掉自动重定向 .build();setRedirectsEnabled(false)这一行是重点我第一次写的时候没关结果 POST 之后 HttpClient 自动跟了 302等我拿到响应时已经是目标页面的 200Location 头早就丢了根本没法判断到底是登录成功跳转还是失败跳回了登录页。关掉之后302 原样返回我自己看 Location 里带的是什么一目了然。连接池的配置对单账号场景意义不大PoolingHttpClientConnectionManager默认maxPerRoute是 2一次登录流程最多也就两三个并发完全够。但如果你要做多账号批量查询就得把它调到账号数以上否则会出现请求排队等待连接看起来像是“卡住了”实际是在等池子里的空闲连接。3.2 会话级 CookieStore 与客户端构建这一段的代码是整个登录流程的地基我写得比较完整public class JwxtLoginClient { private final CloseableHttpClient httpClient; private final BasicCookieStore cookieStore; private final String baseUrl; public JwxtLoginClient(String baseUrl) { this.baseUrl baseUrl.endsWith(/) ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; this.cookieStore new BasicCookieStore(); RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) .setConnectionRequestTimeout(3000) .setSocketTimeout(15000) .setRedirectsEnabled(false) .build(); this.httpClient HttpClients.custom() .setDefaultCookieStore(cookieStore) .setDefaultRequestConfig(config) .setUserAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36) .build(); } }setUserAgent千万不要省。教务系统的服务端有时候会根据 UA 做一些简单的判定空 UA 或者明显是脚本的 UA比如Java/11.0.2有可能被直接拦掉返回一个 200 的空白页让你对着响应体一脸懵。我一般就伪装成主流浏览器的 UA这不是为了骗谁纯粹是避免不必要的误判。baseUrl做了一次去尾斜杠处理这个小细节能省掉后面拼 URL 时反复出错的麻烦。教务系统的地址在不同学校配置不同有的是根路径/有的挂在/jwglxt/下面所以我把 base 抽成构造参数代码里所有路径都基于它拼接换学校只要改这一个值。3.3 拉登录页并提取 csrftoken第一步请求登录页同时把 HTML 里的 csrftoken 抠出来public String fetchCsrfToken() throws Exception { String loginUrl baseUrl /xtgl/login_slogin.html?time System.currentTimeMillis(); HttpGet get new HttpGet(loginUrl); get.setHeader(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8); get.setHeader(Accept-Language, zh-CN,zh;q0.9); try (CloseableHttpResponse resp httpClient.execute(get)) { int code resp.getStatusLine().getStatusCode(); if (code ! 200) { throw new IllegalStateException(登录页请求失败, status code); } String html EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); Document doc Jsoup.parse(html); // 页面上可能不止一个 csrftoken 输入框取第一个非空值 for (Element input : doc.select(input[namecsrftoken])) { String val input.attr(value); if (!val.isEmpty()) { return val; } } throw new IllegalStateException(未找到 csrftoken检查 baseUrl 是否指向正确版本); } }URL 后面加的?time时间戳是防浏览器和服务端缓存的有些学校的教务系统前面挂了缓存层不加时间戳有可能拿到一份旧的 HTML里面的 csrftoken 早就失效了表现出来就是“明明提取成功了提交却报参数错误”。关于 csrftoken 的提取我用 Jsoup 遍历了所有同名 input 而不是直接selectFirst。原因是有一次我碰到一个版本的页面顶部有一个隐藏的 csrftoken值空底部表单里还有一个真正的selectFirst拿到的是空的那个后面提交自然失败。这种细节不实际跑一遍根本想不到所以我干脆写成遍历取非空多几行代码换个心安。3.4 RSA 加密的实现细节公钥的获取和加密封装成一个工具类方便复用public static String[] fetchPublicKey(CloseableHttpClient client, String baseUrl) throws Exception { String url baseUrl /xtgl/login_getPublicKey.html?time System.currentTimeMillis(); HttpGet get new HttpGet(url); try (CloseableHttpResponse resp client.execute(get)) { String json EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); Document doc Jsoup.parse(json, , Parser.xmlParser()); // 也可以用任意 JSON 库解析 String modulus JsonUtil.get(json, modulus); String exponent JsonUtil.get(json, exponent); return new String[]{modulus, exponent}; } }返回的 JSON 形如{modulus:xxxx,exponent:AQAB}。这里我故意用了最朴素的字符串处理示意实际项目里用 Jackson 或者 Gson 都行别跟我一样偷懒。exponent绝大多数情况下就是AQAB也就是十进制的 65537这是 RSA 的标准公共指数但也有极个别系统用别的值所以还是老老实实从接口读别硬编码。加密方法public static String rsaEncrypt(String plain, String modulusB64, String exponentB64) throws Exception { byte[] modBytes Base64.getDecoder().decode(modulusB64.replaceAll(\\s, )); byte[] expBytes Base64.getDecoder().decode(exponentB64.replaceAll(\\s, )); BigInteger modulus new BigInteger(1, modBytes); // 1 表示正数必须传 BigInteger exponent new BigInteger(1, expBytes); RSAPublicKeySpec spec new RSAPublicKeySpec(modulus, exponent); PublicKey publicKey KeyFactory.getInstance(RSA).generatePublic(spec); Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.ENCRYPT_MODE, publicKey); int keyBytes (modulus.bitLength() 7) / 8; // 2048 位 256 字节 int maxChunk keyBytes - 11; // PKCS1 填充占 11 字节 byte[] raw plain.getBytes(StandardCharsets.UTF_8); if (raw.length maxChunk) { return Base64.getEncoder().encodeToString(cipher.doFinal(raw)); } // 超长内容分段加密教务系统密码用不到留着备用 ByteArrayOutputStream out new ByteArrayOutputStream(); for (int i 0; i raw.length; i maxChunk) { int len Math.min(maxChunk, raw.length - i); out.write(cipher.doFinal(raw, i, len)); } return Base64.getEncoder().encodeToString(out.toByteArray()); }new BigInteger(1, modBytes)那个1我单独标了注释因为它太容易被忽略。如果你写成new BigInteger(modBytes)当一个 base64 解出来的首字节大于等于 0x80 时BigInteger 会把它当成负数补码处理构造出来的公钥完全是另一把加密出来的密文服务端当然解不开。这个 bug 的隐蔽性在于代码不报错、加密也成功只是结果不对表现出来就是“密码错误”能让人查一整天。3.5 提交表单与结果判定登录提交这段参数名一定要跟你抓包看到的一字不差public boolean login(String username, String password) throws Exception { String csrf fetchCsrfToken(); String[] keys fetchPublicKey(httpClient, baseUrl); String encryptedPwd rsaEncrypt(password, keys[0], keys[1]); ListNameValuePair form new ArrayList(); form.add(new BasicNameValuePair(csrftoken, csrf)); form.add(new BasicNameValuePair(yhm, username)); form.add(new BasicNameValuePair(mm, encryptedPwd)); form.add(new BasicNameValuePair(yzm, )); // 无验证码时留空 HttpPost post new HttpPost(baseUrl /xtgl/login_slogin.html); post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); post.setHeader(Referer, baseUrl /xtgl/login_slogin.html); post.setHeader(Origin, baseUrl); post.setHeader(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8); try (CloseableHttpResponse resp httpClient.execute(post)) { int code resp.getStatusLine().getStatusCode(); Header location resp.getFirstHeader(Location); String loc location null ? : location.getValue(); if (code 302 loc.contains(index_initMenu)) { return true; // 成功跳转到内部菜单页 } if (code 302 loc.contains(login_slogin)) { // 失败会跳回登录页tip 参数里带错误原因先做 URL 解码再打日志 log.warn(登录失败, Location{}, URLDecoder.decode(loc, UTF-8)); return false; } // 极少数情况返回 200 直接渲染主页 return code 200 !loc.contains(login); } }post.setEntity(new UrlEncodedFormEntity(...))这一句已经把Content-Type: application/x-www-form-urlencoded; charsetUTF-8设置好了。千万不要再手动setHeader(Content-Type, ...)我之前这么干过手动覆盖成了不带 charset 的版本服务端按 ISO-8859-1 解码中文用户名直接变乱码登录当然失败。这个坑我在不同项目里见过至少三次都是因为“怕它没设保险起见再设一遍”导致的。Referer和Origin两个头也不是可选项。有些学校的教务系统在前面挂了应用防火墙会校验请求来源缺少这两个头会直接被拦成 403。我一般把Referer写死成登录页地址Origin写死成站点根地址跟浏览器行为保持一致。结果判定的逻辑不同版本差异挺大我整理成一张表方便对照场景HTTP 状态码Location 特征判定结果新版登录成功302含index_initMenu成功新版登录失败302含login_slogin且有tip参数失败读 tip新版无重定向200无 Location需检查响应体是否为主页老版登录成功302含Main.aspx或main.aspx成功老版登录失败200无响应体含Alert提示被拦截403 / 200 空白无检查 UA、Referer、频率3.6 保持会话去拉数据登录成功不代表结束真正有价值的是登录之后拿数据。只要 client 还是同一个实例Cookie 会自动带上直接发 GET 就行public String fetchPage(String path) throws Exception { HttpGet get new HttpGet(baseUrl path); get.setHeader(Referer, baseUrl /xtgl/index_initMenu.html); try (CloseableHttpResponse resp httpClient.execute(get)) { int code resp.getStatusLine().getStatusCode(); Header loc resp.getFirstHeader(Location); if (code 302 loc ! null loc.getValue().contains(login)) { throw new IllegalStateException(会话已失效需要重新登录); } return EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); } }这里我特地加了一个会话失效检测教务系统的会话超时时间普遍不长二三十分钟不操作就可能被踢。如果响应是 302 且指向登录页说明 Cookie 已经无效了这时候不能傻乎乎地去解析返回的登录页 HTML而得重新走一遍登录流程。这个判断逻辑看起来不起眼但它是整个工具能长期稳定运行的关键之一。拿到 HTML 之后成绩、课表这类数据用 Jsoup 解析就行。老版本的成绩页是xscjcx.aspx返回一张标准表格doc.select(table#Datagrid1 tr)逐行读单元格新版本多数已经改成前端 AJAX 拉 JSON 了那就直接请求对应的数据接口用 JSON 库解析比解析 HTML 舒服太多。具体路径以你抓包看到的为准不同学校部署的版本差别很大我这边不写死路径避免误导。4. 踩坑记录常见异常与排查思路4.1 问题速查表下面这张表是我这些年攒下来的基本覆盖了模拟登录会遇到的绝大多数报错现象最可能的原因排查动作一直提示密码错误RSA 填充模式不对 / BigInteger 符号问题 / 加密后未 base64打印加密前后的值用在线工具比对前端结果返回 200 空白页缺 UA / 缺 Referer / 被风控补齐请求头降低请求频率参数错误、请求非法csrftoken 与 Cookie 不在同一会话检查是否误用了多个 HttpClient 实例302 一直跳回登录页表单字段名写错或漏了隐藏域逐字段对照抓包的 Form Data老版系统提交后提示视图状态无效__VIEWSTATE未带或编码错误用 Jsoup 提取原始值交给表单编码器处理中文用户名乱码Content-Type 缺 charset 或被手动覆盖删除手动 setHeader交给 UrlEncodedFormEntity偶发超时教务系统负载高未设超时或超时过短设置 15 秒 socket 超时加重试登录成功但后续请求 302会话过期捕获 Location触发重新登录表格里的每一行我基本都实打实踩过至少一次。其中最折磨人的是“一直提示密码错误”——因为这个提示会让人本能地怀疑账号密码输错了而实际上十有八九是加密环节出的问题。我的排查顺序是先把明文密码打印出来确认没错再把加密结果和浏览器里断点拿到的密文对比如果长度不一致基本就是填充模式或者分段的问题如果长度一致但内容不同那就是公钥构造错了回头检查 BigInteger 那个1。4.2 几个不那么容易想到的坑第一个坑公钥接口有缓存。有些版本的服务端会对公钥接口做缓存短时间内多次请求返回同一把公钥这本身没问题。但如果你的代码里把公钥缓存到一个静态变量里长期不刷新而服务端那边密钥轮换了就会出现“昨天能登今天登不上”的诡异现象。稳妥的做法是每次登录流程都重新拉一次公钥多一次请求而已不差这点开销。第二个坑登录页有两个表单。我遇到过一个学校的页面顶部有个搜索框底部才是登录表单两个 form 里都有 name 相同的隐藏域。用selectFirst取到的是搜索框那个值完全是错的。所以我在提取隐藏域的时候会先定位到登录表单所在的容器再在里面找比如doc.select(form#loginForm input[namecsrftoken])虽然要多看几眼 HTML 结构但准确率高得多。第三个坑密码里有特殊字符。密码包含、、这类字符的时候如果加密后没做 URL 编码就拼字符串提交服务端解析出来的值会被截断。用UrlEncodedFormEntity的话它会自动处理但如果你是手动拼yhm user mm pwd这种字符串就一定会出问题。base64 编码的结果里和/都是常客所以这个坑触发概率相当高。第四个坑HTTP 和 HTTPS 混用。有的学校教务系统 HTTP 能访问有的强制跳 HTTPS。如果你的 baseUrl 写的是 HTTP服务端返回 301 跳 HTTPS而你又关掉了自动重定向就永远拿不到登录页。这种情况要么把 baseUrl 直接写成最终地址要么在初始化时先探测一次。我个人倾向于直接抓包看浏览器地址栏最终是什么协议写死到配置里省去运行时的额外判断。第五个坑并发登录互相踢。同一个账号在多个客户端登录很多教务系统会把前一个会话踢掉。如果你开了多线程去拉数据结果就是互相顶号全都在 302 回登录页。所以单账号场景下老老实实串行别贪那点并发。5. 让它长期可用工程化收尾5.1 重试、限速与失败降级一次能跑通的代码和能跑一个学期的代码中间差着不少东西。最基础的是重试网络抖动、服务端偶发 502、读取超时这些在教务系统上都不罕见。我一般用指数退避第一次失败等 2 秒第二次等 5 秒第三次等 10 秒最多重试三次。重试的时候要判断异常类型——连接超时值得重试但“密码错误”这种业务失败重试一万次也没用只会把账号锁掉。限速同样重要。我的习惯是同一账号的两次请求之间至少间隔 800 毫秒到 1 秒批量拉取多个页面时按这个节奏排队。这不是技术限制是基本的克制。教务系统的服务器在选课、查成绩的高峰期本身就吃紧脚本再猛冲对谁都没好处。失败降级指的是当自动登录连续失败时别死循环停下来把失败原因记清楚然后转人工介入。我见过有人写了个 while(true) 一直重试结果把账号试到被临时锁定第二天自己都登不上了。给重试次数设个硬上限超了就抛异常退出这是底线。5.2 配置化与多账号隔离如果只服务自己一个账号硬编码几个常量无所谓。但只要涉及多个账号就必须做隔离否则 Cookie 串号A 的成绩可能出现在 B 的报表里这种事故很难看也很难查。隔离的最小单位是“一个账号一个 client 实例”每个实例持有自己的BasicCookieStore绝对不要共享。我把这些东西封装成一个Session类内部持有 client、cookieStore、登录状态和最后活跃时间外部只暴露login()、get()、isAlive()三个方法。多账号就是多个 Session 实例放在一个 Map 里key 用学号取用的时候检查一下isAlive()过期了就重新登录。配置方面我把 baseUrl、账号、密码、请求间隔、超时时间全部抽到配置文件里。密码不写明文用环境变量或者本地加密存储这点在个人项目里也值得坚持因为脚本经常会被扔到服务器上跑配置文件一旦进了代码仓库就麻烦了。提醒一句所有自动化操作都应当限定在本人账号、本人数据的范围内控制请求频率不要在他人不知情的情况下代为操作。技术本身是中性的用在哪里才是关键。到这里从抓包、会话管理、RSA 加密、表单提交到结果判定和数据拉取整条链路就闭环了。我个人在实际操作中的体会是模拟登录这件事最难的部分从来不是写代码而是在动手之前把浏览器的每一步都看清楚。我现在每次接手一个新的教务系统版本都会先花半小时纯粹地抓包、记录、比对把每个请求的参数来源标注清楚写代码的时间反而不到一小时。那些卡了好几天的问题回头看几乎都是“少看了一眼抓包结果”——漏了个隐藏域、填充模式写错、Cookie 没共享。所以如果你正准备动手我的建议是别急着打开 IDE先把开发者工具里的 Network 面板打开把那次成功的登录完整走一遍把每个请求的 URL、请求头、表单体、响应头都复制到记事本里。这份记录比任何教程都管用。