凌晨一点多,我刚提交完当天最后一批选择题。看着页面上那个“练习进度100%”的进度条,脑子里第一个想法是——这活儿本来根本不需要人来做。
这就是 AutoUnipus 这个项目的起点。简单说,它是一个基于 Playwright 的自动化答题系统,专门解决某外语在线学习平台上反复刷题、重复点击、答案记忆这类枯燥操作。标题里所谓的“架构设计与实现原理”,拆开来看其实就三件事:怎么稳定控制浏览器、怎么准确地识别题目、怎么把答案填回去。这篇文章我打算把整个项目的设计思路、核心模块拆分、关键代码实现,以及实操过程中踩过的几个大坑都交代清楚。不管你是想学 Playwright 的工程化用法,还是想了解一个完整自动化工具该怎么搭,应该都能从里面找到能直接抄作业的东西。
先给结论:Playwright 的定位极其适合这类场景。它不像 Selenium 那样需要你手动维护一套等待策略,也不像 Puppeteer 那样被浏览器厂商绑死。后面我会逐层拆解,也会把一些参数选择背后的“为什么”讲明白,而不是简单扔给你一堆代码。
1. 为什么选 Playwright:自动化答题方案的技术选型底层逻辑
1.1 三个主流浏览器自动化方案的横向对比
动手写之前,我其实把现成的方案都过了一遍。Selenium 是老牌选手,生态成熟,但它的 WebDriver 协议在如今的前端架构下越来越显得笨重。Puppeteer 很好用,API 设计现代,可它只支持 Chromium 系。Playwright 是三者里最年轻但野心最大的一个,由微软团队在维护,底层走的是 CDP 加自研协议,一套 API 通吃 Chromium、Firefox 和 WebKit。
关于架构差异,用一个类比:Selenium 像是在操作系统外头雇了一个“操作员”,每次操作都要通过中间人去点击、输入,中间人还得随时听你指挥,慢且容易误事。Playwright 则是把“操作员”直接请进了浏览器内部,通过 CDP 协议和浏览器引擎对话,很多动作可以批量执行、精准等待,效率和稳定性完全不在一个量级上。对自动化答题这种要求频繁操作页面、等待异步渲染结果的场景来说,这个差异是决定性的。
我把三者的核心差异整理成了一张表:
| 对比项 | Selenium | Puppeteer | Playwright |
|---|---|---|---|
| 协议 | WebDriver | CDP | CDP + 自有协议 |
| 浏览器支持 | Chrome/Firefox/IE等 | 仅Chromium | Chromium/Firefox/WebKit |
| 自动等待机制 | 需显式配置 | 部分自动 | 全面自动等待actionability |
| 隔离上下文 | 不支持(靠多实例) | 单浏览器多页面 | 原生支持多context隔离 |
| 调试工具 | 一般 | 一般 | Trace Viewer与Inspector |
| 网络拦截 | 中间人代理 | 支持API | 内置route API,能力更强 |
单看这张表,Playwright 几乎是全面胜出。但真正让我下定决心的是它的自动等待机制,这个后面展开说。
1.2 Playwright 自动等待机制:为什么它能大幅提升自动化稳定性
我在最初用 Selenium 写脚本时,最常见的问题就是sleep满天飞。页面加载快慢、接口返程时间、动画过渡时间都有波动,你永远不能准确预判某个元素什么时候可点。用显式等待(WebDriverWait)能解决一部分问题,但每个元素都要单独写等待条件,代码量暴增,维护成本很高。
Playwright 的做法完全不同。它的操作会自动等待元素达到可操作状态(actionability),包括:元素可见、稳�定不抖动、不被遮挡、可编辑、可点击。也就是说,你调用locator.click()时,Playwright 内部会反复检查这些条件,直到元素满足要求或超时。这个机制藏在底层,代码层根本不需要写任何 sleep。
这个设计对于答题场景有多重要?在线平台的题目渲染完全是异步的——点“下一题”后,题目内容、选项、图片都是分批次加载,有些选择题还有倒计时动画。如果靠固定 sleep 1秒,网络稍微一卡就全盘崩掉。Playwright 的自动等待,相当于把“等人的耐心”内置进了操作原语里,这套系统的底座稳定性就是从这儿来的。
1.3 这个项目的几个特殊技术诉求
除了上述通用优势,AutoUnipus 还有几个特定场景诉求,Playwright 恰好都能满足。
第一个是多标签页与会话隔离。很多在线平台同时开着练习题页、成绩查询页、个人中心页,答题过程中经常需要在不同页面间切换。Playwright 原生支持多 context 隔离,每个 context 相当于一个独立的会话,cookie、localStorage 互不干扰。我可以开一个后台 context 查成绩,用另一个 context 做题,互不影响。
第二个是低侵入调试。自动化脚本跑着跑着突然报错是最让人头疼的。Playwright 的 trace viewer 能录制完整操作轨迹,包括每一步的 DOM 快照、网络请求、控制台日志。排查答题失败时,我可以回放整个操作过程,看清楚在哪一步出了问题,这种体验是以往任何自动化工具都给不了的。
第三个是可编程的响应拦截。有些题型需要临时获取接口返回的数据,比如多选题的正确答案分布。用page.route()拦截 XHR 请求,我可以把接口响应存下来,作为答题判断的外部数据源。
2. 系统整体架构:从页面渲染到答案提交的数据流设计
2.1 顶层架构与模块划分
AutoUnipus 没有把逻辑都塞在一个脚本文件里,而是按职责拆成了四个模块:浏览器控制层、题目解析层、决策引擎层、执行与验证层。对应到目录上,大约是:
autounipus/ ├── core/ │ ├── browser_ctl.py # 浏览器生命周期与上下文管理 │ ├── page_parser.py # 页面DOM解析与题目特征提取 │ ├── answer_engine.py # 题库检索与答案匹配算法 │ └── executor.py # 答题动作编排与提交 ├── data/ │ ├── question_bank.json # 本地题库(指纹索引) │ └── session_state.json # 答题进度、会话状态 ├── utils/ │ ├── text_norm.py # 文本归一化 │ └── logger.py # 结构化日志 └── main.py # 入口与调度逻辑整体数据流是单向的:浏览器控制层渲染页面,题目解析层从 DOM 里提取结构化题目,决策引擎层根据题目指纹去题库里找答案,执行层把答案填回页面并提交,最后把结果写回浏览器控制层进入下一题。
2.2 浏览器控制层:既然要玩自动化,先把“手”练稳
这一层负责所有和浏览器进程相关的脏活累活。用 Playwright 启动浏览器、创建 context、管理页面生命周期。工程上有几个细节值得注意。
第一个是用户数据目录(user_data_dir)的复用。在线平台登录之后会有记住登录态,如果每次跑脚本都重新走一遍登录流程,体验太差。我把 context 配置成指定用户数据目录,第一次手动登录完成后把 user_data_dir 保留下来,后续启动直接复用本地会话。
from playwright.sync_api import sync_playwright with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir="./profile_data", # 持久化登录态 headless=False, # 调试时用有头模式 viewport={"width": 1360, "height": 768}, locale="zh-CN", ) page = context.new_page() page.goto("https://platform.example.com/login") # 后续可复用登录态第二个是网络空闲的判断。答题页面是典型的单页应用,首次进入时请求非常多。用page.goto(url, wait_until="networkidle")会等待网络空闲再返回,能在绝大多数情况下保证页面核心渲染完成。但要注意,如果页面里一直有轮询请求(比如跑圈、进度条动画、markdown渲染探活),networkidle 可能永远等不到。实践中我通常用一个替代方案:等待关键选择器可见。所以这一层既保留 networkidle 作为兜底,也提供显式等待容器元素的方式。
第三个是页面异常稳定策略。做自动化的人都知道,页面出现意外弹窗是最常见的坑。我要在这一层做统一处理:注册一个全局的 dialog 监听,统一点击“确定”,把 popup 页面自动关闭的逻辑挂在 browser 级别的事件上。这样即使答题过程中弹了登录超时提醒之类的东西,也不会让整个流程卡死。
2.3 题目解析层:把 DOM 变成结构化数据结构
题目解析是整套系统的咽喉。Playwright 的定位能力再强,填不对解析逻辑,照样白搭。在线平台的题目 DOM 结构通常有一定规律,但每套题的实际渲染又有差异,所以解析层要同时兼顾“约定好的 label”和“容错性强的路径回退”。
我总结的解析策略分三层:
- 首选稳定标志:页面里题型卡片往往带特异的 data 类名,平台前端工程师可能特意标了
>from playwright.sync_api import Page class PageParser: @staticmethod def parse_question(page: Page) -> dict: # 定位根元素:优先>import hashlib import json import difflib class AnswerEngine: def __init__(self, bank_path): with open(bank_path, "r", encoding="utf-8") as f: self.bank = json.load(f) def _fingerprint(self, text: str) -> str: normalized = normalize_text(text) return hashlib.sha256(normalized.encode("utf-8")).hexdigest() def lookup(self, stem: str) -> dict | None: key = self._fingerprint(stem) if key in self.bank: return {"confidence": 1.0, "answer": self.bank[key]} best_score, best_ans = 0.0, None norm_stem = normalize_text(stem) for stored_key, stored_ans in self.bank.items(): ratio = difflib.SequenceMatcher(None, norm_stem, stored_key).ratio() if ratio > best_score: best_score, best_ans = ratio, stored_ans if best_score >= 0.85: return {"confidence": round(best_score, 2), "answer": best_ans} return None这里有个隐藏的大坑:如果题库本身有脏数据,模糊匹配反而会命中错误的题。所以我后来又给题库加了字段维护“来源批次”和“验证次数”,只有验证次数高于阈值的答案才允许参与模糊匹配。这个经验后面在问题排查章节还会细说。
2.5 执行与验证层:动作编排与结果回读
执行层做的是最机械也最容易出错的事:把决策引擎给的答案落到页面控件上。看起来只是 fill 和 click,但对不同题型的动作编排差异非常大。
单选题:定位选项的 label 元素,点击。这里要小心许多单选是包在自定义组件里的,原生 radio 被设成
display:none,直接点 input 是无效的,必须点对应的 label 或父级 div。多选题:需要先对比选项文本和答案集合,逐项点击。点击顺序错乱可能导致部分选择被覆盖,所以编排时先做勾选去重,再统一提交。
判断题:本质上也是单选,但选项文本可能是“对/错、正确/错误/√/×”,需要先做二值映射。
填空题:文本框直接 fill 答案。但有些平台会对输入做防抖监听,填完还要触发一次 blur 让前端校验通过。
提交阶段的动作编排反而比答题更讲究。直接点“提交”按钮很容易触发校验弹窗——“还有X题未作答,确定提交?”这些弹窗的文案千变万化,硬编码不可取。我的做法是:先尝试点提交按钮,再检查页面是否出现“确定提交”“确认”类按钮,如果出现就再点一次。这样即使传错文案,也能被兜底逻辑接住。
最后一步验证:提交完成后,页面通常会给出得分或正确率。我会读取这个结果区间元素,记录到 session_state 里,以备整体汇总和日志分析。如果读取不到分数,也可以退而求其次判断是否有“已完成”或“继续学习”这类状态变更,作为“提交动作已生效”的依据。
3. 核心实现原理与关键代码拆解
3.1 元素定位策略:三个层级的“寻找答案”
Playwright 的定位器(locator)设计比 Selenium 领先一代,但也容易让人掉进“选择器太死”的陷阱。在线平台的页面重新上线后,CSS 类名经常变,硬编码选择器是最脆弱的写法。我的定位策略分了三个层级:
第一层用语义化标记。有些前端框架会渲染出类似
>option = page.locator( f"label:has-text('{answer_text}')" ).first这个写法用“文本包含”来定位答案文本对应的 label,能在绝大多数情况下命中。但 text 过长时可能因为匹配到多个候选而报错,所以前面还要加个
.filter(has_text=...)约束,同时在选不到的时候用all()遍历候选再比对精确文本。3.2 单页应用(SPA)的动态加载:等待真正的内容就绪
在线平台的答题页是一个典型的 SPA,每次点击“下一题”之后,页面并不会整页刷新,只有局部 DOM 被替换。这带来的问题在于:如果用传统方式等页面加载完成,等来的往往是旧页面。正确做法是等待题目根元素的内容变化,而不是页面 load 事件。
Playwright 里提供了
page.expect_response()来配合请求返回。但更笨但更可靠的方式是:记录当前题目的指纹,点击“下一题”后,用page.wait_for_function检测题目根元素的文本是否改变。original_hash = fingerprint(page.locator(".question-item").first.inner_text()) page.locator(".next-btn").click() page.wait_for_function( """(oldHash) => { const el = document.querySelector('.question-item'); if (!el) return false; const hash = el.innerText.trim(); return hash !== oldHash; }""", arg=original_hash, )这个方案不依赖任何平台特定的接口,只依赖通用 DOM 语义,所以平台改版时不需要动这套逻辑。
3.3 HTTP 请求拦截:从接口层面判断答题状态
有一些判断题,页面会根据用户的点击实时在接口里反馈正确与否,前端再渲染一个对勾或叉叉。如果每次都靠 DOM 回读状态来判断是否答对了,会非常慢且容易误判。Playwright 的
page.route()能让我直接看网络层发生了什么事。比如某个提交答案的 API 会在响应的 JSON 里带
is_correct字段,我用 route 拦截把这个字段存到全局变量:correctness = [] def on_response(response): if "submit_answer" in response.url: try: data = response.json() correctness.append(data.get("is_correct")) except Exception: pass page.on("response", on_response)这个技巧在调试答题逻辑时极其好用。它把“答案对不对”的判定从 DOM 层提升到了数据层,逻辑变得更简洁、判定也更可靠。
3.4 异常处理:让脚本在极端情况下也不至于失控
任何一个面向“无人值守”的自动化工具,都必须有充分的异常兜底。我在执行器里定义了一个
AnswerExecutionError的自定义异常,并在每一题的循环里 catch:- 如果某道题在定位选项时抛出 TimeoutError,且已经重试了两轮,就跳过这道题并记录日志。
- 如果连续 3 道题都异常,说明可能遇到了改版或登录失效,直接停止整个流程并截图、导出 trace。
- 对于提交按钮的失效情况,如果点击后 10 秒内没检测到状态变化,尝试点击确认弹窗里的按钮并检查页面返回的错误提示。
这层防护在上线之后救过我至少两次:一次是平台深夜维护引发的整体白屏,一次是登录 token 过期导致所有题目无法渲染。
3.5 日志与可见性:自动化工具的可观测性设计
你可能觉得日志是最无聊的部分,但恰恰是它决定了调试效率。AutoUnipus 的日志分成三层:控制台交互日志、本地文件日志、TMS 结构化日志(JSON lines)。每答完一道题,结构化日志里会记下题目指纹、匹配方式、置信度、执行耗时、页面 URL 这些字段。解决“为什么这道题答对了但下一题错了”这类问题时,这层日志是唯一的可靠线索来源。
logger = logging.getLogger("autounipus") logger.setLevel(logging.INFO) handler = logging.FileHandler("run.log") handler.setFormatter(logging.Formatter( "%(asctime)s | %(levelname)s | %(name)s | %(message)s" )) logger.addHandler(handler)务必要记录的一个关键字段是 Playwright 的 trace 文件名。报错时我可以直接用
playwright show-trace xxx.zip打开操作回放,比靠猜要高效得多。4. 实战踩坑记录:几个常见问题与排查思路
4.1 元素被自定义组件遮挡,点击无效
这是最高频的问题。平台的选项按钮通常不是原生 radio,而是自家封装的
ChoiceButton组件,点击区域覆盖层很厚。用 Playwright 的click()点原生 input 时会报element is not clickable,原因是另一个元素(覆盖层)遮挡了它。我的排查思路:先录 trace 看遮挡元素是哪一个,再用 Playwright Inspector 生成替代定位。最终方案是点 label 或父级容器。如果你也遇到类似问题,建议优先检查
label[for]和label包裹的结构,其次是点击选项文本元素。4.2 题号乱序与随机化打乱
部分练习存在随机抽题和打乱选项顺序的机制,体现在题目顺序上不固定。单纯靠“题号 + 指纹”匹配不可靠——同题干可能出现在不同题号位置,选项 A/B/C 的顺序也不同。
解决方案是上文提到的:题目指纹只依赖题干语义文本,不依赖题号和选项 label。匹配答案时也只对重新归一化后的选项文本做集合比较,而不是按位置填选项。这样即使选项顺序换了,正确答案依然能被正确勾选。
4.3 平台改版导致定位器全部断裂
有一次平台改版把所有
class="question-item"换成了自定义组件类名,导致我原来写的一整套 CSS 定位全部失效。Playwright 在这种情况下会抛出严格模式异常,说“找到多个匹配元素”或“找不到元素”。我的长期对策是:把定位器全部收敛到少数几个函数里,并支持从配置文件里热切换选择器。这样改版时我只要改配置里的选择器,不用修改代码。日常里也要定期跑一次“冒烟测试”,只做定位器验证不答题,快速发现定位器失效的隐患。
4.4 弹窗拦截导致流程卡死
答题中途最容易出现的弹窗有三类:活动广告、登录过期提示、新手引导蒙层。广告可以用 route 拦截 URL 跳过渲染,但登录过期和引导蒙层处理不掉。我的做法是在执行器循环里周期性地检查一个“关键弹窗识别”函数:如果检测到登录过期弹窗,就暂停整套流程并等待人工介入;如果是引导蒙层,点掉“下一步/跳过”按钮继续。
4.5 题库数据污染导致的连锁错误
题库积累到一定量后,我发现在模糊匹配阶段偶尔会命中一道“长相相似但实际不同”的题,导致后面好几道题连续答错。排查后发现是早期手动录入题库时输入错了一个字,误导了相似度匹配。
这个坑让我后来把题库管理当成了完整的数据治理问题来做:每一条答案都记录来源(手动录入、接口采集、人工验证)、添加时间、验证次数;新增答案时必须先对比全文指纹,确认库里没有相同指纹后才会写入;每周跑一次全量自检,找出相似度过高的题目对,人工确认是否需要合并或删除。
4.6 常见问题速查表
问题现象 根因 解决思路 click 无效,提示元素被遮挡 自定义组件覆盖层 改点 label、父级容器或文本元素 定位到多个元素,严格模式报错 选择器过宽 用 filter(has_text=...)或遍历匹配题目渲染了但答案找不到 题目指纹未命中 检查题库收录情况,记录未匹配题目 页面加载后瞬间白屏 SPA 渲染时序问题 等关键容器出现后再操作 提交按钮点击无响应 前端校验弹窗或按钮置灰 点击后检测弹窗再点确认 登录态失效导致全部失败 token 过期 检测登录页特征,及时停止并人工处理 5. 风险边界与合规使用思考
5.1 自动化答题工具的合法性边界
必须先说清楚:AutoUnipus 是我用来学习浏览器自动化技术的一个练习项目,它能完成一套完整的“页面渲染、数据提取、决策、回填”工程闭环,从技术角度是非常有代表性的实践。但任何自动化答题工具,如果用到实际的课程学习或作业完成场景中,都存在明显的学术诚信风险。
在线教育平台的学习进度和答题结果,本质上是用户完成学习计划的真实凭证。如果用脚本代答,相当于伪造了学习过程和成绩记录,这在高校的课程管理和学籍评价体系里通常被视为学术不端行为。我在开发过程中一直把它严格限定在自己的技术测试环境,用公开 Demo 页面做功能验证,从未用于真实课程学习。这个边界不能突破。
如果你也想练手,建议同样只在本地测试环境或公开的练习 Demo 上跑,用技术能力验证可行性就好。工具无罪,但使用方式必须对得起身处的规则体系。
5.2 对线上服务的影响与反检测考量
再说一个工程层面的伦理问题。自动化脚本如果高频操作,会给平台服务器带来额外压力,甚至影响其他正常用户的使用。Playwright 虽然不主动提供反检测能力(它本身是给测试工程师用的),但我在工程上仍然做了几个常规的“有礼”设置:
- 每次答题动作之间加入随机延迟 800ms 到 1800ms,模拟人的阅读和点击节奏。
- 限制单次运行的最大题量,超过后自动暂停。
- 设置请求超时和重试上限,避免网络异常时形成无意义的轰炸。
这些不是为了“躲检测”,而是为了让工具行为更像一个“合理使用系统的人”,减少对平台服务的影响。它和我做分布式压测时的限流思路是一样的——好的自动化系统应该考虑对被测系统的友好性。
5.3 项目后续可扩展的方向
做完这个项目,我最大的收获不是“能自动刷题”的成就感,而是把一套完整的“浏览器控制 + 数据解析 + 决策引擎 + 状态管理”的架构摸熟了。这套架构可以无缝平移去写其他任何网页自动化场景,比如:
- 自动巡检系统:定时检查网站核心功能是否正常。
- 数据采集工具:从公开网页提取结构化数据。
- Web 端到端测试框架:把解析层换成断言逻辑,就是一个合格的 E2E 测试框架。
- 表单自动填充工具:应用于内部运营系统的重复填表工作。
尤其是最后一点,我后来真的重构出了一个内部工具的雏形,把大量人工填报工作变成了流程化脚本。这也说明:技术本身没有属性,决定价值的一直是使用场景与初衷。
写在最后的个人体会
项目从开始动手到最终跑通,前前后后花了两周多时间。第一版用 Selenium 写,代码到了 800 行就开始失控,等待逻辑在各种
WebDriverWait里反复打转;重构到 Playwright 之后,代码量反而缩到 500 行以内,稳定性却翻了倍。这让我对“架构”两个字有了更具体的感受:好的架构不是锦上添花,它能直接决定一个工具能维护多久、能扛住多少变化。我个人最大的建议是:如果你想从这个项目里学到东西,不要只盯着“怎么把答案填进去”这一小步,多关注题目指纹的生成逻辑、多级定位的回退机制、异常状态的可观测性设计——这些才是能迁移到所有自动化场景的通用能力。往后再做类似项目,你会发现百分之八十的时间都花在与平台自身的不可控性对抗上,而这些经验,只有亲手踩过坑才能真正留下来。