1. 为什么教务系统是爬虫练手的"硬骨头"
很多人入门爬虫的第一个练手项目,都是爬豆瓣电影Top250或者某天气网站。这些站点结构规整、反爬宽松,跑通一遍确实能建立信心,但说实话,练完之后你会发现,真正到了要解决实际问题的时候,那些经验几乎用不上。教务系统就不一样了,它几乎集齐了新手能遇到的所有"麻烦":登录态校验、表单提交、Session维持、动态参数、嵌套表格、编码混乱,甚至还有页面结构不统一的问题。
我当初选教务系统作为练手目标,原因很直接——我想把自己课表上的课程信息导出来,做成一个可以导入日历的格式。手动抄?一学期几十门课,每门课还有上课时间、地点、教师、周次,抄完眼睛都花了。用现成的工具?要么收费,要么功能不匹配。索性自己写一个,用Requests处理网络请求,用re做数据提取,两个库加起来不到两百行代码就能搞定。
这个组合看起来"原始",但恰恰是最适合理解爬虫本质的搭配。Requests负责把HTTP请求这件事变得像呼吸一样自然,re负责从HTML文本里精准地捞出你需要的东西。没有框架的封装,没有ORM的抽象,每一步你都得自己想清楚:这个请求要带什么参数?这个页面返回的数据长什么样?正则该怎么写才能既准确又不容易崩?把这些想明白了,再去用Scrapy或者Playwright,你会发现自己对底层逻辑的理解完全不一样。
这篇文章适合两类人:一类是刚学完Python基础、想找一个真实项目练手的初学者;另一类是用过爬虫框架但总觉得"知其然不知其所以然"的开发者。我会从登录认证开始,一步步拆解整个流程,包括我踩过的坑、调试的技巧、以及那些文档里不会写的经验。代码会给出完整可运行的版本,但更重要的是,我会解释每一步为什么这么做。
提醒:爬取教务系统数据仅限个人学习使用,获取的数据不要公开传播或用于商业用途。建议控制请求频率,避免对学校服务器造成不必要的负担。
2. 登录环节:Session维持与表单参数的完整拆解
2.1 为什么直接用requests.get拿不到课表页面
这是新手最容易卡住的地方。你打开浏览器,登录教务系统,看到课表页面,然后复制URL,用requests.get()去请求,结果返回的是一个登录页,或者干脆是"请先登录"的提示。原因很简单:HTTP是无状态的,服务器不知道两次请求来自同一个人。浏览器之所以能保持登录状态,是因为它在每次请求时自动带上了Cookie,而requests.get()默认不会帮你做这件事。
解决方案就是用requests.Session()。Session对象会自动维护Cookie,你在登录请求中拿到的Set-Cookie,后续请求会自动带上。这就像你去银行办业务,第一次出示身份证之后,柜员给你一个号牌,后面每次叫号你出示号牌就行,不用反复掏身份证。
import requests session = requests.Session() 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" })这里设置User-Agent不是可有可无的。很多教务系统会检查请求头,如果发现是Python的默认UA(python-requests/2.x.x),直接拒绝。伪装成浏览器是最基本的礼貌,也是最基本的生存技能。
2.2 登录表单里那些"隐藏"的字段
登录页面通常不止有用户名和密码两个输入框。你打开浏览器的开发者工具,切换到Network面板,勾选"Preserve log",然后手动登录一次,找到那个POST请求,看看它到底提交了什么。
你会发现,除了用户名和密码,往往还有几个隐藏字段,比如__VIEWSTATE、__EVENTVALIDATION、lt、execution之类的。这些是服务器生成的令牌,用来防止CSRF攻击,也顺便防止了你直接构造请求。这些值藏在登录页的HTML里,你需要先GET登录页,用正则把它们提取出来,再连同用户名密码一起POST回去。
import re login_url = "http://example.edu.cn/login" resp = session.get(login_url) html = resp.text # 提取隐藏字段,不同学校字段名不同,需要根据实际情况调整 viewstate = re.search(r'name="__VIEWSTATE"\s+value="([^"]+)"', html) eventvalidation = re.search(r'name="__EVENTVALIDATION"\s+value="([^"]+)"', html) payload = { "username": "your_username", "password": "your_password", "__VIEWSTATE": viewstate.group(1) if viewstate else "", "__EVENTVALIDATION": eventvalidation.group(1) if eventvalidation else "", }这里有个细节:re.search()返回的是Match对象,如果没匹配到就是None,直接.group(1)会报AttributeError。所以要么加判断,要么用findall然后检查列表是否为空。我见过太多人在这里翻车,调试半天以为是请求参数错了,其实是正则没匹配上。
2.3 登录成功的判断标准
POST之后怎么知道登录成功了?不能只看状态码,因为很多系统登录失败也返回200。我的做法是检查响应内容里有没有登录后才出现的特征字符串,比如"欢迎"、"退出"、或者某个只有登录后才能看到的菜单项。
resp = session.post(login_url, data=payload) if "欢迎" in resp.text or "退出" in resp.text: print("登录成功") else: print("登录失败,检查账号密码或隐藏字段")更稳妥的方式是直接请求课表页面,看返回的内容里有没有课程数据。如果又被重定向回登录页,说明Session没生效。这时候要检查:是不是登录URL写错了?是不是需要先访问某个中间页面?是不是有验证码?
验证码是另一个话题。如果教务系统有验证码,纯Requests方案就比较吃力了,需要配合OCR或者打码平台。不过很多学校的教务系统验证码只是摆设,输入框存在但后端不校验,或者可以用固定值绕过。这个需要具体测试,不在本文讨论范围内。
3. 课表页面的结构分析与正则提取策略
3.1 先看清HTML长什么样,再动手写正则
拿到课表页面之后,千万别急着写正则。先把HTML保存到本地,用编辑器打开,仔细看结构。教务系统的课表通常是一个大表格,每个单元格代表一个时间段,里面可能嵌套了多个<div>或<td>,课程信息以文本形式散落在里面。
我遇到过的课表结构大概有三种:
第一种是规整的表格,每行是一个时间段,每列是一天,单元格里直接是课程名称。这种最好处理,用正则匹配<td>内容就行。
第二种是嵌套表格,外面一个大表格控制布局,里面每个单元格又嵌套一个小表格放课程信息。这种需要先定位到外层,再逐层提取。
第三种是动态加载,课表数据通过AJAX请求获取,HTML里只有空壳。这种纯Requests方案就搞不定了,需要分析XHR请求,找到真正的数据接口。
我这次遇到的是第二种。页面结构大致如下:
<table id="courseTable"> <tr> <td>第一节</td> <td> <table> <tr><td>高等数学</td></tr> <tr><td>张老师</td></tr> <tr><td>教一楼101</td></tr> <tr><td>1-16周</td></tr> </table> </td> <!-- 更多单元格 --> </tr> </table>3.2 正则的"贪婪"与"非贪婪":一个字符之差,结果天壤之别
写正则提取HTML内容时,.*和.*?的区别是致命的。.*是贪婪匹配,会尽可能多地匹配字符;.*?是非贪婪匹配,会尽可能少地匹配。在HTML这种嵌套结构里,用贪婪匹配很容易跨过多个标签,把不该要的内容也捞进来。
举个例子,假设HTML是这样的:
<td>高等数学</td><td>大学英语</td>如果你写<td>(.*)</td>,匹配到的会是高等数学</td><td>大学英语,因为.*会一直吃到最后一个</td>。正确的写法是<td>(.*?)</td>,这样它会在第一个</td>处停下。
但非贪婪也不是万能的。如果课程名称本身包含<或>,或者HTML里有注释、脚本,正则就可能匹配到奇怪的东西。所以我的经验是:先用非贪婪匹配定位大致范围,再用更精确的模式提取具体字段。
# 先提取所有课程单元格 cells = re.findall(r'<td>\s*<table>(.*?)</table>\s*</td>', html, re.S) for cell in cells: # 在每个单元格内提取具体信息 rows = re.findall(r'<tr>\s*<td>(.*?)</td>\s*</tr>', cell, re.S) if len(rows) >= 4: course_name = rows[0].strip() teacher = rows[1].strip() location = rows[2].strip() weeks = rows[3].strip() print(course_name, teacher, location, weeks)注意re.S这个标志,它让.可以匹配换行符。HTML里标签之间经常有换行和缩进,不加这个标志,.*?遇到换行就停了,什么都匹配不到。这是新手常犯的错误之一。
3.3 处理HTML实体和空白字符
教务系统返回的HTML里,经常有 、&这样的HTML实体,还有大量的空格和换行。提取出来的文本如果不处理,会带着这些杂质,影响后续使用。
import html def clean_text(text): text = html.unescape(text) # 转换 & 等实体 text = re.sub(r'\s+', ' ', text) # 多个空白字符合并为一个空格 return text.strip()html.unescape()是Python标准库里的函数,能把 转成空格,&转成&。这个函数很多人不知道,自己写替换逻辑又容易漏掉某些实体,直接用标准库最省事。
4. 从零散数据到结构化课表:清洗与存储的实战细节
4.1 课程名称里的"隐藏信息"怎么拆
提取出来的课程名称往往不是干净的。比如"高等数学(上)"、"大学英语-听说"、"体育(篮球)",括号和连字符里包含了课程性质、方向等信息。如果你只是简单地把整串文字存下来,后续想按课程类型筛选就很麻烦。
我的做法是用正则把课程名称拆成"主名称"和"备注"两部分:
def parse_course_name(name): match = re.match(r'^([^\(\(\-]+)[\(\(\-]?(.*?)[\)\)]?$', name) if match: main_name = match.group(1).strip() note = match.group(2).strip() if match.group(2) else "" return main_name, note return name, ""这个正则考虑了中文括号和英文括号两种情况。[\(\(]匹配左括号,[\)\)]匹配右括号,\-匹配连字符。?表示括号和备注都是可选的,没有也能正常匹配。
4.2 周次信息的解析:从"1-16周"到具体日期
"1-16周"这种表示法对人来说很直观,但对程序来说不够精确。如果你想生成日历事件,需要知道具体是哪几天上课。这就需要结合学期的起始日期来计算。
from datetime import datetime, timedelta def parse_weeks(week_str, semester_start): # 匹配 "1-16周" 或 "1-8,10-16周" 等格式 weeks = [] for part in week_str.replace("周", "").split(","): if "-" in part: start, end = part.split("-") weeks.extend(range(int(start), int(end) + 1)) else: weeks.append(int(part)) dates = [] for week in weeks: # 假设每周一上课,具体要根据课表列来确定 date = semester_start + timedelta(weeks=week - 1) dates.append(date.strftime("%Y-%m-%d")) return dates这里有个坑:不同学校的周次计算方式可能不同。有的从开学第一周算起,有的从自然年第一周算起。我建议先手动验证一两门课的日期,确认计算逻辑正确后再批量处理。
4.3 存储格式的选择:CSV、JSON还是直接入库
数据提取出来之后,存成什么格式?我的建议是:
- CSV:适合用Excel打开查看,但不适合嵌套结构(比如一门课有多个上课时间)。
- JSON:适合程序后续处理,结构灵活,但人眼阅读不如CSV直观。
- SQLite:适合数据量大、需要复杂查询的场景,但对这个项目来说有点重。
我最终选了JSON,因为后续要导入日历,JSON的结构最方便转换。每门课存成一个对象,包含课程名、教师、地点、周次列表、星期几、第几节等信息。
import json course_data = { "name": "高等数学", "teacher": "张老师", "location": "教一楼101", "weeks": ["2024-02-26", "2024-03-04", "..."], "weekday": 1, "period": "1-2节" } with open("courses.json", "w", encoding="utf-8") as f: json.dump(course_data, f, ensure_ascii=False, indent=2)ensure_ascii=False是关键,不加这个参数,中文会被转成\uXXXX的形式,虽然数据没丢,但看起来很不友好。
5. 调试与排错:那些让我熬夜的坑和最终解法
5.1 登录成功但课表页面返回空数据
这个问题困扰了我一个晚上。登录明明成功了,Session也维持住了,但请求课表页面返回的HTML里就是没有课程数据。后来用浏览器的开发者工具对比请求,发现课表页面需要一个额外的参数,比如学期ID或者学生ID,这些参数藏在登录后的某个页面里。
解决方法是:登录成功后,先访问首页或者个人信息页,用正则提取出这些参数,再带着参数去请求课表。
# 登录后先访问首页 home_resp = session.get("http://example.edu.cn/home") # 提取学期ID semester_match = re.search(r'name="semester"\s+value="(\d+)"', home_resp.text) semester_id = semester_match.group(1) if semester_match else "1" # 带着参数请求课表 course_resp = session.get("http://example.edu.cn/course", params={"semester": semester_id})这个经验告诉我:不要假设登录后就能直接访问任何页面。很多系统有权限校验和参数依赖,需要模拟浏览器的完整访问路径。
5.2 正则匹配不到内容的排查思路
正则写好了却匹配不到,原因通常有这几个:
第一,HTML结构和你想的不一样。用print(html[:2000])打印前2000个字符看看,或者保存到文件里用编辑器打开。我遇到过页面返回的是GBK编码,但Requests默认按UTF-8解码,导致中文全是乱码,正则自然匹配不到。
resp.encoding = resp.apparent_encoding # 自动检测编码 # 或者手动指定 resp.encoding = "gbk"第二,标签属性顺序或格式有差异。比如你写<td class="course">,但实际HTML是<td class="course">(两个空格),或者属性顺序变了。这时候要么放宽正则,要么用更通用的模式。
第三,内容是通过JavaScript动态插入的。查看页面源代码(Ctrl+U),如果里面没有课程数据,说明是JS加载的,纯Requests方案需要找到数据接口。
5.3 请求频率过高导致IP被临时限制
这是最容易被忽视的问题。你调试的时候可能短时间内发了几十次请求,学校服务器可能不会永久封禁,但会临时限制你的IP,表现为请求超时或返回403。
我的做法是在每次请求之间加一个随机延时:
import time import random time.sleep(random.uniform(1, 3))1到3秒的随机延时,既不会太慢,也能有效降低被限制的概率。如果是批量爬取,建议把延时设得更长一些,比如3到5秒。毕竟教务系统不是商业网站,服务器资源有限,我们作为使用者应该自觉控制请求频率。
注意:如果发现请求开始大量失败,先停下来,等十几分钟再试。不要不停地重试,那样只会让情况更糟。
6. 把课表变成日历事件:数据导出的实用延伸
6.1 生成ICS格式文件
爬下来的数据如果只是躺在JSON文件里,价值有限。把它变成日历事件,才是真正能提升效率的用法。ICS是日历通用的格式,Google Calendar、Outlook、苹果日历都支持。
def generate_ics(courses, filename="courses.ics"): lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Course Exporter//CN", ] for i, course in enumerate(courses): for date in course["weeks"]: lines.extend([ "BEGIN:VEVENT", f"UID:course-{i}-{date}@example", f"DTSTART:{date.replace('-', '')}T080000", f"DTEND:{date.replace('-', '')}T094000", f"SUMMARY:{course['name']}", f"LOCATION:{course['location']}", f"DESCRIPTION:{course['teacher']}", "END:VEVENT", ]) lines.append("END:VCALENDAR") with open(filename, "w", encoding="utf-8") as f: f.write("\n".join(lines))时间部分我写的是固定值,实际使用时需要根据课程的具体节次来调整。比如第一节是8:00开始,第二节是9:40开始,这些需要根据学校的作息时间表来设置。
6.2 用pandas做数据统计
如果你还想分析一下这学期的课程分布,比如每周有几节课、哪些天最忙,pandas可以帮你快速搞定。
import pandas as pd df = pd.DataFrame(courses) # 统计每门课的周次数量 df["week_count"] = df["weeks"].apply(len) # 按教师分组统计 teacher_stats = df.groupby("teacher")["name"].count() print(teacher_stats)这个延伸不是必须的,但能让你看到数据的更多价值。爬虫不只是"把数据拿下来",更重要的是"让数据为你所用"。
7. 关于Requests+re这套组合的一些个人体会
写了这么多,最后分享几点我在实际使用中的真实感受。
Requests+re不是"低级"方案,而是"透明"方案。用Scrapy的时候,很多细节被框架封装了,你不需要关心Cookie怎么维持、重定向怎么处理、编码怎么转换。但这也意味着,当出问题的时候,你不知道该从哪里下手。用Requests+re,每一步都是你自己写的,出了问题你能快速定位到具体是哪一行代码、哪一个正则、哪一个参数。
正则不是万能的,但在结构相对固定的页面上,它是最快的。教务系统的页面结构通常几年都不会大改,用正则提取完全够用。如果你要爬的是结构经常变化的网站,那确实应该考虑用BeautifulSoup或者lxml。工具没有优劣,只有适不适合。
调试时间远大于写代码时间。这个项目我写代码可能只用了两个小时,但调试花了整整一个下午。登录参数、编码问题、正则匹配、请求频率,每一个环节都可能出问题。所以如果你也在做类似的项目,不要因为调试时间长就觉得自己不适合,这是正常的。
尊重目标网站,控制请求频率。这一点怎么强调都不为过。教务系统是学校的基础设施,不是给你练手用的。加延时、限制并发、避开高峰期,这些都是基本的素养。如果每个人都毫无节制地爬,最终受害的是所有使用者。
这套方案我已经用了好几个学期,每学期开学跑一次,几分钟就能把课表整理好。代码不复杂,但确实解决了实际问题。如果你也在为课表整理烦恼,不妨试试这个思路。遇到问题的时候,先别急着换方案,把当前的方案调试通,你对爬虫的理解会上一个台阶。