1. 从“一键答完整个练习页”说起:油猴脚本到底做了什么
说实话,看到“油猴自动答题”这个标题,我的第一反应不是“又来一个作弊脚本”,而是“终于有人开始认真研究浏览器自动化了”。我自己写这类脚本,最初的动机其实特别朴素:某内部培训平台的练习题,全是单选和多选,每次重做都要重新点一遍,答案我又记得,纯粹是机械劳动。后来我就想,能不能让浏览器自己把这些题点完,我只负责检查结果。
这里必须先把边界说清楚:自动答题脚本本身是个很经典的前端自动化技术练习,但它只应该用在你自己有权限、且平台允许自动化操作的场景里,比如本地部署的演示项目、内部测试系统、或者你自己搭建的题库练习环境。拿去刷真实考试的题、绕过在线测验的规则,轻则被平台封号,重则涉及学术诚信和合规问题,这个责任得你自己扛。我的所有分享都建立在“合法练习、技术学习”的前提下。
那油猴在这件事里扮演什么角色?很多人把油猴(Tampermonkey)当成一个“外挂工具”,其实它更像一个浏览器端的脚本运行容器。它不生产答案,也没有自带题库,它提供的是一个稳定的注入环境:你写一段JavaScript,声明好“在哪些网站上运行、什么时候运行、需要哪些权限”,油猴就负责把这段脚本塞进目标页面里执行。自动答题的所有逻辑,本质上都是这段注入的脚本在前台页面DOM里干活。
我简单拆一下它的执行链路:
- 你打开目标网页,浏览器开始加载页面文档。
- 油猴根据脚本的
@match规则判断当前网址是否匹配。 - 匹配后,按照
@run-at指定的时机把脚本注入页面。 - 脚本开始监听DOM、读取题目、匹配答案、触发点击事件。
- 页面上的表单状态变化由浏览器正常处理,脚本并不需要“绕过”什么。
也就是说,答题脚本之所以能工作,不是因为油猴有什么黑魔法,而是因为网页本身就是一堆可以被JavaScript操作的DOM节点。只要页面里的题目、选项、按钮都是标准HTML元素,那理论上任何前端脚本都能操作它们。油猴只是让这件事变得非常方便——你不需要自己写浏览器扩展、不需要打包、不需要发布审核,改完代码刷新页面就能生效。
1.1 油猴不是答题工具,而是一个脚本运行环境
很多人第一次接触油猴,是从“装一个脚本就能看视频VIP”“装一个脚本就能去广告”这种使用场景开始的。这导致一个普遍误解:油猴本身好像自带很多功能。其实你把油猴卸了再装上,它是空的,什么功能都没有。它提供的唯一核心能力是用户脚本(User Script)管理。
拿自动答题来说,你在油猴里写的那个脚本,本质上就是一个带特殊注释头的JavaScript文件。油猴读取这个文件头部的元数据块,就知道这个脚本叫什么、在哪些网址生效、需要哪些API权限。剩下的全是纯前端代码。
这里有个重要的概念:普通页面里的脚本和油猴注入的脚本,权限是不一样的。页面自身的脚本受同源策略约束,想跨域请求别的接口需要服务端配合;但油猴脚本可以通过@grant声明获得一些扩展API,比如跨域请求(GM_xmlhttpRequest)、本地存储(GM_setValue/GM_getValue)、弹出通知(GM_notification)等。自动答题脚本里最有用的是GM_xmlhttpRequest,因为如果你的题库放在自己的服务器或者一个接口里,纯页面内脚本会被CORS挡住,而油猴脚本只要声明了@grant GM_xmlhttpRequest,就可以绕过页面的跨域限制,直接请求外部接口。这让“脚本读取在线题库”变成可能。
但我要提醒一句:跨域能力是双刃剑。它能让你方便地拉题库,也同样意味着你的脚本有权限把你的操作数据发给任意第三方。如果你用的是网上下载的答题脚本,尤其是那种要求配置“答案API地址”的脚本,你最好先搞清楚这个地址是谁的服务器。我见过不少打着“免费题库”旗号的脚本,实际上在偷偷收集用户的答题记录和账号信息。自己写脚本,自己掌控数据流向,才是安全的。
1.2 元数据块:决定脚本何时、何地、以什么权限运行
写油猴脚本的第一步不是写逻辑,而是写头部注释。这个头部注释虽然有//开头,看起来像普通注释,但油猴会专门解析它。我把一个典型的答题脚本元数据块贴出来:
// ==UserScript== // @name 本地练习题库自动作答 // @namespace https://example.com/auto-answer // @version 0.1.0 // @description 在允许自动化操作的本地练习页面上自动完成选择题作答,仅用于技术学习与合法授权场景 // @match http://localhost:8080/exam/* // @match https://demo.example.com/practice/* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect api.example.com // ==/UserScript==几个关键字段我说一下:
@match:决定脚本在哪些网址运行。http://localhost:8080/exam/*是带路径匹配的写法,星号是通配符。这里我建议匹配路径写细一点,免得脚本在无关页面上空跑。@run-at:决定注入时机。可选值有document-start、document-end、document-idle。答题脚本一般用document-idle,也就是DOM加载完、页面基本就绪之后再跑。如果你要拦截页面上的某些初始化请求,那是另一个话题,需要document-start配合更底层的API。但对自动答题来说,等DOM就绪是最稳的。@grant:声明需要的油猴扩展API。如果脚本不需要任何特殊权限,可以写@grant none。一旦用到了GM_开头的API,就必须在@grant里写明,否则脚本会报错。@connect:配合GM_xmlhttpRequest使用,声明允许跨域请求的域名白名单。这个字段很实用,它防止脚本乱请求任意网站。
很多人写脚本半天不生效,排查半天发现是@match写错了。比如页面实际地址是http://localhost:8080/exam/index.html,你写的是http://localhost:8080/exam,不带通配符,那子路径就不会匹配。我的习惯是@match写得稍微宽一点,然后在代码里再用location.href做二次判断,双保险。
1.3 用 MutationObserver 处理动态加载的题目
自动答题脚本最怕的一种情况是:打开页面时题目还没渲染,等脚本跑完一轮查找,发现页面上一个题目节点都没有。传统的window.onload只能保证资源加载完成,但很多现代前端框架(尤其是Vue和React)会在加载完成后异步请求题目数据,再渲染到页面上。这时候你不管在哪个时机注入,都可能扑空。
正确的姿势是监听DOM变化。这里我会重点讲MutationObserver,因为它是原生API,不依赖任何框架。
const config = { childList: true, subtree: true }; function onDomChange() { const questions = document.querySelectorAll('.question-item'); if (questions.length > 0) { // 有题目了,开始处理 answerPendingQuestions(questions); } } const observer = new MutationObserver(onDomChange); observer.observe(document.body, config);这个监听器一旦挂上,页面上任何新增节点、删除节点的操作都会触发回调。你可以在回调里做两件事:一是判断题目是否出现,二是判断题目是否已经答完。当所有题目都答完时,记得调用observer.disconnect()把监听器摘掉,否则函数会一直空跑,浪费性能。
这里有个细节:childList: true监听的是子节点增删,subtree: true表示递归监听所有后代节点。如果你只想监听某个容器,可以把observe的第一个参数从document.body换成那个容器元素,性能会好很多。页面越大,subtree: true对性能的影响越明显,我建议能收窄就收窄。
2. 自动答题脚本的核心三问:题在哪、答案在哪、怎么点
一个答题脚本能不能用,本质上就回答三个问题:题目文本怎么提取,答案从哪来,选项怎么被选中。这三个问题看似简单,但每个都有不少门道。
2.1 题目文本提取的两条路线
题目的DOM结构因平台而异,但提取文本就两个大方向:按选择器拿、按文本特征找。
按选择器拿是最直接的。绝大多数练习页面,题目区域都有语义化class,比如.question-title、.option-item、.choice-label。你打开浏览器F12,选中一个题目,看它的class名,然后写对应的选择器即可。
function getQuestions() { return [...document.querySelectorAll('.question-item')].map(item => { const titleElem = item.querySelector('.question-title'); const optionElems = item.querySelectorAll('.option-label'); return { title: titleElem.innerText.trim(), options: [...optionElems].map(opt => opt.innerText.trim()), root: item }; }); }这里有个经典坑:innerText和textContent的差异。innerText会触发重排,拿到的是“用户看到的文本”,对隐藏元素的内容会忽略;textContent拿的是DOM里的原始文本,不关心显示状态。对于题目提取,我多数用innerText,因为我们要匹配的答案是给用户看的文字。但要注意,如果某些选项是通过CSS隐藏了文字、只显示图片,innerText就会拿不到内容,这时候你得针对图片场景单独写逻辑。
另一条路线是“按文本特征找”。适用于那些class名混乱、带随机后缀、每次刷新都会变的页面。这种页面没法依赖class,得靠正则或者文本匹配来定位。比如:
function findQuestionsByText() { // 找到包含“1. ”“2. ”这种题号前缀的元素 const allTextBlocks = document.querySelectorAll('div, span, p'); return [...allTextBlocks].filter(el => /^\s*\d+[.、.]/.test(el.innerText)); }这种方法比较暴力,容易误伤,但有时候确实比找class靠谱。我的建议是:能用class就用class,文本特征匹配只作为兜底方案。写这个脚本花最多时间的地方其实就在这里——分析页面的DOM结构。
2.2 题库匹配:从精确哈希到文本归一化
题库的设计无非两种:一种是本地静态题库,你把题目和答案的映射关系写死在脚本里;另一种是远端题库,通过GM_xmlhttpRequest请求自己的接口获取。个人练习用的话,本地静态题库就够用了。
静态题库的存储格式我推荐用对象映射:
const answerMap = { 'typeof 运算符对数组的返回值是什么': 'object', '以下哪个方法可以遍历对象自身的属性': 'Object.keys', 'Vue 3 中响应式数据的核心 API 是': 'reactive', };这里有个最容易踩坑的地方:题库里的题目文本和页面上提取的题目文本几乎永远不可能完全一致。页面上可能多了空格、多了标点、换行符、题号前缀,甚至全角半角标点不一样。所以直接拿answerMap[question.title]去查,大概率查不到。
正确做法是先做文本归一化,也叫normalize:
function normalize(text) { return text .replace(/\s+/g, ' ') // 所有空白字符统一成单个空格 .replace(/[,。!?、;:""''()【】]/g, '') // 去掉常见中文标点 .replace(/[A-Za-z0-9]/g, ch => ch.toLowerCase()) // 英文字母统一小写 .trim(); }然后题目和答案都走一遍这个归一化函数,再去做匹配。我做本地题库时,会先跑一个“试运行”模式:把页面上所有题目的归一化结果打印到控制台,然后我对着这个结果去整理答案映射,而不是凭空猜。这样能极大降低匹配失败率。
另外,有些页面会把题号前缀混在题目文本里,比如“1. 以下哪个说法是正确的?”。如果题库里存的是不带题号的纯题目文本,直接normalize也没用,因为“1.”没被去掉。我一般会在归一化之前先剥掉开头的题号:
title = title.replace(/^\s*\d+[.、.]\s*/, '');这个处理看起来微不足道,但就是这些小细节决定了脚本的可用性,实战里我见过太多因为题号没剥干净导致匹配率为零的情况。
3. 答案命中率上不去的真实原因:不只是“匹配不到”那么简单
很多新手写完脚本,发现一部分题能答上,一部分答不上。第一反应是“题库不完整”,但我告诉你,相当多的情况是你的提取逻辑和作答逻辑有缺陷,而不是题库缺题。
3.1 选项文本里有“隐形”内容
我调试一个单选页面时发现,有些题的选项文本在DOM里被拆分成了多个节点。比如:
<span class="option-label"> <span>Object.</span> <span>keys()</span> </span>如果你用opt.innerText拿,合并之后是Object.keys(),看着没问题。但如果你用的是opt.textContent,中间可能会因为节点边界多出换行或空格。更麻烦的是某些富文本编辑器生成的页面,选项里会嵌入图片、公式、高亮标记,innerText会把图片忽略掉,导致提取出来的文本不完整。
我的排查方法很简单:写脚本的时候不要急着写匹配逻辑,先写一个dump函数,把所有题目的原始DOM结构、提取后的文本都打印到控制台。对着控制台输出的结构去写提取逻辑,比对着页面肉眼猜靠谱得多。
3.2 相似度匹配:当精确匹配失效时的补救
即使做了归一化,还是有些题目提取出来的文本和题库文案对不上,比如页面把“JavaScript”写成了“JS”,题库里写的是全称。这时候就得引入相似度匹配。
我自己会用一个轻量级的方案,不引第三方库,直接用字符交集计算相似度,超过阈值就认为是同一道题:
function similarity(a, b) { const setA = new Set(a.split('')); const setB = new Set(b.split('')); let intersection = 0; setA.forEach(ch => { if (setB.has(ch)) intersection++; }); return intersection / Math.max(setA.size, setB.size); } function findAnswer(questionTitle) { const normalizedTitle = normalize(questionTitle); let bestMatch = null; let bestScore = 0; Object.keys(answerMap).forEach(key => { const score = similarity(normalize(key), normalizedTitle); if (score > bestScore) { bestScore = score; bestMatch = answerMap[key]; } }); return bestScore > 0.85 ? bestMatch : null; }这个方法的本质是“字符集合重叠度”,算法简单,对短文本效果尚可,但对“JavaScript”和“JS”这种缩写场景没用,因为字符集重叠度太低。要是真遇到这种情况,我会在题库里同时维护几条别名映射,一劳永逸。相似度匹配只是兜底,别指望它解决所有问题。
3.3 多选题、判断题:作答逻辑要分类型
不同类型的题目,点击选项的方式不同。单选题直接点击选项文本所在元素即可;多选题需要点击多个选项;判断题本质上也是单选的变体,只是选项变成了“正确/错误”。
如果页面上的选项是标准的<input type="radio">或<input type="checkbox">,那script可以直接设置checked属性再触发change事件。但现代前端框架(Vue/React)里,直接改checked属性往往不生效,因为框架的数据流不认DOM属性的强制修改。这种情况下,最稳的方式是触发元素的原生点击事件:
function clickOption(optionEl) { if (optionEl instanceof HTMLElement) { optionEl.click(); } }不要用dispatchEvent(new MouseEvent('click', { bubbles: true }))去自己构造事件,因为React等框架有自己的事件委托机制,构造的事件可能不被识别。直接用.click()方法是浏览器原生的行为,大多数情况下都能被框架捕获。
多选题的话,我会把题库里的答案存成数组:
const answerMap = { '以下哪些属于前端框架': ['Vue', 'React', 'Angular'], };然后遍历选项,如果选项文本与答案数组里的某项匹配,就点击它。注意多选题有些页面会设置“点击已选项会取消选中”,所以必须判断当前选中状态,避免重复点击把选项又取消了。判断状态可以用optionEl.classList.contains('selected')或者aria-checked属性,具体看页面实现。
4. 定时策略与生命周期管理:别让脚本自己“送人头”
脚本写好了,能答题了,但如果执行得太莽,照样翻车。最常见的问题就是:脚本一注入就疯狂点击,结果有些题目还没渲染,有些弹窗还没关闭,页面整个乱掉。
4.1 为什么必须加随机延迟
自动答题脚本最忌讳的是“瞬间完成”。且不论平台的风控逻辑,单从页面稳定性来说,脚本执行速度过快会导致事件排队、渲染阻塞,甚至浏览器直接提示“页面无响应”。我做过的实测是:在同一个页面上连续点击100次,如果不加任何延迟,页面卡顿时间超过3秒是常态。
所以必须给每次点击之间加上随机延迟。这里的“随机”不是可有可无的装饰,而是必要的防抖手段:
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function answerOneByOne(questionItems) { for (const item of questionItems) { const delay = Math.floor(Math.random() * 2000) + 800; // 800~2800ms 随机 await sleep(delay); // 处理当前题目 await answerQuestion(item); } }延迟区间的上下限,取决于你页面的复杂度。简单页面800ms到2秒足够,复杂页面建议拉到1.5秒到4秒。延迟太低没意义,延迟太高又显得假。这个尺度需要你自己根据实际情况调。
4.2 监听路由变化而不是盲目轮询
现在很多练习平台是SPA(单页应用),点“下一题”的时候URL不变,只是页面内容变化。这种情况下,用setInterval轮询不是不行,但很浪费资源,而且容易出现“题目还没加载完就触发作答”的竞态问题。更好的做法是继续用MutationObserver监控题目容器的变化。
我之前遇到过一个Vue页面,切换题目时整个题目容器被替换成新DOM。思路是:监听容器子树变化,当检测到新的题目节点出现时,等一小段时间(比如500ms),等渲染稳定后再提取题目和作答。这里有个经验值:不要在MutationObserver回调里立刻处理DOM,因为回调触发时DOM可能只更新了一半。加一个短暂的延迟再处理,会稳很多。
4.3 中断与重试:面对弹窗和卡死
答题过程中最烦人的是“半路杀出个弹窗”。比如平台提示“是否确定提交”“还有未答题目”,或者答题超时提醒。如果脚本完全忽略弹窗,继续点下一步,很可能会把弹窗上的按钮当成选项误点。
我的通用做法是:每次作答前,先扫描页面上是否存在已知的固定弹窗容器,如果存在就先关闭它:
function closeKnownModals() { const modalSelectors = ['.modal-close', '.popup-close', '.dialog-cancel']; modalSelectors.forEach(sel => { const btn = document.querySelector(sel); if (btn) btn.click(); }); }另外,脚本要设置一个“全局任务状态”。一旦某个题目的作答逻辑抛了异常,或者等待超时,不要让它无限循环下去。我会在脚本里加一个计数器,连续失败5次就自动停止,并打印日志到控制台:
let consecutiveErrors = 0; const MAX_ERRORS = 5; async function safeAnswer(item) { try { await answerQuestion(item); consecutiveErrors = 0; } catch (e) { consecutiveErrors++; console.warn('作答失败', e); if (consecutiveErrors >= MAX_ERRORS) { console.error('连续失败次数过多,脚本停止'); observer.disconnect(); } } }这个设计其实很实用,很多人写脚本只考虑“顺利路径”,从不考虑失败恢复。但真实页面里,一道题作答超时、一个选项点击无效、一次网络抖动导致题库接口没返回,都足以让整个脚本卡死在那。有状态管理、有重试上限,脚本才能算得上健壮。
5. 合规使用与工程化收尾:脚本写完之后还能做什么
写到这里,一个能跑、能停、能抗异常的自动答题脚本就算完成了。但在收尾之前,我必须再次强调:这个脚本的定位是技术学习和对你有合法操作权限的练习场景,不是拿去破坏平台规则、代替真人考试的工具。我在开头就说过,自动答题属于典型的“能力中立”技术——它能帮你在自家练习系统里节省时间,也同样可能让你在正式考试里翻车、被封号甚至惹上麻烦。请一定只在明确允许自动化的环境中使用,并且对自己写的代码负责。
抛开合规问题,从工程角度讲,把这段脚本写得更好用有几个方向。
一是把题库从代码里拆出去。脚本里硬编码的answerMap,每次更新题库都得改脚本、重新保存、刷新页面。更好的做法是放在一个单独的JSON文件里,用GM_getValue配合一个简单的“导入题库”功能,在脚本界面上粘贴JSON就能加载。我自己的习惯是:
// 通过油猴菜单打开一个简易面板 GM_registerMenuCommand('导入题库', () => { const input = prompt('请粘贴题库 JSON 内容'); if (input) { try { const data = JSON.parse(input); GM_setValue('answerMap', data); console.log('题库已更新,共', Object.keys(data).length, '条'); } catch (e) { console.error('JSON 解析失败,题库未更新'); } } });题库数据存在油猴自己的存储区域,既不影响页面,也不占页面localStorage的空间,刷新不丢失,非常方便。
二是加日志汇总。答题结束时,打印一份汇总:总题数、命中数、失败数、耗时。这个汇总能让你快速判断脚本的匹配逻辑是否需要优化:
function summarize(answered, hit, failed, startTime) { console.log(`答题完成:共 ${answered} 题,命中 ${hit} 题,失败 ${failed} 题,用时 ${((Date.now() - startTime) / 1000).toFixed(1)}s`); }三是调试模式的开关。开发阶段打开调试模式,会把每一步的中间结果都输出到控制台;正式运行可以关掉,减少控制台噪音。这个用变量控制即可,但很多人会忽略,导致排查问题时信息不够。
我最初写这个脚本只是为了少点几次鼠标,后来发现,这个项目其实是一堂很完整的浏览器自动化实践课:分析DOM结构、匹配数据、处理异步渲染、管理异常流程、设计可维护的代码结构,每一步都踩过坑、也都有收获。如果你也想练手,不要急着去下载别人的成品脚本,自己从头写一遍,把上面这些边界情况都处理好,你会比看十篇教程都更有感觉。