news 2026/10/13 1:35:53

Python爬虫实战:用Requests和正则搞定教务系统课表提取

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:用Requests和正则搞定教务系统课表提取

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里,经常有&nbsp;、&amp;这样的HTML实体,还有大量的空格和换行。提取出来的文本如果不处理,会带着这些杂质,影响后续使用。

import html def clean_text(text): text = html.unescape(text) # 转换 &nbsp; &amp; 等实体 text = re.sub(r'\s+', ' ', text) # 多个空白字符合并为一个空格 return text.strip()

html.unescape()是Python标准库里的函数,能把&nbsp;转成空格,&amp;转成&。这个函数很多人不知道,自己写替换逻辑又容易漏掉某些实体,直接用标准库最省事。

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。工具没有优劣,只有适不适合。

调试时间远大于写代码时间。这个项目我写代码可能只用了两个小时,但调试花了整整一个下午。登录参数、编码问题、正则匹配、请求频率,每一个环节都可能出问题。所以如果你也在做类似的项目,不要因为调试时间长就觉得自己不适合,这是正常的。

尊重目标网站,控制请求频率。这一点怎么强调都不为过。教务系统是学校的基础设施,不是给你练手用的。加延时、限制并发、避开高峰期,这些都是基本的素养。如果每个人都毫无节制地爬,最终受害的是所有使用者。

这套方案我已经用了好几个学期,每学期开学跑一次,几分钟就能把课表整理好。代码不复杂,但确实解决了实际问题。如果你也在为课表整理烦恼,不妨试试这个思路。遇到问题的时候,先别急着换方案,把当前的方案调试通,你对爬虫的理解会上一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:35:12

C#手写算法与数据结构:性能、内存与生产级落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:35:04

计算机组成原理运算器习题解析:补码、Booth算法与浮点数运算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:34:40

离线环境用RPM包安装GCC:本地源搭建与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:34:40

精密单点定位(PPP)原理、误差建模与实战收敛指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:33:00

产品EMC设计与风险评估:从后期整改到事前预防的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华