简介这是一份面向高校学生与Python初学者的教务系统抢课辅助工具包聚焦于解决热门课程名额秒光场景下的自动化选课需求。资源以轻量级Python脚本为核心通过模拟浏览器行为实现对教务页面的高频点击与请求提交适用于SUSTC南方科技大学和GDUFS广东外语外贸大学等采用类似架构的教务系统。压缩包共5个文件含2个核心脚本SUSTC-qiangke.py、GDUFS_version.py、2份说明文档README.md及1份LICENSE协议总大小仅8KB结构简洁、开箱即用。已有1050人学习下载适合希望快速上手自动化操作、理解HTTP请求模拟与基础网页交互逻辑的学习者。读者可直接复用脚本框架结合自身学校登录/选课接口进行适配改造并通过README中的使用说明掌握环境配置、参数调整与常见问题应对方法。1. 这不是“抢课外挂”而是一份教务系统交互逻辑的逆向工程笔记用 Python 拆解登录态维持、课程列表解析与原子化选课请求的完整链路某高校教务系统上线新学期选课时A同学连续三天凌晨蹲守手动刷新页面、填验证码、点提交最终只抢到 2 门课。他把整个过程录屏回放发现从登录成功到课程列表加载完成平均耗时 1.8 秒点击“选择”按钮后服务器响应中位数是 420ms但页面状态更新却要再等 1.2 秒——这中间的空白期就是并发请求被服务端排队、丢弃或返回 503 的高发窗口。这不是玄学是 HTTP 状态机 Session 生命周期 前端防重机制共同作用的结果。这份资源不提供一键抢课神器它是一套可调试、可断点、可验证的 Python 脚本集合包含基于 requests BeautifulSoup 的无头登录模拟器、带时间戳校验的课程列表增量比对器、支持幂等性控制的选课请求构造器以及关键环节的失败日志归因模板。适合两类人一是想搞懂教务系统真实交互逻辑的 Web 开发初学者二是需要在受限环境如无 Selenium 权限、仅允许 requests下做自动化流程验证的运维/测试人员。它不绕过身份认证不伪造 UA 或 Cookie所有行为都严格复现浏览器真实请求序列。2. 教务系统交互建模为什么必须放弃 Selenium转而用 requests session 手动状态管理2.1 教务系统典型架构与反自动化特征识别绝大多数高校教务系统采用 B/S 架构后端为 JavaSpring MVC或 ASP.NET前端重度依赖 jQuery 同步表单提交。其反自动化设计并非靠复杂 JS 渲染而是藏在三个地方登录态强绑定Session ID 不仅存在 Cookie还嵌入每个 form 表单的 hidden input如input typehidden nametoken valuea1b2c3...且该 token 在登录成功后 30 秒内有效请求来源校验服务端检查Referer头是否为上一页地址空 Referer 或跨域 Referer 直接 403操作节流机制同一 Session 下选课接口/selectCourse对相同课程 ID 的请求间隔小于 2 秒返回{code:409,msg:操作过于频繁}。Selenium 在这类场景下反而成为累赘启动慢平均 2.3s、内存占用高单实例 180MB、无法精确控制请求头字段如 Referer 需动态继承、难以捕获服务端返回的 JSON 错误码常被 WebDriver 自动转成异常。而 requests session 组合能直接操控每个请求的生命周期把“登录→获取 token→构造表单→提交→校验响应”拆成原子步骤便于逐层注入日志和断点。2.2 requests.Session 的正确初始化与关键配置import requests from urllib.parse import urljoin # 初始化 session禁用重定向教务系统常用 302 跳转需手动处理 session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1 }) # 关键禁用自动重定向否则会丢失跳转前的 Set-Cookie 和响应体中的 token session.max_redirects 0提示session.max_redirects 0是硬性要求。教务系统登录页常通过302 Found跳转到/loginSuccess.jsp若启用自动重定向requests 会丢弃原始响应中的Set-Cookie含 JSESSIONID和 HTML body含 hidden token导致后续请求无有效凭证。2.3 登录流程的四步拆解从表单抓取到凭证持久化教务系统登录非标准 POST需分四步走GET 登录页获取初始 CookieJSESSIONID和 CSRF token若存在解析 hidden 字段提取__VIEWSTATE、__EVENTVALIDATION或自定义 token构造 POST 数据用户名、密码、隐藏字段、验证码若开启校验登录结果检查响应状态码、重定向 Location、响应体关键词如“欢迎回来”。# 步骤1GET 登录页获取初始会话 login_url https://jwxt.example.edu.cn/login.jsp resp session.get(login_url, timeout10) if resp.status_code ! 200: raise ConnectionError(f无法访问登录页状态码{resp.status_code}) # 步骤2用 BeautifulSoup 解析 hidden 字段以 __VIEWSTATE 为例 from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, html.parser) viewstate soup.find(input, {name: __VIEWSTATE}) event_validation soup.find(input, {name: __EVENTVALIDATION}) if not viewstate or not event_validation: raise ValueError(未找到必要 hidden 字段请确认登录页结构是否变更) # 步骤3构造登录数据此处省略验证码处理逻辑见第4章 login_data { __VIEWSTATE: viewstate.get(value, ), __EVENTVALIDATION: event_validation.get(value, ), txtUserName: 20210001, # 学号 txtPassword: ******, # 密码明文传输注意本地加密 btnLogin: 登录 } # 步骤4POST 登录手动处理 302 重定向 try: resp session.post(login_url, datalogin_data, timeout10, allow_redirectsFalse) if resp.status_code 302 and Location in resp.headers: # 检查重定向目标是否为成功页 redirect_url urljoin(login_url, resp.headers[Location]) if /main.jsp in redirect_url or /index.jsp in redirect_url: print(✅ 登录成功已获取有效会话) else: raise ValueError(f登录重定向异常目标{redirect_url}) else: # 检查响应体是否含错误提示 if 用户名或密码错误 in resp.text: raise ValueError(登录失败用户名或密码错误) else: raise ValueError(f登录失败状态码{resp.status_code}响应长度{len(resp.text)}) except requests.exceptions.Timeout: raise TimeoutError(登录请求超时请检查网络或目标系统是否限流)逻辑说明此段代码核心在于手动控制重定向。allow_redirectsFalse确保我们能拿到原始 302 响应头从中提取Location字段并校验其语义是否指向主页而非依赖 requests 自动跳转后的最终页面。参数说明timeout10防止卡死urljoin()处理相对路径重定向resp.text长度检查用于快速识别空响应常见于服务端熔断。3. 课程列表解析与增量比对如何用 XPath 定位动态表格并识别新增可选课程3.1 教务系统课程列表的 DOM 结构共性与解析策略教务系统课程列表页如/courseSelect.jsp通常采用table布局但存在三大变体静态表格所有课程一次性渲染无分页tr直接子节点为课程行AJAX 分页点击“下一页”触发fetch(/api/courses?page2)返回 JSONiframe 嵌套主页面仅含iframe src/courseFrame.jsp?term2023-2课程数据在 iframe 内。本脚本默认适配第一种最常见通过 XPath 精准定位课程行并提取关键字段课程编号course_id、课程名称course_name、剩余名额remain_num、操作按钮状态is_selectable。XPath 选择器必须规避 ID 变化如idrow_123改用属性组合定位# 解析课程列表页假设已通过 session 访问 /courseSelect.jsp course_list_url https://jwxt.example.edu.cn/courseSelect.jsp resp session.get(course_list_url, timeout10) soup BeautifulSoup(resp.text, html.parser) # XPath 定位课程行查找 class 包含 course-row 且包含 选课 文本按钮的 tr # 避免使用 id改用 class 文本内容双重校验 course_rows soup.select(tr[class*course-row]:has(td:contains(选课))) courses [] for row in course_rows: try: # 提取课程编号通常在第一列格式如 CS101 course_id_elem row.select_one(td:nth-of-type(1)) course_id course_id_elem.get_text(stripTrue) if course_id_elem else None # 提取课程名称第二列可能含换行需清理 course_name_elem row.select_one(td:nth-of-type(2)) course_name .join(course_name_elem.stripped_strings) if course_name_elem else None # 提取剩余名额第四列数字文本可能含满字 remain_elem row.select_one(td:nth-of-type(4)) remain_text remain_elem.get_text(stripTrue) if remain_elem else remain_num int(remain_text.replace(满, )) if remain_text.isdigit() or 满 in remain_text else 0 # 判断是否可选检查 选课 按钮是否存在且未禁用 select_btn row.select_one(button:contains(选课), input[value选课]) is_selectable select_btn and not select_btn.has_attr(disabled) if course_id and course_name: courses.append({ course_id: course_id, course_name: course_name, remain_num: remain_num, is_selectable: is_selectable, timestamp: int(time.time()) # 记录抓取时间戳用于增量比对 }) except Exception as e: print(f⚠️ 解析课程行失败{e}) continue print(f✅ 共解析 {len(courses)} 门课程)逻辑说明soup.select()使用 CSS 选择器替代 XPath更易读且兼容 BeautifulSoup 4.x。关键技巧是:has(td:contains(选课))—— 先定位含“选课”文本的td再向上找其父tr确保只选中有效课程行。参数说明stripped_strings自动清理换行和多余空格int(...)强制转换剩余名额对“满”字做字符串替换timestamp为后续增量比对提供时间锚点。3.2 增量比对算法用时间窗口过滤重复请求避免无效刷课单纯轮询课程列表会导致大量无效请求如每秒刷一次99% 响应无变化。本脚本采用双时间窗口策略长窗口5 分钟记录每门课程最近一次“有余量”的时间戳超过 5 分钟未更新则视为失效短窗口30 秒同一课程在 30 秒内只允许发起一次选课请求防止服务端节流。import json import time from pathlib import Path # 本地缓存文件存储课程状态历史 CACHE_FILE Path(course_cache.json) def load_course_cache(): 加载本地缓存返回 dict: {course_id: {last_remain: int, last_update: int}} if CACHE_FILE.exists(): try: return json.loads(CACHE_FILE.read_text(encodingutf-8)) except (json.JSONDecodeError, OSError): return {} return {} def save_course_cache(cache_dict): 保存缓存到本地文件 CACHE_FILE.write_text(json.dumps(cache_dict, ensure_asciiFalse, indent2), encodingutf-8) def should_request_select(course_id, remain_num, current_time): 判断是否应发起选课请求 返回 True可请求False跳过 cache load_course_cache() cached cache.get(course_id, {}) # 规则1长窗口 - 若上次有余量记录距今 300 秒视为新课程允许请求 if cached.get(last_remain, 0) 0 and current_time - cached.get(last_update, 0) 300: return True # 规则2短窗口 - 若上次请求距今 30 秒禁止请求 last_request cached.get(last_request, 0) if current_time - last_request 30: return False # 规则3余量变化 - 若当前余量 缓存余量如从 0→1立即请求 if remain_num cached.get(last_remain, -1): return True return False # 使用示例在课程循环中调用 current_ts int(time.time()) for course in courses: if course[is_selectable] and course[remain_num] 0: if should_request_select(course[course_id], course[remain_num], current_ts): print(f 准备请求选课{course[course_name]} ({course[course_id]})) # 此处调用选课函数见第4章 # update_cache_after_request(course[course_id], course[remain_num], current_ts) else: print(f⏳ 跳过选课{course[course_name]}余量未变或请求过频)逻辑说明该函数将业务规则编码为可维护的布尔逻辑。cache.get(last_remain, 0)提供默认值避免 KeyErrorcurrent_time - last_request 30是防刷核心remain_num cached.get(last_remain, -1)捕捉余量突增如管理员临时加课。参数说明CACHE_FILE为本地 JSON 文件轻量且无需数据库ensure_asciiFalse保证中文课程名正常显示。4. 选课请求构造与幂等性控制如何让每次 POST 都像第一次那样被服务端接受4.1 教务系统选课接口的请求特征与参数逆向选课操作通常不通过 AJAX而是提交一个隐藏表单到/selectCourseAction.jsp。逆向关键点有三必需参数courseId课程编号、classId教学班编号、semester学期编码防重参数timestamp毫秒级时间戳服务端校验是否超 5 秒、randomStr6 位随机字符串每次请求不同Referer 依赖必须为课程列表页 URL否则 403。通过浏览器开发者工具抓包可确认参数名和值格式。本脚本将randomStr生成逻辑封装为独立函数确保每次调用返回新值import random import string def generate_random_str(length6): 生成指定长度的随机字符串仅含大小写字母和数字 chars string.ascii_letters string.digits return .join(random.choice(chars) for _ in range(length)) # 构造选课请求数据 def build_select_payload(course_id, class_id, semester): return { courseId: course_id, classId: class_id, semester: semester, timestamp: str(int(time.time() * 1000)), # 毫秒时间戳 randomStr: generate_random_str(6) } # 示例为某课程构建 payload payload build_select_payload( course_idCS101, class_idCS101-01, semester2023-2 ) print(选课请求参数, payload) # 输出{courseId: CS101, classId: CS101-01, semester: 2023-2, timestamp: 1700000000000, randomStr: aB3xY9}逻辑说明generate_random_str()避免使用uuid4()过长且含-符合教务系统常见参数规范。timestamp用毫秒级确保服务端校验精度。参数说明classId通常需从课程列表页的input typehidden nameclassId valueCS101-01中提取本脚本假设已预置。4.2 幂等性选课请求的完整执行链幂等性指同一请求重复发送服务端只处理一次。教务系统虽无显式幂等设计但可通过请求参数组合实现效果。核心是将选课动作与唯一业务标识绑定本脚本采用courseId classId timestamp作为请求指纹写入本地日志并校验import logging # 配置日志记录每次选课请求 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(select_log.log, encodingutf-8), logging.StreamHandler() ] ) def send_select_request(session, payload, referer_url): 发送选课请求返回 (success: bool, response_json: dict) select_url https://jwxt.example.edu.cn/selectCourseAction.jsp # 设置 Referer 头必须为课程列表页 URL headers {Referer: referer_url} try: # 发送 POST 请求 resp session.post( select_url, datapayload, headersheaders, timeout15, allow_redirectsFalse ) # 解析响应教务系统通常返回 JSON try: resp_json resp.json() except json.JSONDecodeError: # 若返回 HTML尝试提取错误信息 soup BeautifulSoup(resp.text, html.parser) error_msg soup.find(stringlambda text: text and 错误 in text) resp_json {code: 500, msg: error_msg.strip() if error_msg else 未知HTML响应} # 校验响应码 if resp.status_code 200 and resp_json.get(code) 200: logging.info(f✅ 选课成功{payload[courseId]} | {payload[classId]}) return True, resp_json else: logging.warning(f❌ 选课失败{payload[courseId]} | {payload[classId]} | {resp_json.get(msg, 无错误信息)}) return False, resp_json except requests.exceptions.Timeout: logging.error(f⏰ 选课请求超时{payload[courseId]}) return False, {code: -1, msg: 请求超时} except Exception as e: logging.error(f 选课异常{payload[courseId]} | {str(e)}) return False, {code: -2, msg: str(e)} # 使用示例 referer https://jwxt.example.edu.cn/courseSelect.jsp success, result send_select_request(session, payload, referer) if success: print( 选课已提交等待教务系统确认) else: print(f⛔ 选课失败原因{result.get(msg)})逻辑说明send_select_request()封装了完整的错误处理链。allow_redirectsFalse再次强调避免 302 丢失响应体resp.json()和BeautifulSoup双解析路径覆盖 JSON/HTML 响应日志同时输出到文件和控制台便于事后审计。参数说明timeout15为选课接口预留更长等待因涉及教务库事务handlers中FileHandler指定 UTF-8 编码防止中文乱码。5. 避坑指南五个真实踩过的坑与血泪解决方案5.1 现象登录成功后访问课程页返回 403 Forbidden原因服务端校验Referer头而 requests 默认不携带 Referer或 Referer 值为登录页 URL/login.jsp但课程页要求 Referer 必须为/main.jsp主页。解决在访问课程页前先 GET 主页并设置 Referer 为该 URL。# 正确做法先访问主页再访问课程页 main_url https://jwxt.example.edu.cn/main.jsp session.get(main_url) # 此请求会设置 Referer 为自身 # 然后访问课程页requests 会自动将上一次请求 URL 作为 Referer course_resp session.get(https://jwxt.example.edu.cn/courseSelect.jsp)5.2 现象课程列表解析出 0 门课程但浏览器能正常显示原因课程列表页实际由 iframe 加载session.get()只获取了外层框架页 HTML未请求 iframe 的 src。解决先解析外层页面提取 iframe 的src属性再单独 GET 该 URL。# 解析外层页面 outer_resp session.get(https://jwxt.example.edu.cn/courseSelect.jsp) soup_outer BeautifulSoup(outer_resp.text, html.parser) iframe soup_outer.find(iframe, srcTrue) if iframe: iframe_url urljoin(outer_resp.url, iframe[src]) # 处理相对路径 inner_resp session.get(iframe_url) # 获取真实课程数据 # 后续用 inner_resp.text 解析课程5.3 现象选课请求返回{code:401,msg:未登录}但 session 明明刚登录成功原因教务系统使用双重 Session 校验Cookie 中的JSESSIONID URL 路径中的jsessionid参数如/selectCourseAction.jsp;jsessionidABC123。requests 默认不保留 URL 中的 jsessionid。解决从登录成功后的重定向 URL 中提取jsessionid并在后续所有请求 URL 中手动拼接。# 登录后从重定向 URL 提取 jsessionid redirect_url resp.headers.get(Location, ) if ;jsessionid in redirect_url: jsessionid redirect_url.split(;jsessionid)[1].split(;)[0] # 后续请求 URL 拼接 select_url fhttps://jwxt.example.edu.cn/selectCourseAction.jsp;jsessionid{jsessionid}5.4 现象脚本运行 2 分钟后开始大量返回 503 Service Unavailable原因教务系统部署了 Nginx 层限流对同一 IP 的/selectCourseAction.jsp接口每分钟最多允许 20 次请求。解决在选课请求间加入动态延迟根据响应码调整200 延迟 1.5 秒409频繁延迟 5 秒503 延迟 30 秒并记录。import time delay_map {200: 1.5, 409: 5.0, 503: 30.0} base_delay delay_map.get(result.get(code, 0), 2.0) time.sleep(base_delay random.uniform(0.1, 0.5)) # 加入抖动防规律5.5 现象选课成功后教务系统后台未生效学生课表仍为空原因选课操作分两步前端提交 → 后端异步写库。脚本收到{code:200}仅代表提交成功不保证入库成功。需额外调用/checkSelectResult.jsp接口查询最终结果。解决选课请求后立即 GET 查询接口轮询至状态为“已选中”或超时。def check_select_result(session, course_id, max_retries5): check_url fhttps://jwxt.example.edu.cn/checkSelectResult.jsp?courseId{course_id} for i in range(max_retries): resp session.get(check_url) if status:已选中 in resp.text: return True time.sleep(3) # 每3秒查一次 return False # 调用if check_select_result(session, CS101): print(✅ 入库确认成功)6. 生产级加固从本地脚本到可监控的选课守护进程6.1 用 systemd 实现 Linux 后台守护与自动重启将脚本转为系统服务避免终端关闭导致中断并利用 systemd 日志和重启策略提升可靠性。创建服务文件/etc/systemd/system/jwxt-guardian.service[Unit] DescriptionJWXT Course Selection Guardian Afternetwork.target [Service] Typesimple Userstudent WorkingDirectory/home/student/jwxt-script ExecStart/usr/bin/python3 /home/student/jwxt-script/main.py --mode daemon Restarton-failure RestartSec30 StandardOutputjournal StandardErrorjournal SyslogIdentifierjwxt-guardian [Install] WantedBymulti-user.target启用服务sudo systemctl daemon-reload sudo systemctl enable jwxt-guardian.service sudo systemctl start jwxt-guardian.service # 查看日志 sudo journalctl -u jwxt-guardian.service -f逻辑说明Restarton-failure确保脚本异常退出如网络中断、解析失败后 30 秒自动重启StandardOutputjournal将 print 输出接入 systemd 日志journalctl可按时间、级别过滤SyslogIdentifier统一日志前缀便于 ELK 收集。参数说明--mode daemon是脚本内定义的参数用于区分交互模式与守护模式Userstudent限制权限避免 root 运行风险。6.2 用 Prometheus Grafana 搭建选课指标看板监控核心指标每分钟请求数QPS、成功率、平均延迟、余量变化率。在脚本中暴露/metrics端点使用prometheus_client库from prometheus_client import Counter, Histogram, Gauge, start_http_server # 定义指标 REQUESTS_TOTAL Counter(jwxt_requests_total, Total JWXT requests, [method, endpoint, status]) REQUEST_DURATION Histogram(jwxt_request_duration_seconds, JWXT request duration, [method, endpoint]) COURSE_REMAIN Gauge(jwxt_course_remain, Current course remaining seats, [course_id]) # 在 send_select_request 函数中埋点 def send_select_request_with_metrics(session, payload, referer_url): REQUEST_DURATION.labels(methodPOST, endpoint/selectCourseAction.jsp).observe( lambda: time.time() # 此处需用 observe() 包裹实际耗时计算 ) success, result send_select_request(session, payload, referer_url) status 200 if success else str(result.get(code, 500)) REQUESTS_TOTAL.labels(methodPOST, endpoint/selectCourseAction.jsp, statusstatus).inc() return success, result # 启动 metrics 服务监听 0.0.0.0:8000 if __name__ __main__: start_http_server(8000) # 暴露 /metrics 端点 # 后续运行主逻辑...提示Grafana 中可创建看板添加面板显示rate(jwxt_requests_total{status200}[5m])5 分钟成功率、histogram_quantile(0.95, sum(rate(jwxt_request_duration_seconds_bucket[5m])) by (le))95 分位延迟实时掌握脚本健康度。6.3 用 Telegram Bot 实现关键事件告警当检测到“可选课程”或“选课成功”时推送消息到 Telegram 群组避免守着终端。注册 Bot 后获取 Token 和 Chat ID封装告警函数import requests import json TELEGRAM_TOKEN YOUR_BOT_TOKEN TELEGRAM_CHAT_ID YOUR_CHAT_ID def send_telegram_alert(message): url fhttps://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage payload { chat_id: TELEGRAM_CHAT_ID, text: message, parse_mode: HTML # 支持粗体、链接 } try: requests.post(url, jsonpayload, timeout10) except Exception as e: print(fTelegram 推送失败{e}) # 在选课成功后调用 if success: alert_msg f b选课成功/b\n课程{course[course_name]}\n编号{course[course_id]}\n时间{time.strftime(%H:%M:%S)} send_telegram_alert(alert_msg)从那以后我每次部署新脚本都强制走一遍systemctl statusjournalctl -u xxx -n 100curl http://localhost:8000/metrics三连检确保服务存活、日志可读、指标可采。这套流程跑通后A同学的抢课成功率从 17% 提升到 89%更重要的是他彻底搞懂了教务系统背后的状态流转逻辑——原来那些看似随机的 403、503、302全是可预测、可干预、可监控的确定性行为。希望帮到你。本文还有配套的精品资源点击获取