资讯详情

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

📅 2026/10/17 19:07:28 | 华诺云谱 👁 阅读
教务系统接口逆向与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登录成功后返回JSESSIONIDABC123后续所有请求必须携带该 Cookie且服务端校验 Session 是否过期、是否被并发挤下线课程列表接口带时间戳签名/course/list?timestamp1715678901234signabcd1234其中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_urlhttps://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用正则rsecret_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, jsonpayload) data resp.json() if data.get(code) 0: self._login_status True # 解析 Set-Cookie 中的 JSESSIONIDrequests.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关键点jsonpayload触发Content-Type: application/json而非datapayload表单格式。若用错服务端直接返回 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: 5004token 已使用。因此必须严格管理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: ftoken获取失败: {e}} # 2. 构造选课请求 payload { courseId: course_id, classId: class_id, token: token # 注意token 同时放 header 和 body } resp self.post(/course/select, jsonpayload) return resp.json()注意部分教务系统要求 token 同时出现在X-TokenHeader 和请求 Body 中缺一不可。这是典型的“双因子校验”设计防止 token 被截获后滥用。3.2 课程锁定机制为什么“选课成功”不等于“已选上”调用/course/select后响应code: 0仅代表请求被接收但课程名额可能在排队中被其他请求抢占。真实状态需轮询/course/status?courseIdxxxdef 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 新 token5005网络超时是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[!] 尝试 {attempt1}/{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[!] 尝试 {attempt1}/{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()中强制设置 Refererheaders.setdefault(Referer, https://jw.xxx.edu.cn/course)4.2 现象/course/select返回code: 5004token 已使用但确认未重复提交原因/course/token接口本身也受 Referer 限制且其返回的 token 与请求 Referer 绑定。若获取 token 时 Referer 是/login而选课时 Referer 是/coursetoken 会被拒绝。解决统一所有教务相关请求的 Referer 为/course并在_fetch_token()中显式指定resp self.get(/course/token, headers{Referer: https://jw.xxx.edu.cn/course})4.3 现象课程列表数据为空data: []但浏览器能正常显示原因前端 JS 动态拼接了额外参数如?deptIdALLyear2024term1而脚本只传了分页参数。解决用浏览器开发者工具 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 fdebug_{int(time.time())}_{method}_{hash(url) % 10000}.json with open(filename, w, encodingutf-8) as f: json.dump(snapshot, f, ensure_asciiFalse, indent2) 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(pagepage, size50) 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, keylambda x: x[score], reverseTrue)[: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.yamluniversity: 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, encodingutf-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 小时内完成适配。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑