
1. 先搞清楚答题参考脚本到底在解决什么问题js答题参考脚本这个东西说穿了就是把「看题—找答案—点选项」这套人工流程用 JavaScript 重写一遍。我第一次动这个念头是因为给自己整理了一份 JS 基础题库三百多道题手动对着答案一题题核到半夜眼睛发花还容易看串行。索性写了个小脚本把题干和选项从页面上抓出来去本地题库里匹配命中的直接把结果标在题目旁边。做完之后我发现真正费劲的根本不是「点」这个动作而是文本归一化和模糊匹配——同一个知识点换个说法、多一个标点、全角半角混着用字符串就完全对不上了。这篇就把我自己从零搭这套东西的完整过程拆开讲整体怎么分层、匹配算法怎么选、阈值怎么调、页面动态渲染怎么盯、踩过哪些坑以及最关键的这东西的合理使用边界在哪。适合有 JavaScript 基础、想给自己做一套学习自测工具的人看纯新手也能照着代码跑起来因为每一段我都给了能直接复制的实现。1.1 把一次答题拆成四个独立动作很多人一上来就想写「自动点击」结果写到一半发现到处是补丁。我的经验是先把整件事拆干净一个答题流程实际上只有四个动作每个动作的输入输出都很明确题面提取从当前页面的 DOM 里把题干文本、题型、选项列表、以及必要的题目标识拿到手输出一个结构化的题目对象。文本归一化把原始题干和题库里的题干都过一遍清洗管道去掉空白、标点差异、全角半角差异输出可以直接比较的字符串。答案匹配用归一化后的题干去题库里找先精确命中命中不了再走相似度兜底输出候选答案和置信度。作答与记录把答案落到页面上点击选项或者标记同时把这一题的题干、我的作答、正确答案、置信度记下来供后面复盘。拆成四步之后调试就变得非常清爽。匹配不上我只用检查归一化管道点不中我只用检查选择器配置。最怕的是四个步骤揉在一个几百行的函数里出了问题根本不知道是哪一环。1.2 四种常见题型的结构差异题型不同解析策略差得很远。我整理了一张对照表是我实际遇到过的主要形态题型选项特征判断依据解析难点单选题固定 4 个左右选项答案只有一个选项顺序可能被打乱多选题选项数不定答案集合需要比对集合而非单个值判断题只有「正确/错误」两项布尔值文案可能是「对/错」「√/×」填空题无选项有输入框文本或关键词需要处理同义表述单选题和多选题的差别在于多选题的答案要当成集合处理不能用直接比。判断题最坑的是文案不统一有的页面写「正确/错误」有的写「对/错」还有用符号的所以归一化阶段最好把这几类映射到统一的布尔值上。填空题我一般不追求完全自动而是把匹配到的答案当作提示显示出来因为填空题的判分标准往往很宽松脚本硬填反而容易写错。1.3 技术选型三种落地方案的取舍脚本写完之后往哪儿跑这件事决定了你后面所有的工程结构。我把常见的三种方案列出来对比了一下方案上手成本调试便利度适合场景浏览器控制台直接粘贴极低一般刷新即丢临时验证、单次练习用户脚本管理器低好可持久化长期自测、固定站点本地自动化测试框架中高好可写断言自建练习页面的回归测试我个人的选择是先用控制台验证核心逻辑跑通之后再挪到用户脚本管理器里做成常驻版本。理由很简单控制台调试的时候我可以随时改一行、回车看结果迭代速度最快等逻辑稳定了再封装成常驻脚本避免每次刷新页面都要重新粘贴。至于自动化框架如果只是为了自己练习有点杀鸡用牛刀但如果你是想给自己的练习页面写一套回归测试那它反而是最合适的因为可以配合断言和截图。2. 骨架搭起来一个最小可运行的答题脚本2.1 目录与运行环境我不喜欢一上来就上构建工具一个单文件加上一个题库 JSON 就够了。真正稳定之后我会拆成下面这样的结构quiz-helper/ ├── core/ │ ├── normalize.js // 文本归一化 │ ├── extract.js // 题面提取 │ ├── match.js // 匹配算法 │ └── answer.js // 作答与记录 ├── data/ │ └── bank.json // 本地题库 ├── config.js // 选择器、阈值等配置 └── main.js // 入口串起整条流程为什么要把配置单独抽出来因为我踩过太多「换个练习页面就得翻遍代码改选择器」的坑。把选择器、相似度阈值、是否自动作答这些会变的东西全部塞进config.js换站点时只改一个文件核心逻辑一行不动。这是唯一一个我强烈建议一开始就做对的结构决策后面省下来的时间非常可观。2.2 四层结构设计这四层的依赖方向是单向的解析层依赖数据层匹配层依赖解析层的输出执行层依赖匹配层的结果。不允许反向依赖也不允许跨层调用。数据层负责题库的加载、缓存、索引构建。对外只暴露「根据归一化题干查答案」这一个能力。解析层负责从 DOM 里抽题。它不知道答案是什么也不知道要怎么作答只负责把页面上的东西变成结构化数据。匹配层纯函数输入两个字符串输出相似度分数。它不碰 DOM也不碰存储所以可以单独写单元测试。执行层唯一允许操作 DOM 的一层。它接住匹配结果决定点哪个选项、怎么标记、记录什么。我之所以把匹配层做成纯函数是因为这部分最容易出错也最需要反复调参。纯函数意味着我可以把几百道题喂进去离线跑一遍统计准确率而不用开着页面一题题试。这个习惯帮我省了大量时间。2.3 题库字段设计题库每一条记录长什么样直接决定了后面匹配能做到多准。我最后定下来的字段是这样的{ id: q_1001, raw: 以下关于事件循环的说法正确的是, norm: 以下关于事件循环的说法正确的是, type: single, options: [ 宏任务先于微任务执行, 微任务在当前宏任务结束后执行, Promise 的回调属于宏任务, 定时器回调属于微任务 ], answer: [1], tags: [JS基础, 事件循环], source: 自整理 }几个字段的设计理由值得说一下。raw保留原始文本方便人工核对norm是归一化后的结果匹配时只用它省去每次重新计算tags后面用来做知识点统计比如「我在事件循环这个标签下的错题率是多少」source用来记录这道题的来源这一点在合规层面很重要后面会专门讲。很多人偷懒只存question和answer两个字段等到想起来要做错题分析的时候发现连题目标签都没有只能从头补。3. 核心实现题面解析与答案匹配3.1 文本归一化90% 的匹配失败都出在这一步我统计过自己踩的坑匹配失败的原因里绝大多数都不是算法不够聪明而是归一化没做干净。同一个题干页面上可能是全角标点题库里是半角页面上有换行符和多余空格题库里是紧凑的英文大小写不统一数字有的是阿拉伯数字有的是中文数字。只要有一项没对齐字符串就完全对不上。我的归一化管道按顺序做这几件事function toHalfWidth(str) { return str .replace(/[\uFF01-\uFF5E]/g, ch String.fromCharCode(ch.charCodeAt(0) - 0xFEE0) ) .replace(/\u3000/g, ); } function normalize(text) { if (!text) return ; let s toHalfWidth(text); s s.toLowerCase(); s s.replace(/[\s\u00A0]/g, ); s s.replace(/[。、《》【】,.;:?!()\[\]]/g, ); return s; }全角转半角的原理是把\uFF01到\uFF5E这段字符统一减0xFEE0偏移量就能映射到对应的 ASCII 字符这个区间覆盖了全角字母、数字和常见标点。\u3000是全角空格单独处理成普通空格再在下一步跟其他空白一起干掉。注意归一化之后不要再保留原始大小写去做比较但也别把原始文本丢掉。我建议归一化结果只用于匹配展示和记录永远用raw字段否则出了问题你连原题都还原不出来。还有一个容易忽略的点归一化前要先判断字符串是否包含关键内容。有些选项里会有「以上都不对」这种兜底项它在不同题目里的语义完全不同如果你提前把它归一化掉就会误判。我的做法是匹配阶段先看题干选项单独比对不把选项拼进题干里去算相似度。3.2 题干与选项的提取提取这步的关键是配置化不要写死选择器。我的做法是给每种元素准备一个候选选择器列表按顺序尝试第一个命中的就用const SELECTORS { stem: [.question-title, .q-title, [data-rolestem]], option: [.option-item, .answer-item, li[data-option]], nextBtn: [.next-btn, .btn-next, [data-actionnext]] }; function pick(selectors) { for (const sel of selectors) { const el document.querySelector(sel); if (el) return el; } return null; } function extractQuestion() { const stemEl pick(SELECTORS.stem); if (!stemEl) return null; const optionEls document.querySelectorAll(SELECTORS.option.join(,)); return { raw: stemEl.textContent.trim(), norm: normalize(stemEl.textContent), options: Array.from(optionEls).map(el ({ raw: el.textContent.trim(), norm: normalize(el.textContent), el })) }; }这里有个细节值得说optionEls用join(,)拼成一个选择器一次查完比循环查询效率高得多。另外记得在提取的时候顺手把 DOM 引用存下来就是那个el字段不然后面作答的时候还得重新查一遍一来费性能二来如果页面在这中间重新渲染过两次查到的可能都不是同一个节点。3.3 相似度算法选型与阈值调参精确匹配靠哈希模糊匹配才有算法的事。我实测下来用到的就三种按成本从低到高排列第一种是编辑距离Levenshtein适合短文本、错别字少的场景function levenshtein(a, b) { const m a.length, n b.length; if (m 0) return n; if (n 0) return m; let prev Array.from({ length: n 1 }, (_, i) i); let curr new Array(n 1); for (let i 1; i m; i) { curr[0] i; for (let j 1; j n; j) { const cost a[i - 1] b[j - 1] ? 0 : 1; curr[j] Math.min(curr[j - 1] 1, prev[j] 1, prev[j - 1] cost); } [prev, curr] [curr, prev]; } return prev[n]; } function similarityByEdit(a, b) { const maxLen Math.max(a.length, b.length); if (maxLen 0) return 1; return 1 - levenshtein(a, b) / maxLen; }第二种是 Dice 系数按二元组切分对语序变化更宽容适合题干较长的情况function bigrams(str) { const set new Set(); for (let i 0; i str.length - 1; i) set.add(str.slice(i, i 2)); return set; } function dice(a, b) { if (!a || !b) return 0; if (a b) return 1; const A bigrams(a), B bigrams(b); let inter 0; for (const g of A) if (B.has(g)) inter; return (2 * inter) / (A.size B.size); }第三种是关键词 Jaccard把题干切词后比集合交集适合题干里废话很多的情况。阈值我是这么定的实测下来比较稳相似度区间处理策略说明1.0直接采用精确命中无需确认0.92 ~ 1.0直接采用基本可以放心0.75 ~ 0.92标黄提示展示候选人工确认0.60 ~ 0.75仅展示参考用不自动作答低于 0.60视为未命中记入待补充名单为什么 0.92 这个点比较合适因为题干通常在 20 到 60 字之间编辑距离差 1 到 3 个字对应的相似度大概就在这个区间。低于 0.75 的时候我遇到过好几次「看起来像但其实是两道不同的题」的情况比如「下列说法正确的是」和「下列说法错误的是」就差一个字意思完全相反。这种题一旦自动作答错得毫无痕迹所以宁可降到人工确认档。3.4 匹配索引从线性遍历到哈希加速一开始我写的是最朴素的线性遍历题库里三千道题每道题都算一遍相似度。编辑距离的时间复杂度是 O(m×n)题干平均 40 字那单次比较就是 1600 次基本操作乘以 3000 道题差不多五百万次操作在页面上跑一次要几十毫秒。题目一多、页面一卡体验就崩了。后来改成两级索引function buildIndex(bank) { const exact new Map(); const byLength new Map(); for (const item of bank) { exact.set(item.norm, item); const len item.norm.length; if (!byLength.has(len)) byLength.set(len, []); byLength.get(len).push(item); } return { exact, byLength }; }第一级用Map做精确命中Map的查找平均是常数时间绝大多数题干能直接命中这一层就挡掉了大部分请求。第二级按长度分桶只有当精确匹配失败时才启用而且只跟长度接近的候选比——题干长度差超过 30% 的基本不可能是同一道题直接跳过。加了这一层过滤之后我实测单题匹配耗时从十几毫秒降到了 1 毫秒以内提升非常明显。这里顺便提一句Map和普通对象的区别。用对象当字典的时候键会被强制转成字符串如果题目里恰好有数字或者特殊符号容易出诡异问题Map支持任意类型的键而且有明确的size属性可以直接拿数量遍历顺序也稳定。做索引这种场景Map是更省心的选择。4. 自动作答、结果记录与动态页面监听4.1 点击作答 vs 直接改状态这两条路的差别比看起来大得多方式实现方式优点风险模拟点击对选项节点派发 click 事件跟人工操作一致页面逻辑能正常跑依赖事件绑定方式有的页面绑在父节点上直接改状态修改 class 或数据模型快不依赖事件页面内部状态可能不同步结果不落库我的建议是优先模拟点击。原因是大部分练习页面在点击之后才会更新内部状态、记录作答、触发下一题逻辑你要是直接改 class页面上看着是选中了但页面自己的状态没变提交的时候结果对不上。模拟点击也有讲究有些页面用的是事件委托绑定在父容器上这时候你对子节点派发事件要带上bubbles: truefunction clickOption(el) { const evt new MouseEvent(click, { bubbles: true, cancelable: true, view: window }); el.dispatchEvent(evt); }如果页面用的是自定义事件或者框架的合成事件派发原生事件可能不生效这时候得换成框架对应的触发方式。这种情况我一般先手动点一次、在开发者工具里看事件监听器挂在哪一层再决定派发目标比瞎试快得多。4.2 答题结果记录与错题本生成每答完一题就记一条数据量不大用localStorage就够了。字段我保留了这些function recordResult({ id, raw, myAnswer, correct, score }) { const key quiz_log; const log JSON.parse(localStorage.getItem(key) || []); log.push({ id, raw, myAnswer, correct, score, ts: Date.now() }); localStorage.setItem(key, JSON.stringify(log)); }为什么要存score相似度分数因为后面复盘的时候你可以专门把「低置信度但答对了」的题目筛出来这些是题库质量问题的重灾区说明题库里可能有重复题或者表述差异过大的题。我用这招找出来过十几道重复题删掉之后匹配准确率明显提升。存ts是为了看自己的答题节奏顺便也方便按天统计。注意localStorage有容量限制一般 5MB 左右。题干文本长了之后很容易撑满所以只存题目 ID 和必要字段完整题干放题库里日志里不要重复存。或者干脆换成IndexedDB容量大得多。4.3 动态页面监听MutationObserver 的正确用法练习页面基本都是答完一题就换下一题DOM 是动态变的你不可能靠一次提取搞定所有题。我一开始用的是定时器轮询每 500 毫秒查一次题干有没有变能用但很浪费。后来换成MutationObserver只在 DOM 真的变化时才触发function debounce(fn, wait) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), wait); }; } const onQuestionChanged debounce(() { const q extractQuestion(); if (!q) return; handleQuestion(q); }, 300); const observer new MutationObserver(onQuestionChanged); observer.observe(document.body, { childList: true, subtree: true });这里必须加防抖而且防抖时间不能太短。原因是切换题目的时候 DOM 往往会连续变化好几次不防抖的话回调会触发很多遍轻则重复作答重则递归触发观察器直接把页面卡死——这个坑我踩过页面白屏了好几次才反应过来是观察器递归。另外要注意事件循环的时机问题。MutationObserver的回调是放在微任务队列里执行的也就是说它会在当前宏任务结束后、下一个宏任务开始前跑。这意味着如果你在回调里又同步修改了 DOM会再次触发观察器。避免的方法有两个一是防抖二是把处理逻辑放到setTimeout里推到下一个宏任务跟观察时机错开。如果题目内容在 iframe 里主文档的观察器是看不到的。这种情况需要先拿到 iframe 的contentDocument在它上面再挂一个观察器const iframe document.querySelector(#quiz-frame); const doc iframe.contentDocument || iframe.contentWindow.document; const innerObserver new MutationObserver(onQuestionChanged); innerObserver.observe(doc.body, { childList: true, subtree: true });跨 iframe 通信的话用postMessage比直接访问属性更稳妥尤其是跨域的时候后者根本访问不到。父子页面之间可以用window.parent.postMessage和message事件配合把提取到的题目数据传出去处理结果再传回来作答。5. 常见问题与排查速查5.1 匹配类问题排查表现象最可能的原因处理办法明明有这题却匹配不上归一化不一致把两边归一化结果打印出来逐字对比匹配到了但是答错相似度过低误命中把阈值从 0.75 提到 0.85 以上答对率忽高忽低题库有重复题按归一化结果去重多选题只选中一个答案当成单值处理答案统一用数组存比对集合判断题总答反文案映射错误检查「对/错」「正确/错误」映射表这张表里的每一条都是我真金白银踩出来的。尤其是第一条看起来最蠢实际上最常发生。归一化函数里少写一个标点符号就可能让某几道题永远命中不了。我的排查习惯是随便挑一道匹配失败的题把页面上的题干和题库里的题干各跑一遍normalize把两个结果并排打印出来一眼就能看出差在哪。5.2 元素定位失败的三类原因第一类是动态渲染。元素还没渲染出来你就去查当然查不到。这种情况要么用观察器等它出现要么加个重试。重试不要用死循环我一般写最多 20 次、每次间隔 200 毫秒超时就放弃并打日志。第二类是 iframe。外面查不到里面的元素必须先拿contentDocument。跨域的话连contentDocument都拿不到只能放弃或者事先约定好用postMessage传数据。第三类是 Shadow DOM。组件库做的练习页面经常把内容塞在影子树里document.querySelector是穿不进去的得先拿到宿主元素的shadowRoot再往下查const host document.querySelector(quiz-card); const root host host.shadowRoot; const stem root root.querySelector(.stem);这三类原因覆盖了我遇到过的绝大多数定位失败排查顺序就按这个来先看渲染时机再看是不是在 iframe最后看是不是影子树。5.3 性能与频率控制页面上一旦挂上观察器性能就得时刻盯着。我的几条原则观察器的回调里只做提取不做重活。匹配和作答都推到下一个宏任务里别阻塞渲染。题干没变就直接返回不要在回调里无脑重跑整条流程。用一个变量存上一次的归一化题干相同就跳过。批量操作走requestIdleCallback让浏览器有空再算。日志攒够一批再写存储别每答一题就写一次localStorage那个是同步操作写频繁了会卡。我实测过按这个原则优化之后脚本挂在页面上几乎感觉不到额外开销答题流畅度跟不用脚本时没有区别。6. 边界与合规这类脚本的合理使用范围6.1 什么场景适合用什么场景不要碰这一点我必须说清楚因为它决定了你做这个东西的性质。适合的场景是自己整理的题库做自测、公开的练习题做复习、给自己写的练习页面做回归测试、教学场景里做演示。这些场景的共同点是你面对的是自己的数据目的是学习。不适合的场景也很明确任何形式的正式考试、测评、认证任何需要你本人独立完成的考核都不要用。这不是技术问题是原则问题。脚本能力本身是中性的你用它刷自己的错题本和用它去应付考核完全是两回事。我在写这套东西的时候给自己定的规矩就是只在本地自建页面和自己整理的题库上跑绝不对任何在线考核系统使用。这条线守住了这东西就是一个纯粹的学习工具。6.2 题库来源与版权注意事项题库从哪来这个问题比匹配算法重要得多。我的做法是自己整理的知识点笔记转成题目这是最干净的来源。公开授权允许使用的练习数据用之前看清楚授权条款。别人分享的题库只用在自己本地学习不二次分发。不要做的事包括抓取需要登录才能访问的内容、批量复制别人的付费题库、把整理好的题库打包传播。我这里特别提一句题库字段里那个source字段不是摆设它是给自己留的一个提醒——每道题都要能说清楚来源说不清楚的就别放进题库。7. 延伸玩法把答题脚本改造成学习工具7.1 错题本与知识点统计答题日志攒起来之后价值才开始显现。我写了一个简单的统计函数按标签算错题率function statsByTag(log, bank) { const map new Map(); const byId new Map(bank.map(item [item.id, item])); for (const row of log) { const item byId.get(row.id); if (!item) continue; for (const tag of item.tags || []) { if (!map.has(tag)) map.set(tag, { total: 0, wrong: 0 }); const s map.get(tag); s.total; if (!row.correct) s.wrong; } } return Array.from(map.entries()) .map(([tag, s]) ({ tag, total: s.total, wrong: s.wrong, rate: (s.wrong / s.total * 100).toFixed(1) % })) .sort((a, b) parseFloat(b.rate) - parseFloat(a.rate)); }跑一遍你就能看到自己的薄弱环节在哪。我第一次跑出来的结果是「原型链」和「闭包」两个标签的错题率明显偏高后面复习就有了重点。这比漫无目的地刷题高效得多。7.2 题库清洗去重、补全、格式统一题库用久了必然会有重复和格式问题我一般定期跑一次清洗。去重的思路很简单先按归一化文本分组组内数量大于一的就挑一条保留function dedupe(bank) { const seen new Map(); const result []; for (const item of bank) { const key item.norm; if (seen.has(key)) { const first seen.get(key); if (!first.dupes) first.dupes []; first.dupes.push(item.id); continue; } seen.set(key, item); result.push(item); } return result; }对于表述略有差异但实际是同一道题的归一化去重搞不定得用相似度兜底。我一般把相似度 0.95 以上的配对列出来人工扫一眼确认几十对的量级手动过一遍不花多少时间。清洗完之后顺手把norm字段全部重算一遍保证跟最新的归一化逻辑一致避免逻辑改了、老数据没更新导致的匹配失败。最后分享一个我自己一直用的习惯每次改动归一化逻辑或者阈值都把整个题库离线跑一遍匹配统计命中率和误判数量记在一个小本子上。这样能清楚地看到每次调整带来的实际影响而不是凭感觉觉得「好像好一点了」。这套东西说到底不复杂难的是细节和耐心。来源https://github.com/dengjian2026/js-quiz-helper