如果你也被学校那款外语在线学习平台折磨过,应该能秒懂这个场景:登录、进班、选单元、等内嵌的视频加载、播放、答题、保存、再切下一个任务节点。一个单元几十个节点,手动点完差不多一个钟头,中间还不能断,断了还得从头翻进度。这种活机械、重复、毫无长进,纯属消耗注意力。
为了把这些重复点击解放掉,我基于 Playwright 写了套叫 AutoCampus 的自动化脚本,把登录、遍历、播放、答题录入、进度记录、定时执行整条链路串了起来。这篇文章会把我踩过的坑和最终方案完整记录一遍。先说清楚:重点不是教你怎么“刷课”,而是讲明白一个浏览器自动化工具从零到能稳定挂机跑一周,需要解决哪些工程问题、绕开哪些坑。适合被重复性网页操作困扰的人,也适合想系统学 Playwright 自动化测试的同学参考。
1. 项目定位与核心设计思路
1.1 先把“假学习”和“真痛点”分开
先说句实话:这类在线学习平台的日常任务里,一部分是真正需要动脑的练习,另一部分则纯粹是机械打卡。机械打卡的部分包括:进入课程页面后按顺序打开每个学习节点、等待视频播放、翻页点选项、填词、保存、等到平台显示已完成后再关掉。这些动作放到网页自动化的视角下,就是一组固定的 UI 操作序列,不需要任何智能判断。
我的原则从一开始就很明确:只自动化“机械操作”,不碰需要理解内容的答题逻辑,也不做破解验证码、伪造进度之类的对抗性功能。原因后面我会单开一节细说,但核心就一句——这类工具的价值在工程框架本身,而不在于帮谁偷懒混学分。
这个原则也直接决定了技术选型方向:我需要的是一个能稳定驱动真实浏览器的工具,能处理动态加载的 DOM,能操作 iframe,能在异常时留下足够的调试信息。Playwright 几乎每一条都命中。当初选型时我对比过 Selenium 和 Puppeteer,最后锁定 Playwright,不是因为它“最新最火”,而是它在几个关键痛点上确实更省心。
1.2 为什么是 Playwright:三条关键对比
| 对比维度 | Playwright | Selenium | Puppeteer |
|---|---|---|---|
| 等待机制 | 内置自动等待,定位即等待 | 隐式等待与显式等待混用,易出幺蛾子 | 自带等待 API,但文档和生态较薄 |
| 定位方式 | Locator API,语义化选择器 | find_element 系列,维护成本高 | 与 Playwright 类似,但缺少多浏览器支持 |
| iframe 处理 | frame_locator 直接操作 | switch_to.frame 手动切换 | 支持,但操作略繁琐 |
| 登录态持久化 | storage_state 一行导出导入 | 需要自己拼 Cookie | 有类似能力,但封装较弱 |
| 调试工具 | Trace Viewer + Codegen 录制 | 主要靠截图和日志 | 有 HAR 之类,但整体不如前者顺手 |
Selenium 的问题在于脚本里到处都是 time.sleep,跑一次慢不说,稍一抖动就超时。Puppeteer 底层只能驱动 Chromium,对我们这种要长期挂机跑的平台来说差别不大,但 Playwright 把 context 隔离、持久化、多标签页管理做得更顺手。
还有一个非常实际的点:Playwright 自带 codegen,录制出来的定位表达式基本能直接用;Trace Viewer 能把一次运行里每个操作前后的页面 DOM 快照都留下来。后面排查问题的时候,trace 文件几乎就是“行车记录仪”,比看日志猜原因高效太多。
1.3 总体架构:把脚本拆成五个模块
我把整个项目拆成五个模块:浏览器管理器负责启动浏览器、加载登录态、注入通用配置;任务遍历器负责读课程结构、按顺序进入每个学习节点;任务执行器负责具体节点类型的操作(视频、选择题、阅读);状态管理器负责把每个节点的完成情况落盘;日志与通知模块负责记录运行全程并在异常时推送。
运行时序大概是:主程序读取 config → 浏览器管理器启动 context → 任务遍历器从左侧课程菜单拿到任务节点列表 → 逐个交给任务执行器 → 执行器操作完向状态管理器回报 → 全部结束后浏览器管理器回收资源。
这样拆的原因很实际:学习平台的页面结构更新很频繁。今天任务按钮在 A 位置,下周可能就挪到 B 位置。如果把页面结构和任务逻辑写死在一起,平台一改版,整个脚本就报废。拆开后,页面改版时只需要改任务配置里的选择器,任务逻辑和执行器不用动。这个思想后来帮我省了很多次返工。
2. 五大关键技术点的拆解与实操细节
2.1 登录与会话保持:先手动一次,后面全是复用
登录是这类平台自动化里最容易卡住的地方。账号密码输入本身不难,但很多平台会加短信验证码或行为验证,自动化代码去处理验证码属于典型的对抗性开发,既不稳定也没有必要。
我的做法是:第一次运行脚本时启动有界面的浏览器,用户手动完成登录;登录成功后用 context.storage_state() 把 cookie 和本地存储全部导出到一个 JSON 文件;之后的每次运行都直接加载这个文件,相当于把登录态“睡了一觉”再叫起来。第一次的“手动登录”只做一次,后续全程无人值守。
# 首次登录:有界面模式,手动完成验证码 context = browser.new_context( storage_state="state.json" if os.path.exists("state.json") else None, user_agent=USER_AGENT, ) page = context.new_page() page.goto(BASE_URL) # 此处人工输入账号密码并处理验证码 page.wait_for_url("**/home/**", timeout=180_000) context.storage_state(path="state.json") # 保存登录态注意,storage_state 文件里是真实的会话凭据,别把它提交到代码仓库,也别发给别人。另外平台一般七到十五天会强制重新登录,脚本每次跑完都应该检查自己是否还在首页;如果被踢回登录页,就触发一次人工重新登录流程。我在任务遍历器开始前会先做一次“登录态探活”,用 expect(page.get_by_text("我的课程")) 快速判断有没有进到首页。
2.2 选择器和等待策略:把脆弱的选择器替换成语义选择器
自动化的日常维护成本,90% 花在选择器上。初版脚本我图省事,直接复制了页面元素的 class 属性,结果平台一改版,class 全变,脚本全线崩溃。后来我改用语义化定位:能用文本就用文本,能用角色就用 page.get_by_role(),只有按钮和链接没有文本时才用 CSS 定位到稳定的父容器。
学习平台的页面是典型的单页应用,顶部菜单、左侧任务列表、右侧内容区,很多时候内容任务窗口还是嵌套 iframe。Playwright 对 iframe 的处理比 Selenium 优雅得多:不需要手动切 frame,直接用 frame_locator 在 iframe 里定位元素。
# iframe 内定位,不用切 frame frame = page.frame_locator("#activityFrame") task_link = frame.get_by_role("button", name="开始任务") task_link.click() # 等待一个“已完成”标记出现 frame.get_by_text("已完成").wait_for(timeout=60_000)等待策略也提一句:不要上来就 time.sleep(5)。Playwright 的 locator 自带 actionability 等待——点击一个元素前,它会自动等元素可见、稳定且可接收事件。你只需要设置一个合理的全局超时,然后依赖自动等待,配合极少数 expect(...).to_have_text(...) 来确认关键状态变化。我项目里只有视频播放这种特殊场景才显式等待,其余地方一律交给框架。
2.3 视频任务:别想着“瞬间刷完”,老老实实等它播完
学习平台里占任务量最大的类型就是视频任务。一开始我也想过直接 video.currentTime = duration,瞬间完成。实测后放弃了这个方案:平台的完成判断会在下一次刷新时校验播放时长,甚至会把 currentTime 异常的任务标记为未完成,要求重新观看。
老实做反而稳定——把视频交给播放器自然播放,脚本只负责两件事:一是确保播放器真的在播放,二是盯住平台的“完成信号”出现后关掉任务。很多平台的视频自动播放会被浏览器的自动播放策略拦下来;改成 muted 之后可以绕过拦截,但部分平台会检测 muted 状态并拒绝记录播放。这时候脚本就要先点击播放入口、再合理设置播放参数,模拟用户的正常操作。
def handle_video(frame, max_wait=3600): video = frame.locator("video") video.wait_for(state="visible", timeout=30 * 1000) # 确保进入播放状态 video.evaluate("(el) => { el.muted = true; el.play(); }") # 可把播放速率设置在合理范围,避免长时间空等 video.evaluate("(el) => { el.playbackRate = 1.0; }") # 等待平台的完成信号 frame.get_by_text("已完成").wait_for(timeout=max_wait * 1000)完成信号每个平台不完全一样:有的在任务内页显示“已完成”,有的在课程列表里把节点图标变绿,再有的需要看进度百分比。我是先手动做完一个完整任务,观察页面 DOM 里哪个元素先出现变化,然后把那个变化当作信号写进配置。这样脚本就不是在猜,而是在等一个明确的业务状态。
2.4 任务状态机与断点续跑
挂机脚本最怕的不是慢,而是一次崩溃后从头再来。解决方法是给每个任务节点维护一个状态:pending / doing / done。脚本每完成一个节点,立刻写入本地 progress.json;下次启动时先读取这个文件,从第一个 pending 节点继续。
状态机还有一个好处:可以随时中断。比如我想在某个节点临时插入人工操作,改一下文件里的状态就能让脚本跳过或从指定位置恢复。数据驱动的任务定义也很重要,课程结构、节点类型、选择器尽量写成 JSON 配置,而不是硬编码在代码里。
{ "course": { "unit_1": [ { "id": "a1", "type": "video", "frame": "#activityFrame", "done_flag": "已完成" }, { "id": "a2", "type": "choice", "frame": "#activityFrame", "next_btn": "下一题" } ] } }这里有个细节:完成标记不要只依赖“按钮变成灰色”这类样式变化,要监听业务状态。比如某题做完后页面出现“答案正确”的文字,或者节点列表里出现“已完成”的角标。把这些业务信号做成配置项,比写死在代码里更容易应对平台改版。
3. 从零搭建:实操过程与核心代码
3.1 环境准备与项目骨架
项目用 Python 写的,Python 3.10 以上,安装就两条命令:pip install playwright 然后 playwright install chromium。如果用 Node 环境也完全没问题,代码思路一致,只是 API 换皮。
目录结构我长期维护下来,推荐这样分层:
autocampus/ ├─ main.py # 入口 ├─ config.json # 任务配置 ├─ browser_manager.py # 浏览器启动、登录态加载 ├─ task_walker.py # 任务遍历与状态流转 ├─ actions.py # 各任务类型执行器 ├─ state_manager.py # 进度落盘 ├─ notifier.py # 日志与通知 └─ logs/ # 日志目录config.json 里除了任务列表,还放了账号位、UA、视口、延时区间、超时时间。账号密码这类敏感信息我建议用环境变量注入,而不是直接写进 JSON,因为你可能会把这个目录同步到自己的服务器仓库里。
3.2 关键代码:浏览器管理器与登录探活
from playwright.sync_api import sync_playwright import os UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." def create_browser(playwright, state_file="state.json", headless=False): browser = playwright.chromium.launch(headless=headless) context = browser.new_context( storage_state=state_file if os.path.exists(state_file) else None, user_agent=UA, viewport={"width": 1440, "height": 900}, ) page = context.new_page() page.set_default_timeout(15_000) return browser, context, pageUA 和视口设置主要是为了稳定,不是为了隐藏什么。平台没有强反爬,正常认证后的请求都是合规的。但一个以手机 UA 打开的页面和一个桌面 UA 打开的页面,DOM 结构可能完全不同;统一 UA 能避免很多莫名其妙的选择器失效问题。
登录探活的代码也很简单:进入首页地址后,等“我的课程”元素出现,如果超时就说明登录态失效,通知人工处理。
def ensure_login(page): try: page.goto(BASE_URL, wait_until="domcontentloaded") page.get_by_text("我的课程").wait_for(timeout=10_000) return True except Exception: logger.error("登录态已失效,需要重新手动登录") return False3.3 任务遍历与执行主循环
任务遍历器先从左侧课程菜单拿节点列表,再把每个节点交给 actions 里的执行函数。actions 内部按 type 分发:video 走视频处理,choice 走选择题点击模块,阅读类走翻页模块。
def run_all(page, tasks, state): for node in tasks: if state.is_done(node["id"]): continue state.mark(node["id"], "doing") try: execute_by_type(page, node) # 按 type 分发到 actions state.mark(node["id"], "done") except Exception as e: logger.error(f"{node['id']} failed: {e}") notifier.push(node, e) state.mark(node["id"], "pending") # 失败回滚,下次重试注意异常处理里把状态回滚成 pending,这样脚本崩溃后重新跑,不会跳过失败节点。如果平台出新任务类型,只需要在 actions.py 里新增一个 handler 函数并注册到分发表,其他代码不用动。
3.4 定时执行与无人值守
要真正“挂着跑”,脚本得能自己启动。Windows 上用任务计划程序建一个每天固定时间运行的任务,Linux 上用 cron,命令都是 python main.py --config config.json。
为了不让人看出来是机器在点,我在两次操作之间加了随机延时。延时范围放在配置里:MIN_DELAY=2 秒,MAX_DELAY=6 秒。随机延时的目的不是对抗平台,而是避免固定节奏在服务端形成明显模式,降低账号被风控的概率。
无人值守最怕出问题没人知道。我在 notifier.py 里封装了一个 Webhook 通知,脚本正常完成一天任务或者异常退出时,把摘要推到自己的通知渠道。日志按天滚动,保留最近七天;每次跑完还会检查一下日志磁盘占用,避免日志无限增长把磁盘撑爆。
3.5 实际运行一周的效果
项目完整跑了两周后,我把实际效果记录一下:每天任务量大约 40 个节点,脚本平均运行 70 分钟。两天内出现过一次因网络断线导致的节点超时,状态机从 pending 节点继续,没有造成重复劳动。
还有一次是平台改版导致两个节点的选择器失效,trace 文件帮我定位后更新了配置,五分钟就修掉。整体自动化率大约 96%,剩下 4% 是平台偶尔弹出的异常问卷,需要人工兜底。这个数字对个人自用工具来说已经相当理想了。
4. 疑难杂症与避坑经验实录
4.1 高频问题速查表
| 现象 | 常见原因 | 解法 |
|---|---|---|
| 脚本启动后停在登录页 | storage_state 过期或被服务端吊销 | 删除 state.json,重新手动登录一次 |
| 元素定位不到,报 timeout | 平台页面改版,或 iframe 层级变化 | 打开 trace 看 DOM,更新选择器配置 |
| 视频一直不播 | 浏览器自动播放拦截,或需要点击播放 | 先点击播放入口,或 muted 播放后再做处理 |
| 视频播完但节点未标完成 | 平台校验连续播放语义,不是单纯时间够 | 播放前先检查当前进度,避免从中间跳播 |
| 进度过夜后丢失 | 脚本崩溃后没有断点续跑 | 用状态机落盘,启动时读取 progress.json |
| 日志文件越来越大 | 没有轮转策略 | 按天轮转,保留最近 7 天 |
| 通知没有推送 | Webhook 回调失败 | 捕获异常,把通知失败也打进日志 |
4.2 让脚本稳定跑一周的实操心得
第一,选择器稳定五原则:优先用 role 和文本定位;如果平台有>