基于Playwright的自动化答题系统架构设计与实现解析
凌晨一点多我刚提交完当天最后一批选择题。看着页面上那个“练习进度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 协议和浏览器引擎对话很多动作可以批量执行、精准等待效率和稳定性完全不在一个量级上。对自动化答题这种要求频繁操作页面、等待异步渲染结果的场景来说这个差异是决定性的。我把三者的核心差异整理成了一张表对比项SeleniumPuppeteerPlaywright协议WebDriverCDPCDP 自有协议浏览器支持Chrome/Firefox/IE等仅ChromiumChromium/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, # 持久化登录态 headlessFalse, # 调试时用有头模式 viewport{width: 1360, height: 768}, localezh-CN, ) page context.new_page() page.goto(https://platform.example.com/login) # 后续可复用登录态第二个是网络空闲的判断。答题页面是典型的单页应用首次进入时请求非常多。用page.goto(url, wait_untilnetworkidle)会等待网络空闲再返回能在绝大多数情况下保证页面核心渲染完成。但要注意如果页面里一直有轮询请求比如跑圈、进度条动画、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, encodingutf-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( flabel: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; }, argoriginal_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 平台改版导致定位器全部断裂有一次平台改版把所有classquestion-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 行以内稳定性却翻了倍。这让我对“架构”两个字有了更具体的感受好的架构不是锦上添花它能直接决定一个工具能维护多久、能扛住多少变化。我个人最大的建议是如果你想从这个项目里学到东西不要只盯着“怎么把答案填进去”这一小步多关注题目指纹的生成逻辑、多级定位的回退机制、异常状态的可观测性设计——这些才是能迁移到所有自动化场景的通用能力。往后再做类似项目你会发现百分之八十的时间都花在与平台自身的不可控性对抗上而这些经验只有亲手踩过坑才能真正留下来。