1. 项目缘起与整体设计思路
1.1 为什么选择 Requests + re 这套组合
做课程信息抓取这件事,我前前后后换过好几套方案。最早用 Selenium 模拟浏览器,能跑通但太重,一个学期几千条课程数据要跑十几分钟,而且服务器上还得配浏览器环境,维护成本高得离谱。后来试过 Scrapy,框架确实强大,但对于"登录一次、翻几页、把表格里的字段抠出来"这种轻量场景,属于杀鸡用牛刀,光是配 middleware 和 pipeline 就够折腾半天。
最后落回到Requests + re这套最朴素的组合,原因很实在:教务系统这类站点的页面结构通常极其稳定,几年都不带大改的,而且返回的基本是服务端渲染好的 HTML,字段位置固定、格式规整。这种场景下,正则表达式(re)的解析效率远高于 BeautifulSoup 或 lxml,因为它不需要构建完整的 DOM 树,直接在字符串层面匹配,速度快、内存占用低。Requests 负责会话维持和请求发送,re 负责精准提取,两者配合,一个几百行的脚本就能把整件事干完。
这套方案适合谁?适合有一定 Python 基础、想快速拿到课程数据做分析的同学,也适合需要把抓取逻辑嵌到已有数据处理流程里的开发者。如果你追求的是"配置化、可扩展、多站点通用",那 Scrapy 更合适;但如果你要的是"今天下午就能跑出结果",Requests + re 是最短路径。
1.2 整体流程拆解:从登录到落库的完整链路
整个抓取流程可以拆成五个阶段,每个阶段都有它的坑:
- 会话建立与登录:教务系统几乎都要求先登录才能查课表。核心是用
requests.Session()维持 Cookie,先 GET 登录页拿到隐藏字段(比如 token、验证码标识),再 POST 提交账号密码。 - 目标页面请求:登录成功后,带着同一个 Session 去请求课程信息页面。这里要注意请求头里的 Referer 和 User-Agent,很多教务系统会校验来源。
- HTML 内容获取:拿到响应后先确认编码,教务系统用 GBK 的不在少数,直接
.text可能乱码,得手动指定response.encoding。 - 正则提取字段:用 re 从 HTML 里抠出课程名、教师、时间、地点、学分等字段。这一步的关键是先把 HTML 压缩成单行,去掉换行和多余空格,否则正则里的
\s匹配会很难写。 - 数据清洗与存储:把提取出来的原始字符串做去空格、去标签、类型转换,最后存成 CSV 或写进数据库。
这五步里,登录和正则编写是最容易翻车的两个环节,后面我会重点展开。
1.3 方案选型的边界:什么时候不该用这套
我得说句实话,Requests + re 不是万能的。以下几种情况建议直接换方案:
- 页面数据由 JavaScript 动态渲染:如果课程表格是前端拿到 JSON 后再渲染的,那你请求 HTML 只能拿到空壳,这时候要么找它背后的接口直接请求 JSON,要么上 Selenium/Playwright。
- 页面结构频繁变动:正则对结构变化极其敏感,改一个 class 名可能整个匹配就失效了。如果站点经常改版,用解析库配合 CSS 选择器会更抗造。
- 需要处理复杂验证码:图形验证码、滑块验证码这类,纯 Requests 搞不定,得配合打码平台或自动化工具。
判断方法很简单:用浏览器打开课程页面,右键"查看网页源代码",如果能在源码里直接搜到课程名称,那 Requests + re 就能干;如果搜不到,说明是动态渲染的,得换思路。
2. 核心细节解析与实操要点
2.1 会话维持:Session 对象的正确用法
很多人第一次写登录脚本,会用requests.get()和requests.post()分开调用,结果发现登录后再请求课程页还是被踢回登录页。原因就是两次请求的 Cookie 没关联上。正确做法是全程用一个Session对象:
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", "Referer": "http://example-edu-system/login" })Session 会自动帮你管理 Cookie,登录时服务器 Set-Cookie 的内容,后续请求都会自动带上。这里有个细节:User-Agent 一定要设,很多教务系统对空 UA 或 Python 默认 UA 直接返回 403。Referer 也建议带上,模拟从登录页跳转过来的正常行为。
提示:不要图省事用全局
requests.get(),Session 是维持登录态的基础,这一步偷懒后面全是坑。
2.2 登录环节:隐藏字段与表单提交
教务系统的登录表单通常不只是账号密码两个字段。打开登录页源码,你大概率会看到类似这样的隐藏 input:
<input type="hidden" name="lt" value="LT-xxxxx-token"> <input type="hidden" name="execution" value="e1s1"> <input type="hidden" name="_eventId" value="submit">这些是服务端生成的会话令牌,必须原样带回。所以登录流程是"两步走":先 GET 登录页,用正则把这些隐藏字段的值抠出来,再连同账号密码一起 POST。
import re login_page = session.get("http://example-edu-system/login").text lt = re.search(r'name="lt"\s+value="([^"]+)"', login_page).group(1) execution = re.search(r'name="execution"\s+value="([^"]+)"', login_page).group(1) payload = { "username": "your_account", "password": "your_password", "lt": lt, "execution": execution, "_eventId": "submit" } resp = session.post("http://example-edu-system/login", data=payload)判断登录是否成功,别只看状态码,200 也可能是"登录失败请重试"的页面。稳妥的做法是检查响应内容里有没有"退出""欢迎"这类只有登录后才出现的字样,或者看最终 URL 有没有跳转。
2.3 正则编写:把 HTML 压成一行再动手
这是整个项目里最考验耐心的一步。原始 HTML 里全是换行和缩进,比如:
<tr> <td class="course-name"> 高等数学 </td> <td class="teacher">张老师</td> </tr>你直接写r'<td class="course-name">(.*?)</td>'是匹配不到"高等数学"的,因为中间隔着换行和空格。我的习惯是先把 HTML 做一次"压缩":
html = re.sub(r'\s+', ' ', resp.text) # 所有空白字符压成单个空格 html = re.sub(r'>\s+<', '><', html) # 标签之间的空白去掉压缩之后,上面那段就变成了<tr><td class="course-name"> 高等数学 </td><td class="teacher">张老师</td></tr>,这时候再写正则就顺手多了:
pattern = re.compile( r'<td class="course-name">\s*(.*?)\s*</td>' r'.*?<td class="teacher">\s*(.*?)\s*</td>', re.S )re.S让.能匹配换行,.*?用非贪婪模式避免跨行匹配过多内容。这里的关键经验是:正则里的每个.*?都要想清楚它的边界在哪,边界模糊是匹配错乱的头号原因。
2.4 编码处理:GBK 乱码的排查思路
教务系统用 GBK 编码的特别多。Requests 默认会根据响应头里的 Content-Type 猜编码,但很多老系统压根不返回正确的 charset,导致.text出来全是乱码。
排查方法:先看resp.encoding是什么,再看resp.apparent_encoding(chardet 猜的),如果两者不一致且内容乱码,就手动指定:
resp.encoding = "gbk" # 或者 "utf-8" html = resp.text如果指定后还是乱码,试试gb18030,它是 GBK 的超集,兼容性更好。我踩过的坑是:有些页面一部分是 UTF-8 一部分是 GBK,这种情况只能分段处理,或者干脆用resp.content.decode('gb18030', errors='ignore')硬解。
3. 实操过程与核心环节实现
3.1 完整脚本骨架与参数说明
把前面的碎片拼起来,一个可运行的脚本长这样:
import requests import re import csv import time BASE = "http://example-edu-system" 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" }) # 第一步:拿登录页隐藏字段 login_page = session.get(f"{BASE}/login").text lt = re.search(r'name="lt"\s+value="([^"]+)"', login_page).group(1) execution = re.search(r'name="execution"\s+value="([^"]+)"', login_page).group(1) # 第二步:提交登录 session.post(f"{BASE}/login", data={ "username": "your_account", "password": "your_password", "lt": lt, "execution": execution, "_eventId": "submit" }) # 第三步:请求课程页 resp = session.get(f"{BASE}/course/list") resp.encoding = "gbk" html = re.sub(r'\s+', ' ', resp.text) html = re.sub(r'>\s+<', '><', html) # 第四步:正则提取 rows = re.findall( r'<tr class="course-row">(.*?)</tr>', html, re.S ) courses = [] for row in rows: name = re.search(r'<td class="name">(.*?)</td>', row) teacher = re.search(r'<td class="teacher">(.*?)</td>', row) credit = re.search(r'<td class="credit">(.*?)</td>', row) if name and teacher and credit: courses.append({ "课程名": name.group(1).strip(), "教师": teacher.group(1).strip(), "学分": credit.group(1).strip() }) # 第五步:写入 CSV with open("courses.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["课程名", "教师", "学分"]) writer.writeheader() writer.writerows(courses) print(f"共抓取 {len(courses)} 条课程记录")几个参数值得说明:
encoding="utf-8-sig":写 CSV 时用这个,Excel 打开才不会中文乱码,sig就是加 BOM 头。time.sleep():如果课程页分页多,每请求一页之间加个 0.5~1 秒的延迟,别把人家服务器打崩了,也降低被封的风险。re.S:让点号匹配换行,处理跨行内容必备。
3.2 分页抓取的循环设计
课程列表通常分页,URL 可能是?page=1、?page=2这种。循环抓取时要注意两个点:一是终止条件,二是异常兜底。
all_courses = [] page = 1 while True: resp = session.get(f"{BASE}/course/list?page={page}") resp.encoding = "gbk" html = re.sub(r'\s+', ' ', resp.text) rows = re.findall(r'<tr class="course-row">(.*?)</tr>', html, re.S) if not rows: break # 没有数据了,说明到最后一页 for row in rows: # ... 提取逻辑同上 pass page += 1 time.sleep(0.8)终止条件用"这一页没解析出任何行"来判断,比去猜总页数靠谱。有些系统最后一页会返回一个空表格,有些会返回"无数据"提示,用if not rows都能覆盖。
3.3 数据清洗:从原始字符串到干净字段
正则抠出来的字符串往往带着多余空格、HTML 实体( 、&)甚至嵌套标签。清洗步骤我一般这么写:
def clean(text): if not text: return "" text = re.sub(r'<[^>]+>', '', text) # 去残留标签 text = text.replace(' ', ' ') # 实体替换 text = text.replace('&', '&') text = re.sub(r'\s+', ' ', text) # 多空格合一 return text.strip()学分这种数值字段,还要做类型转换,遇到"3.0"转成 float,遇到"3学分"这种带单位的,用re.search(r'[\d.]+', text)把数字抠出来再转。这一步别嫌烦,脏数据留到后面分析阶段会更头疼。
4. 常见问题与排查技巧实录
4.1 登录失败与反爬拦截的排查表
下面这张表是我实际踩坑后整理的,基本覆盖了 90% 的登录问题:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 登录后请求课程页跳回登录页 | Cookie 没维持住 | 确认全程用同一个 Session 对象 |
| 返回 403 Forbidden | UA 或 Referer 缺失 | 补全请求头,模拟真实浏览器 |
| 登录页隐藏字段匹配失败 | 字段名或格式变了 | 打印登录页源码,重新确认正则 |
| 响应内容乱码 | 编码判断错误 | 手动指定 gbk 或 gb18030 |
| 请求几次后被封 | 频率过高 | 加 time.sleep,降低并发 |
| 验证码导致登录失败 | 有图形验证码 | 需人工介入或接打码服务 |
注意:如果连续多次登录失败,有些系统会临时锁定账号,测试阶段建议用测试账号,别拿自己的主账号反复试。
4.2 正则匹配错位的三个典型场景
场景一:贪婪匹配吃太多。r'<td>(.*)</td>'这种写法,如果页面有多个 td,它会从第一个 td 一直匹配到最后一个 td 的闭合标签。解决:把.*改成.*?,用非贪婪模式。
场景二:跨行匹配失效。正则里用了.但没加re.S,遇到换行就断了。解决:编译时加re.S标志,或者先把 HTML 压成单行。
场景三:特殊字符没转义。课程名里如果有(、)、.这些正则元字符,匹配会出问题。解决:提取出来的内容只做展示用,别拿它再去拼正则;如果非要拼,用re.escape()转义。
4.3 提升稳定性的几个实操心得
第一,先小后大。别一上来就循环抓所有页,先手动请求一页,把正则调通、字段确认无误,再套循环。我见过太多人循环跑了一百页才发现字段全错位,白跑。
第二,加日志。每请求一页打印一下页码和解析到的条数,出问题时一眼就能定位是哪一页开始异常的。
第三,存原始 HTML。调试阶段把每页的 HTML 存到本地文件,正则改一次不用重新请求,直接读本地文件测,效率高很多。
第四,尊重服务器。加延迟、控制并发、别在高峰期跑,这是基本的职业素养,也能让你的脚本活得更久。
4.4 数据落库的两种选择
小规模数据(几百条)直接写 CSV 就够了,简单直接,Excel 一开就能看。数据量大或者需要反复查询的,建议写进 SQLite:
import sqlite3 conn = sqlite3.connect("courses.db") conn.execute(""" CREATE TABLE IF NOT EXISTS course ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, teacher TEXT, credit REAL ) """) conn.executemany( "INSERT INTO course (name, teacher, credit) VALUES (?, ?, ?)", [(c["课程名"], c["教师"], float(c["学分"])) for c in courses] ) conn.commit() conn.close()SQLite 的好处是不用装数据库服务,一个文件搞定,后续用 SQL 做统计、去重、关联都很方便。CSV 适合交付,SQLite 适合自己反复折腾。
5. 抓取之后的延伸玩法
数据拿到手只是开始。课程信息这类结构化数据,能玩的方向不少:按教师统计开课数量、按学分排序找"水课"、把上课时间做成日历视图看时间冲突、分析哪些课程常年爆满。我自己最常用的是把课表导出成 iCal 格式,直接导入手机日历,比对着网页看方便多了。
另外,这套 Requests + re 的骨架其实可以迁移到很多类似场景:成绩查询、考试安排、图书馆借阅记录,只要页面是服务端渲染的 HTML,改改 URL 和正则就能复用。真正值钱的不是这段代码,而是"先看源码确认数据在不在 HTML 里、再决定用什么方案"这个判断习惯。
最后分享一个我调试正则的小技巧:把待匹配的 HTML 片段和正则表达式一起丢到在线的正则测试工具里,实时看匹配结果,比在代码里反复 print 快得多。等正则调通了再贴回脚本,能省下大量来回改代码的时间。