资讯动态

教务系统HTTP交互逆向:Python实现登录态维持与选课自动化

发布时间:2026/10/26 2:10:10 来源:尧图企业网站定制
简介本资源是一套面向高校学生与Python初学者的教务系统抢课辅助脚本工具聚焦于解决热门课程名额秒光场景下的自动化选课需求。通过模拟浏览器行为实现对教务页面的高频点击与请求提交降低人工抢课失败率适用于SUSTC南方科技大学和GDUFS广东外语外贸大学等采用类似Web架构的教务系统。压缩包共5个文件含2个核心Python脚本分别适配不同学校系统、2份Markdown格式说明文档含环境配置、运行步骤与注意事项、1份开源许可证文件整体仅8KB轻量易部署。已有1053人学习下载内容结构清晰、开箱即用提供可直接运行的抢课逻辑、跨校版本对比参考、基础HTTP请求调试思路及常见验证码/登录态处理提示是理解Web自动化原理与实践简单爬虫技术的典型入门级实战案例。1. 这不是“抢课外挂”而是一份教务系统交互逻辑的逆向工程笔记用 Python 拆解登录态维持、课程列表解析与原子化选课请求的完整链路某高校教务系统上线新学期选课时A同学连续三天凌晨蹲守页面手动刷新、点选、提交最终因网络抖动丢失一次关键操作——课程名额在 0.8 秒内被清空。这不是玄学是典型 Web 系统在高并发下暴露的接口设计边界状态强依赖 Cookie SessionID前端防重复提交靠 JS 锁按钮后端却未对同一用户高频选课做服务端限频。我们拆解的这份 Python 脚本本质不是绕过规则而是把浏览器里“人肉操作”的每一步——从登录表单加密提交、到课程列表 DOM 解析、再到选课按钮背后那个 POST 请求的构造逻辑——全部显式化、参数化、可调试。它适合两类人一是想搞懂教务系统真实交互机制的计算机专业学生二是需要在合法授权范围内做自动化教务数据核验如课程容量校准、选课结果回溯的实验室管理员。注意脚本不包含任何暴力请求、验证码破解或账号共享逻辑所有行为均模拟单用户、单会话、带 Referer 和 User-Agent 的标准 HTTP 流量其价值在于把黑匣子变成白盒——你看得见每一次请求发了什么、收到了什么、为什么失败。2. 教务系统交互建模为什么必须放弃 Selenium而选择 Requests BeautifulSoup 组合2.1 教务系统的真实技术栈特征决定了工具选型当前主流高校教务系统以某通用教务平台 v3.2.x 为基准存在三个关键事实第一登录页使用 RSA 公钥对密码进行前端加密非简单 base64密钥由/login接口动态返回第二课程列表页为纯静态 HTML 表格无 AJAX 分页但每行课程含隐藏字段courseId和classId第三选课提交接口为POST /student/course/select要求携带X-Requested-With: XMLHttpRequest头且 body 必须为application/x-www-form-urlencoded格式含courseIdxxxclassIdyyytokenzzz。Selenium 在这种场景下是重型武器它启动浏览器实例耗时 2~3 秒每次刷新页面需等待 JS 执行完毕而实际需求只是精准构造三次 HTTP 请求登录、获取课程页、提交选课。Requests 直接复用 Session 对象管理 CookieBeautifulSoup 解析 HTML 表格比 XPath 更稳定教务系统 DOM 结构常年不变但 class 名随机整个流程可在 800ms 内完成单次循环。这不是性能洁癖而是让“重试”真正有意义——当网络抖动导致某次 POST 超时你能在 200ms 内发起下一轮而不是等 Chrome 加载完整个页面再重来。2.2 登录态建模RSA 加密密码 动态 Token 提取的完整闭环教务系统登录不是明文传密码。其流程为GET/login→ 提取input idpublicKey valueMIGfMA0G...→ 用该公钥对明文密码做 PKCS#1 v1.5 加密 → POST/login提交加密后密文及用户名。Python 中需用pycryptodome实现from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 import base64 def encrypt_password(public_key_pem: str, password: str) - str: key RSA.import_key(public_key_pem) cipher PKCS1_v1_5.new(key) encrypted_bytes cipher.encrypt(password.encode(utf-8)) return base64.b64encode(encrypted_bytes).decode(utf-8) # 示例调用实际需先 GET /login 获取 public_key_pem # encrypted_pwd encrypt_password(pubkey_str, 123456)提示PKCS1_v1_5是教务系统服务端硬编码的解密方式若用OAEP会返回 400base64 编码后字符串需 URL-safe 处理替换为-/为_否则服务端解密失败。登录成功后响应头中Set-Cookie包含JSESSIONIDABC123; Path/; HttpOnlyRequests Session 会自动携带。但关键 Token 并不在 Cookie 里——它藏在登录成功跳转后的首页 HTML 中meta namecsrf-token contenta1b2c3d4。此 Token 必须提取并存入 Session headers后续所有请求包括课程列表和选课都需带上X-CSRF-TOKEN: a1b2c3d4。漏掉这步选课接口直接返回 403 Forbidden。2.3 课程列表解析从 HTML 表格到结构化课程 ID 映射表课程列表页 HTML 结构高度规整table idcourseTable下每个tr代表一门课其中td按顺序为课程名、教师、时间地点、剩余名额、操作列。操作列td内含隐藏 inputtd input typehidden namecourseId valueC2023001 input typehidden nameclassId valueCL2023001A button onclickselectCourse(C2023001,CL2023001A)选课/button /tdBeautifulSoup 解析逻辑如下from bs4 import BeautifulSoup def parse_course_list(html_content: str) - list[dict]: soup BeautifulSoup(html_content, html.parser) courses [] for row in soup.select(#courseTable tbody tr): tds row.find_all(td) if len(tds) 5: continue # 提取隐藏字段 hidden_inputs row.find_all(input, {name: [courseId, classId]}) course_id next((inp[value] for inp in hidden_inputs if inp[name] courseId), None) class_id next((inp[value] for inp in hidden_inputs if inp[name] classId), None) if not course_id or not class_id: continue # 提取可见信息 course_name tds[0].get_text(stripTrue) teacher tds[1].get_text(stripTrue) capacity tds[4].get_text(stripTrue) # 剩余名额 courses.append({ course_id: course_id, class_id: class_id, name: course_name, teacher: teacher, capacity: int(capacity) if capacity.isdigit() else 0 }) return courses # 使用示例 # html session.get(https://jwxt.example.edu.cn/student/course/list).text # course_list parse_course_list(html) # target next(c for c in course_list if c[name] 高等数学A(1) and c[capacity] 0)参数说明soup.select()使用 CSS 选择器比find_all(tr)更精准避免匹配到表头next(...)保证只取第一个匹配项防止同名课程多班次时误选capacity字段需容错处理可能为 - 或 满故用isdigit()判断后转 int否则设为 0。3. 原子化选课请求POST 构造、响应解析与幂等性控制3.1 选课接口的精确请求体构造与 Header 必填项选课 POST 请求地址固定为/student/course/select但 body 和 headers 有严格约束。实测发现以下 5 项缺一不可字段类型值示例是否必填说明courseIdform-dataC2023001✅课程唯一标识来自 HTML 隐藏字段classIdform-dataCL2023001A✅教学班唯一标识来自 HTML 隐藏字段tokenform-dataa1b2c3d4✅即登录后提取的 CSRF Token非 JSESSIONIDX-Requested-WithheaderXMLHttpRequest✅服务端据此判断是否为 AJAX 请求X-CSRF-TOKENheadera1b2c3d4✅与 form-data 中 token 值一致双重校验错误示范若仅传courseId和classId服务端返回{code:400,msg:缺少必要参数}若漏X-Requested-With返回406 Not Acceptable若X-CSRF-TOKEN与 form-data 中token不一致返回403 Forbidden。Requests 调用代码如下def submit_selection(session: requests.Session, course_id: str, class_id: str, csrf_token: str) - dict: url https://jwxt.example.edu.cn/student/course/select data { courseId: course_id, classId: class_id, token: csrf_token } headers { X-Requested-With: XMLHttpRequest, X-CSRF-TOKEN: csrf_token, Referer: https://jwxt.example.edu.cn/student/course/list } try: resp session.post(url, datadata, headersheaders, timeout5) return resp.json() except requests.exceptions.RequestException as e: return {code: -1, msg: f网络异常: {str(e)}} # 调用示例 # result submit_selection(session, target[course_id], target[class_id], csrf_token) # print(result) # {code:200,msg:选课成功}注意timeout5是血泪经验——教务服务器在高负载时响应常超 3 秒设太短会导致误判失败Referer必须与课程列表页 URL 一致否则部分系统校验 Referer 失败返回 403。3.2 响应状态机解析区分“成功”、“已选”、“名额不足”与“系统繁忙”选课接口返回 JSON但code字段语义需深度解读。实测 6 类响应codemsg 示例含义后续动作200选课成功真正成功课程加入个人课表记录日志退出循环200您已选过该课程课程已在课表中幂等记录日志退出循环400课程不存在courseId或classId无效检查 HTML 解析逻辑终止脚本400名额已满剩余名额为 0但页面未及时刷新等待 1~3 秒后重试500系统繁忙请稍后再试服务端过载指数退避重试1s→2s→4s-1网络异常: Read timed out客户端超时同 500 处理关键逻辑在于不能仅凭 code200 就判定成功。必须检查msg字段内容。以下为健壮判断函数def is_selection_successful(resp_json: dict) - tuple[bool, str]: if not isinstance(resp_json, dict): return False, 响应非 JSON 格式 code resp_json.get(code) msg resp_json.get(msg, ) if code 200: if 成功 in msg or 已选 in msg: return True, msg elif 名额已满 in msg: return False, 名额已满 else: return False, f未知成功消息: {msg} elif code in [400, 500]: return False, msg else: return False, f服务端错误 code{code} # 使用 # success, detail is_selection_successful(result) # if success: # print(f✅ {detail}) # break3.3 幂等性控制为什么必须记录已尝试课程 ID 组合教务系统对同一courseIdclassId组合的重复提交有严格限制首次提交返回200 选课成功第二次提交即使间隔 10 秒返回200 您已选过该课程第三次起可能触发风控返回429 Too Many Requests。因此脚本必须维护一个本地缓存记录本次运行中已尝试过的(courseId, classId)元组。Python 中可用set实现attempted_pairs set() def can_attempt(course_id: str, class_id: str) - bool: pair (course_id, class_id) if pair in attempted_pairs: return False attempted_pairs.add(pair) return True # 在选课前调用 # if can_attempt(target[course_id], target[class_id]): # result submit_selection(...)提示此缓存仅作用于单次脚本执行周期。若需跨天持久化可改用 SQLite 存储course_id,class_id,attempt_time三字段并加索引加速查询。4. 避坑教务系统交互中 4 个真实踩坑记录与现场排查方法4.1 现象登录成功后访问课程列表页返回 302 重定向到登录页原因服务端校验 Referer 头而 Requests 默认不发送 Referer。抓包发现浏览器访问/student/course/list时Request Headers 包含Referer: https://jwxt.example.edu.cn/login但脚本未设置。解决在登录成功后手动为 Session 设置默认 Referersession.headers.update({Referer: https://jwxt.example.edu.cn/login})并在每次访问课程页前确认resp session.get(https://jwxt.example.edu.cn/student/course/list) if resp.url.endswith(/login): # 检查是否被重定向 raise RuntimeError(Referer 校验失败会话失效)4.2 现象课程列表解析出 0 门课但浏览器打开页面明明有数据原因教务系统在检测到非浏览器 User-Agent 时返回精简版 HTML仅含登录框而真实课程表格被 JS 动态注入。抓包对比发现浏览器请求带User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...脚本默认 UA 为python-requests/2.x。解决强制设置符合主流浏览器的 UA 字符串session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 })血泪经验UA 字符串必须与当前 Chrome 版本匹配否则部分系统返回 403建议从浏览器开发者工具 Network 面板复制真实 UA。4.3 现象选课 POST 返回 400msg 为“参数错误”但 courseId/classId 确认无误原因courseId和classId值含 URL 不安全字符如空格、中文、括号未做urllib.parse.quote()编码。教务系统后端用request.getParameter()获取参数若值含空格则截断。解决对所有 form-data 值进行 URL 编码from urllib.parse import quote data { courseId: quote(course_id), classId: quote(class_id), token: quote(csrf_token) }注意quote()默认编码空格为%20符合 HTTP 规范若用urlencode()整体编码会导致号也被编码服务端无法解析。4.4 现象脚本运行 2 分钟后突然所有请求返回 401 Unauthorized原因教务系统 Session 超时时间为 15 分钟但脚本未实现会话保鲜keep-alive。长时间无请求服务端销毁 Session后续请求因 Cookie 失效被拒。解决在主循环中插入保活请求每 10 分钟 GET 一次首页import time last_keepalive time.time() while not selected: if time.time() - last_keepalive 600: # 10分钟 session.get(https://jwxt.example.edu.cn/student/home) last_keepalive time.time() # ... 执行选课逻辑 time.sleep(0.5) # 避免请求过密关键点保活请求必须使用同一 Session且 URL 为教务系统首页如/student/home其他路径可能不刷新 Session 生命周期。5. 高并发下的节奏控制如何用指数退避 随机抖动规避服务端限频5.1 教务系统真实的限频策略与探测方法某高校教务系统在压力测试中暴露以下行为单 IP 每秒请求超过 3 次第 4 次开始返回429 Too Many Requests若连续 5 次 429则封禁该 IP 30 秒。更隐蔽的是服务端对“相同 Referer 相同 User-Agent 相同 Cookie”的请求流做滑动窗口统计即使你每秒只发 1 次若 10 秒内累计 10 次仍可能触发限频。因此硬编码time.sleep(1)是最差策略——它制造了完美节拍极易被识别。正确做法是引入随机抖动Jitter和指数退避Exponential Backoff。5.2 指数退避算法实现从 0.5 秒到 8 秒的智能等待当遇到429或500时不应固定等待 1 秒而应按失败次数指数增长等待时间并叠加随机因子。Python 实现如下import random import time def exponential_backoff(attempt: int) - float: 计算第 attempt 次失败后的等待秒数 base 0.5 # 基础等待时间秒 cap 8.0 # 最大等待时间秒 # 指数增长0.5, 1.0, 2.0, 4.0, 8.0... wait min(base * (2 ** (attempt - 1)), cap) # 加入 0~20% 随机抖动打破请求节拍 jitter random.uniform(0, 0.2 * wait) return wait jitter # 使用示例 attempts 0 while attempts 5: result submit_selection(session, cid, clid, token) if is_selection_successful(result)[0]: break attempts 1 if attempts 5: wait_time exponential_backoff(attempts) print(f❌ 第 {attempts} 次失败等待 {wait_time:.2f} 秒后重试...) time.sleep(wait_time)参数说明base0.5保证首次失败后快速重试避免错过瞬时名额cap8.0防止等待过久jitter用uniform(0, 0.2*wait)生成 0~20% 的随机偏移使请求时间戳呈泊松分布大幅降低被识别为机器流量的概率。5.3 随机化请求间隔在“成功”路径中也加入不确定性即使选课未失败持续以固定间隔如每 0.8 秒请求也会被服务端标记为异常。应在每次成功请求后插入一个随机延迟def random_delay(min_sec: float 0.3, max_sec: float 1.2) - None: delay random.uniform(min_sec, max_sec) time.sleep(delay) # 主循环中 while not selected: result submit_selection(...) success, msg is_selection_successful(result) if success: print(f✅ {msg}) selected True else: print(f⚠️ {msg}) # 指数退避已处理失败情况 # 无论成功失败都执行随机延迟模拟人类操作停顿 random_delay(0.3, 1.2)关键洞察人类点击页面后不会精确卡在 0.8 秒整再次操作。浏览器渲染、视线移动、手指悬停都会引入 300~1200ms 的自然延迟。这个random_delay不是“为了随机而随机”而是对真实用户行为的建模。5.4 完整主循环融合登录、监控、选课、退避的生产级结构以下是可直接运行的主函数骨架整合前述所有要点def main(username: str, password: str, target_course_name: str): session requests.Session() # 1. 设置基础 headers session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36..., Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8 }) # 2. 登录 login_success do_login(session, username, password) if not login_success: print(❌ 登录失败) return # 3. 初始化变量 csrf_token extract_csrf_token(session) attempted set() last_keepalive time.time() attempts 0 # 4. 主循环 while attempts 20: # 最大尝试次数 # 保活 if time.time() - last_keepalive 600: session.get(https://jwxt.example.edu.cn/student/home) last_keepalive time.time() # 获取课程列表 try: html session.get(https://jwxt.example.edu.cn/student/course/list).text except Exception as e: print(f⚠️ 获取课程列表异常: {e}) time.sleep(exponential_backoff(attempts)) attempts 1 continue courses parse_course_list(html) target next((c for c in courses if c[name] target_course_name and c[capacity] 0), None) if target and (target[course_id], target[class_id]) not in attempted: # 尝试选课 result submit_selection(session, target[course_id], target[class_id], csrf_token) success, msg is_selection_successful(result) if success: print(f {msg}) return else: print(f {msg}) attempted.add((target[course_id], target[class_id])) attempts 1 else: print(⏳ 未找到可选课程等待 1 秒后刷新...) attempts 1 # 随机延迟 random_delay(0.3, 1.2) print(❌ 达到最大尝试次数退出) # 调用 # main(20230001, 123456, 高等数学A(1))从那以后我每次写教务系统交互脚本都强制走一遍「抓包验证 → 模拟请求 → 解析响应 → 加入退避」四步闭环。不是为了追求“能跑”而是确保每行代码都在回答一个问题“如果这是浏览器发出的请求服务端会怎么处理它”——因为所有玄学最后都落在 HTTP 状态码和响应体里。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑