“用 Selenium 自动化抓取在线学习平台作业答案”——说实话,我最早看到类似的项目需求时,第一反应是这东西没什么技术含量,用 requests 模拟一下请求、解析一下 HTML 不就行了?但真正上手之后才发现,这类在线学习平台的前端早就不是十年前那种“服务端渲染完直接给你 HTML”的玩法了。页面全是异步加载、动态渲染,甚至按钮的点击状态都藏在某个 JavaScript 变量里。这时候 Selenium 的价值才真正体现:它不跟你玩协议、不猜接口,直接用真人浏览器视角完成“看到——点击——读取”的全流程。
这篇文章我不打算教你如何去拿某个平台的答案库,那既违反平台规则,也不尊重课程设计者的劳动。我会把这件事拆解成一个更干净的版本:如何用 Selenium 自动化完成一套“练习题页面数据采集与状态记录”的流程。这套流程覆盖登录、页面跳转、DOM 定位、显式等待、iframe 切换、数据落盘等核心技术点。你把这套能力吃透,之后用到自动化测试、数据采集、流程巡检都完全成立。
1. 为什么会盯上 Selenium:项目动机与技术选型
1.1 这类需求背后的真实诉求
“抓取作业答案”这类标题,本质上反映的是一种需求:用脚本代替手工重复操作。用户真正需要的往往不是题目答案本身,而是“我想用程序把自己的账号里那几十套练习题的题干、选项、我的提交记录、判分结果统一导出到 Excel 里,方便做知识复习整理”。这种批量导出的需求在考研刷题、考证备考、内部培训考核的场景里非常常见。
答案是什么不重要,重要的是你要顺手掌握“如何稳定地把网页上反复出现的数据结构抽出来”这套方法论。我见过太多人一开始只是为了抄答案,后来做着做着就入了自动化测试的门,玩起了测试框架和 CI 集成。所以别把这标题当歪门邪道,把它当个练习项目就好。
1.2 为什么 requests + BeautifulSoup 搞不定这类页面
拿某在线学习平台的课程练习页面举例,你用 requests 请求练习页 URL,拿回来的 HTML 里根本找不到题干和选项。原因很简单:这些内容不是写在初始响应里的,而是页面在 onload 之后通过 XHR 请求 API 接口拿 JSON 数据,再交给前端框架渲染出来的。
你打开浏览器开发者工具,切到 Network 面板,刷新页面,能看到十几个请求,其中某个 .json 或带 token 参数的接口才是真正返回题目数据的。理论上你可以逆向这个接口,用 requests 直接调它,但问题随之而来:
- token 怎么生成?要不要做签名校验?
- 请求头里的防爬参数如何构造?
- 返回的 JSON 结构是否会低频调整?
一旦接口做了调整,你的脚本立刻失效,又得花时间抓包、比对、改代码。Selenium 不存在这个问题。它操作的是真实浏览器,所有请求、cookie、渲染、事件绑定都是浏览器自己完成的,你只需要关心“人眼看到的东西”——也就是最终渲染出来的 DOM 结构。数据字段变了,改一下 CSS 选择器就行,成本低得多。
1.3 Selenium 的定位与能力边界
Selenium 不是抓包工具,也不是接口调试工具,它是“浏览器自动化框架”。它通过 WebDriver 协议和浏览器内核沟通,模拟真人操作:
- 打开网页,执行跳转,切换标签页
- 填写表单,点击按钮,处理弹窗
- 执行 JavaScript,提取元素属性
- 等待某个条件满足后再继续
它的能力边界也很清晰:凡是真人能在浏览器里做的,它基本都能做;凡是得靠协议逆向才能做的(比如直接伪造接口数据),它不一定擅长,也不建议去做。
所以这个项目的正确玩法是:把浏览器当成你的无头小助手,让它按固定的流程走一遍,把页面上该读取的数据稳定抓下来。这样既不需要逆向签名算法,也能最大程度贴合平台前端的变化节奏。
2. 环境准备:别在第一步就栽跟头
2.1 安装 Selenium 与浏览器驱动
环境是这类项目里最容易让人劝退的关卡。先装 Python 的 selenium 库,这一步几乎不会出问题:
pip install selenium真正麻烦的是浏览器驱动。Chrome 和 ChromeDriver 之间有着严格的版本对应关系,版本不匹配,启动浏览器时直接抛 SessionNotCreatedException。手动下载驱动然后配环境变量的老办法太累人了,我现在都推荐用 webdriver-manager:
pip install webdriver-manager用起来非常顺滑:
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver = webdriver.Chrome( service=Service(ChromeDriverManager().install()) ) driver.get("https://example.com")首次运行时它会自动下载匹配的驱动,后续走本地缓存。这能省掉八成环境问题。
2.2 三个最容易忽略的环境问题
第一,Chrome 的自动更新会静默升级浏览器版本,导致之前匹配好的驱动突然失灵。我建议要么关掉 Chrome 的自动更新,要么每次重跑脚本时让它先检测一次驱动版本。
第二,Linux 服务器上跑这个项目,通常缺各种系统依赖库,比如 libnss3、libgconf-2-4 之类的。启动时报缺少共享库的错误时,别慌,缺什么yum或apt装什么就行。
第三,无头模式(headless)在中文页面上的字体渲染可能导致元素宽度跟有头模式不一致,个别 CSS 选择器在无头模式下会选不到元素,或者元素被判定为不可见。调试时先用有头模式,脚本稳定后再切无头。
注意:无头模式加上禁用 GPU 这两个参数并非必须,但如果你的环境是 Linux 服务器,最好把这两项都设置好,不然很容易出现诡异的启动失败。
2.3 浏览器被识别为自动化工具的应对
现在的平台基本都会在前端挂检测脚本,检查 window.navigator.webdriver 是否为 true。一旦检测到,行为正常的账号也可能被要求额外验证。
常规手段是在启动参数里加排除项:
options = webdriver.ChromeOptions() options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) options.add_argument("--disable-blink-features=AutomationControlled")需要说明,这些手段不会让你突破底线去做什么违规的事情,它们的作用只是让你的脚本在合规使用场景下减少被平台无差别风控的干扰。如果你要在生产环境长期跑,更稳妥的方案是用浏览器的用户数据目录登录一次,把登录状态持久化,避免频繁触发登录验证:
options.add_argument("--user-data-dir=/path/to/profile")这样手动登录一次之后,后续启动都带着这个身份态。要注意控制请求节奏,Selenium 本身已经够“拟人”了,没必要再加代理池之类的骚操作,保持低调才是长期稳定运行的前提。
3. 核心机制:定位、等待、执行脚本
3.1 DOM 定位:不要迷信最简写法
很多初学者的第一直觉是 driver.find_element(By.CLASS_NAME, “xxx”),结果经常因为 class 带有动态变化或复合名称而直接报错。定位元素要遵循一个优先级逻辑:
- 优先用 id,id 是相对稳定的锚点
- 其次用 CSS 选择器,借由结构找要素
- 再次用 XPath,解决复杂结构和文本匹配场景
- 最后才用 class 名称,重点是选那种业务自定义的、不随渲染变化的静态 class
还有个建议:在写定位表达式之前,先在浏览器开发者工具的 Console 里验证一下 document.querySelector(你的选择器) 能不能选到唯一目标。这一步能省很多来回调试的时间。
3.2 显式等待与隐式等待的取舍
Selenium 等待机制分三类:强制等待、隐式等待、显式等待。
强制等待就是 time.sleep(3),稳定但笨拙,页面快了浪费时间,页面慢了照样崩。隐式等待是 driver.implicitly_wait(10),设置之后所有 find 操作都会在元素未出现时进行轮询,但它管不了“元素已出现但内容未更新”的情况。
真正要养成习惯用的是显式等待:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait = WebDriverWait(driver, 15) question_block = wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, "div.question-item")) )显式等待的优雅之处在于它明确表达“我等的就是这个条件”,可读性强,且不受全局设置影响。我个人的习惯是不设隐式等待,全程用显式等待,每个关键节点明确等它该等的东西,比如元素可见、元素可点击、某个文本出现。
3.3 execute_script 的妙用
Selenium 原生 API 覆盖了绝大多数点击、输入场景,但总有些交互是它搞不定的,比如页面里某个按钮绑定了一堆自定义鼠标事件,又比较傲娇。这时候别硬跟它较劲,直接上 JavaScript。
driver.execute_script("arguments[0].click();", button_element)同理,读取元素不可见文本、获取元素的滚动高度、强制设置为可点击状态这些场景,都适合用 execute_script 解决。但要记住:JavaScript 是绕过了事件模型的,如果某个按钮的点击回调依赖 mousedown、mouseup 事件的完整顺序,直接用 click() 触发可能不生效,这时候就得模拟完整的事件序列。所以我的原则是:先试原生方法,原生不行再上 JS,别上来就极端。
4. 实操:把练习题页面变成结构化数据
下面这一整套流程,我以某个在线学习平台的课程练习模块作为演示场景,把“抓取作业答案”这个敏感需求替换成“自动化整理练习题集”,效果一样,技术细节完全通用。
4.1 从登录启动到页面到达
平台都有扫码登录或账密登录。账密登录这种形式对自动化的干扰最少,但要注意登录接口可能在第二重验证上做文章。我的建议是把登录状态持久化,用 user-data-dir 那种方案,只用脚本完成一次完整的登录流程,把关键 cookies 保存下来。
代码骨架大致长这样:
import pickle from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = webdriver.ChromeOptions() options.add_argument("--window-size=1920,1080") # 登录态持久化 options.add_argument("--user-data-dir=/tmp/course_profile") driver = webdriver.Chrome(options=options) wait = WebDriverWait(driver, 20) driver.get("https://course-platform.example.com/login") # 如果是二维码登录:手动扫码后等待跳转 wait.until(EC.url_changes(driver.current_url)) # 进入课程详情页 course_url = "https://course-platform.example.com/course/1024/practices" driver.get(course_url)登录这步有个细节:如果你不需要持久化,也可以在登录成功后用 driver.get_cookies() 把 cookie 存到本地文件,下次启动时用 add_cookie() 灌回去。两种方案都行,前者更省事,因为 JS 写入的 localStorage 状态也能保留;后者更可控,适合多账号切换的场景。
4.2 遍历题目列表与定位题干
进入练习页后,先观察页面的结构。我拿到的页面大概长这样:左侧是题目进度列表,右侧是答题区域,题目区域包含题干、选项、已选状态或提交状态。按 F12 逐个检查这些元素,定位选择器。
这个环节的通用解是:
question_cards = wait.until( EC.presence_of_all_elements_located( (By.CSS_SELECTOR, "div.practice-question") ) ) print(f"发现 {len(question_cards)} 道题目")注意这里用的是 presence_of_all_elements_located,意思是所有匹配元素出现在 DOM 中即可,不要求可见。实际做题时可能有分页或懒加载,一页只显示几题,这样拿到的就不是全部题目。遇到这种情况,要么翻页遍历,要么检查是否有一个“全部题目列表”的侧栏,能快速切换题目索引,直接把题目编号对应的题干都读一遍。
4.3 提取文本、选项与提交状态
遍历题目卡片,对每个卡片做一次字段提取。这类页面结构通常稳定,直接查子节点:
for idx, card in enumerate(question_cards, start=1): try: title = card.find_element(By.CSS_SELECTOR, "div.question-title").text options = [ opt.text for opt in card.find_elements( By.CSS_SELECTOR, "li.option-item" ) ] # 状态可能以图标样式或文本形式存在 status_el = card.find_elements( By.CSS_SELECTOR, "span.status-text" ) status = status_el[0].text if status_el else "未作答" print(idx, title, "|", status) except Exception as e: print(f"第 {idx} 题解析失败: {e}") continue提文本这种操作看着简单,实际上有几个坑。第一,题目里混入公式图片、代码块、表格,text 属性只拿到可见文本,数学公式取不到。第二,选项文本带富文本标签时,比如加粗、上标,get_attribute("textContent") 比 .text 更稳。第三,状态字段不一定是文本,可能是一个 icon 的 class 名变化,需要去读 class 属性做判断。
遇到这些情况,我的建议是统一存 HTML 或 textContent,别只依赖 .text,宁可多存一点粗糙数据,也别漏了关键信息。
4.4 数据落盘与后续整理
Python 里最顺手的就是 pandas 加 openpyxl,直接把数据写进 Excel:
import pandas as pd rows = [] for idx, card in enumerate(question_cards, start=1): ... rows.append({ "题号": idx, "题干": title, "选项": "|".join(options), "状态": status, }) df = pd.DataFrame(rows) df.to_excel("practice_questions.xlsx", index=False)如果抓出来的数据量比较大,或者需要增量更新,用 SQLite 更合适。每轮抓取写一个带时间戳的批次字段,后续查询时就能对比差异。这个做法在常规文档里很少提到,但在数据增量同步场景中非常实用:
import sqlite3 conn = sqlite3.connect("practice.db") df["batch_time"] = "2024-06-01 10:00:00" df.to_sql("questions", conn, if_exists="append", index=False)数据落盘之后,建议顺手做一轮去重和一致性检查。比如同一道题在两个批次中题干完全一致,但选项被平台调整了顺序,就可以标记出来提醒自己注意,而不至于默默丢数据。
5. 常见问题与排查小结
5.1 元素找不到的三种典型情况
这类问题占我调试时间的一半以上,主要是三种情况:页面还没渲染完,元素压根没出现;动态 id 每次刷新都会变化;元素在 iframe 内部,当前上下文根本看不到它。
页面没渲染完用显式等待解决。动态 id 需要换个稳定的静态属性来定位,比如 class 或>iframe = wait.until( EC.presence_of_element_located((By.TAG_NAME, "iframe")) ) driver.switch_to.frame(iframe) # 操作完记得切回来 driver.switch_to.default_content()
还有个不太容易察觉的场景:平台面板中的滚动加载,元素虽在 DOM 中但尚未渲染进可视区,Selenium 的 is_displayed() 可能返回 false,直接执行 click() 会报元素点不到。解决办法是先 scrollIntoView:
driver.execute_script( "arguments[0].scrollIntoView({block:'center'});", element )5.2 页面元素被遮挡导致点击失败
弹层、悬浮广告、底部的客服浮窗,都会在物理上挡住目标按钮。Selenium 基于元素中心点执行原生点击,一旦遮挡就报元素点击被拦截。最稳妥的方式是先等遮挡物消失,或者主动关闭浮层。
浮层关闭按钮经常是静态 class 隐藏在页面深处,直接定位并点击即可。不行再用 JavaScript 点击绕过。但如果网站的弹窗是 fixed 定位盖在上面,直接把目标元素滚动到可见区的方案不一定奏效,必须把弹层干掉。
5.3 弹窗、iframe、新标签页三个坑
这三类问题每个都是真实踩坑的高频点。
弹窗分 JavaScript alert 和自定义模态框两种。alert 弹窗用 switch_to.alert.accept() 处理,自定义模态框就当成一个普通 DOM 去找它的关闭按钮。
iframe 我在前面已经讲过,遇到 Selenium 找不到内部元素时,先检查元素嵌套层数,逐层 switch_to.frame 进入。某些网页有多个同层级 iframe,需要根据 name 或 src 里的关键字精准切换。
新标签页的跳转机制也容易让人懵。某个点击会触发 target=_blank 打开新页面,但 driver 当前还是停留在旧页面。需要先获取所有窗口句柄,切到新窗口:
main_window = driver.current_window_handle # 点击操作触发新标签页 new_window = [w for w in driver.window_handles if w != main_window][0] driver.switch_to.window(new_window)用完记得切回主窗口,免得后续操作鬼打墙。
5.4 常见问题速查表
我把实战中碰到的高频问题整理成一张表,方便你粘贴到自己的笔记里速查:
| 问题 | 典型原因 | 解决方案 |
|---|---|---|
| 元素找不到 NoSuchElementException | 页面未加载完成 / 选择器错误 | 显式等待 + 在 Console 验证选择器 |
| 元素不可见 ElementNotInteractableException | 元素被遮挡 / 在视口外 | scrollIntoView 或先关闭浮层 |
| 点击不生效但无报错 | 事件绑定未触发 / 元素覆盖 | 用 execute_script click 或派发鼠标事件序列 |
| 内容在 iframe 中 | 未切换 frame 上下文 | switch_to.frame 进入操作 |
| 页面跳转后定位失效 | 旧元素引用失效 | 重新 find 元素,别保存陈旧引用 |
| 浏览器版本升级驱动失效 | 驱动版本不匹配 | webdriver-manager 自动安装 |
| 登录状态频繁失效 | 平台风控 / cookie 过期 | 持久化 user-data-dir 降低触发频率 |
| 偶发的超时 | 网络波动 / 资源加载慢 | 循环重试,最多重试 3 次,间隔指数递增 |
5.5 几个值得留意的细节
页面长时间连续操作后被平台判定为异常,这种问题和代码关系不大,更多是操作频率问题。我自己的节奏是每步操作之间控制一点间隔,别做成“无停顿连环点”的机器样。
还有个小细节:每跑完一轮,记得 driver.quit(),把进程和连接彻底关掉。只调 close() 的话,旧的浏览器进程有可能残留,时间长了系统一堆僵尸进程占内存,而且下一次启动容易出怪问题。
6. 边界与扩展:同样的技术,换个方向就是好工具
6.1 合规边界必须自己守住
一开始我就说了,标题里那个“抓答案”的方向不适合直接执行。作为在这个领域做过不少项目的从业者,我得把话讲透:自动化脚本本身没有原罪,但用它去获取非授权数据、绕过付费墙、破解验证码、侵犯他人账号信息,都属于越界。在线学习平台上的作业和测验是课程设计者教学考核的一部分,答案和评分逻辑属于平台和教师的合法权益,脚本批量收集并公开传播,轻则账号封禁,重则涉及法律风险。
所以我现在做练习项目,固定两个原则:第一,只操作自己账号权限范围内的内容;第二,只做“把数据拿回来整理成对自己学习有用的形态”这类工作,不碰修改成绩、批量提交答案这类危害公平性的操作。
这不是唱高调,而是吃过亏的人回头看才想明白的账。批量抓取这类行为一旦对平台运营造成明显压力,平台升级风控、改动页面结构,最后买单的还是你自己——不仅要重新适配代码,账号还可能被封,得不偿失。
6.2 把技术迁移到正经场景
这整套技术栈——页面自动化、数据提取、信息落盘——换一层皮就是通用的自动化工作流。我把它迁移到过好几个方向,效果都很好:
自动化巡检,每天定时打开业务后台,检查各核心页面关键数据是否正常,发现数值异常自动截图并推送提醒。这个方向用到的 Selenium 技术跟抓练习题的流程完全一样,只是把“抓题干”换成“抓指标”。
数据报表汇总,把分散在多个系统的数据页面按流程打开、取数、合并,形成一张总表,替代人工每天复制粘贴的操作。
线上课程质量保障,自动化模拟学生视角把课程视频播一遍,检查是否存在资源失效、样式错乱、练习题无法提交等问题。这正是基于 Selenium 的常用自测思路。
如果你所在团队在做教学平台或在线培训系统,可以尝试把上方这套流程改造成平台的自检工具,让测试人员不用重复走一遍完全相同的课程学习路径。
这些方向的共性特征是:重复、固定、人工操作成本高、出错概率大。只要满足这四个特征,就是 Selenium 适合介入的场景。判断要不要用它,标准很简单——如果一个熟练操作者每天都要花半小时做同一件事,那这事就该交给脚本去做。
7. 最后想说的几句实在话
写完这套自动化流程,我最深的体会是:Selenium 本身不难,真正难的是“等你真正搞清楚页面为什么这么设计的时候,你会比写十页脚本收获更多”这件事。每一次定位失败、每一次等待超时,背后都是前端渲染逻辑、事件机制、网络时序三者的配合。你把问题一个个查过去,实际上是在无意中补一门“网页运行原理”的课。
给想上手的朋友一个建议:别一上来就追求什么框架、并发、高可用,先用最简单的方式把一条主流程跑通。跑通之后再考虑增加异常处理、重试机制、日志记录这些工程化能力。等你能稳定做出一个能连续跑三天不出错的自动化任务时,你再回看这个项目,标题是什么已经不重要了,因为你手里真正掌握的是“让浏览器替你干活”这套通用能力。
顺手再分享一个耐用的习惯:长期跑的脚本,建议把控制台输出直接重定向到文件,加上时间戳,方便事后回溯。配合简单的断言逻辑——每个关键节点确认操作结果符合预期——就能让脚本从“能跑”进化到“能稳定跑”。这才是这类技术项目真正值得花时间的部分。