☰
JavaScript答题脚本开发:文本归一化与模糊匹配核心实现
2026/10/1 15:05:29 网站建设 项目流程

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-role="stem"]'], option: ['.option-item', '.answer-item', 'li[data-option]'], nextBtn: ['.next-btn', '.btn-next', '[data-action="next"]'] }; 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: true:

function 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询