ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

教务系统接口逆向与Python会话管理实战

教务系统接口逆向与Python会话管理实战

简介:这是一份面向高校学生与Python初学者的教务系统抢课辅助工具包,聚焦于解决热门课程名额秒光场景下的自动化选课需求。资源以轻量级Python脚本为核心,通过模拟浏览器行为实现对教务页面的高频点击与请求提交,适用于SUSTC(南方科技大学)和GDUFS(广东外语外贸大学)等采用类似架构的教务系统。压缩包共5个文件,含2个核心脚本(SUSTC-qiangke.py、GDUFS_version.py)、2份说明文档(README.md)及1份LICENSE协议,总大小仅8KB,结构简洁、开箱即用。已有1050人学习下载,适合希望快速上手自动化操作、理解HTTP请求模拟与基础网页交互逻辑的学习者。读者可直接复用脚本框架,结合自身学校登录/选课接口进行适配改造,并通过README中的使用说明掌握环境配置、参数调整与常见问题应对方法。

1. 这不是“抢课外挂”,而是一份教务系统交互逻辑的逆向工程笔记:用 Python 拆解登录态维持、课程列表解析与原子化选课请求的完整链路

某高校教务系统上线新学期选课时,A同学连续三天凌晨蹲守页面,手动刷新、点选、提交,最终因网络抖动丢失一次关键操作——课程名额在 0.8 秒内被清空。这不是玄学,是典型 Web 系统在高并发下暴露的接口设计边界:状态强依赖 Cookie + SessionID,前端防重复提交靠 JS 锁按钮,后端却未对同一用户高频选课做服务端限频。这份 Python 脚本,本质是一套「可调试、可验证、可中断」的教务系统交互探针:它不绕过身份认证,而是完整复现浏览器从登录、查课、加课到结果校验的每一步 HTTP 请求;它不模拟点击,而是直击接口层,把“点击”翻译成带签名参数的 POST 请求;它不追求全自动抢课,而是提供细粒度控制——比如只对某门课重试 5 次、每次间隔 1.2 秒、失败时自动保存响应体供人工分析。适合两类人:一是想搞懂教务系统底层通信机制的计算机专业学生,二是需要批量处理选课异常(如退课重选、跨年级补选)的教务助理。它不能帮你“秒杀”,但能让你看清“为什么没抢到”。


2. 教务系统交互建模:为什么必须放弃 Selenium,转向 Requests + 手动会话管理?

2.1 教务系统典型架构特征决定技术选型

某高校教务系统采用 Java Web 技术栈,前端为 Vue.js 单页应用,但核心选课逻辑仍由传统 Servlet 接口承载。其关键特征包括:

  • 登录态强绑定 SessionID:登录成功后返回JSESSIONID=ABC123,后续所有请求必须携带该 Cookie,且服务端校验 Session 是否过期、是否被并发挤下线;
  • 课程列表接口带时间戳签名:/course/list?timestamp=1715678901234&sign=abcd1234,其中sign是MD5(timestamp + secret_key),secret_key 埋在前端 JS 中;
  • 选课请求需二次校验 token:提交选课前,前端调用/course/token获取一次性 token,该 token 有效期仅 30 秒,且一次有效;
  • 响应体含业务码而非 HTTP 状态码:即使 HTTP 返回 200,响应 JSON 中code: 5001表示“课程已满”,code: 5003表示“选课冲突”,code: 0才代表成功。

Selenium 在此场景下天然失效:它无法直接读取前端 JS 计算出的sign参数;无法精准捕获/course/token返回的 token 并注入到下一个请求头;更无法在页面未加载完成时提前拦截并解析响应体。而 Requests + 手动会话管理,让我们能把每个环节拆成独立可测单元。

2.2 构建可复用的会话管理器:Session 对象的初始化与生命周期控制

核心是封装一个JWSession类,继承自requests.Session,并重写request()方法注入通用逻辑:

import requests import time import hashlib from urllib.parse import urlencode class JWSession(requests.Session): def __init__(self, base_url="https://jw.xxx.edu.cn"): super().__init__() self.base_url = base_url.rstrip('/') self._login_status = False self._last_token = None self._token_expire = 0 def _gen_sign(self, timestamp: int, path: str) -> str: """根据教务系统前端 JS 逆向得出的签名算法""" secret_key = "jw_secret_v2_2023" # 实际需从登录后加载的 JS 文件中提取 raw = f"{path}?timestamp={timestamp}{secret_key}" return hashlib.md5(raw.encode()).hexdigest()[:16] def request(self, method, url, **kwargs): # 自动拼接 base_url if not url.startswith(('http://', 'https://')): url = f"{self.base_url}{url}" # 强制添加通用 headers headers = kwargs.get('headers', {}) headers.setdefault('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') headers.setdefault('X-Requested-With', 'XMLHttpRequest') kwargs['headers'] = headers # 自动注入 token(如果存在且未过期) if self._last_token and time.time() < self._token_expire: headers['X-Token'] = self._last_token return super().request(method, url, **kwargs)

提示:secret_key不是硬编码常量。实际项目中,应在登录成功后,通过GET /static/js/app.xxx.js下载前端 JS,用正则r'secret_key\s*=\s*["\']([^"\']+)["\']'提取。脚本中暂用占位符,运行时需动态替换。

2.3 登录流程:POST 表单 vs JSON 接口?如何识别真实登录入口

教务系统登录页看似是表单提交,但抓包发现真实请求是:

POST /login/check HTTP/1.1 Content-Type: application/json ... {"username":"20210001","password":"e10adc3949ba59abbe56e057f20f883e","captcha":"abcd"}

密码是 MD5(明文),验证码需 OCR 识别(本脚本暂跳过验证码,聚焦主干逻辑)。登录代码如下:

def login(self, username: str, password_md5: str, captcha: str = "") -> bool: payload = { "username": username, "password": password_md5, "captcha": captcha } resp = self.post("/login/check", json=payload) data = resp.json() if data.get("code") == 0: self._login_status = True # 解析 Set-Cookie 中的 JSESSIONID(requests.Session 已自动处理) print(f"[✓] 登录成功,SessionID: {self.cookies.get('JSESSIONID', 'N/A')}") return True else: print(f"[✗] 登录失败,code={data.get('code')}, msg={data.get('msg')}") return False

关键点:json=payload触发Content-Type: application/json,而非data=payload(表单格式)。若用错,服务端直接返回 400。

2.4 课程列表获取:时间戳签名与分页参数的协同构造

接口/course/list要求timestamp和sign,且支持分页:

def get_course_list(self, page: int = 1, size: int = 20) -> list: timestamp = int(time.time() * 1000) path = "/course/list" sign = self._gen_sign(timestamp, path) params = { "page": page, "size": size, "timestamp": timestamp, "sign": sign } resp = self.get(f"{path}?{urlencode(params)}") data = resp.json() if data.get("code") != 0: raise RuntimeError(f"获取课程列表失败: {data.get('msg')}") return data.get("data", [])

注意:urlencode(params)必须在get()之前拼接,因为sign计算依赖原始 URL 查询字符串顺序。若用params=参数传入,Requests 会重新排序,导致签名失效。


3. 选课原子操作:从 token 获取、课程锁定到结果确认的三段式闭环

3.1 Token 获取:一次性凭证的时效性与幂等性控制

选课前必须调用/course/token,响应体为:

{"code":0,"data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","expire":1715679200}}

该 token 有效期 30 秒,且服务端记录使用状态,重复提交同一 token 会返回code: 5004(token 已使用)。因此必须严格管理:

def _fetch_token(self) -> str: resp = self.get("/course/token") data = resp.json() if data.get("code") != 0: raise RuntimeError(f"获取token失败: {data.get('msg')}") token_data = data["data"] self._last_token = token_data["token"] self._token_expire = token_data["expire"] # Unix 时间戳(秒级) return self._last_token def select_course(self, course_id: str, class_id: str) -> dict: # 1. 先获取 token try: token = self._fetch_token() except Exception as e: return {"success": False, "error": f"token获取失败: {e}"} # 2. 构造选课请求 payload = { "courseId": course_id, "classId": class_id, "token": token # 注意:token 同时放 header 和 body! } resp = self.post("/course/select", json=payload) return resp.json()

注意:部分教务系统要求 token 同时出现在X-TokenHeader 和请求 Body 中,缺一不可。这是典型的“双因子校验”设计,防止 token 被截获后滥用。

3.2 课程锁定机制:为什么“选课成功”不等于“已选上”?

调用/course/select后,响应code: 0仅代表请求被接收,但课程名额可能在排队中被其他请求抢占。真实状态需轮询/course/status?courseId=xxx:

def check_selection_status(self, course_id: str, max_retries: int = 5, interval: float = 1.0) -> dict: for i in range(max_retries): resp = self.get(f"/course/status?courseId={course_id}") data = resp.json() if data.get("code") != 0: return {"success": False, "status": "query_failed", "detail": data.get("msg")} status = data["data"]["status"] # "waiting", "success", "failed", "full" if status in ["success", "failed", "full"]: return {"success": status == "success", "status": status, "data": data["data"]} time.sleep(interval) return {"success": False, "status": "timeout", "detail": "轮询超时"}

这就是为什么脚本必须包含状态轮询——教务系统采用“异步选课”模型,类似银行排队叫号,提交只是取号,结果需等待叫号。

3.3 失败重试策略:基于错误码的差异化重试逻辑

不同错误码对应不同处理方式,不能无脑重试:

错误码含义是否重试重试间隔说明
5001课程已满否—名额真实耗尽,无需重试
5002未登录是0.5sSession 过期,需重新登录
5003选课冲突否—时间冲突,需人工调整
5004Token 已使用是1.0s需重新 fetch 新 token
5005网络超时是2.0s服务端抖动,可重试

实现逻辑:

def robust_select(self, course_id: str, class_id: str, max_attempts: int = 3) -> dict: for attempt in range(max_attempts): try: result = self.select_course(course_id, class_id) code = result.get("code", -1) if code == 0: # 成功提交,进入状态轮询 status_result = self.check_selection_status(course_id) return {"submit_ok": True, **status_result} # 根据错误码决策 if code in [5002, 5004, 5005]: wait_time = {5002: 0.5, 5004: 1.0, 5005: 2.0}[code] print(f"[!] 尝试 {attempt+1}/{max_attempts} 失败,code={code},等待 {wait_time}s 后重试...") time.sleep(wait_time) continue else: return {"submit_ok": False, "code": code, "msg": result.get("msg", "")} except Exception as e: print(f"[!] 尝试 {attempt+1}/{max_attempts} 异常: {e}") if attempt < max_attempts - 1: time.sleep(1.5) continue return {"submit_ok": False, "error": "达到最大重试次数"}

4. 避坑指南:五个血泪经验换来的高频翻车点与排查路径

4.1 现象:登录成功后,调用/course/list返回 403 Forbidden

原因:服务端校验 Referer 头。前端页面访问/course/list时,Referer 是https://jw.xxx.edu.cn/course,而脚本请求默认无 Referer 或为https://jw.xxx.edu.cn。
解决:在JWSession.request()中强制设置 Referer:

headers.setdefault('Referer', 'https://jw.xxx.edu.cn/course')

4.2 现象:/course/select返回code: 5004(token 已使用),但确认未重复提交

原因:/course/token接口本身也受 Referer 限制,且其返回的 token 与请求 Referer 绑定。若获取 token 时 Referer 是/login,而选课时 Referer 是/course,token 会被拒绝。
解决:统一所有教务相关请求的 Referer 为/course,并在_fetch_token()中显式指定:

resp = self.get("/course/token", headers={"Referer": "https://jw.xxx.edu.cn/course"})

4.3 现象:课程列表数据为空(data: []),但浏览器能正常显示

原因:前端 JS 动态拼接了额外参数,如?deptId=ALL&year=2024&term=1,而脚本只传了分页参数。
解决:用浏览器开发者工具 Network 面板,筛选 XHR 请求,找到真实发送的/course/list完整 URL,复制全部 Query 参数,转为字典传入params。切勿凭经验猜测。

4.4 现象:select_course()返回code: 0,但轮询/course/status始终返回"status": "waiting",超时后变成"failed"

原因:课程存在“预选阶段”和“正选阶段”,脚本在预选阶段提交,但系统只在正选开始后才处理队列。/course/status的"waiting"是正常状态,不代表失败。
解决:增加阶段判断逻辑。先 GET/system/config获取当前选课阶段标识(如phase: "preselect"),若为预选,则轮询逻辑改为等待阶段切换,而非等待"success"。

4.5 现象:脚本运行数分钟后,所有请求开始返回code: 5002(未登录),但 SessionID 未变

原因:服务端设置了 Session 最大活跃时间(如 15 分钟),且检测到无操作即主动销毁。浏览器因有心跳请求(如定时拉取通知)维持 Session,脚本没有。
解决:在JWSession中添加后台心跳线程,或在关键操作前插入保活请求:

def keep_alive(self): self.get("/user/info") # 一个轻量级、不改变状态的接口

并在robust_select()开头调用self.keep_alive()。


5. 生产级加固:从“能跑”到“稳跑”的四个落地技巧

5.1 请求节流:用指数退避替代固定间隔,避免触发服务端风控

教务系统通常部署 WAF,对同一 IP 短时高频请求(如 1 秒内 5 次)会返回 429 Too Many Requests。固定间隔(如time.sleep(1))在高并发下极易撞线。改用指数退避:

import random def exponential_backoff(attempt: int) -> float: """计算第 attempt 次重试的等待时间(秒)""" base = 0.5 max_wait = 5.0 wait = min(base * (2 ** attempt) + random.uniform(0, 0.5), max_wait) return wait # 在 robust_select 的重试循环中: if code in [5002, 5004, 5005]: wait_time = exponential_backoff(attempt) time.sleep(wait_time)

这样,第一次重试等 0.5~1.0 秒,第二次等 1.0~1.5 秒,第三次等 2.0~2.5 秒……既降低被限频概率,又避免无限等待。

5.2 响应快照:自动保存失败请求的完整上下文,告别“黑匣子”调试

每次请求失败,不仅打印错误,更要保存原始请求和响应:

import json from datetime import datetime def safe_request(self, method, url, **kwargs): try: resp = self.request(method, url, **kwargs) return resp except Exception as e: # 保存快照 snapshot = { "timestamp": datetime.now().isoformat(), "method": method, "url": url, "kwargs": {k: v for k, v in kwargs.items() if k != "headers"}, "error": str(e), "session_cookies": dict(self.cookies), } filename = f"debug_{int(time.time())}_{method}_{hash(url) % 10000}.json" with open(filename, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) print(f"[DEBUG] 错误快照已保存至 {filename}") raise

下次遇到问题,直接打开 JSON 文件,看到完整的 Cookie、时间戳、请求参数,比看日志快十倍。

5.3 课程匹配引擎:用模糊匹配替代精确 ID,解决前端展示名与后端 ID 不一致

教务系统中,前端显示“高等数学(A)”,后端 courseId 可能是"MATH101_2024A",且每学期 ID 都变。脚本不应硬编码 ID,而应构建匹配规则:

def find_course_by_name(self, keyword: str, max_results: int = 5) -> list: """按关键词模糊搜索课程,返回匹配的 course_id 列表""" all_courses = [] page = 1 while len(all_courses) < 1000: # 防止无限分页 courses = self.get_course_list(page=page, size=50) if not courses: break for c in courses: # 多字段匹配:课程名、教师名、上课周次 match_score = 0 if keyword.lower() in c.get("name", "").lower(): match_score += 2 if keyword.lower() in c.get("teacher", "").lower(): match_score += 1 if "周" in keyword and c.get("week_info", ""): match_score += 1 if match_score > 0: all_courses.append({ "id": c["id"], "name": c["name"], "teacher": c["teacher"], "score": match_score, "capacity": c.get("capacity", 0), "selected": c.get("selected", 0) }) page += 1 # 按匹配分排序,取前 max_results return sorted(all_courses, key=lambda x: x["score"], reverse=True)[:max_results] # 使用示例: matches = session.find_course_by_name("高数") if matches: target = matches[0] print(f"匹配到: {target['name']} ({target['id']}), 剩余名额: {target['capacity'] - target['selected']}")

这样,输入"高数"、"王老师"、"周三"都能命中,大幅提升脚本鲁棒性。

5.4 环境隔离:用配置文件驱动行为,一份代码适配多所高校

不同学校接口路径、签名算法、错误码含义均不同。将差异点抽离为config.yaml:

university: "xxx_uni" base_url: "https://jw.xxx.edu.cn" login_endpoint: "/login/check" course_list_endpoint: "/course/list" sign_method: "md5_timestamp_secret" secret_key: "jw_secret_v2_2023" error_codes: full: 5001 conflict: 5003 token_used: 5004

加载逻辑:

import yaml def load_config(config_path: str) -> dict: with open(config_path, "r", encoding="utf-8") as f: return yaml.safe_load(f) # 在 JWSession.__init__ 中: self.config = load_config("config.yaml") self.base_url = self.config["base_url"] def _gen_sign(self, timestamp: int, path: str) -> str: if self.config["sign_method"] == "md5_timestamp_secret": secret = self.config["secret_key"] raw = f"{path}?timestamp={timestamp}{secret}" return hashlib.md5(raw.encode()).hexdigest()[:16] # 可扩展其他算法...

从那以后我每次接手新学校的教务系统,第一件事就是抓包分析接口,然后更新config.yaml,从不碰核心JWSession代码——它已经成了我的教务系统交互标准库。这套模式让我在三个不同技术栈的教务系统(Java/Spring、.NET、PHP)上都实现了 1 小时内完成适配。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进