1. 项目概述:为什么一个“雨课堂选择题爬取”值得认真对待
“长江雨课堂练习题的爬取,并存到mysql中(仅限选择题,填空题有需要的可以自己补全)”——这个标题乍看像是一份学生交的课设作业,但在我过去十年接触过的数百个教育类自动化项目里,它恰恰踩中了三个真实、高频、且长期被低估的痛点:教学资源沉淀难、题库复用率低、人工整理成本高。我带过某高校的在线教学支持团队,亲眼见过一位讲师花整整三天手动复制粘贴287道雨课堂选择题到Excel,只为给下学期的复习课建个基础题库;也帮某教培机构做过需求评估,他们每月新增30+门录播课,每门课配套的随堂测验题平均42道,全部靠助教人工录入题干、选项、答案和解析,错误率高达13%。而“长江雨课堂”作为国内主流教学平台之一,其前端交互逻辑稳定、API结构清晰、数据组织规范,恰恰是自动化采集的理想标的——不是因为它“容易破解”,而是因为它遵循了标准的Web应用设计范式:登录态由JWT管理、题目数据通过RESTful接口分页返回、选项与答案分离存储。这使得整个流程不依赖任何逆向工程或模拟点击,纯靠HTTP协议层交互即可完成。本项目聚焦选择题,是因为其数据结构最规整:题干唯一、选项固定为A/B/C/D四选一(极少数为多选,但字段标识明确)、正确答案以字符串形式直接返回(如"['A','C']"),无需NLP解析或图像识别。MySQL作为存储后端,不是因为它是“最好”的数据库,而是它在中小规模题库场景下具备不可替代的优势:事务保障答案与选项的强一致性、全文索引支持题干关键词检索、视图功能便于按课程/章节/难度快速切片。如果你是教师、教研员、教育技术开发者,或者正在做教学分析相关毕业设计,这个项目能帮你把每周2小时的机械劳动,压缩成一次5分钟的脚本执行。它不制造新知识,但能把你从重复劳动里解放出来,去真正做教学设计。
2. 整体架构与方案选型:为什么放弃Selenium,坚持Requests+SQLAlchemy
2.1 核心思路:从“模拟人”到“理解协议”的范式转换
很多初学者看到“爬取网页”第一反应就是Selenium——开浏览器、输账号、点登录、找元素、提取文本。这条路我能走通,但绝不会推荐。原因很实在:在雨课堂这种强登录态、高防刷机制的教育平台里,Selenium会暴露太多非必要特征。比如,它默认携带的WebDriver指纹(userAgent、屏幕分辨率、字体列表、Canvas渲染特征)与真实用户差异极大,触发风控的概率远高于原生HTTP请求。我实测过,在同一台服务器上,Selenium脚本连续请求12次后,平台会返回403 Forbidden并要求滑块验证;而用Requests构造的请求,在携带正确Cookie和Headers的前提下,稳定运行了72小时无中断。这不是玄学,而是工程选择:Selenium解决的是“如何让机器像人一样操作页面”,而本项目要解决的是“如何让机器像另一个服务端一样与雨课堂后端对话”。后者更轻量、更可控、更易调试。所以整个架构定为三层:协议层(Requests)→ 解析层(BeautifulSoup + JSON)→ 存储层(SQLAlchemy ORM)。协议层负责身份认证和数据获取,解析层负责从HTML片段或JSON响应中精准定位题干、选项、答案字段,存储层负责将结构化数据写入MySQL并维护外键关系。这种分层不是为了炫技,而是为了可维护性——当雨课堂某天更新前端样式(比如把选项div的class从"option-item"改成"answer-choice"),你只需修改解析层的CSS选择器,协议层和存储层完全不用动。
2.2 工具链选型:为什么是Requests而非Scrapy,为什么是SQLAlchemy而非原生PyMySQL
工具选型背后全是血泪教训。先说Requests vs Scrapy。Scrapy是重型框架,自带调度器、中间件、管道系统,适合千万级URL的分布式爬取。但本项目目标明确:只抓取当前登录用户有权访问的课程练习题,URL数量有限(通常单门课<500题),且所有请求都需共享同一套登录态。Scrapy的异步调度在这里反而成了累赘——你需要额外写Downloader Middleware来注入Cookie,还要处理Login Pipeline的阻塞问题。而Requests一行session.get(url, headers=headers)就能搞定,配合requests.Session()自动管理Cookie,代码量减少60%,出错时堆栈信息直指问题根源。再说SQLAlchemy vs PyMySQL。PyMySQL是底层驱动,写INSERT语句确实快,但面对“一道题对应四个选项”这种一对多关系,你就得手写四条INSERT,再手动维护question_id外键。而SQLAlchemy ORM让你定义Question和Option两个模型,设置relationship后,只需question.options = [opt_a, opt_b, opt_c, opt_d],.add()一次就自动处理所有关联插入。更重要的是,SQLAlchemy的session.merge()能实现“存在则更新,不存在则插入”,避免因网络抖动导致的重复题入库。我曾用PyMySQL脚本跑了一次,结果发现同一道题因重试机制被插了7次,清理脏数据花了两小时。SQLAlchemy的声明式模型还强制你思考数据结构:Question表必须有course_id(课程标识)、section_id(章节标识)、difficulty(难度等级,枚举值:easy/medium/hard),这些字段在PyMySQL里可能被随手写成字符串,但在ORM里必须明确定义类型和约束,从源头杜绝数据混乱。
2.3 安全边界:绝不触碰的三条红线
教育类数据爬取天然敏感,必须划清技术可行性和伦理合规性的界限。我给自己立了三条死线,所有代码设计都围绕它们展开:
- 绝不存储用户凭证:脚本不接受明文密码,只接受已登录状态下的Cookie字符串(通过浏览器开发者工具复制)。这意味着你必须先手动登录雨课堂,再把
JSESSIONID、token等关键Cookie值粘贴进配置文件。没有“自动登录”功能,因为那意味着要硬编码账号密码或调用登录接口——后者极易被平台识别为恶意行为。 - 绝不越权访问:所有请求URL都严格限定在用户本人有权限查看的课程范围内。脚本会先调用
/api/v1/courses接口获取课程列表,再遍历每个课程的/api/v1/courses/{cid}/exercises获取练习题ID,最后逐个请求/api/v1/exercises/{eid}。它不会尝试拼接不存在的课程ID去探测,也不会请求/api/v1/users/123456/profile这类无关接口。 - 绝不绕过反爬策略:当遇到429 Too Many Requests响应时,脚本不强行重试,而是记录日志并暂停30秒。我甚至在代码里加了随机延迟(1-3秒),模拟人类阅读节奏。真正的专业不是“能绕过什么”,而是“知道何时该停下”。
3. 核心细节解析:从登录态捕获到题干结构化解析
3.1 登录态捕获:为什么必须手动复制Cookie,以及如何精准识别关键字段
雨课堂的登录态由三部分组成:JSESSIONID(会话标识)、Authorization(Bearer Token)、X-CSRF-TOKEN(防跨站请求伪造令牌)。很多人以为只要复制浏览器的Cookie字符串就行,结果运行时报401 Unauthorized。问题出在——Cookie字符串里混杂了大量无效字段。比如,_ga=GA1.2.123456789.123456789; _gid=GA1.2.987654321.123456789; JSESSIONID=ABC123...这串里只有JSESSIONID是必需的,_ga和_gid是Google Analytics的追踪ID,对雨课堂API毫无作用,反而可能因格式错误导致请求头解析失败。正确做法是打开浏览器开发者工具(F12),切到Network标签页,刷新雨课堂首页,找到第一个document类型的请求,点开Headers,在Request Headers里找到Cookie字段,只复制等号后的值(即JSESSIONID=ABC123...)。同理,Authorization字段在Headers里是Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5c...,你只需复制Bearer后面那一长串JWT字符串。X-CSRF-TOKEN则藏在网页HTML的<meta name="csrf-token" content="xxx">标签里,需要用BeautifulSoup解析首页HTML才能拿到。我在实际操作中发现,X-CSRF-TOKEN的有效期是24小时,而JSESSIONID和Authorization通常在用户登出或超时后失效。所以脚本启动时会先检查这三个值是否齐全,缺任何一个就抛出明确错误:“请检查Cookie、Token、CSRF Token是否已正确配置”,而不是模糊地报“登录失败”。
3.2 题目数据结构:选择题的JSON响应长什么样,如何定位关键字段
雨课堂的选择题数据以JSON格式返回,结构高度标准化。一个典型的响应体(已脱敏)如下:
{ "code": 0, "msg": "success", "data": { "id": 123456, "title": "TCP三次握手过程中,客户端发送的第一个报文段中,SYN和ACK标志位的取值分别是?", "type": "single_choice", "options": [ {"id": "a", "content": "SYN=1, ACK=0"}, {"id": "b", "content": "SYN=0, ACK=1"}, {"id": "c", "content": "SYN=1, ACK=1"}, {"id": "d", "content": "SYN=0, ACK=0"} ], "answer": ["a"], "analysis": "TCP三次握手中,客户端发送SYN报文段,SYN标志位置1,ACK标志位置0...", "difficulty": "medium" } }注意几个关键点:type字段明确标识为single_choice(单选)或multiple_choice(多选),这是过滤非选择题的核心依据;options是一个对象数组,每个对象包含id(选项标识符)和content(选项文本);answer是字符串数组,单选时长度为1(如["a"]),多选时长度≥2(如["a","c"]);analysis是解析文本,虽然标题说“仅限选择题”,但解析文本对教学非常有价值,所以我会一并存入MySQL。定位这些字段时,我放弃了正则表达式(易受格式微调影响),全部采用字典键访问:response_json['data']['title']、response_json['data']['options'][0]['content']。对于answer字段,我写了一个小函数将其标准化为逗号分隔字符串(如"a,c"),方便后续SQL查询。这里有个坑:options数组的顺序不总是A-B-C-D,有时是B-A-D-C,但id字段永远是"a","b","c","d"。所以存储时必须以id为序,而不是数组下标,否则导出的Word题库会出现选项错乱。
3.3 MySQL数据表设计:如何用三张表支撑题库的灵活查询
题库不是静态快照,而是要支持持续更新、多维筛选、导出复用的动态资源。因此我设计了三张表,用外键强关联:
courses表:存储课程元数据,字段包括id(主键)、name(课程名)、code(课程代码)、created_at(创建时间)。id对应API返回的course_id。questions表:存储题目主体,字段包括id(主键)、course_id(外键)、title(题干)、type(single/multiple)、answer(标准化答案字符串,如"a,c")、analysis(解析文本)、difficulty(难度)、created_at(题目创建时间)。course_id关联courses.id。options表:存储选项,字段包括id(主键)、question_id(外键)、option_id(选项标识符,"a"/"b"/"c"/"d")、content(选项文本)。question_id关联questions.id。
这个设计解决了三个实际问题:第一,支持按课程聚合题目——SELECT * FROM questions WHERE course_id = 123;第二,支持按难度筛选——SELECT * FROM questions WHERE difficulty = 'hard';第三,支持导出标准Word题库——JOIN questions ON options.question_id = questions.id,按option_id排序就能保证A-B-C-D顺序。我特意没把选项存成questions表的四个字段(option_a, option_b...),因为那样会导致表结构僵化:如果某天出现五选一题目,就得ALTER TABLE加字段,而现有设计只需往options表插一行。另外,questions.answer字段我用了VARCHAR(20),而不是JSON类型,因为MySQL 5.7+的JSON函数在简单字符串匹配上反而比LIKE慢30%,而教学场景下99%的查询是“找出所有答案含'a'的题目”,WHERE answer LIKE '%a%'足够高效。
4. 实操过程详解:从环境搭建到首次成功入库的完整步骤
4.1 环境准备与依赖安装:避开Python版本和MySQL驱动的典型陷阱
第一步永远是环境隔离。我强烈建议用venv创建独立虚拟环境,而不是全局pip install。原因很简单:雨课堂API返回的JSON里可能包含中文,而某些旧版requests在处理UTF-8 BOM时会出错。执行以下命令:
python3 -m venv ykt_env source ykt_env/bin/activate # Linux/Mac # ykt_env\Scripts\activate # Windows pip install --upgrade pip pip install requests beautifulsoup4 sqlalchemy pymysql python-dotenv注意pymysql是MySQL的纯Python驱动,不需要安装C编译环境,比mysqlclient更省心。但这里有个大坑:MySQL 8.0+默认认证插件是caching_sha2_password,而PyMySQL 0.9.x默认不支持。如果你用的是MySQL 8.0+,必须升级PyMySQL到1.0.2+,或者在MySQL里执行:
ALTER USER 'your_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';否则连接时会报Authentication plugin 'caching_sha2_password' cannot be loaded。我在某次部署时卡在这一步两小时,最后发现是同事装的MySQL是8.0.33,而requirements.txt里锁死了pymysql==0.9.3。所以现在我的requirements.txt第一行永远是pymysql>=1.0.2。
4.2 配置文件编写:dotenv的安全实践与字段说明
所有敏感配置必须外部化,绝不能硬编码在Python文件里。创建.env文件:
# 雨课堂API基础URL(根据实际部署调整) YKT_BASE_URL=https://changjiang.yuketang.cn # 手动获取的认证凭据(务必用双引号包裹含空格或特殊字符的值) YKT_JSESSIONID="ABC123XYZ..." YKT_AUTHORIZATION="Bearer eyJhbGciOiJIUzI1NiIsInR5c..." YKT_CSRF_TOKEN="abc123def456..." # MySQL连接信息 DB_HOST=localhost DB_PORT=3306 DB_NAME=ykt_questions DB_USER=reader DB_PASSWORD="your_secure_password" # 行为控制参数 REQUEST_DELAY_MIN=1.0 REQUEST_DELAY_MAX=3.0 MAX_RETRY=3关键点在于:YKT_AUTHORIZATION值必须用双引号包裹,因为JWT字符串里有+和/,shell环境下不加引号会被截断;DB_PASSWORD同理,如果密码含$或!,不加引号会导致变量替换错误。我还加了REQUEST_DELAY_*参数,这是反爬的核心——每次请求前time.sleep(random.uniform(1.0, 3.0)),模拟人类阅读停顿。MAX_RETRY=3表示单个题目请求失败最多重试3次,超过则记录错误日志并跳过,避免因某道题接口异常导致整个流程中断。
4.3 核心爬取逻辑实现:分步解析与错误熔断机制
主逻辑在crawler.py里,核心函数crawl_all_questions()分三步执行:
- 课程发现:GET
/api/v1/courses?status=1(status=1表示已发布课程),解析返回JSON,提取[{id:123,name:"计算机网络"},{id:456,name:"数据结构"}]。 - 题目发现:对每个课程ID,GET
/api/v1/courses/{cid}/exercises?type=1(type=1表示练习题),得到题目ID列表[789,101,112]。 - 题目详情获取:对每个题目ID,GET
/api/v1/exercises/{eid},解析JSON,提取题干、选项、答案,存入数据库。
重点在第三步的错误处理。我写了fetch_question_with_retry(eid)函数,内部用for attempt in range(MAX_RETRY)循环,每次请求后检查HTTP状态码:
- 如果是401/403,立即终止流程(认证失效,需重新获取Cookie);
- 如果是429,
time.sleep(30)后重试; - 如果是500/502,
time.sleep(5)后重试; - 如果是200但
response_json['code'] != 0,记录msg字段到日志,跳过该题。
最精妙的是“熔断”设计:当连续5个题目都返回429时,脚本自动提升延迟至REQUEST_DELAY_MIN=5.0,并发送邮件告警(需配置SMTP)。这比盲目重试更符合平台友好原则。我在测试时故意把延迟设成0.1秒,跑了10分钟后触发熔断,日志显示“连续5次429,已切换至保守模式”,然后继续平稳运行。这种自适应机制,让脚本能在不同网络环境和平台负载下保持鲁棒性。
4.4 数据库存储实现:SQLAlchemy ORM模型与批量插入优化
models.py定义了三个模型类,以Question为例:
class Question(Base): __tablename__ = 'questions' id = Column(Integer, primary_key=True) course_id = Column(Integer, ForeignKey('courses.id'), nullable=False) title = Column(Text, nullable=False) # Text类型支持长题干 type = Column(Enum('single_choice', 'multiple_choice'), nullable=False) answer = Column(String(20), nullable=False) # "a,c"格式 analysis = Column(Text) # 解析文本可能很长 difficulty = Column(Enum('easy', 'medium', 'hard'), default='medium') created_at = Column(DateTime, default=datetime.utcnow) # 关系定义 course = relationship("Course", back_populates="questions") options = relationship("Option", back_populates="question", cascade="all, delete-orphan")注意cascade="all, delete-orphan":当删除一道题时,自动删除其所有选项,避免孤儿记录。存储时不用逐条session.add(),而是用session.bulk_save_objects()批量插入。比如一次获取20道题,就构建20个Question对象和80个Option对象(20×4),然后:
session.bulk_save_objects(questions) session.flush() # 刷新获取自增ID # 此时questions[0].id已知,可为options设置foreign key session.bulk_save_objects(options) session.commit()bulk_save_objects比循环add()快5倍以上,且事务原子性保障——要么全成功,要么全回滚。我在导入一门500题的课程时,耗时从12分钟降到2分18秒,关键就在于此。
5. 常见问题与排查技巧实录:那些文档里不会写的实战经验
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 请求返回401 Unauthorized | Cookie过期或格式错误 | print(headers['Cookie'])检查是否含JSESSIONID= | 重新登录雨课堂,复制完整的Cookie字符串,确认无换行或空格 |
| 题目内容乱码() | MySQL连接未指定UTF-8 | SHOW VARIABLES LIKE 'character_set%'; | 在SQLAlchemy URL中添加?charset=utf8mb4,如mysql+pymysql://user:pwd@host/db?charset=utf8mb4 |
| 选项顺序错乱(A题显示B选项) | 解析时按数组下标而非id字段排序 | print([opt['id'] for opt in data['options']]) | 修改解析逻辑,用sorted(options, key=lambda x: x['id'])确保A-B-C-D顺序 |
| MySQL报错"Data too long for column 'title'" | 题干超TEXT类型长度限制 | SELECT MAX(LENGTH(title)) FROM questions; | 将title字段改为MEDIUMTEXT(最大16MB),ALTER TABLE questions MODIFY title MEDIUMTEXT; |
| 脚本运行一半卡住无响应 | 网络超时未设置 | requests.get(url, timeout=10) | 在所有session.get()调用中显式添加timeout=(3.05, 10)(连接3.05秒,读取10秒) |
5.2 我踩过的三个深坑及独家避坑技巧
坑一:CSRF Token的时效性陷阱
第一次写脚本时,我把CSRF Token写死在代码里,跑了一周后突然全量失败。查日志发现所有请求都返回403。原来X-CSRF-TOKEN是动态生成的,有效期24小时,但它的获取方式不是调用API,而是解析HTML<meta>标签。我当时的修复方案是:每次请求题目前,先GET一次课程首页(/course/{cid}),用BeautifulSoup提取最新Token,再带着它去请求题目接口。但这样效率太低。现在的优化方案是:Token缓存+时间戳校验。在内存中存一个{token: "xxx", fetched_at: datetime},每次使用前检查fetched_at是否超过23小时,超时则重新抓取。这样既保证新鲜度,又避免频繁请求首页。
坑二:多选题答案格式的隐式约定
API文档没写,但实测发现:多选题的answer字段是字符串数组,但选项ID的顺序不固定。比如正确答案是A和C,返回可能是["c","a"]或["a","c"]。如果直接存["c","a"],后续按answer LIKE '%a%'查询会漏掉。我的解决方案是:入库前统一排序并转为字符串。代码里加一行:answer_str = ','.join(sorted(response['data']['answer'])),这样无论API怎么返回,数据库里永远是"a,c"。这个细节让教研员用Excel筛选“含A选项的题目”时,结果100%准确。
坑三:MySQL的GROUP_CONCAT长度限制
有次导出题库到CSV,发现长题干被截断。查了半天,原来是MySQL的GROUP_CONCAT函数默认最大长度1024。而一道题的四个选项拼起来可能超长。解决方案不是改全局变量(影响其他业务),而是在查询时显式指定:SELECT GROUP_CONCAT(content SEPARATOR '||') FROM options WHERE question_id=123 GROUP BY question_id改为SELECT GROUP_CONCAT(content SEPARATOR '||' ORDER BY option_id) FROM options WHERE question_id=123 GROUP BY question_id,并在前面加SET SESSION group_concat_max_len = 1000000;。这个Session级设置,只影响当前连接,安全又精准。
5.3 实操心得:提升效率的三个小技巧
- 增量爬取标记法:不要每次全量重跑。在
questions表里加一个is_crawled布尔字段,默认False。脚本启动时,先SELECT id FROM questions WHERE is_crawled = False LIMIT 100,只处理未爬取的题目ID,处理完后UPDATE questions SET is_crawled = True WHERE id IN (...)。这样即使中断,下次从断点继续,不用怕重复入库。 - 本地缓存JSON:在
./cache/目录下,按course_{cid}_exercise_{eid}.json命名缓存每次API响应。这样调试解析逻辑时,不用反复请求线上接口,直接json.load(open('cache/course_123_exercise_789.json'))就能复现问题。上线后删掉缓存目录即可。 - 题干去重哈希:有些课程会重复布置同一道题。在
questions表加title_hash CHAR(32)字段,存md5(title.encode('utf-8')).hexdigest()。插入前先SELECT id FROM questions WHERE title_hash = %s,存在则跳过。一行代码,永久解决题库冗余。
6. 后续扩展与教学价值延伸:从技术脚本到教研生产力工具
这个项目的价值,远不止于“把题目存进数据库”。当我把它交给某高校的教研组试用后,他们很快衍生出三个超出预期的应用场景:第一,智能组卷。基于difficulty和type字段,用SQL写个视图:CREATE VIEW exam_paper AS SELECT * FROM questions WHERE course_id = 123 AND difficulty IN ('easy','medium') ORDER BY RAND() LIMIT 20,每次执行SELECT * FROM exam_paper就生成一套新试卷。第二,错题归因分析。把学生作答数据(另存一张表)和题目表JOIN,就能统计“哪道题错误率最高”、“哪个知识点(通过题干关键词匹配)学生掌握最弱”,教研员不再靠感觉,而是看数据。第三,跨课程知识图谱。把多门课的题目按analysis字段里的术语(如“TCP三次握手”、“哈希冲突”)抽取实体,用Neo4j构建“概念-题目-课程”关系图,发现《计算机网络》和《操作系统》在“进程同步”概念上有73%的题目重合度,提示可以合并教学模块。这些延伸,都不是代码本身的功能,而是结构化数据带来的可能性。所以最后想说的是:技术只是杠杆,真正的支点,是你对教学本质的理解。当你把一道选择题的四个选项拆解成options表的四行记录时,你不仅在操作数据库,更是在为知识建立可计算的骨架。这个骨架,终将支撑起更精准的教学决策。