拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

油猴脚本自动答题实战:从DOM操作到浏览器自动化

油猴脚本自动答题实战:从DOM操作到浏览器自动化 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]去查大概率查不到。正确做法是先做文本归一化也叫normalizefunction 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 classoption-label spanObject./span spankeys()/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 typeradio或input typecheckbox那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结构、匹配数据、处理异步渲染、管理异常流程、设计可维护的代码结构每一步都踩过坑、也都有收获。如果你也想练手不要急着去下载别人的成品脚本自己从头写一遍把上面这些边界情况都处理好你会比看十篇教程都更有感觉。
返回列表