☰
Spring Boot与HttpClient实战:从教务系统自动拉取课表并解析
2026/10/17 5:21:52 网站建设 项目流程

1. 项目需求分析与方案选型

1.1 从教务网拉取课表到底在解决什么问题

每个在学校待过的人应该都有这种经历:想查一下明天上什么课,得先打开浏览器,输入教务系统地址,登录,再点进课表页面,来回好几层菜单,最后还要手动把课程名、教室、节次抄到手机日历里。遇上服务器慢一点的时段,光登录就要转半天圈。

做这个项目的出发点很直接:把"登录教务系统 -> 查询课表 -> 整理课程信息"这一整套流程自动化。用一个Java Spring Boot服务在后台定时请求教务系统,把课表数据拉回来,解析成结构化JSON,再供自己的课表小程序、桌面提醒工具、日历订阅服务去调用。你只需要登录一次,之后每天的课程安排自动同步。

从技术本质上看,这件事就是一个典型的HTTP接口对接场景:目标系统不是你开发的,你只能通过模拟浏览器的请求方式来获取数据。这里面有几个关键难点——身份认证怎么处理、会话状态怎么保持、返回的HTML怎么解析、页面改版后怎么应对。这篇文章就是围绕这些实际难点展开的,适合那些有Java基础、想动手做校园工具开发,或者工作中需要对老旧Web系统做数据对接的开发者参考。

1.2 HTTP Client选型:不用一把梭

很多人一看"HTTP请求"这几个字,第一反应就是引入Apache HttpClient或者OkHttp。其实在Java 11之后,JDK自带的java.net.http.HttpClient已经完全够用了。

这个内置的HTTP Client支持HTTP/1.1和HTTP/2,支持同步和异步两种调用模式,而且自带CookieHandler接口,可以通过CookieManager维护会话状态。教务系统这种场景,最大的特点是请求次数不多、目标服务器性能一般、每次请求间隔时间较长,根本不需要高并发连接池这些重型能力。用内置HttpClient,省掉了额外依赖,也避免不同HTTP库之间cookie管理方式差异带来的坑。

我在实际项目里也对比过几种方案:Spring的RestTemplate在旧版本里用起来确实方便,但它默认不保存cookie,想在多个请求之间保持会话需要额外配置HttpClient和CookieStore,绕一圈下来反而更麻烦。OkHttp的cookie持久化同样要自己处理。而Java标准库的HttpClient在构建时就提供了cookieHandler()方法,一行代码就能把CookieManager挂上去,请求和响应全走标准API,出错时排查路径也最短。

1.3 整体技术路线

这个项目的整体流程可以拆成四条线:

  • 认证线:带着账号密码请求登录接口,拿到会话凭证(Cookie),后续所有请求都复用这个凭证。
  • 会话线:用CookieManager统一存储服务端下发的Cookie,保证每次请求都"带上状态"。
  • 数据线:请求课表查询接口,把返回的HTML或JSON抓下来。
  • 解析线:针对HTML表格结构做解析,转换成Java对象,再以JSON格式输出给下游使用。

代码结构上,我建议按功能分包,不要全部写在一个类里。请求部分和业务解析部分要分开,因为教务系统页面一旦改版,你只需要替换解析逻辑,HTTP通信层完全不用动。这是整个项目最值得提前规划的地方。

2. 前置准备:搭好框架再动手

2.1 环境与依赖配置

项目基础用的是Spring Boot 2.7.x,JDK版本要求11以上,因为要用到内置的java.net.http.HttpClient。如果你还在用JDK 8,要么换成JDK 11+,要么退回到Apache HttpClient方案,但我不建议那么做,旧接口在Cookie管理上实在不够省心。

依赖方面,Spring Boot的两件套必带:spring-boot-starter-web用来提供Web服务能力,spring-boot-starter-test写单元测试。额外的第三方库只引入一个jsoup,用来解析HTML,这是整个项目唯一的非官方依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>

有人可能会问,能不能直接用正则表达式解析HTML?能,但体验非常痛苦。教务系统的课表页面通常是嵌套表格结构,课程信息散落在<table>的多层行和列里,用正则匹配表格结构等于自己给自己找麻烦。Jsoup能把HTML解析成DOM树,像用jQuery选择器一样定位元素,还能处理表格的缺省格式,省下的时间远超引入依赖的成本。

2.2 会话管理设计:Cookie和Session的关系

搞明白教务系统的登录机制之前,要先分清两个概念:Session是服务器端的"记忆",Cookie是客户端保存的"凭证"。用户登录成功后,服务器在内存或数据库中创建一个Session,同时把Session ID通过Set-Cookie响应头下发到浏览器。浏览器在后续请求的Cookie请求头中带上这个ID,服务器通过ID找到对应的Session,就知道"这个人已经登录过了"。

用Java的HttpClient模拟这个流程时,关键代码就这么几行:

import java.net.CookieManager; import java.net.CookiePolicy; import java.net.http.HttpClient; CookieManager cookieManager = new CookieManager(); cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .cookieHandler(cookieManager) .followRedirects(HttpClient.Redirect.NORMAL) .version(HttpClient.Version.HTTP_1_1) .build();

这里有几个细节值得注意:

  • CookiePolicy.ACCEPT_ALL表示接收服务端下发的所有Cookie。教务系统这种老系统,经常在登录过程中设置多个Cookie,有的用于记录登录时间,有的用于保存加密Token,漏掉任何一个都可能触发后续请求失败。
  • followRedirects(HttpClient.Redirect.NORMAL)会自动跟随重定向。很多教务系统的登录逻辑是:提交表单后302跳转到首页,再302跳转到课表页面,不跟随重定向的话,你自己处理跳转逻辑会非常头疼。
  • connectTimeout设置10秒,避免教务系统响应慢时请求无限挂起。读超时(或者说整个请求的等待时间)需要通过HttpRequest的timeout方法单独设置,我习惯在每类请求上都配一个30秒的读超时。

2.3 验证码与加密参数处理的准备工作

老牌教务系统通常有两种风格:一种走统一身份认证平台,登录跳转中间页,带验证码或者短信验证码;另一种是传统的学生端登录,简单用户名密码,运气好一点没有验证码,运气差一点还要处理一个动态生成的图形验证码。

如果你的目标系统有验证码,我建议冷静评估一下:是接第三方打码平台,还是用本地OCR识别,还是绕开验证码走其他登录通道(比如很多学校的校园网统一认证支持微信扫码、企业微信免登)。这个决策直接影响整个项目的复杂度和成本。

以我处理过的某高校教务系统为例,登录页面前端会生成一个加密参数,是由时间戳和一段固定密钥拼接后再做MD5哈希得到的,提交登录表单时必须带上。这种参数的生成逻辑一般藏在login.js文件里,抓包后整个读一遍就能摸清规律。做前期准备时,一定要把开发者工具的网络面板用熟,把登录过程中每一次请求的URL、请求头、请求体、响应体记录下来,整理成一份简单的请求时序表,后面编码时对照着写,效率高得多。

3. 登录认证模块实现

3.1 请求起始页面:拿下隐藏参数

教务系统虽然老旧,但为了防止恶意的机器登录,登录页面通常藏了不止一个隐藏字段。有的是lt(login token),是服务器生成的临时票据,跟Session绑定,提交表单时必须原样带回;有的是execution,是CAS认证流程的流程标识;还有的是_csrf之类的跨站请求伪造防护Token。

实现的第一步,是先GET一次登录页面,把页面里的隐藏字段解析出来:

import org.jsoup.Jsoup; import org.jsoup.nodes.Document; public LoginPageInfo fetchLoginPage(HttpClient client, String loginUrl) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(loginUrl)) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .GET() .timeout(Duration.ofSeconds(30)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); Document doc = Jsoup.parse(response.body()); LoginPageInfo info = new LoginPageInfo(); info.setExecution(extractValue(doc, "execution")); info.setLt(extractValue(doc, "lt")); info.setHiddenParams(extractAllHiddenParams(doc)); info.setCookieParams(response.headers().allValues("Set-Cookie")); return info; } private String extractValue(Document doc, String name) { return doc.selectFirst("input[name=" + name + "]") == null ? "" : doc.selectFirst("input[name=" + name + "]").attr("value"); }

这一步有两个容易犯的错。第一个是直接用String接收响应体,教务系统的页面编码经常是GBK,不是UTF-8,这就导致中文乱码。HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)默认按UTF-8解码,如果你的页面是GBK,需要显式指定ofString(Charset.forName("GBK")),或者先拿到字节数组再手动解码。第二个是把隐藏字段硬编码在代码里,一旦教务系统调整页面结构,代码马上就废,所以动态解析页面字段是必须的。

3.2 提交登录表单:维持会话状态

拿到页面参数后,下一步就是构造POST请求提交登录表单。这里有两个关键点:请求体的格式必须跟浏览器保持一致,Content-Type必须设置成那几个字一个都不能错。

public boolean login(HttpClient client, LoginPageInfo pageInfo, String username, String password) throws Exception { String loginPostUrl = "https://教务系统地址/login.do"; Map<String, String> formData = new LinkedHashMap<>(); formData.put("username", username); formData.put("password", password); formData.put("lt", pageInfo.getLt()); formData.put("execution", pageInfo.getExecution()); formData.put("_eventId", "submit"); // 这里按实际抓包结果把每个隐藏字段都放进来 String formBody = buildFormBody(formData); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(loginPostUrl)) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .header("Content-Type", "application/x-www-form-urlencoded") .header("Referer", "/login页面地址") .POST(HttpRequest.BodyPublishers.ofString(formBody)) .timeout(Duration.ofSeconds(30)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); // 登录是否成功,看响应URL或者响应体特征 if (response.uri().toString().contains("index") || response.body().contains("欢迎") || response.body().contains("个人中心")) { return true; } return false; } private String buildFormBody(Map<String, String> formData) { return formData.entrySet().stream() .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining("&")); }

登录成功的判断不能依赖单一条件。有的教务系统登录成功后返回302,你在NORMAL模式下会自动跟随,最终落地到首页,此时response.uri()已经变成首页地址;有的系统返回200但页面内容里直接包含"登录成功"或者学号姓名。最稳妥的判断方式:先用响应体特征判断,再用落地页面的关键元素判断,两者都不要命中就当作登录失败。

密码这里要特别说明,教务系统的密码通常不是明文传输。有的系统前端用MD5加密后再提交,有的用RSA公钥加密,还有的做了一次Base64编码。这个必须通过抓包确认,伪造请求时要把加密算法完整复刻。网上很多教程直接明文提交,遇到加密系统必然失败,所以不要想当然,先去开发者工具里看浏览器实际发送的password值是什么。

3.3 登录态验证:别急着拉课表

登录成功以后,很多人直接就去调课表接口了,结果发现拿回来的是一堆乱码或者一个"会话超时"的提示。原因很简单——登录后跳转过程中服务器又种了几个Cookie,或者某个Cookie的值被更新了,你的CookieManager虽然自动存储了,但某些场景下需要再访问一两个页面才真正建立会话。

我在实操中习惯增加一个"会话预热"步骤:登录成功后,先GET一次学生首页或者个人信息页面,确认返回内容中包含真实姓名或学号,确认当前会话有效,再进入课表查询流程。另外,很多教务系统的课表查询接口要求在URL上携带一个学期的参数,如果参数不对,接口会返回空数据,但不会报错,这种问题排查起来相当隐蔽。

public boolean validateSession(HttpClient client, String infoUrl) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(infoUrl)) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .GET() .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); return response.body().contains("学号") || response.body().contains("姓名"); }

这里的infoUrl可以根据你对教务系统的了解填成个人信息页或学生首页地址,一般都在登录成功后的导航栏里能抓到。

4. 课表数据拉取与解析

4.1 课表接口请求的拼装

教务系统的课表查询接口,地址格式五花八门,但核心逻辑都类似:前端提交学号、学期编号,后端返回一段HTML表格。有些系统在查询前还需要请求一个"查询页面"来获取当前学期的默认值,再根据这个默认值去调课表数据。

学期参数很关键。教务系统里的学期通常用一个四位数字字符串表示,比如"2024-2025-2"代表2024到2025学年的第二学期。比如要查询秋季学期,学期代码就是2024-2025-1。你需要先搞清楚目标教务系统用的学期格式,再在程序里做一个可用学期列表配置。

请求课表时,我建议把所有请求参数单独抽一个类来管理:

public class CourseScheduleRequest { private String studentId; // 学号 private String semester; // 学期代码,例如 "2024-2025-1" private String scheduleType; // 课表类型,例如 "student" // getter、setter }

拼装HTTP请求的时候,参数要按教务系统前端页面的实际参数名来,这些名字通常不会很直观,可能叫xnxq01id、zcx之类的简写字,必须以抓包结果为准。如果你发现查询接口返回数据为空,优先检查反查参数名是否正确、学期代码格式是否正确、Cookie是否完整。

4.2 HTML表格解析:从乱糟糟的表格中抠出课程信息

拿到课表页面的HTML后,真正的难点才开始。大多数教务系统的课表页面是一个典型的合并单元格表格:行代表节次(一般12节或者14节),列代表星期一到星期日,课程单元格经常跨行跨列。因为有的课程是两节连上,在表格里就表现为rowspan="2",有的课程还分单双周,解析的时候需要同时判断周次条件。

我用Jsoup解析时,先从整个Document中选取课表<table>,再按行遍历<tr>,每一行有若干个<td>。课程信息一般集中在少部分<td>里,文本格式大致是:

课程名 授课教师 教室 周次 节次

实际解析代码大致是这样:

public List<Course> parseScheduleTable(String html) { List<Course> courseList = new ArrayList<>(); Document doc = Jsoup.parse(html); Elements rows = doc.select("table#trxktable tr"); for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { Elements cells = rows.get(rowIndex).select("td"); for (int colIndex = 0; colIndex < cells.size(); colIndex++) { Element cell = cells.get(colIndex); String cellText = cell.text().trim(); if (cellText.isEmpty() || !cellText.contains("周")) { continue; } // 判断当前单元格所在的星期几、第几节 int weekday = colIndexToWeekday(colIndex); int startSection = rowIndexToSection(rowIndex); // 解析课程名、教师、教室、周次 Course course = parseCourseCell(cellText); course.setWeekday(weekday); course.setStartSection(startSection); // 如果 rowspan 大于1,说明连续占两节 if (cell.hasAttr("rowspan")) { int rowspan = Integer.parseInt(cell.attr("rowspan")); course.setEndSection(startSection + rowspan - 1); } courseList.add(course); } } return courseList; }

这里要注意,遍历表格的时候,rowspan的单元格会导致行数和列数对不上。有的HTML表格因为单元格合并,某一行只有6个td,但它的第一个td实际上占了两列。如果只依赖循环遍历,极容易出现错位。我建议用一个二维数组来构建"课程格子地图":先扫描一遍所有rowspan和colspan,把每个单元格实际占据的行列范围算清楚,再根据格子地图来确定课程归属,这样虽然代码量多一点,但解析结果非常稳定。

4.3 数据封装与JSON输出

解析完的课程数据,最终要输出成JSON给下游服务。我建议直接用一个Course对象承载:

public class Course { private String courseName; // 课程名称 private String teacher; // 教师 private String location; // 教室 private int weekday; // 星期几,1-7 private int startSection; // 开始节次 private int endSection; // 结束节次 private String weeks; // 周次,例如 "1-8周", "1-16周(单)" // 省略getter/setter }

把解析结果先放到List<Course>,再用Jackson转成JSON字符串返回。要注意课程名和教师名可能会混在同一个文本块里,用String.split("\\s+")按空白字符切分时,不同教务系统的格式差异很大,有的用空格分隔,有的用换行符,有的用特定符号。切分后还要做一轮清洗:剔除空字符串、去掉首尾空格、把全角空格转成半角。别小看这些细节,我在实盘里见过课程名里带换行符的,直接导致下游解析报错。

输出JSON时,为了下游方便使用,可以顺便按星期字段做分组排序,把周一到周日的课程分开输出。也可以在这个环节增加一个统计字段,比如每周总课时数,方便课表工具直接展示。数据严洁、结构清晰,下游集成就会舒服很多。

5. 常见问题与排查实录

5.1 登录失效与Cookie过期

不少人在开发时发现程序跑得好好的,隔了一天再跑就登录失败,或者课表接口返回"未登录"。绝大多数原因是登录页面重新生成了隐藏参数,你直接跳过了GET登录页的步骤,带着旧参数去POST,被服务器判定为非法请求。

处理方式很简单:每次登录前都强制执行GET登录页、解析隐藏参数的流程,不要为了省一次请求把参数缓存下来。另一类情况是会话超时,服务器对Session设置了有效期,超时需要重新登录。在定时任务场景里,建议在课表接口返回"未登录"特征时自动触发一次重连,不需要每次任务都重新登录。

我的经验是写一个带状态的LoginService,内部维护一个volatile的登录标志和最新登录时间,对外提供getAvailableClient()方法,如果当前客户端不可用就自动重新认证。这样调用方完全不用关心登录流程。

5.2 接口参数加密:别硬解

这类项目里最耗时间的环节就是逆向加密参数。我遇到过前端把用户名做了Base64、密码做了RSA加密、请求参数里带一个跟时间戳相关的HMAC签名的系统,刚开始觉得没法搞了,但实际梳理后发现规律并不复杂。

处理加密参数的步骤基本固定:用开发者工具把提交请求的JS执行过程过一遍,定位到加密函数所在的JS文件,在本地用同样的算法重写一遍,再跟浏览器生成的密文做比对。如果是RSA加密,私钥和解密逻辑都在前端,直接把公钥和加密过程抠出来用Java复刻。这里要提醒一句:不要想着去破解决密,你只需要能在本地生成一个服务器认可的值就行。

个别系统会在请求之间校验请求头里的Referer和Origin,缺了任何一个都会拒绝响应。凑一个正确的请求头其实就是从抓包记录里抄一遍,把所有必要的header都设置好,不要偷懒。

5.3 请求频次控制与超时处理

教务系统这种老旧的服务器,承载能力普遍有限。如果你写一个定时任务每5分钟去刷一次课表,很容易把对方的服务弄得很紧张,管理员一旦发现异常流量,轻则接口限流,重则封IP。所以请求频次必须有意识去控制,课表本来一天顶多变化几次,定时任务设置每天凌晨和中午各同步一次就够了。

在Java实现里,可以在HTTP请求之间加一个简单的间隔控制,避免连续请求压到服务器:

private void throttle() { try { Thread.sleep(ThreadLocalRandom.current().nextLong(1500, 3000)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

随机间隔比固定间隔更友好,既能降低对服务器的压力,也能降低被限流策略识别的概率。连接超时和读超时要分别设置,连接超时通常在5到10秒,读超时建议长一点,因为课表接口响应慢是常态,直接给30秒,超过就重试一次,重试失败就记日志告警,不要无限重试。

5.4 数据越权与合规使用

这是必须说清楚的一件事。从教务系统拉课表,本质上是访问你自己的个人信息,用这类自动化工具的目标应该是个人使用,而不是批量抓取他人数据。

代码里设计的参数一定不要预留批量遍历学号的功能,更不要集中去拉别人的课表。某个同学告诉我,他给班级做了个课表小程序,结果需要导入全班同学的课表,直接拿全班同学的账号去做自动化,这已经不是技术层面的问题了。合规边界要提前想清楚:做个人课表助手、日历提醒、班级公用课表自动同步,涉及他人数据时必须获得相应授权,且只能在合理的范围内使用。代码再好,用错了方向就是给自己惹麻烦。

6. 实操心得与后续扩展

6.1 我在实际开发中的几点体会

从项目搭建到跑通完整流程,我踩过不少坑,挑几个印象最深的说说。

第一,抓包信息一定要完整记录。我一开始直接把登录接口的参数名字写死在代码里,后来发现教务系统在前端给表单加了个动态校验字段,程序立刻失效。后来我改成每次从登录页动态解析隐藏字段,再也没出过这个问题。

第二,编码问题比想象中更隐蔽。有次课表里的教师名全是乱码,排查了半天,发现是教务系统的某个内页用的是GBK编码,而HttpClient用UTF-8解码了响应体。后来我统一封装了一个响应读取方法,根据响应头里的Content-Type自动判断字符集,问题彻底解决。

第三,不要在异常处理上省事。登录失败、课表接口异常、解析结果为空,这三种情况要分别写日志,并包含尽量多的上下文信息:请求的URL、返回的状态码、响应体的前200个字符。几个星期后你自己回看日志时,这些上下文信息能帮你节省大量时间。

6.2 后续扩展:缓存策略与API暴露

项目打通之后,可以围绕"课表数据服务"继续加功能。

首先是加一层本地缓存。教务系统的课表数据基本不会随时变化,每次定时任务拉取成功后,把解析好的JSON序列化存储到本地文件或Redis里,设置一个适当的过期时间。下游接口优先走缓存,缓存过期再触发重新拉取,这样既能减少对教务系统的请求压力和依赖,又能保证服务的稳定性——教育网偶尔断连,有缓存兜底,用户界面不会突然变空。

其次是暴露一个简洁的对外API接口。用Spring Boot的@RestController提供两个接口,一个返回某周的完整课表,一个返回当天课程。参数尽量简化,鉴权用简单的Token或AppKey,避免直接裸奔。

6.3 最后分享一个小技巧

如果你准备把课表同步到手机日历,解析后的Course对象可以直接按iCalendar格式拼装成.ics文件。课程开始和结束时间映射成事件,周次信息映射成重复规则,一个简单的接口就能把整个学期课表变成可订阅的日历链接。用户体验比看JSON好很多,也是这个项目展示价值最直观的方式。我自己就是这样用的,省掉了手动往日历里抄课表这个大麻烦。


以上是我个人在"用Spring Boot的HTTP Client从教务系统拉课表"这个项目里的完整实践记录。从方案选型到编码实现,从踩坑记录到合规边界,基本覆盖了这类对接场景的常见问题。如果你也在做类似的校园工具开发,希望这些经验能帮你少走几步弯路。做这类自动化数据对接,核心不是堆代码而是梳理清楚对方的请求逻辑,心态放稳,一步一步来,总能跑通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询