1. 从抢课难说起一个自动化选课脚本到底在解决什么问题每到选课季教务系统的服务器就像早高峰的地铁站——几千人同时挤在同一个入口页面转圈、验证码刷不出来、好不容易点进去发现名额已满。这种场景下手动选课拼的其实不是网速而是运气和手速的叠加。我见过不少同学提前十分钟就守在电脑前结果开放选课的那一秒页面直接卡死等刷新出来想选的课早就没了。这个项目的核心思路很直接用程序代替人工去轮询教务系统的选课接口一旦检测到目标课程有名额立刻发起选课请求。听起来简单但真正动手做的时候有几个关键问题需要想清楚——怎么模拟登录、怎么维持会话、怎么判断课程是否有余量、请求频率怎么控制才不会被系统拦截。这些问题的答案构成了整个脚本的技术骨架。需要先说明的是这类工具的使用有一个基本前提仅用于个人学习和技术研究不要用来恶意抢占资源或干扰教务系统正常运行。选课系统是公共资源任何自动化操作都应该控制在合理范围内比如只针对自己需要的课程、设置合理的请求间隔、在选课结束后及时停止脚本。这是技术使用的边界也是对自己和他人负责。这篇文章会从零开始拆解一个自动化选课脚本的完整实现过程包括登录流程分析、会话维持、课程余量检测、请求调度策略以及实际运行中遇到的各种坑。代码部分用Python实现涉及requests、BeautifulSoup等常用库。适合有Python基础、想了解Web自动化实战的同学参考。2. 登录环节会话维持与身份验证的完整链路2.1 为什么不能简单用requests.post模拟登录很多人第一反应是打开浏览器的开发者工具找到登录请求把URL和表单参数复制下来用requests.post发过去就完事了。实际操作会发现登录请求确实返回了200但后续访问选课页面时却被重定向回登录页。原因在于教务系统通常依赖Cookie来维持会话状态而简单的post请求虽然能拿到响应但如果没有正确处理Set-Cookie头、没有在后续请求中携带Cookie服务器就不认你这个已登录身份。更麻烦的是很多教务系统在登录流程中加入了动态参数。比如某个隐藏字段的值是在页面加载时由JavaScript生成的或者登录请求需要携带一个时间戳签名。这种情况下直接复制表单参数就会失败因为那个动态值已经过期了。2.2 用requests.Session管理Cookie正确的做法是使用requests.Session()对象。Session会自动处理Cookie的存储和发送你只需要在同一个Session实例中依次发起登录请求和后续请求Cookie就会自动带上。import requests from bs4 import BeautifulSoup session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: http://example-jwxt.edu.cn/login }) # 第一步获取登录页面提取隐藏字段 login_page session.get(http://example-jwxt.edu.cn/login) soup BeautifulSoup(login_page.text, html.parser) # 提取所有hidden类型的input hidden_fields {} for inp in soup.find_all(input, typehidden): hidden_fields[inp.get(name)] inp.get(value, ) # 第二步构造登录表单 login_data { username: your_student_id, password: your_password, **hidden_fields } # 第三步发起登录请求 resp session.post(http://example-jwxt.edu.cn/login, datalogin_data)这里的关键点是先GET登录页拿到隐藏字段再POST登录。隐藏字段可能包含CSRF token、时间戳等服务器校验用的参数漏掉任何一个都可能导致登录失败。2.3 登录成功的判断标准登录请求返回200不代表登录成功。教务系统常见的做法是登录失败时返回200但页面中包含用户名或密码错误的提示登录成功则返回302重定向到首页。所以判断登录是否成功不能只看状态码要综合检查响应内容或重定向行为。# 方法一检查是否重定向 if resp.url ! http://example-jwxt.edu.cn/login: print(登录成功已跳转) else: # 方法二检查页面内容 if 密码错误 in resp.text or 登录失败 in resp.text: print(登录失败) else: print(可能登录成功需进一步验证)我个人的习惯是登录后再访问一个需要权限的页面比如个人信息页如果能正常返回数据就确认登录状态有效。这比单纯检查登录响应更可靠。2.4 验证码的处理思路如果教务系统登录需要验证码事情会复杂一些。常见的处理方式有几种一是手动输入脚本暂停等待用户在控制台输入验证码二是使用OCR库如ddddocr自动识别简单验证码三是如果验证码是前端生成的简单算术题可以直接解析页面中的题目并计算结果。手动输入的方式最稳妥但不够自动化OCR识别的方式需要额外安装依赖且识别率不一定100%。实际项目中我倾向于先用手动输入跑通整个流程确认选课逻辑没问题后再考虑是否加入自动识别。毕竟选课脚本的核心价值在于自动轮询选课登录环节偶尔手动介入一次是可以接受的。3. 课程余量检测从页面解析到接口轮询3.1 找到真正的选课接口登录成功后下一步是找到查询课程余量和发起选课的接口。有两种思路一是模拟浏览器行为请求选课页面然后解析HTML二是直接调用后端API接口。前者更直观但效率低后者效率高但需要分析接口参数。用浏览器开发者工具的Network面板在选课页面点击查询按钮观察发出的请求。通常会看到一个返回JSON数据的接口里面包含课程名称、教师、余量等信息。直接调用这个接口比解析HTML页面要高效得多。# 查询课程余量的接口调用示例 def check_course(session, course_id): url fhttp://example-jwxt.edu.cn/api/course/status params { courseId: course_id, term: 2024-2025-2 } resp session.get(url, paramsparams) data resp.json() return data3.2 解析课程余量数据接口返回的数据结构因学校而异但通常包含类似这样的字段{ courseName: 数据结构, teacher: 某教师, capacity: 60, enrolled: 58, available: 2, status: 可选 }关键字段是available余量或enrolled与capacity的差值。当available 0时说明还有名额可以发起选课。有些学校的接口返回的是HTML片段而非JSON这时候就需要用BeautifulSoup解析。解析时要注意页面结构可能因课程类型不同而变化比如必修课和选修课的表格结构可能不一样。稳妥的做法是先打印出返回内容观察实际结构后再写解析逻辑。3.3 轮询策略频率控制与随机间隔轮询的核心矛盾是频率太低可能抢不到课频率太高可能被系统封禁。我的经验是请求间隔设置在3到8秒之间比较合理并且加入随机抖动避免固定间隔被识别为机器行为。import time import random def poll_course(session, course_id, interval_range(3, 8)): while True: try: data check_course(session, course_id) if data.get(available, 0) 0: print(f检测到余量{data[available]}准备选课) return True else: print(f当前无余量等待中...) except Exception as e: print(f请求异常{e}) wait random.uniform(*interval_range) time.sleep(wait)这里有几个细节值得注意。第一异常处理不能少网络波动或接口临时不可用都会导致请求失败脚本不能因为一次异常就崩溃。第二打印日志要清晰方便观察运行状态。第三如果连续多次请求都返回相同结果可以适当延长间隔减少对服务器的压力。3.4 多课程并行检测的取舍如果需要同时抢多门课串行轮询的效率会很低——每门课等3到8秒轮一圈下来可能要半分钟。这时候可以考虑用多线程每个线程负责一门课的检测。但线程数不宜过多建议控制在3到5个以内否则请求频率叠加起来容易触发系统的频率限制。import threading def start_polling(session, course_ids): threads [] for cid in course_ids: t threading.Thread(targetpoll_course, args(session, cid)) t.start() threads.append(t) for t in threads: t.join()多线程共享同一个Session对象时要注意线程安全问题。requests的Session在并发场景下可能出问题稳妥的做法是每个线程创建独立的Session各自维护登录状态。虽然会多几次登录请求但稳定性更有保障。4. 发起选课请求构造与结果验证4.1 选课请求的参数分析检测到余量后下一步是发起选课请求。选课接口通常需要携带课程ID、教学班ID、学期信息等参数。这些参数可以从之前的查询接口响应中获取也可以在选课页面的表单中找到。def select_course(session, course_id, class_id, term): url http://example-jwxt.edu.cn/api/course/select data { courseId: course_id, classId: class_id, term: term, action: select } resp session.post(url, datadata) return resp.json()有些学校的选课接口是GET请求参数直接拼在URL里有些是POST参数在请求体中。这个需要根据实际抓包结果来确定不能想当然。4.2 选课结果的判断与重试选课请求发出后服务器会返回结果。常见的返回状态包括选课成功、名额已满、时间冲突、已选过该课程等。脚本需要根据返回信息判断是否成功如果失败是因为名额已满说明在请求发出的瞬间被别人抢走了需要重新进入轮询流程。def try_select(session, course_info): result select_course(session, course_info[courseId], course_info[classId], course_info[term]) if result.get(code) 0: print(选课成功) return True elif 名额已满 in result.get(message, ): print(名额被抢重新轮询) return False elif 已选 in result.get(message, ): print(已选过该课程) return True else: print(f选课失败{result.get(message)}) return False这里有一个实战经验选课成功的判断不能只看HTTP状态码。很多教务系统即使选课失败也返回200真正的结果在响应体的JSON或HTML中。所以必须解析响应内容根据业务字段来判断。4.3 处理选课成功但被踢出的情况有些教务系统有选课确认机制第一次选课只是占位需要在规定时间内确认才算最终成功。如果脚本只做了选课请求而没有做确认操作可能会在确认截止后被系统自动清退。处理方式是在选课成功后检查是否有确认页面或确认接口需要调用。如果有立即执行确认操作。这个细节很容易被忽略但一旦漏掉前面的努力就白费了。4.4 日志记录与状态追踪脚本运行过程中建议把关键操作都记录到日志文件里包括每次轮询的时间、检测到的余量、选课请求的发送和结果。这样出问题时可以回溯分析也方便优化轮询策略。import logging logging.basicConfig( filenamecourse_select.log, levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s ) logging.info(f开始轮询课程 {course_id}) logging.info(f检测到余量 {available}) logging.info(f选课结果{result})日志文件建议按日期分割避免单个文件过大。如果脚本需要长时间运行还可以加入日志轮转机制。5. 实战中踩过的坑与应对方案5.1 登录态过期导致轮询失效教务系统的会话通常有有效期比如30分钟不操作就自动登出。脚本如果一直轮询但从不刷新会话可能在运行一段时间后突然所有请求都返回登录页。这个问题很隐蔽因为脚本不会报错只是检测不到余量。解决方案是定期检查登录状态比如每轮询20次就访问一次个人信息页如果被重定向到登录页就重新登录。或者在每次请求后检查响应内容是否包含登录页的特征字符串。def check_session_valid(session): resp session.get(http://example-jwxt.edu.cn/user/profile) if 登录 in resp.text and 密码 in resp.text: return False return True5.2 请求频率过高被临时限制这是最容易踩的坑。刚开始测试时我把轮询间隔设成了1秒结果跑了不到5分钟所有请求都返回403。等了大概十几分钟才恢复正常。后来把间隔调到5秒以上并且加入随机抖动就再没出现过被限制的情况。经验值轮询间隔不低于3秒推荐5到10秒并且每次间隔加入±2秒的随机偏移。如果多线程并行总请求频率是各线程频率之和。比如3个线程各5秒一次实际对服务器的请求频率是每5秒3次。这个总量也要控制建议总频率不超过每秒1次。5.3 课程ID与教学班ID的混淆很多教务系统里一门课程有多个教学班不同教师、不同时间选课实际选的是教学班而不是课程。如果脚本只传了课程ID没传教学班ID选课请求会失败或者选到错误的教学班。解决方式是在查询阶段就获取完整的教学班信息包括教学班ID、教师、上课时间等选课时精确指定目标教学班。如果目标是只要有这门课就行可以遍历所有教学班哪个有余量就选哪个。5.4 页面结构变化导致解析失败教务系统可能会在学期更新时调整页面结构或接口返回格式。如果脚本硬编码了HTML标签的class名或JSON字段名系统一更新脚本就失效了。应对策略是尽量使用接口而非解析HTML接口的稳定性通常比页面结构高。如果必须解析HTML把解析逻辑封装成独立函数方便出问题时快速定位和修改。同时在脚本中加入解析失败的告警比如连续多次解析不到预期字段就打印警告。5.5 选课成功后的确认遗漏前面提到过确认机制这里再强调一下。有些学校的选课流程是选课→确认→完成脚本只做了第一步。确认操作通常有时限比如选课后30分钟内必须确认。如果脚本没有自动确认需要设置提醒手动处理或者在脚本中加入确认逻辑。确认接口的调用方式和选课接口类似关键是找到确认请求的URL和参数。可以在浏览器中手动完成一次选课确认流程抓包分析确认请求的构造方式。6. 代码组织与运行部署的实用建议6.1 配置与代码分离把学号、密码、课程ID、轮询间隔等参数写在配置文件里而不是硬编码在代码中。这样修改参数不需要动代码也避免了敏感信息泄露的风险。# config.py CONFIG { username: your_student_id, password: your_password, base_url: http://example-jwxt.edu.cn, courses: [ {course_id: CS101, class_id: CS101-01}, {course_id: MA102, class_id: MA102-03} ], poll_interval: (5, 10), max_retries: 3 }6.2 异常处理的分层设计脚本运行中可能遇到各种异常网络超时、接口返回格式变化、登录态失效、选课接口限流等。不同异常的处理方式不同需要分层设计。网络超时可以重试重试3次仍失败就跳过本轮登录态失效需要重新登录接口限流需要延长等待时间返回格式变化需要记录日志并告警。把这些逻辑分开处理脚本的健壮性会好很多。def safe_request(session, method, url, **kwargs): for i in range(3): try: resp session.request(method, url, timeout10, **kwargs) return resp except requests.Timeout: print(f请求超时重试 {i1}/3) time.sleep(2) except requests.RequestException as e: print(f请求异常{e}) time.sleep(2) return None6.3 运行环境的选择脚本可以跑在本地电脑上也可以部署在云服务器上。本地运行的好处是方便调试坏处是电脑关机或断网脚本就停了。如果选课时间在凌晨或者你不在电脑前云服务器会更可靠。不管跑在哪里都要确保网络稳定。选课高峰期教务系统的响应可能很慢脚本的超时设置要合理太短会导致大量超时太长会拖慢轮询节奏。建议超时设置在10到15秒。6.4 停止条件的设置脚本不能无限运行。需要设置停止条件比如选课成功、达到最大运行时长、或者手动终止。选课成功后自动停止是最基本的此外还可以设置一个最长运行时间比如2小时超时后自动退出并发送通知。import signal import sys def signal_handler(sig, frame): print(收到终止信号正在退出...) sys.exit(0) signal.signal(signal.SIGINT, signal_handler)这样按CtrlC可以优雅退出而不是留下未完成的请求或损坏的日志文件。7. 关于这类工具的使用边界与个人体会写这个脚本的初衷是解决选课季的焦虑但实际用下来有几个体会值得分享。第一技术手段不能替代对规则的尊重。选课系统的设计初衷是公平分配资源自动化脚本如果被滥用比如同时抢几十门课再退掉、或者用多个账号刷课就会破坏这种公平。我个人的做法是只针对自己真正需要的课程选到就停不囤积名额。第二脚本的稳定性比速度更重要。很多人追求秒杀把间隔调到极低结果被系统限制反而得不偿失。我实测下来5到10秒的间隔虽然看起来慢但持续运行几小时都不会出问题最终抢到课的概率反而更高。第三不要把脚本当成唯一手段。选课季同时用多个渠道——脚本轮询、手机APP手动刷新、同学之间互相提醒——综合成功率会更高。脚本只是工具之一不是万能药。第四代码要留好注释和文档。这类脚本往往一学期只用一两次下次再用时如果忘了参数含义或接口逻辑重新梳理会很费时间。我在代码里对每个关键步骤都写了注释配置文件里也标注了参数说明第二次用的时候直接改配置就能跑。最后说一个细节选课结束后记得把脚本停掉。我见过有同学的脚本忘了关跑了一整晚第二天收到教务系统的异常访问提醒。虽然没造成什么后果但总归是不必要的麻烦。设置好停止条件或者用定时任务控制运行时间都是好习惯。