ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高校教务系统模拟登录与课表解析实战指南

高校教务系统模拟登录与课表解析实战指南 简介川大课程表ScuTimetable是一款面向四川大学本科生的安卓端开源课程管理应用解决学生频繁登录教务系统查课、课程信息杂乱难规划等实际痛点适用于Android开发初学者与校园工具类App实践者。资源包共423个文件含54个Java源码文件核心业务逻辑与网络请求模块、95个XML布局与资源文件UI结构与适配设计、34个JSON与67个TXT配置/说明文档含教务接口解析规则与使用指南以及CMake、Ninja等构建脚本和PNG/WebP图标资源完整呈现GradleNDK混合项目的工程结构压缩包仅4.75MB轻量但架构清晰。目前已有31人学习下载读者可直接获取可编译运行的完整项目源码、模拟教务登录的安全实现方案、课程数据本地缓存与日历视图渲染逻辑以及配套的README说明文档与常见问题解答是理解高校教务系统对接与移动端课程管理App开发的优质实践案例。1. ScuTimetable 不是“爬课表工具”而是教务系统数据链路的轻量级可信代理你手机里装着「小爱课程表」或「拾光课程表」但它们在川大教务系统上大概率会卡在登录页——不是密码错而是教务系统早就不认第三方 App 的 User-Agent 和 Cookie 签名了。ScuTimetable 这个名字听起来像学生社团做的小玩具但它实际是一套严格遵循教务系统交互协议、不依赖全局 root、不注入 WebView、不调用无障碍服务的安卓端可信代理方案。它不做中间人劫持也不走模拟浏览器渲染而是用 Java 层原生 HTTP Client 自研 Session 管理器复现教务系统登录态维持全过程从 RSA 公钥加密密码、到 JSESSIONID 透传、再到课表 HTML 解析时对td嵌套结构的容错处理。适合两类人一是想零成本把个人课表同步进日历/Notion 的川大学生二是想研究高校教务系统反爬逻辑、又不愿碰逆向黑盒的安卓开发新手。它不解决“全校课表抓取”只做“单账号安全导出”这是它和市面上多数课程表 App 的本质分野。2. 模拟登录教务系统为什么必须重写登录流程而不是用 WebView 或 OkHttp 默认行为教务系统以四川大学新版教务平台为例的登录防护有三层嵌套前端 JS 动态生成 RSA 公钥 → 密码明文经公钥加密后 POST → 后端校验 Referer User-Agent Cookie 中的 JSESSIONID 有效性 → 成功后跳转至含动态 token 的课表页。很多开发者第一反应是“WebView 加载登录页自动填密码”但这条路在安卓 11 上已彻底堵死WebView 默认禁用setJavaScriptEnabled(true)的部分能力且教务系统页面会主动检测window.navigator.webdriver和document.documentElement.hasAttribute(data-webview)并拒绝响应。ScuTimetable 的解法是绕过 UI 层直击协议层。2.1 获取 RSA 公钥并加密密码用 Bouncy Castle 替代 Android 内置 Crypto API教务系统登录页 HTML 中藏有input typehidden idpublicKey valueMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...。ScuTimetable 在LoginActivity中用 Jsoup 解析该字段提取 Base64 编码的 PEM 公钥字符串再通过 Bouncy Castle 的RSAEngine完成加密// 使用 Bouncy Castle 提供的 RSA 加密需在 build.gradle 中添加 implementation org.bouncycastle:bcprov-jdk15on:1.70 public static String encryptWithPublicKey(String plainText, String publicKeyPem) throws Exception { PEMParser pemParser new PEMParser(new StringReader(publicKeyPem)); SubjectPublicKeyInfo subjectPublicKeyInfo (SubjectPublicKeyInfo) pemParser.readObject(); RSAPublicKeyStructure keyStruct RSAPublicKeyStructure.getInstance(subjectPublicKeyInfo.parsePublicKey()); RSAKeyParameters keyParams new RSAKeyParameters(false, keyStruct.getModulus(), keyStruct.getPublicExponent()); RSAEngine engine new RSAEngine(); engine.init(true, keyParams); byte[] inputBytes plainText.getBytes(StandardCharsets.UTF_8); byte[] encrypted engine.processBlock(inputBytes, 0, inputBytes.length); return Base64.encodeToString(encrypted, Base64.NO_WRAP); }注意Android 8.0 已废弃javax.crypto.Cipher.getInstance(RSA/ECB/PKCS1Padding)对 PEM 公钥的直接支持硬切系统 API 会导致NoSuchAlgorithmException。Bouncy Castle 是目前最稳定、无需反射、兼容 Android 7.0~14 的方案。别用android.security.keystore—— 那是给设备密钥存储备份用的不是用来解教务系统的临时公钥。2.2 构造合法请求头与 Cookie 链Referer、User-Agent 与 JSESSIONID 的三重绑定教务系统后端会校验三个关键 HeaderReferer: https://zhjw.scu.edu.cn/loginUser-Agent: Mozilla/5.0 (Linux; Android 12; SM-S9010 Build/SP1A.210812.016; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/116.0.5845.110 Mobile Safari/537.36Cookie: JSESSIONIDABC123...; path/; HttpOnlyScuTimetable 在OkHttpClient初始化时强制设置OkHttpClient client new OkHttpClient.Builder() .addInterceptor(chain - { Request original chain.request(); Request request original.newBuilder() .header(Referer, https://zhjw.scu.edu.cn/login) .header(User-Agent, Mozilla/5.0 (Linux; Android 12; SM-S9010 Build/SP1A.210812.016; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/116.0.5845.110 Mobile Safari/537.36) .build(); return chain.proceed(request); }) .cookieJar(new MyCookieJar()) // 自定义 CookieJar持久化 JSESSIONID .build();其中MyCookieJar必须实现saveFromResponse()和loadForRequest()且saveFromResponse()中需过滤掉path/的无效 Cookie只保留path/下带JSESSIONID的条目。否则后续请求会因 Cookie 混乱而 302 跳回登录页。2.3 登录成功后的 Token 提取从重定向响应中抠出动态参数POST 登录成功后服务器返回302 FoundLocation 头为https://zhjw.scu.edu.cn/student/courseSelect/courseTable/index?tokenabc123timestamp1712345678。ScuTimetable 不依赖followRedirects(false)而是用Call.execute().response().headers().get(Location)直接读取重定向地址再用正则token([^])timestamp(\\d)提取两个参数。这两个值是课表接口的必要 query 参数缺一不可。若用followRedirects(true)让 OkHttp 自动跳转token会在跳转过程中被丢弃——因为教务系统跳转页本身不带token它只存在于第一次 302 的 Location 中。3. 课表 HTML 解析为什么 Jsoup 不能直接 parse而要先做 DOM 归一化教务系统返回的课表 HTML 是典型的“高校教务风格”table嵌套 5 层tbody每节课用td rowspan2占位跨周课程用td colspan3拉伸空闲时段填充tdnbsp;/td。更麻烦的是不同学期、不同学院导出的 HTML 结构存在细微差异有的tr包含th表头有的直接td有的td内含br换行有的用p包裹文字有的课程名后跟[实验]标签有的用span classlab标记。Jsoup 的Document.parse()会把nbsp;当作文本节点把br当作独立元素导致element.text()返回结果包含大量换行符和空格无法直接映射到周一第 1 节这样的坐标。ScuTimetable 的解法是两阶段清洗3.1 预处理用正则归一化 HTML 片段在 Jsoup 解析前先对原始 HTML 字符串做三次替换String cleanHtml rawHtml .replaceAll(br[^]*, \n) // 把 br 统一替换成 \n .replaceAll(nbsp;, ) // nbsp; → 空格 .replaceAll(/?[^], ); // 删除所有标签只留文本仅用于后续坐标定位这步不是为了最终展示而是为了快速定位td的文本内容边界。例如td高等数学br王教授/td变成高等数学\n王教授后续按\n分割即可得课程名和教师名。3.2 DOM 结构重建用 Jsoup Selectors 定位核心表格区域教务系统 HTML 中存在多个table但课表主体永远在div idkbtable下的第二个table索引为 1。ScuTimetable 用以下 Selector 精准定位Document doc Jsoup.parse(cleanHtml); Element kbTable doc.select(div#kbtable table).get(1); // 第二个 table Elements rows kbTable.select(tr);然后遍历rows对每一行row执行Elements cells row.select(td[rowspan], td[colspan], td:not([rowspan]):not([colspan]));这个 Selector 显式区分三类tdtd[rowspan]跨行课程如实验课占两行需记录其rowspan值并在后续行跳过对应列td[colspan]跨列课程如某课连上三节需记录其colspan值并扩展列索引td:not([rowspan]):not([colspan])标准单元格直接取.text().trim()。这样避免了传统row.select(td)因rowspan/colspan导致的列错位问题。3.3 时间坐标映射从 HTML 表头反推周次与节次课表 HTML 的第一行tr是星期表头th星期一/thth星期二/th...第二行tr是节次表头th第1-2节/thth第3-4节/th...。ScuTimetable 用rows.get(0).select(th)获取星期数组用rows.get(1).select(th)获取节次数组构建二维坐标系周几节次坐标星期一第1-2节(0, 0)星期二第3-4节(1, 1)后续解析每节课时根据其td在rows.get(n)中的索引colIndex查表得(weekDay, classPeriod)。例如colIndex2对应星期三rowIndex3对应第5-6节——这就是课程的真实时间坐标。4. 安卓端 Session 持久化与失效应对JSESSIONID 不是永久有效的教务系统对JSESSIONID的有效期设为 30 分钟无操作超时且每次登录都会生成新 ID。ScuTimetable 若简单地把 Cookie 存 SharedPreferences下次启动时直接复用旧JSESSIONID大概率会收到401 Unauthorized或跳回登录页。它采用“双保险”策略本地缓存 服务端心跳。4.1 本地 Cookie Jar 实现只保存 JSESSIONID不存其他临时 CookieMyCookieJar类继承CookieJar核心逻辑在saveFromResponse()Override public void saveFromResponse(HttpUrl url, ListCookie cookies) { for (Cookie cookie : cookies) { if (JSESSIONID.equals(cookie.name()) /.equals(cookie.path())) { // 只存 JSESSIONID且 path 必须为 /过滤掉 /login 路径下的临时 Cookie SharedPreferences prefs context.getSharedPreferences(scu_cookie, Context.MODE_PRIVATE); prefs.edit().putString(jsessionid, cookie.value()).apply(); } } }loadForRequest()则只读取JSESSIONID拼成Cookie: JSESSIONIDxxx发送不附加任何其他 Cookie。这是关键——教务系统会校验 Cookie 数量多一个无关 Cookie 就触发风控。4.2 启动时自动续期用 HEAD 请求探测 Session 有效性App 启动时不直接加载课表而是先发一个HEAD https://zhjw.scu.edu.cn/student/courseSelect/courseTable/indexRequest headReq new Request.Builder() .url(https://zhjw.scu.edu.cn/student/courseSelect/courseTable/index) .head() .build(); Response headResp client.newCall(headReq).execute(); if (headResp.code() 302) { // 说明 Session 失效跳转到登录页 startActivity(new Intent(this, LoginActivity.class)); finish(); } else if (headResp.code() 200) { // Session 有效继续加载课表 loadCourseTable(); }HEAD请求比GET更轻量不下载 HTML 内容仅校验状态码。302 表示被重定向到/login即 Session 过期200 表示可直接访问课表页。4.3 网络异常下的离线兜底缓存最近一次成功课表 JSON当网络不可用或教务系统维护时ScuTimetable 会读取本地SharedPreferences中缓存的课表 JSON格式为{week:1,courses:[{name:高等数学,teacher:王教授,day:0,period:0,room:江安二教101}]}并显示“数据已缓存最后更新于 2024-04-05 08:30”。这个 JSON 在每次成功解析课表后写入prefs.edit().putString(cached_course_table, new Gson().toJson(courseList)).apply();提示缓存有效期设为 24 小时。超过时间后即使有缓存也强制联网刷新避免用户看到过期课表如退课、调课未同步。5. 常见问题排查那些让 ScuTimetable 在安卓 11 上集体翻车的玄学坑ScuTimetable 在实测中遇到过大量看似随机、实则有迹可循的失败场景。以下是真实踩过的 4 个坑按现象→原因→解决顺序整理覆盖从开发到用户侧的全链路。5.1 现象登录页能打开输入账号密码后点击登录界面卡住不动Logcat 无任何网络请求日志原因Android 11 默认启用android:usesCleartextTrafficfalse而教务系统 HTTPS 证书由学校自签OkHttp 默认不信任非 CA 签发证书且未配置hostnameVerifier。此时 OkHttp 会静默失败不抛异常也不触发回调。解决在OkHttpClient.Builder()中显式添加信任策略仅限调试正式版需预埋学校根证书client new OkHttpClient.Builder() .sslSocketFactory(getSSLSocketFactory(), getTrustManager()) .hostnameVerifier((hostname, session) - hostname.equals(zhjw.scu.edu.cn)) .build();其中getSSLSocketFactory()需加载R.raw.scu_root_ca学校公开的根证书 PEM 文件。5.2 现象课表能加载出来但所有课程都显示在“星期一第1节”时间坐标全乱原因HTML 表头th被教务系统动态插入span或font标签导致rows.get(0).select(th)返回空列表星期数组为空后续所有colIndex查表失败。解决改用更鲁棒的 Selectorrows.get(0).select(th, th *)然后对每个 Element 调用.ownText()获取纯文本过滤掉空字符串Elements weekHeaders rows.get(0).select(th, th *); ListString weeks new ArrayList(); for (Element th : weekHeaders) { String text th.ownText().trim(); if (!text.isEmpty() text.contains(星期)) { weeks.add(text); } }5.3 现象安卓 12 设备上首次安装后登录成功但课表页白屏反复重启 App 才正常原因MyCookieJar的saveFromResponse()在主线程执行而 Android 12 对 SharedPreferences 的apply()引入了新的异步队列机制若loadForRequest()紧接着调用可能读到空值。解决将 Cookie 写入改为commit()同步操作并加 100ms 延迟保障写入完成prefs.edit().putString(jsessionid, cookie.value()).commit(); SystemClock.sleep(100); // 确保 commit 完成5.4 现象华为/小米手机上登录后立即被登出Logcat 显示Invalid CSRF token原因华为/小米系统自带“智能省电”会杀死后台进程导致JSESSIONID缓存丢失同时教务系统启用了 CSRF Token 校验要求每次请求携带X-CSRF-TOKENHeader而 ScuTimetable 未提取该 Token。解决在登录成功后的课表页 HTML 中提取meta namecsrf-token contentabc123并全局添加 HeaderString csrfToken doc.select(meta[namecsrf-token]).attr(content); if (!csrfToken.isEmpty()) { client client.newBuilder() .addInterceptor(chain - { Request request chain.request().newBuilder() .header(X-CSRF-TOKEN, csrfToken) .build(); return chain.proceed(request); }) .build(); }6. 进阶技巧如何把 ScuTimetable 的课表数据喂给系统日历实现真正的“课表自动化”ScuTimetable 的终极价值不是展示而是打通数据孤岛。我一般会把它作为“课表中枢”把解析出的课程事件写入 Android 原生日历Calendar Provider这样用户就能在系统日历、Google 日历、甚至 Wear OS 手表上看到课程提醒。这不是简单调ContentResolver.insert()而是要处理三类特殊场景重复事件、跨周课程、教室变更。6.1 课程事件建模用 CalendarContract.Instances 替代 Events 表教务系统课表本质是“每周重复事件”若直接往Events表插 16 周 × 5 天 × 8 节 640 条记录会拖慢日历 App。正确做法是插一条Events记录再插多条Instances记录让系统自动展开。关键字段如下字段值说明dtstart2024-02-26T08:00:00第一周周一第1节开始时间固定dtend2024-02-26T09:40:00第一周周一第1节结束时间固定rruleFREQWEEKLY;COUNT16;BYDAYMO每周一重复16次eventTimezoneAsia/Shanghai时区必须显式指定否则华为手机会默认 UTCContentValues values new ContentValues(); values.put(CalendarContract.Events.DTSTART, startTimeMillis); values.put(CalendarContract.Events.DTEND, endTimeMillis); values.put(CalendarContract.Events.TITLE, 高等数学); values.put(CalendarContract.Events.DESCRIPTION, 王教授 江安二教101); values.put(CalendarContract.Events.EVENT_TIMEZONE, Asia/Shanghai); values.put(CalendarContract.Events.RRULE, FREQWEEKLY;COUNT16;BYDAYMO); values.put(CalendarContract.Events.CALENDAR_ID, calendarId); Uri uri getContentResolver().insert(CalendarContract.Events.CONTENT_URI, values);6.2 处理跨周课程用EXDATE排除停课周某门课第 5 周、第 10 周停课不能删Instances而要在rrule后加EXDATEString rrule FREQWEEKLY;COUNT16;BYDAYMO;EXDATE20240304,20240408;日期格式为YYYYMMDD逗号分隔。ScuTimetable 在解析课表时会额外读取教务系统“教学周历”接口/student/courseSelect/teachingWeek提取停课周数组动态拼接EXDATE。6.3 教室变更同步监听课表更新用CalendarContract.Instances查询并更新当用户手动修改某节课教室如从“江安二教101”改成“望江基础教学楼305”ScuTimetable 不删重建事件而是查出该课程所有Instances的event_id再批量更新Events表的DESCRIPTION字段// 查询本周内该课程所有 Instances String selection CalendarContract.Instances.EVENT_ID ? AND CalendarContract.Instances.BEGIN ? AND CalendarContract.Instances.END ?; Cursor cursor getContentResolver().query( CalendarContract.Instances.CONTENT_URI, new String[]{CalendarContract.Instances.EVENT_ID}, selection, new String[]{String.valueOf(eventId), String.valueOf(weekStart), String.valueOf(weekEnd)}, null ); // 批量更新 Events 表 getContentResolver().update( ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), updateValues, null, null );这套方案上线后我们收到最多反馈是“终于不用每天手动输课表到日历了”。其实技术没多高深就是把教务系统那个黑匣子用最朴素的 HTTP HTML Calendar Provider 一层层撬开。ScuTimetable 的价值不在代码多炫酷而在它敢把高校教务这种“半封闭系统”的数据链路做成可验证、可调试、可审计的透明管道。我坚持不用任何混淆、不加广告、不收集用户数据——因为课表这事本就该安静、可靠、只属于学生自己。希望帮到你。本文还有配套的精品资源点击获取
返回列表