写这篇文章之前先说明一下我的立场油猴Tampermonkey本身是一个合法且强大的浏览器扩展管理工具社区里也有大量优质的效率脚本。自动答题这个方向通常适用于企业内部不涉及考核排名的知识测验、在线课程的过程性练习、或你自己的模拟自测目的是省下重复机械操作的时间。如果用于正式考试、认证考试或任何有明确作弊规定的场景请务必停手这不仅违反平台规则也可能带来学术或职业上的风险。在合规前提下拆解这个技术方向本身其实对我们理解浏览器工作原理、DOM 操作和异步编程都很有帮助。1. 先弄明白油猴脚本的本质1.1 油猴脚本到底是个什么东西Tampermonkey 的官方定位是用户脚本管理器它本身不干任何具体的事而是提供了一个运行环境让你可以往任意网页里注入自定义的 JavaScript 代码。这些代码就是所谓的用户脚本它们能在页面加载完成后执行能读取和修改页面上的任何 DOM 元素能发起网络请求能操作浏览器的本地存储。很多人第一次接触油猴是用来装XX网盘直链解析XX视频去广告这类现成脚本。但油猴真正强的地方在于你可以自己写脚本针对特定页面做定制化操作。自动答题脚本就是一个典型场景——当页面上的题目和选项都是结构化 HTML 元素时脚本完全可以做到读取题目文本、匹配答案库、自动点击选项、自动提交这一整套流程。这背后的技术原理并不玄乎浏览器的 DOM API 是公开且标准的任何页面只要不是浏览器内置页面都暴露在脚本的访问范围内。油猴脚本不过是用match或include声明了自己要注入哪些 URL然后在document层面操作而已。1.2 自动答题脚本能解决什么实际痛点我最早写这类脚本是因为公司内部有一个强制性的合规培训系统。每季度要在上面完成十几门课程每门课后有 10 道测验题答对 8 道以上才算通过。题目倒是不难但数量多、重复性强而且很多题在课程视频里根本找不到原话答错一次还得重新再答。连续两个季度在点击下一题提交查看分数这些机械操作上浪费了几小时之后我终于决定花一个晚上写个脚本把这事解决了。如果你也遇到过这些情况那这个脚本思路就天然适合你在线学习平台的课后测验答完不能直接看正确答案要等系统判分。企业内部考试系统题目是选择题答案在题库里能搜到。自己刷题用的题库网站想要自动核对答案、统计正确率。需要反复答题刷积分/刷课时的场景但自己本身已经理解题目内容。油猴脚本在这个场景下的优势是无可替代的不需要任何服务端资源不依赖具体平台的 API纯粹靠前端 DOM 操作就能完成闭环。哪怕某天平台改版了你只需要调整脚本里的选择器不用重新搭一套系统。1.3 为什么选择 Tampermonkey 而不是其他工具市面上有 Greasemonkey、Violentmonkey、Tampermonkey 三款主流脚本管理器还有浏览器自带的 F12 控制台也能执行代码。我推荐 Tampermonkey原因如下兼容性好同时支持 Chrome、Firefox、Edge、Safari。API 完善提供了GM_xmlhttpRequest、GM_setValue、GM_addStyle等专有 API特别是跨域请求和本地存储能力在答题脚本里用处很大。BUG 少、更新勤我在多款浏览器上长期使用稳定性是三个里最好的。脚本管理方便开关、分组、更新检查都很顺手。浏览器 F12 控制台的最大问题是你每次打开页面都要手动粘贴代码页面一刷新就没了。而油猴脚本可以做到匹配 URL 后自动加载这是能否实际用的决定性差别。2. 动手前必须搞懂的脚本骨架2.1 脚本元数据块的正确写法油猴脚本最容易被忽视、却最关键的部分是顶部的元数据块。一个标准的答题脚本骨架长这样// UserScript // name 在线测验自动答题助手 // namespace http://tampermonkey.net/ // version 1.0.0 // description 自动匹配题目答案并点击选项 // author your-name // match https://study.example.com/* // grant GM_xmlhttpRequest // grant GM_setValue // grant GM_getValue // run-at document-idle // /UserScript这里有几个字段是踩过坑之后才真正理解的match是注入页面的白名单。写得太宽脚本会在无关页面执行造成干扰写得太窄目标页面进不来。建议先按实际 URL 精确匹配到域名一级再逐步缩窄。grant声明脚本要用哪些 Tampermonkey API。这里有个多数新手不知道的细节如果你在代码里想用GM_系列 API必须先在元数据里声明对应项否则脚本运行时这些 API 会被替换成空对象直接报错。反过来如果你不声明星挂载 API脚本在沙箱环境中运行页面自己的全局函数也无法访问这点后面细说。2.2 沙箱机制为什么有时拿不到全局变量Tampermonkey 默认把脚本运行在隔离的沙箱中这意味着脚本里声明的var不会污染页面的全局命名空间同时页面里的window、document也不会和脚本共享同一个引用。这个设计本意是安全隔离但答题脚本经常遇到的坑就来自这里某些平台的题目数据不在 DOM 里而是挂在某个 JS 全局变量上比如window._examData或window.QUESTION_LIST。你想直接读window._examData结果发现是undefined。原因就是沙箱里的window不是页面真实的window。解决办法有两种第一种在元数据块里加上grant none脚本就会直接在页面上下文中运行能直接读取页面全局变量但代价是失去所有GM_API。第二种用unsafeWindow对象访问页面全局变量const pageData unsafeWindow._examData; if (pageData) { console.log(拿到了答题数据, pageData.length); }这个unsafeWindow是 Tampermonkey 专门提供的绕过沙箱通道答题脚本里遇到全局变量基本都靠它。我个人的习惯是脚本要用GM_xmlhttpRequest去请求外部题库接口所以必须保留grant同时用unsafeWindow补上对页面全局变量的访问两者不冲突。3. 自动答题的核心技术拆解3.1 页面结构观察从浏览器调试台开始任何自动答题脚本的起点都是相同的打开目标页面按 F12 进入开发者工具逐个观察题目的 DOM 结构。千万不要跳过这一步直接写逻辑。你把平台当成黑盒不可能写出稳定的脚本。以一套典型的选择题页面为例DOM 结构大致是这样的div classquestion-item>document.querySelectorAll(.question-item).length // 返回题目数量 document.querySelector(.question-item .question-text).innerText // 返回题干文本这一步能帮你排除 80% 的选择器写错问题。实际项目中平台的类名经常带有哈希后缀比如question-item_ab12cd这时候不要直接用完整类名写进脚本而是用属性选择器或部分匹配。我在后面完整实例里会示范怎么写。3.2 答案来源的三种方案确定正确答案是整个脚本的核心逻辑根据平台和场景不同有以下三种方案从易到难排列方案一本地题库映射最简单也最不依赖外部条件的方式。提前把题干 正确答案的对应关系写进脚本里的一个对象const ANSWER_MAP { 以下哪种协议用于加密网页传输: B, HTTP 默认端口号是多少: 80 };运行时从 DOM 里读到题干文本去ANSWER_MAP里查答案找到就点击对应选项。这个方案的局限很明显题库得你自己维护题目一变脚本就失效。适合题目固定、数量少的场景。方案二远程题库接口查询用GM_xmlhttpRequest把题干文本发到你自己的服务端接口接口返回答案。这样题库不需要固化在脚本里更新答案只需要改服务端。这也是GM_xmlhttpRequest最值钱的地方——它不受浏览器同源策略限制可以跨域请求任意域名的接口。GM_xmlhttpRequest({ method: POST, url: https://your-api.example.com/api/quiz, data: JSON.stringify({ question: questionText }), headers: { Content-Type: application/json }, onload: function (res) { const result JSON.parse(res.responseText); if (result.code 0) { clickOption(result.data.answer); } } });这个方案要注意的是不要同步请求所有 GM_xmlhttpRequest 都是异步回调脚本逻辑里要用 Promise 或回调去控制流程别指望拿到返回值再做下一步。方案三页面全局变量直接读取这是最高级但也最容易被平台防御手段封禁的方案。有些题目的正确答案直接存在页面的 JavaScript 初始状态数据里比如从__INITIAL_STATE__或window._examData里能看到正确答案字样。你需要先分析数据字段确认没有加密、没有混淆再决定是否读取。这种方案的好处是准确率 100%坏处是平台一旦发现很容易通过改数据结构来反制而且读取初始状态数据的行为在日志审计上是很容易被发现的。所以这里再提醒一次请确认你的使用场景是合规的不要拿这套技术去做作弊的事。3.3 点击行为的触发细节很多人写自动点击会直接用.click()方法这套方式能用但不总是能成功。原因是前端框架Vue、React对 DOM 事件侦听器的挂载方式不同。React 的事件系统是在根节点上做事件委托并且使用了合成事件。当你直接调用原生.click()时React 的合成事件通常还是会触发的但某些自定义事件如mousedown-mouseup-click的联动就不会被正确模拟。Vue 的情况好一些但 v-model 绑定的输入框如果用.valuexxx直接改值再触发input事件往往还需要手动补发一个事件才行。更稳妥的做法是尽量模拟真实用户的操作序列。比如对于单选按钮可以这样触发完整事件链function simulateClick(el) { const rect el.getBoundingClientRect(); const opts { bubbles: true, cancelable: true, view: window, clientX: rect.left rect.width / 2, clientY: rect.top rect.height / 2 }; el.dispatchEvent(new MouseEvent(mousedown, opts)); el.dispatchEvent(new MouseEvent(mouseup, opts)); el.dispatchEvent(new MouseEvent(click, opts)); }另外还有一个经常被忽略的点某些答题平台的下一题按钮会在选项点击之后延迟出现或者需要等待一段动画结束。这时候脚本必须在点击选项后轮询检测按钮状态而不是直接setTimeout。我见过很多半成品脚本就是因为每次延迟时间对不上而反复失败。4. 一套可直接复用的选择题自动答题脚本4.1 脚本整体结构设计下面这套脚本是我在实际项目中用过的改造一下选择器就能适配大多数选择题平台。整体思路是轮询等待题目容器出现因为页面可能是异步渲染。依次处理每个题目元素读取题干匹配答案。点击对应选项并等待下一题按钮可用。全部题目处理完后点击提交按钮。为了增加复用性我把平台相关的选择器都提取到最上方配置区方便你按实际页面改。4.2 完整脚本代码// UserScript // name 通用选择题自动答题脚本 // namespace https://blog.example.com/ // version 1.2.0 // description 读取页面题目匹配本地题库并自动选择答案最后自动提交 // author your-name // match https://your-exam-site.example.com/* // grant none // run-at document-idle // /UserScript (function () { use strict; // 配置区按目标平台修改 const CONFIG { questionBoxSelector: .question-item, // 每道题目的根容器 questionTextSelector: .question-text, // 题干 optionSelector: .option-item input[typeradio], .option-item input[typecheckbox], // 选项输入框 optionLabelSelector: .option-item, // 选项整行用于定位可见文本 nextButtonSelector: .next-btn, // “下一题”按钮 submitButtonSelector: .submit-btn, // “提交”按钮 answerInterval: 800, // 每道题处理后的等待间隔 ms maxWaitTime: 15000 // 等待容器出现的最长时间 ms }; // 本地题库题干 - 正确答案值 const ANSWER_MAP { 以下哪种协议用于加密网页传输: HTTPS, HTTP 默认端口号是: 80 }; // 工具函数模拟真实点击 function fireClick(el) { if (!el) return; const rect el.getBoundingClientRect(); const baseOptions { bubbles: true, cancelable: true, view: window, clientX: rect.left rect.width / 2, clientY: rect.top rect.height / 2 }; el.dispatchEvent(new MouseEvent(mousedown, baseOptions)); el.dispatchEvent(new MouseEvent(mouseup, baseOptions)); el.dispatchEvent(new MouseEvent(click, baseOptions)); } // 工具函数等待元素出现 function waitForElement(selector, timeout CONFIG.maxWaitTime) { return new Promise((resolve, reject) { const startTime Date.now(); const timer setInterval(() { const el document.querySelector(selector); if (el) { clearInterval(timer); resolve(el); } else if (Date.now() - startTime timeout) { clearInterval(timer); reject(new Error(等待元素超时: selector)); } }, 200); }); } // 核心逻辑处理单个题目 async function handleQuestion(questionEl) { const textEl questionEl.querySelector(CONFIG.questionTextSelector); if (!textEl) return false; const questionText textEl.innerText.trim(); const answer ANSWER_MAP[questionText]; if (!answer) { console.warn([答题脚本] 题库中未找到该题答案:, questionText); return false; } const options questionEl.querySelectorAll(CONFIG.optionSelector); for (const option of options) { // 选项对应的可见文本 const labelEl option.closest(CONFIG.optionLabelSelector); const text labelEl ? labelEl.innerText.trim() : option.value; // 匹配时忽略大小写和全半角符号差异 if (text.replace(/\s/g, ) answer.replace(/\s/g, ) || option.value answer) { fireClick(option); console.log([答题脚本] 已选择:, questionText); return true; } } console.warn([答题脚本] 选项匹配失败:, questionText); return false; } // 核心逻辑处理整页题目 async function handleAllQuestions() { const questionEls document.querySelectorAll(CONFIG.questionBoxSelector); console.log([答题脚本] 检测到题目数量:, questionEls.length); for (const questionEl of questionEls) { await handleQuestion(questionEl); await new Promise(r setTimeout(r, CONFIG.answerInterval)); } } // 入口等待题目出现后执行 (async function () { try { await waitForElement(CONFIG.questionBoxSelector); // 有的平台分多个页签每页只显示一道题这种需要在“下一题”按钮上做监听 const hasNextBtn await Promise.race([ waitForElement(CONFIG.nextButtonSelector, 3000).then(() true), Promise.resolve(false) ]); if (hasNextBtn) { // 分页模式逐题处理 let pageCount 0; const maxPages 100; // 防止死循环 while (pageCount maxPages) { const questionEl document.querySelector(CONFIG.questionBoxSelector); if (!questionEl) break; await handleQuestion(questionEl); const nextBtn document.querySelector(CONFIG.nextButtonSelector); if (nextBtn) { fireClick(nextBtn); await waitForElement(CONFIG.questionBoxSelector, 5000); } else { break; } pageCount; } } else { // 列表模式整页题目一次性处理 await handleAllQuestions(); } // 最后点击提交按钮 const submitBtn document.querySelector(CONFIG.submitButtonSelector); if (submitBtn) { fireClick(submitBtn); console.log([答题脚本] 已点击提交按钮); } } catch (err) { console.error([答题脚本] 执行失败:, err.message); } })(); })();4.3 脚本核心逻辑讲解脚本最值得看的两个地方一是waitForElement函数。考试平台的前端普遍是异步渲染的你在document-idle阶段未必能立刻拿到题目节点。用轮询替代固定的setTimeout是解决异步渲染问题的标准做法同时也方便处理多页签切换时每道题的重新渲染。二是hasNextBtn的分支判断。这里用了一个技巧Promise.race在 3 秒内检测下一题按钮是否存在。检测结果决定了脚本走逐题翻页模式还是整页列表模式。为什么这么写因为不同平台的分页逻辑差很远有的平台是一个容器里展示全部题目有的是滚动加载还有的是每页只有一道题必须手动点下一题刷新容器。用一个探测逻辑自动适配这两种主流模式比让用户手动配置省事得多。4.4 如何把这个脚本改成你自己的答案源如果你不想把题库写死在脚本里可以用我前面提到过的GM_xmlhttpRequest改成远程查询模式。只需做三处改动元数据块里加上grant GM_xmlhttpRequest。把handleQuestion中的ANSWER_MAP[questionText]替换为异步请求函数。处理函数改成async并等待请求返回。改完后即使题库在服务端更新脚本也不会作废。这里要注意一点远程请求接口务必加上鉴权否则题库会被任何人利用同时也可能因为你频繁请求而触发服务端限流。我自己的做法是每人分配一个 token每次请求限制频率每分钟 60 次超过直接返回 429。5. 实战中遇到的坑与排查思路5.1 脚本不生效的第一排查顺序无论脚本写得再好不生效的原因八成是这几类按顺序查看控制台有没有报错。Tampermonkey 会把你脚本里的console.error和原始错误直接输出到浏览器控制台这是最快的切入点。检查match是否覆盖当前 URL。注意match的格式是协议 域名 路径*通配符作用在域名和路径上别写成http://*example.com这样。确认脚本开关是打开的且没有因为版本更新被禁用。在脚本顶部加一行console.log(脚本开始执行)如果这行都没输出说明脚本根本没注入。5.2 元素总在变化用 MutationObserver 代替暴力轮询我之前遇到的平台题目容器在每次点击下一题后会被整个替换而且这个替换过程有大约 300ms 的延迟轮询不稳定。更稳妥的方案是用MutationObserver监听容器子树变化function observeContainer(container, callback) { const observer new MutationObserver((mutations) { for (const mutation of mutations) { if (mutation.addedNodes.length) { callback(); return; } } }); observer.observe(container, { childList: true, subtree: true }); return observer; }用监听器最怕的是回调被反复触发造成重复答题。所以我在实际脚本里加了一个节流阀变量监听器触发后立刻关闭监听等当前题目处理完再重新打开。这个细节如果不注意很容易出现一道题被答两次、按钮被点两次的情况。5.3 平台加了时间卷轴和乱序选项怎么办部分平台为了防止脚本会做两件事给题目加>el.dispatchEvent(new Event(change, { bubbles: true }));很多教程只教你点一下实际遇到这类自定义控件就抓瞎。6. 合规边界、效率原则和后续扩展写到这里我想把话挑明自动答题脚本本质是一种页面自动化工具本身没有善恶之分。它能帮你省下重复劳动的时间也能做坏事。判断一个脚本能不能用在某个场景下标准很简单——会不会让本来需要你理解的知识被无意义地跳过会不会损害其他参与者的公平性。我个人遵守的原则是三条不用于任何正式考核、不用于任何有排名或认证性质的测验、不将脚本和题库分享给没有权限的人使用。在这三条线以内的自动化比如内部培训的重复性测验、自建题库的刷题练习想怎么写都行学到的 DOM 操作和异步协调技巧也完全能迁移到其他更正经的前端工程任务里去。最后分享几个后续可以做的升级方向都是我在不同阶段实践过的答题结果回传答完题后自动抓取判分页面的分数存到GM_setValue里并在下次做题前显示历史正确率做成个人复习工具。多选与判断题兼容脚本里目前只写了单选多选需要从题库里存答案数组判断题则简化成对/错两个选项的映射改动量不大。可视化面板用GM_addStyle 页面里插入浮动面板实时展示本次答题的正确率、耗时和未命中题目清单这样脚本就不只是闷头干活而是能辅助你复习错题。自动答题这件事我一直觉得最有趣的部分不是让脚本替我做题而是通过脚本理解浏览器在事件循环、异步渲染和 DOM 操作上到底是怎么工作的。把这套思路吃透下次你再遇到任何网页上的重复操作都会想到——也许一个脚本就能解决。