1. 一个“水”字背后的真实需求
看到“本科生爬虫自动选课”这个标题,再加上一个括号里的“水”字,我第一反应是:这哥们儿要么是在自嘲,要么是在劝退。但不管哪种,这个项目本身涉及的技术点和场景,其实比表面看起来要有意思得多。
先把这个标题拆开看。爬虫是手段,自动选课是目标,本科生是用户画像,水是作者对自己项目含金量的评价。合在一起,这是一个典型的“用技术解决个人刚需”的校园项目。每年选课季,教务系统必然崩溃,热门课程秒没,手速和网速成了决定性因素。于是,有编程基础的同学就会想:能不能写个脚本帮我盯着,一旦有余量就自动提交?
这个需求真实吗?非常真实。但实现起来,坑远比想象的多。这篇文章我就从零开始,把这个项目的完整思路、技术选型、实操步骤、踩坑经验全部拆解一遍。不管你是刚学Python的新手,还是想找个练手项目的在校生,都能从中拿到可以直接复用的东西。
需要提前说明的是:本文讨论的所有技术方案,仅用于学习HTTP协议、会话管理和自动化测试等编程知识。在实际校园环境中,任何自动化操作都应遵守学校教务系统的使用规定,不建议绕过正常选课流程。技术本身是中性的,关键在于怎么用。
2. 项目整体设计与技术选型思路
2.1 为什么选爬虫而不是其他方案
自动选课这件事,理论上有很多种实现路径。我见过有人用按键精灵做屏幕点击,有人用浏览器插件做DOM操作,还有人直接守在电脑前手动刷新。那为什么“爬虫”这个方案最常被提起?
核心原因在于效率和可靠性。屏幕点击方案依赖UI渲染,页面一卡就完蛋;浏览器插件方案受限于浏览器的性能,多开几个标签页内存就爆了。而基于HTTP请求的爬虫方案,直接和服务器对话,跳过了所有渲染环节,速度快、资源占用低,而且可以精确控制请求频率和重试逻辑。
打个比方:屏幕点击就像你亲自去柜台排队,浏览器插件像你打电话让客服帮你排队,而爬虫是你直接给柜台发了一封格式规范的电报,对方处理完就回你。效率完全不在一个量级。
当然,爬虫方案也有门槛。你需要理解HTTP协议、Cookie和Session机制、表单提交格式,还要处理各种反爬策略。但这些东西一旦搞明白,不光选课能用,以后做数据采集、接口测试、自动化运维都用得上。
2.2 技术栈选择:为什么是Python + Requests
技术栈这块,我推荐Python + Requests + BeautifulSoup的组合。理由如下:
- Python:语法简洁,库生态丰富,写脚本的效率极高。对于这种一次性小工具来说,开发速度比运行速度重要得多。
- Requests:HTTP库里的瑞士军刀,API设计极其人性化,处理Cookie和Session只需要几行代码。
- BeautifulSoup:解析HTML的利器,虽然选课系统大部分接口返回的是JSON,但登录页面和某些表单页面还是需要解析HTML的。
如果你更熟悉Node.js,用axios + cheerio也完全可行。但Python的优势在于,即使你以后不写爬虫了,学到的技能在数据分析、自动化办公等场景照样能用。
至于要不要上Selenium或者Playwright这种浏览器自动化工具,我的建议是:先试Requests,搞不定了再考虑浏览器方案。原因很简单,浏览器自动化虽然能绕过很多前端加密,但速度慢、资源消耗大,而且调试起来更麻烦。选课这种场景,速度就是生命,能快一秒是一秒。
2.3 整体架构设计
整个项目的架构可以分成四个模块:
- 登录模块:负责模拟登录,获取并维持会话。
- 监控模块:定时轮询目标课程的余量接口。
- 选课模块:一旦检测到余量,立即发送选课请求。
- 通知模块:选课成功后通过邮件或消息推送告知用户。
这四个模块串起来就是一个完整的自动化流程。下面我会逐个拆解每个模块的实现细节。
3. 核心细节解析与实操要点
3.1 登录环节:Cookie和Session的门道
登录是整个项目的第一道坎。大部分教务系统的登录流程是这样的:你先GET登录页面,服务器返回一个包含隐藏字段的表单;然后你POST用户名、密码和那些隐藏字段;服务器验证通过后,通过Set-Cookie下发会话标识。
这里的关键在于维持会话。Requests库的Session对象会自动帮你管理Cookie,你只需要在同一个Session实例里发请求就行。代码大概长这样:
import requests session = requests.Session() login_page = session.get('https://example.edu/login') # 解析隐藏字段 # ... login_data = { 'username': 'your_id', 'password': 'your_password', 'csrf_token': 'xxx' } session.post('https://example.edu/login', data=login_data)看起来简单,但实际操作中你会遇到几个坑:
第一个坑是密码加密。很多教务系统不会明文传输密码,而是在前端用JavaScript做一次加密(比如MD5、SHA1或者自定义算法)。这时候你就需要去分析前端JS代码,找到加密逻辑,然后在Python里复现。如果加密逻辑太复杂,可以考虑用PyExecJS直接执行JS代码。
第二个坑是验证码。有些系统登录时需要输入验证码。简单的数字验证码可以用OCR库(比如ddddocr)识别,复杂的滑动验证码就需要上浏览器自动化方案了。不过据我了解,大部分高校教务系统为了兼容性,验证码机制并不复杂。
第三个坑是CSRF Token。这是防止跨站请求伪造的常见手段。你需要在登录前先GET一次页面,从HTML里提取出token,然后带着token去POST。有些系统的token是动态刷新的,每次请求都要重新提取。
注意:登录环节涉及个人账号密码,建议把敏感信息存在环境变量或单独的配置文件里,不要硬编码在代码中,更不要上传到公开仓库。
3.2 课程余量接口的抓取与分析
登录成功后,下一步就是找到查询课程余量的接口。这个环节我建议用浏览器的开发者工具(F12)来抓包。
具体操作步骤:
- 打开教务系统的选课页面。
- 按F12打开开发者工具,切换到Network面板。
- 勾选Preserve log,过滤XHR或Fetch请求。
- 在页面上执行一次查询操作,观察发出了哪些请求。
- 找到返回课程列表和余量信息的那个接口,记录下它的URL、请求方法和参数格式。
通常这个接口会返回JSON数据,结构大概是这样的:
{ "code": 200, "data": [ { "courseId": "CS101", "courseName": "计算机导论", "teacher": "某老师", "capacity": 60, "enrolled": 58, "remaining": 2 } ] }你需要关注的是remaining字段。当它大于0时,就说明有余量可以选。
这里有个细节值得注意:有些系统的余量数据不是实时的。它可能每隔几分钟才更新一次缓存。这种情况下,你就算检测到余量,提交选课请求时也可能被告知已满。解决办法是尽量找到最接近实时的接口,或者适当提高轮询频率。
3.3 轮询策略:频率与效率的平衡
轮询频率是个需要仔细权衡的参数。太快了,服务器压力大,容易被封IP;太慢了,课程被别人抢走。
我的经验是:选课高峰期用3到5秒一次,非高峰期用10到15秒一次。这个频率既能保证及时性,又不至于触发服务器的限流机制。
另外,轮询的时间间隔最好加一点随机抖动。比如设定5秒,实际执行时在4到6秒之间随机。这样可以避免请求过于规律,降低被识别为机器人的风险。
import time import random def poll_course(session, course_id): while True: try: resp = session.get(f'https://example.edu/api/course/{course_id}') data = resp.json() if data['remaining'] > 0: return True except Exception as e: print(f'查询出错: {e}') time.sleep(random.uniform(4, 6))这段代码的逻辑很直白:查到有余量就返回True,出错就跳过继续轮询。实际使用中你还可以加上最大重试次数和超时退出机制,避免脚本无限运行。
3.4 选课请求的构造与提交
检测到余量后,下一步就是构造选课请求。这个请求的格式因学校而异,但通常包含以下要素:
- 课程ID
- 教学班ID
- 用户身份标识(通常已经在Session里了)
- 可能的CSRF Token
选课请求一般用POST方法,Content-Type通常是application/json或application/x-www-form-urlencoded。你需要根据抓包结果来构造。
def select_course(session, course_id, class_id): payload = { 'courseId': course_id, 'classId': class_id } resp = session.post('https://example.edu/api/select', json=payload) result = resp.json() if result['code'] == 200: print('选课成功') return True else: print(f'选课失败: {result["msg"]}') return False这里有个重要的经验:选课请求要准备好重试逻辑。因为高峰期服务器响应可能很慢,或者返回“系统繁忙”之类的临时错误。遇到这种情况不要慌,等一两秒重试就行。但要注意设置最大重试次数,避免陷入死循环。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
开始写代码之前,先把环境搭好。我推荐用Python 3.8以上的版本,依赖库就三个:
pip install requests beautifulsoup4 lxml如果你需要处理验证码,再加一个:
pip install ddddocr如果需要执行JavaScript代码来复现加密逻辑:
pip install PyExecJS编辑器用什么无所谓,VS Code、PyCharm甚至记事本都行。但我建议用VS Code,装个Python插件,调试起来方便。
4.2 完整代码框架搭建
下面我把整个项目的代码框架串起来。注意这只是骨架,具体细节需要根据你学校的教务系统来调整。
import requests import time import random from bs4 import BeautifulSoup class CourseSelector: def __init__(self, username, password, base_url): self.session = requests.Session() self.username = username self.password = password self.base_url = base_url self.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' }) def login(self): """模拟登录,获取会话""" login_url = f'{self.base_url}/login' resp = self.session.get(login_url) soup = BeautifulSoup(resp.text, 'lxml') # 提取隐藏字段,具体字段名需要根据实际情况调整 hidden_inputs = soup.find_all('input', type='hidden') login_data = {inp['name']: inp.get('value', '') for inp in hidden_inputs} login_data['username'] = self.username login_data['password'] = self.password resp = self.session.post(login_url, data=login_data) if '登录成功' in resp.text or resp.status_code == 200: print('登录成功') return True print('登录失败') return False def check_remaining(self, course_id): """查询课程余量""" url = f'{self.base_url}/api/course/{course_id}' try: resp = self.session.get(url, timeout=10) data = resp.json() return data.get('remaining', 0) except Exception as e: print(f'查询异常: {e}') return -1 def select(self, course_id, class_id): """提交选课请求""" url = f'{self.base_url}/api/select' payload = {'courseId': course_id, 'classId': class_id} try: resp = self.session.post(url, json=payload, timeout=10) result = resp.json() return result.get('code') == 200 except Exception as e: print(f'选课异常: {e}') return False def run(self, course_id, class_id, max_attempts=1000): """主循环""" if not self.login(): return attempts = 0 while attempts < max_attempts: attempts += 1 remaining = self.check_remaining(course_id) if remaining > 0: print(f'检测到余量: {remaining},尝试选课...') if self.select(course_id, class_id): print('选课成功!') return True else: print('选课失败,继续监控') else: print(f'第{attempts}次查询,余量: {remaining}') time.sleep(random.uniform(4, 6)) print('达到最大尝试次数,退出') return False if __name__ == '__main__': selector = CourseSelector( username='your_student_id', password='your_password', base_url='https://example.edu' ) selector.run(course_id='CS101', class_id='001')这个框架涵盖了登录、查询、选课、轮询四个核心环节。你可以直接拿去改,把URL和字段名换成你学校的就行。
4.3 参数计算与策略调优
轮询间隔的计算前面提过了,这里再补充几个可以调优的参数:
超时时间:建议设置10秒。太短了容易误判为超时,太长了卡住不动。选课高峰期服务器响应慢,可以适当放宽到15秒。
最大尝试次数:这个取决于你的耐心和选课窗口的时长。一般来说,选课窗口开放后的前30分钟是黄金时间,按5秒一次算,大概360次。设置1000次足够覆盖整个高峰期。
重试延迟:选课失败后的重试延迟建议设为1到2秒。太短了服务器可能还没处理完上一个请求,太长了可能错过时机。
并发策略:有些同学会想用多线程同时监控多门课程。这个思路可行,但要注意控制并发数。我建议最多开3到5个线程,再多就容易触发服务器的限流机制。
5. 常见问题与排查技巧实录
5.1 登录失败的那些原因
登录失败是最常见的问题,排查思路可以按以下顺序来:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 返回401或403 | 密码错误或加密方式不对 | 检查密码是否正确,抓包对比加密后的密码 |
| 返回200但提示未登录 | Cookie未正确携带 | 检查Session是否复用了,打印Cookie看看 |
| 返回验证码错误 | 验证码识别失败 | 手动登录一次对比,或换OCR方案 |
| 请求超时 | 网络问题或服务器限流 | 检查网络,降低请求频率 |
我踩过最坑的一次是:教务系统在登录时会对密码做一次RSA加密,公钥是动态获取的。这种情况下,你需要先请求一个接口拿到公钥,然后用公钥加密密码再提交。解决方法是分析前端JS代码,找到加密函数,用PyExecJS调用。
5.2 选课请求被拒绝的排查
选课请求被拒绝的原因通常有以下几种:
余量数据不同步。你查到有余量,但实际已经被别人选了。这种情况没办法完全避免,只能尽量提高查询频率,缩短时间差。
缺少必要的请求头。有些系统会校验Referer和Origin,你需要在请求头里补上。
Token过期。选课接口可能需要一个动态的Token,这个Token有时效性。解决办法是每次选课前重新获取Token。
请求格式不对。Content-Type或者字段名搞错了。这个只能通过抓包对比来排查。
5.3 账号安全与风险规避
这部分我必须重点强调。自动选课脚本涉及个人账号密码,一旦泄露后果很严重。几个基本的安全原则:
- 密码不要硬编码在代码里,用环境变量或加密配置文件。
- 不要把包含账号信息的代码上传到公开仓库。
- 不要频繁登录,一次登录后复用Session。
- 如果学校有明确禁止自动化选课的规定,请遵守规定。
提示:很多学校的教务系统会记录异常请求日志。如果请求频率过高或行为模式异常,可能会被临时封禁账号。建议在非高峰期先小规模测试,确认稳定后再正式使用。
5.4 常见问题速查表
| 问题 | 排查方向 | 解决方案 |
|---|---|---|
| 登录后立即失效 | Cookie管理 | 确保所有请求使用同一个Session |
| 查询接口返回空 | 参数缺失 | 抓包对比请求参数 |
| 选课提示“系统繁忙” | 服务器限流 | 增加重试延迟,降低频率 |
| 脚本运行一段时间后崩溃 | 异常未捕获 | 加try-except,记录日志 |
| 余量一直为0 | 接口不对 | 换一个接口或检查课程ID |
6. 从“水项目”到真本事
说实话,这个项目被作者标了个“水”字,我猜是因为代码量不大,逻辑也不复杂。但我想说的是,项目水不水,取决于你怎么做。如果你只是抄一份代码跑起来,那确实水。但如果你在做的过程中搞懂了HTTP协议、Session机制、反爬策略、异常处理,那这个项目就是一个非常好的入门实战。
我见过不少同学,做完这个项目之后,顺手就把学校图书馆的座位预约、食堂的菜单查询、甚至宿舍的电费查询都自动化了。技术是通用的,关键是你能不能从一个点延伸到一条线。
最后分享一个我在实际调试中的小技巧:把每次请求和响应都记到日志文件里。这样出问题的时候可以回溯,看看是哪一步出了差错。日志格式建议包含时间戳、请求URL、请求参数、响应状态码和响应内容摘要。这个习惯不光写爬虫有用,做任何调试都有用。
另外,如果你真的想把这个项目做得更“不水”,可以考虑加一个Web界面,用Flask或者FastAPI搭一个简单的控制面板,可以实时查看监控状态、手动启停任务、配置监控参数。这样既练了后端开发,又练了前端交互,项目含金量直接上一个台阶。