尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

原生JS答题参考脚本:DOM解析、题库匹配与事件派发实战

原生JS答题参考脚本:DOM解析、题库匹配与事件派发实战 1. 需求拆解一个答题参考脚本到底要干哪几件事写 js答题参考脚本 这件事说穿了就是把人眼看题、脑子回忆、鼠标点击这条链路拆成三段可以自动化的动作把题目从页面上读出来、在本地题库里找到对应答案、把答案通过浏览器能识别的方式写回去。听起来简单但真正上手之后你会发现难点从来不在写脚本而在于页面的 DOM 结构千奇百怪、前端框架对输入事件有自己的脾气、题库匹配的准确率又直接决定了这个脚本能不能用。我自己第一次做这类东西是在给自己搭的一个刷题练习页做批量自测。当时页面上一共 80 道单选我一边点一边骂自己为什么不写个脚本。结果第一版脚本跑完25 道题的选项没点上原因是那个页面用的是受控组件我直接改input.checked true页面根本不认。从那以后我就明白了答题脚本的技术含量其实分布在三个不太相关的领域DOM 解析、字符串匹配、浏览器事件模型。这三块任何一块理解不到位脚本就是个玩具。这篇文章我打算按能直接抄作业的标准来写。目标读者分两类一类是想搞清楚 DOM 事件、字符串相似度算法这些基础知识的同学这类脚本是个非常好的练手项目因为它麻雀虽小五脏俱全另一类是真的有批量自测需求的人比如公司内部培训平台的练习题自检、自己维护的题库数据校验、录播课配套的练习页批量过一遍看看有没有错题。内容会覆盖需求拆解、核心算法、完整实现步骤、以及我踩过的那些坑。先说清楚一件事这套东西我建议只用在你有权限操作的页面上——自己搭的练习页、内部培训里明确允许自动化自测的题库、或者纯粹为了练手写个 demo。任何正式考核、有明确禁止自动化规则的场景都别碰这不只是道德问题很多时候也是账号安全问题。技术是中性的但用在哪儿决定了它是不是好东西这一点我会在 1.3 节展开讲。1.1 三种典型页面形态与对应取数策略答题页面看起来都差不多实际上根据渲染方式可以分成三类取数策略完全不一样选错了后面全是坑。第一类是静态 DOM 页面。服务端直出 HTML题目就在源码里没有异步渲染。这类页面最友好document.querySelectorAll一把梭就能拿到所有题目节点。判断方法很简单打开页面直接看源代码CtrlU如果题目文本能在源码里搜到那就是静态的。第二类是前端渲染页面。Vue、React 把数据挂载到根节点之后才生成 DOM源码里只有一个空的div idapp。这类页面你不能在DOMContentLoaded的时候就去抓那时候题目还没渲染出来抓到的是一堆空壳。正确做法是用MutationObserver监听容器变化或者用一个轮询 超时的兜底策略等题目出现。第三类是 iframe 嵌套页面。题目主体在一个 iframe 里顶部导航和底部按钮在外面。这种情况下document.querySelectorAll是抓不到 iframe 内部元素的得先document.querySelector(iframe).contentDocument拿到子文档而且这里还有同源限制——跨域 iframe 你拿不到contentDocument会返回null。所以做之前先确认一下 iframe 的 src 是不是同源这一步能省掉后面半小时的 debug。三类页面的取数策略我整理成一张表上手前先对号入座页面类型判断方法取数时机主要风险静态 DOM源码里能搜到题目文本DOMContentLoaded 之后即可基本无注意分页前端渲染源码里只有空根节点MutationObserver 或轮询等待抓取过早拿到空节点iframe 嵌套题目在独立文档中等 iframe load 事件跨域拿不到 contentDocument提示不要用固定的setTimeout(3000)去等渲染。网速快的时候浪费两秒网速慢的时候直接抓空。用 MutationObserver 加一个 10 秒超时兜底稳得多。1.2 为什么选原生 JS注入成本与可调试性选型这块我用过三种方式浏览器插件、油猴类用户脚本管理器、直接在控制台粘贴代码。最后稳定留用的是原生 JS 单文件 控制台注入理由有三个。第一是依赖成本。插件要打包、要签名、要安装改一行代码要走一遍完整流程控制台粘贴改一行就生效迭代速度差一个数量级。做这种一次性、探索性很强的脚本迭代速度就是一切。第二是调试体验。原生 JS 在浏览器 DevTools 里可以直接打断点、看作用域、看调用栈。你在Sources面板里给匹配函数打个断点鼠标悬停就能看到当前题目的文本和候选答案的相似度分数这个体验是任何黑盒插件给不了的。我第一次调相似度阈值的时候就是靠断点把 200 道题的分数全看了一遍才发现 0.6 以下误匹配率飙升。第三是可移植性。原生 JS 不依赖任何运行时你写完丢给同事他粘贴到控制台就能跑换台电脑、换个浏览器代码本身不用改。原生 JS 的代价是没有持久化。控制台粘贴的代码刷新就没了题库也没法长期保存。我的解法是用localStorage存题库和错题记录刷新后脚本重新注入数据还在。这个思路在 3.5 节会具体实现。另外提醒一句现代浏览器控制台在粘贴长代码时会弹一个是否允许粘贴的确认框这是防社工的机制不是你的代码有问题点允许就行。如果嫌烦可以在 DevTools 设置里关掉这个确认。1.3 使用边界哪些场景适合哪些别碰这一节我想说得直白一点因为见过太多人做着做着就踩线了。适合的场景自己搭的练习页面批量自测内部培训题库的数据质量校验比如你想知道题库里有没有重复题、有没有答案缺失学习 DOM 事件模型和字符串匹配算法的练手项目给自己的错题本做自动化整理。不适合的场景任何正式考核、认证考试、有明确规则禁止自动化的测评。这类场景下脚本不只是没意义而是会给你带来实际的账号风险。很多平台会检测异常的作答速度、异常的鼠标轨迹、异常的页面停留时间一个每道题 0.3 秒的答题记录基本等于自曝。还有一个技术层面的边界不要写任何绕过平台校验的逻辑。比如有些页面会校验答题时间下限你绕过它就是在对抗平台规则这类代码我不写也建议你别写。答题参考脚本的定位应该是辅助自测和技术练手不是对抗系统。把边界划清楚之后剩下的内容就可以放心展开了。下面进入技术核心。2. 关键技术点题目抓取、答案匹配与事件派发这三块是脚本的主干。我见过很多脚本把 80% 的代码写在了点按钮上结果题目都读不准点了也是白点。正确的权重分配大概是取数 30%、匹配 50%、填答 20%。匹配为什么要占一半因为匹配的准确率直接决定了这个脚本是省时间还是制造错误——一个 85% 准确率的脚本意味着你要人工复查 15% 的题而这个复查成本可能比你自己做还高。2.1 题干与选项的稳定提取提取的核心思路是先定位题目容器再在容器内部找题干和选项而不是全局按 class 名抓。为什么因为很多页面的题干和选项目 class 名是一样的全局抓会把选项文本混进题干里。我的通用套路分三步。第一步找到所有题目容器。优先用语义化的属性[data-question-id]、[data-qid]、.question-item、.subject-item这类。如果找不到就退而求其次找包含至少两个 input 或者一个 input 列表的最近公共祖先。function findQuestionContainers() { // 优先级从高到低依次尝试 const selectors [ [data-question-id], [data-qid], .question-item, .subject-item, .exam-question ]; for (const sel of selectors) { const nodes document.querySelectorAll(sel); if (nodes.length) return Array.from(nodes); } // 兜底找包含选项的最外层容器 const inputs document.querySelectorAll(input[typeradio], input[typecheckbox]); const set new Set(); inputs.forEach(el { let p el.parentElement; // 往上爬到合理的题目边界一般不超过 8 层 for (let i 0; i 8 p; i) { if (p.querySelectorAll(input[typeradio], input[typecheckbox]).length 2) { set.add(p); break; } p p.parentElement; } }); return Array.from(set); }第二步在容器内部找题干。题干的特征通常是没有 input 子节点、文本长度较长、可能在某个特定 class 里。我会优先匹配.title、.stem、.question-title、.q-title这类 class找不到就取容器的第一个非空文本块。第三步抓选项。这一步要特别小心因为选项文本和选项 input 往往是兄弟节点。常见的结构是labelinput typeradio选项A文本/label或者div classoptioninputspan选项A文本/span/div。所以取选项文本的正确姿势是先拿到所有 input然后对每个 input 找它最近的label或者包含它的选项容器再取该容器的文本。有个细节容易被忽略选项文本里会带上 A/B/C/D 前缀。有些页面是A. 选项内容有些是A、选项内容有些是单独的span classopt-indexA/span。如果你不做归一化题库里存的是选项内容页面上读到的是A. 选项内容匹配相似度会被这段前缀拉低。我的做法是在归一化阶段统一剥掉开头 1-3 个字符内的字母加点/顿号/括号。function cleanOption(text) { return String(text || ) .replace(/^\s*[A-Ha-h]\s*[.、)]\s*/, ) // 剥掉 A. A、 A) 这类前缀 .replace(/\s/g, ) .trim(); }注意剥前缀的正则要限制在开头如果写成全局替换选项内容里本身带A.的内容会被误伤。我踩过这个坑一道关于计算机编号规则的题选项里的字母点被吃掉了结果匹配到了一个完全不相干的答案。2.2 题库索引从线性遍历到 Trie 前缀树题库规模决定了索引方式。50 道题的线性遍历完全够用2000 道题就必须上索引了——每次匹配都全表扫一遍50 道题就是 10 万次字符串比较页面会卡住。最直接的索引是哈希索引。把题干归一化之后做 key答案做 value查找是 O(1)。命中率取决于题库的归一化规则和页面文本的一致性通常能覆盖 70% 左右的题。归一化函数我会这么写function normalize(text) { return String(text || ) // 全角转半角 .replace(/[\uFF01-\uFF5E]/g, ch String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)) // 去掉所有空白 .replace(/\s/g, ) // 去掉常见中英文标点 .replace(/[。、“”‘’《》【】,.!?;:()[\]{}]/g, ) .toLowerCase(); }归一化之后题干里的空格差异、全半角差异、标点差异都被抹平了命中率会明显上升。剩下 30% 命不中的就需要模糊匹配。这时候如果还是线性扫全表代价就大了。我的优化是前缀树Trie做候选粗筛把所有题库题干建一棵 Trie用页面上抓到的题干开头 8 到 12 个字去 Trie 里查能命中就只在这几个候选里做精确相似度计算命中不了再退回全表。class TrieNode { constructor() { this.children new Map(); this.ids []; // 该节点结尾的题目 id 列表 } } class Trie { constructor() { this.root new TrieNode(); } insert(text, id) { let node this.root; for (const ch of text) { if (!node.children.has(ch)) node.children.set(ch, new TrieNode()); node node.children.get(ch); } node.ids.push(id); } searchPrefix(prefix, limit 50) { let node this.root; for (const ch of prefix) { if (!node.children.has(ch)) return []; node node.children.get(ch); } // 广度优先收集最多 limit 个避免爆量 const result []; const queue [node]; while (queue.length result.length limit) { const cur queue.shift(); result.push(...cur.ids); for (const child of cur.children.values()) queue.push(child); } return result.slice(0, limit); } }这里有个工程细节值得说前缀截取长度怎么定。截太短候选集太大粗筛没意义截太长题干开头若有细微差异就直接命中不了。我实测下来的经验值是10 个字前提是归一化已经做过。这个数字不是拍脑袋我拿了 300 道题做统计前缀长度 6 字时候选集平均 180 条10 字时候选集平均 12 条12 字时候选集降到 3 条但漏配率从 4% 涨到 11%。10 字是拐点。2.3 相似度打分编辑距离 关键词权重的组合拳纯编辑距离在实际题库匹配上是不够用的。原因在于题干很长而差异往往集中在几个关键字上。比如下列关于 HTTP 状态码的说法正确的是和下列关于 HTTP 状态码的描述正确的是编辑距离算出来差异极小但如果题干里有个错误变成正确编辑距离只差一个字语义却完全相反。所以我的打分公式是三项加权score 0.6 * 编辑距离相似度 0.3 * 关键词命中率 0.1 * 长度惩罚项编辑距离相似度定义为1 - dist / max(lenA, lenB)取值 0 到 1。实现用滚动数组把空间降到 O(n)function levenshtein(a, b) { if (a b) return 0; const m a.length, n b.length; if (m 0) return n; if (n 0) return m; // 保证 b 是较短的那个省内存 if (m n) return levenshtein(b, a); let prev new Array(n 1); let curr new Array(n 1); for (let j 0; j n; j) prev[j] j; for (let i 1; i m; i) { curr[0] i; const ca a.charCodeAt(i - 1); for (let j 1; j n; j) { const cost ca b.charCodeAt(j - 1) ? 0 : 1; curr[j] Math.min( prev[j] 1, // 删除 curr[j - 1] 1, // 插入 prev[j - 1] cost // 替换 ); } [prev, curr] [curr, prev]; } return prev[n]; } function similarity(a, b) { const maxLen Math.max(a.length, b.length); if (!maxLen) return 0; return 1 - levenshtein(a, b) / maxLen; }编辑距离的问题是 O(m*n)题干长度平均 40 字两个题干对比就是 1600 次操作。单次不算什么但如果你有 100 道题、候选集 12 条那就是 1200 次对比接近 200 万次基本操作。这个量级在桌面上跑没问题但如果你在低配设备上跑就得注意了。一个实用的剪枝技巧是先比长度如果两个字符串长度差超过 40%直接判为不相似跳过编辑距离计算。这一刀能砍掉大约六成的计算量。关键词命中率这块我抽取的是数字、英文缩写、以及长度 2-6 的连续中文片段。数字和英文缩写的重要性最高因为它们在题干里往往是唯一标识。比如TCP 三次握手中第二次握手发送的标志位是TCP、三次、第二次这些就是高权重词。function extractKeywords(text) { const words new Set(); // 英文和数字串 const en text.match(/[A-Za-z0-9]/g) || []; en.forEach(w { if (w.length 2) words.add(w.toLowerCase()); }); // 中文 2-4 字滑窗只取高频实词位置的 for (let i 0; i text.length - 1; i) { const seg text.slice(i, i 2); if (/^[\u4e00-\u9fa5]{2}$/.test(seg)) words.add(seg); } return words; } function keywordScore(kwA, kwB) { if (!kwA.size || !kwB.size) return 0; let hit 0; for (const w of kwA) if (kwB.has(w)) hit; // 用较小集合做分母避免长题干被惩罚 return hit / Math.min(kwA.size, kwB.size); }长度惩罚项是个保险丝如果两个题干长度差特别大即使编辑距离相似度不低也不太可能是同一道题。我用的是1 - Math.abs(lenA - lenB) / Math.max(lenA, lenB)。三项加权之后就得到了 0 到 1 的最终分数。阈值怎么定这是这篇文章里最值得抄的一个数字。我拿了 300 道已经人工标注过的题做了分布统计结果是这样分数区间样本量正确匹配率结论0.90 - 1.0018799.5%可以自动填0.80 - 0.896296.8%可以自动填0.72 - 0.793488.2%建议人工确认0.60 - 0.712661.5%必须人工确认 0.604123.1%视为未命中所以我的阈值设定是自动填答线 0.80人工确认线 0.72低于 0.72 直接标记未命中。这三个数字你可以按自己的题库质量微调但方法论是通用的别拍脑袋定阈值拿几十道标注样本跑一遍分布阈值自然就出来了。2.4 填答动作模拟为什么改了 value 页面没反应这是新手最容易卡住的地方我单独拿一节讲。你写input.checked true页面上那个小圆点确实被选中了但点击下一题的时候页面说你没作答。原因在于现代前端框架用的是受控组件它不读 DOM 的实际值而是读自己内部维护的状态。你改 DOM框架的状态没变它下次渲染还会把你改的覆盖掉。正确做法是派发原生事件让框架的监听器收到通知。React 那边还有个更坑的点它重写了 input 的 value setter 来追踪变化你直接el.value x会被它识别为程序化赋值而忽略。绕过的办法是拿到原型链上的原始 setter 手动调一次function setNativeValue(el, value) { const proto Object.getPrototypeOf(el); const desc Object.getOwnPropertyDescriptor(proto, value); if (desc desc.set) { desc.set.call(el, value); } else { el.value value; } // 两个事件都要派发不同框架监听的不一样 el.dispatchEvent(new Event(input, { bubbles: true })); el.dispatchEvent(new Event(change, { bubbles: true })); }如果是单选、多选这类通常不需要操作 value直接el.click()就够了因为 click 会触发框架自己的 onClick 处理器状态自然就更新了。但有些页面把真正的处理逻辑绑在了 label 上而不是 input 上这时候你点 input 可能没反应得点它的父级 labelfunction clickOption(inputEl) { inputEl.click(); // 兜底如果 click 之后状态没变尝试点 label if (!inputEl.checked) { const label inputEl.closest(label); if (label) label.click(); } }还有一个更隐蔽的坑页面用了 pointerdown / mousedown 而不是 click。这种情况el.click()不触发。我的兜底做法是补一轮手动事件链function fireFullClick(el) { const opts { bubbles: true, cancelable: true, view: window }; el.dispatchEvent(new PointerEvent(pointerdown, opts)); el.dispatchEvent(new MouseEvent(mousedown, opts)); el.dispatchEvent(new PointerEvent(pointerup, opts)); el.dispatchEvent(new MouseEvent(mouseup, opts)); el.dispatchEvent(new MouseEvent(click, opts)); }注意事件链不要无脑全发。有些页面会统计点击次数发两遍 click 会导致选项被取消勾选。我的策略是先试 click检测状态状态没变再试 label再没变才走完整事件链逐级升级不要一上来就火力全开。3. 一步步写可复现的完整实现流程前面拆的是原理这一节给完整实现。我会按四个版本的迭代顺序来讲每个版本都是可运行、可验证的你可以跟着一步步加功能。3.1 准备工作与调试环境开始之前先做三件事。第一建一个调试用的练习页面。别一上来就在真实页面上试你连自己的脚本对不对都不知道。我一般会手搓一个包含 10 道题的静态页面题干、选项、radio 都按最常见的结构写这样脚本的行为是完全可控的。第二打开 DevTools 的几个面板。Elements用来看结构Console用来跑代码Sources用来打断点Application下的Local Storage用来看题库有没有存进去。第三准备一份题库。最简单的格式是 JSON 数组[ { id: 1, type: single, stem: 下列关于 HTTP 状态码 404 的说法正确的是, answer: [B], options: { A: 表示服务器内部错误, B: 表示请求的资源不存在, C: 表示请求被重定向, D: 表示权限不足 } } ]type字段很关键单选、多选、判断的处理逻辑不一样。判断题我建议也归一化成单选处理把正确/错误当作两个选项代码路径统一少一堆分支。3.2 第一版扫描页面输出题目清单第一版目标很简单把页面上所有题目打印到控制台格式和题库 JSON 一样。这一步不需要自动填答只要你能稳定地看到识别到 10 道题题干分别是……就说明取数逻辑通了。(function scanQuestions() { const containers findQuestionContainers(); console.log([scan] 识别到 ${containers.length} 个题目容器); const questions containers.map((box, idx) { const stemEl box.querySelector( .title, .stem, .question-title, .q-title, .subject-title ) || box; const stem cleanStem(stemEl.innerText || stemEl.textContent); const inputs box.querySelectorAll(input[typeradio], input[typecheckbox]); const type inputs[0] inputs[0].type checkbox ? multi : single; const options {}; inputs.forEach((el, i) { const label el.closest(label) || el.parentElement; const raw (label label.innerText) || ; const key String.fromCharCode(65 i); options[key] cleanOption(raw); }); return { id: idx 1, type, stem, options }; }); console.table(questions.map(q ({ id: q.id, type: q.type, stem: q.stem.slice(0, 20) }))); // 挂到全局方便后续调试 window.__qs questions; return questions; })();cleanStem的作用是剥掉题干里的题号前缀比如1. 、第 1 题、单选题这些。这一步不做题库匹配会一直被这几个字符干扰。function cleanStem(text) { return String(text || ) .replace(/^\s*第?\s*\d\s*[题.、)]\s*/, ) .replace(/^\s*[(【\[]\s*(单选|多选|判断|填空)题?\s*[)】\]]\s*/, ) .replace(/\s/g, ) .trim(); }跑完这一版你要重点看两个东西题目的条数对不对题干有没有混进选项文本。如果条数偏多说明容器选择器选到了嵌套节点去重一下如果条数偏少说明有题目用了不同的结构把兜底逻辑放宽一点。3.3 第二版接入题库跑出匹配结果第二版把题库接进来对每道题算出最佳匹配和分数但先不填答只输出结果。这个只看不动的中间态非常重要它能让你在没有任何副作用的情况下评估匹配质量。function matchAll(questions, bank, thresholdAuto 0.80, thresholdManual 0.72) { const normalizedBank bank.map(q ({ ...q, _n: normalize(q.stem), _kw: extractKeywords(normalize(q.stem)) })); const trie new Trie(); normalizedBank.forEach((q, i) trie.insert(q._n.slice(0, 10), i)); return questions.map(q { const nq normalize(q.stem); const kwq extractKeywords(nq); // 粗筛 let pool trie.searchPrefix(nq.slice(0, 10), 50); if (!pool.length) pool normalizedBank.map((_, i) i); let best null; for (const i of pool) { const item normalizedBank[i]; if (Math.abs(item._n.length - nq.length) / Math.max(item._n.length, nq.length) 0.4) continue; const s 0.6 * similarity(nq, item._n) 0.3 * keywordScore(kwq, item._kw) 0.1 * (1 - Math.abs(item._n.length - nq.length) / Math.max(item._n.length, nq.length)); if (!best || s best.score) best { item, score: s }; } const level !best ? miss : best.score thresholdAuto ? auto : best.score thresholdManual ? manual : miss; return { page: q, best: best ? best.item : null, score: best ? Number(best.score.toFixed(3)) : 0, level }; }); }跑完之后用console.table输出重点看三类数据auto的数量、manual的数量、miss的数量。健康的结果大概是 auto 占七成、manual 占两成、miss 占一成。如果 auto 特别多但你在人工抽查时发现错了说明阈值定低了如果 auto 特别少说明题库归一化规则和页面不一致回头调normalize。3.4 第三版自动填答 人工确认双模式第三版才是真正的填答。这里的核心设计是双模式auto直接填manual高亮标记但不填让人工过一眼。function applyAnswers(results, mode auto) { const log []; results.forEach(r { if (!r.best || r.level miss) return; if (r.level manual mode auto) { markForReview(r.page); log.push({ id: r.page.id, action: review, score: r.score }); return; } const inputs r.page.inputs || []; const answerKeys r.best.answer; // 先清空当前题目的选择多选题场景 inputs.forEach(el { if (el.checked) el.click(); }); answerKeys.forEach(key { const idx key.charCodeAt(0) - 65; const el inputs[idx]; if (el) clickOption(el); }); log.push({ id: r.page.id, action: filled, answer: answerKeys.join(), score: r.score }); }); console.table(log); return log; } function markForReview(page) { const box page.box || page.container; if (!box) return; box.style.outline 2px dashed #e6a23c; box.setAttribute(data-review, 1); }注意page.inputs这个字段第一版的 scan 里没存得补上。另外page.box也要存用于高亮。这里有个多选题的坑多选题先清空再填看起来安全但如果页面对取消勾选有次数限制有些平台会记录操作次数清空动作会白白消耗配额。更稳的做法是先读取当前选中状态只对需要变更的选项操作function applyMultiChoice(inputs, answerKeys) { const want new Set(answerKeys.map(k k.charCodeAt(0) - 65)); inputs.forEach((el, i) { const shouldCheck want.has(i); if (el.checked ! shouldCheck) clickOption(el); }); }这个改动看起来小但在真实页面上能明显减少操作异常的概率。3.5 第四版错题记录与复盘最后一版加两个东西结果持久化和错题回看。持久化用 localStoragekey 用页面路径加日期避免不同页面的数据混在一起。function saveSession(pathKey, payload) { const key quiz_session_${pathKey}; const old JSON.parse(localStorage.getItem(key) || []); old.push({ time: Date.now(), payload }); // 只保留最近 20 次防止撑爆存储 localStorage.setItem(key, JSON.stringify(old.slice(-20))); } function exportWrongList(pathKey) { const key quiz_session_${pathKey}; const sessions JSON.parse(localStorage.getItem(key) || []); const wrong []; sessions.forEach(s { (s.payload || []).forEach(item { if (item.action review || item.correct false) { wrong.push({ stem: item.stem, myAnswer: item.answer, score: item.score }); } }); }); // 输出成可以直接粘进题库的格式 console.log(JSON.stringify(wrong, null, 2)); return wrong; }错题记录的真正价值在于反向优化题库。跑了几轮之后你会发现某些题反复出现在 manual 列表里说明这些题的题干在题库和页面上的表述差异很大或者题库里的答案本身有问题。把这些挑出来修一遍下次的 auto 比例会明显上升。我自己的题库经过三轮这样的迭代auto 比例从 68% 提到了 91%。4. 踩坑实录常见问题排查与调优前面讲的是应该怎么做这一节讲实际会怎么坏。这些问题我基本都踩过按排查顺序整理。4.1 抓不到题目 / 抓串行抓不到的典型症状是containers.length 0。排查顺序建议这样走先在Elements面板里手动选中一道题的题干节点然后往上数层级看哪一层的 class 或 data 属性是每道题都有、每个题只出现一次的。用 DevTools 的CtrlF在 Elements 面板里搜这个 class 名数一下命中数量如果等于题目数量那就是它了。如果确实是前端渲染页面抓取时机就是问题。用 MutationObserver 监听function waitForQuestions(timeout 10000) { return new Promise((resolve, reject) { const found findQuestionContainers(); if (found.length) return resolve(found); const timer setTimeout(() { mo.disconnect(); reject(new Error(等待题目渲染超时)); }, timeout); const mo new MutationObserver(() { const nodes findQuestionContainers(); if (nodes.length) { clearTimeout(timer); mo.disconnect(); resolve(nodes); } }); mo.observe(document.body, { childList: true, subtree: true }); }); }抓串行是另一种常见问题题干里混进了选项文本或者两道题的题干粘在一起。这通常是因为题目容器选得太大把相邻题目也包进去了或者题干选择器命中了多个元素。排查办法是把stem的长度打印出来正常的题干长度在 15 到 80 字之间超过 150 字的八成是抓串了。还有个隐蔽的坑页面用了虚拟滚动。只渲染视口内的题目你滚动才加载。这种情况下querySelectorAll只能拿到当前可见的那几道。解决办法是分批滚动加载每滚动一次等 300 毫秒再抓把所有批次合并去重。4.2 点击没反应、状态没变这类问题的排查我总结成一个升级路径按成本从低到高依次尝试别一上来就发全套事件。第一步直接el.click()然后立刻检查el.checked。变了就结束。第二步没变就点label。有些页面把for属性写错了或者用 div 模拟 label直接点 label 反而有效。第三步还没变就走完整事件链fireFullClick。这一步能覆盖那些监听 pointerdown 的自定义组件。第四步检查是不是被遮挡了。有些页面在题目上盖了一层透明遮罩防复制用的el.click()在有些浏览器上会因为这个遮罩失效。判断方法是document.elementFromPoint(x, y)看返回的是不是那个 input 本身。如果是遮罩那就得先把遮罩display: none掉——这一步要谨慎属于改变页面行为只在你自己搭的练习页上做。function isBlocked(el) { const rect el.getBoundingClientRect(); const top document.elementFromPoint( rect.left rect.width / 2, rect.top rect.height / 2 ); return top ! el !el.contains(top); }提示click()之后立刻检查checked有可能拿到旧值因为某些框架是异步更新状态的。加一个 20 到 50 毫秒的等待更保险但别加太长批量处理时会明显拖慢速度。4.3 匹配准确率低怎么调准确率低有两种表现漏配该匹配的没匹配上和误配匹配到了错的。两种问题的调法完全相反先分清楚。漏配的排查方法把miss的题目导出和题库里的题干肉眼对比。常见原因有三个——题库里有生僻符号比如全角的引号没被归一化掉题干里有动态内容比如第 3 题里的数字是随机的题干太长编辑距离分母大导致相似度天然偏低。第三个原因最容易被忽略解法是把长题干的长度惩罚项权重调低或者对超过 60 字的题干改用分段匹配取最大。误配的排查方法把auto但分数在 0.80 到 0.85 之间的题挑出来人工看。这个区间是误配高发区。常见的误配模式是同系列题目——题库里有关于 A 的说法正确的是和关于 A 的说法错误的是两个字之差编辑距离只差一个字相似度高达 0.97但答案完全不同。对付这种坑我的做法是加一条否定词检测规则如果题干里含不正确、错误、不属于、除外这类否定词匹配时必须校验候选题干是否也含同类否定词不一致就降权 0.15。const NEG_WORDS [不正确的, 错误的是, 不属于, 不包括, 除外, 不是]; function hasNegation(text) { return NEG_WORDS.some(w text.includes(w)); } // 在打分环节 if (hasNegation(nq) ! hasNegation(item._n)) score - 0.15;这一条规则加进去我那批正反问的误配率从 12% 降到了 1% 以下。这类领域规则比调算法参数有用得多因为算法看的是字面相似度而领域规则看的是语义方向。4.4 常见问题速查表下面这张表是我这几年攒下来的出问题的时候按症状查能省不少时间。症状大概率原因快速验证方法处理方式容器数量为 0前端渲染未完成 / 选择器不对在 Elements 里数命中数量用 MutationObserver 等待题干里混进选项容器层级选得太高打印 stem 长度收紧容器选择器点击后 checked 不变受控组件 / 监听 pointerdown手动点 label 试试走完整事件链匹配分数普遍偏低归一化不一致拿两条题干跑 normalize 对比补全角转半角、剥前缀正反问误配否定词语义反转看误配对是否含否定词加否定词校验降权脚本跑起来页面卡顿全表编辑距离计算Performance 面板看长任务上 Trie 粗筛 长度剪枝刷新后数据没了没有持久化看 localStorage用 localStorage 存结果每道题耗时 0.3 秒无节流循环太快打时间戳看间隔分批处理 随机延时最后一行值得单独说。批量操作一定要加随机延时。不是为了对抗什么检测而是纯粹从工程角度连续几百次同步 DOM 操作会让主线程长时间占用页面会假死用户看得到的现象就是浏览器卡住了。用requestAnimationFrame或者setTimeout把循环拆成一批 5 到 8 道题每批之间留 100 到 300 毫秒体验会好非常多。5. 工程化收尾让脚本更好维护的几个习惯脚本能跑通只是第一步。真正用起来你会发现需求是不断变的——题库要更新、页面改版了、新题型要支持。这一节讲几个让维护成本降下来的习惯。5.1 配置与逻辑分离最常见的坏味道是把阈值、选择器、延时这些数字硬编码在函数里。改一次阈值要翻遍整个文件改错了还不知道影响哪儿。我的做法是文件顶部放一个CONFIG对象所有可调参数集中管理const CONFIG { selectors: { container: [[data-question-id], .question-item, .subject-item], stem: [.title, .stem, .question-title], option: [label, .option] }, threshold: { auto: 0.80, manual: 0.72 }, weights: { similarity: 0.6, keyword: 0.3, length: 0.1 }, batch: { size: 6, delayMs: 200 }, storageKey: quiz_bank_v1 };这么一改调参就变成了改一行配置的事。而且配置对象本身可以序列化导出你可以把不同页面的配置存成不同的 profile切页面的时候换一份配置就行。5.2 日志分级与可视化不要用console.log一把梭。脚本跑起来之后控制台会被几十条日志刷屏出问题根本找不到关键信息。我一般分三级error用于匹配失败、点击无效这类需要人工介入的问题warn用于走了兜底逻辑的地方比如click 无效改用 labelinfo用于流程节点比如识别到 20 道题。const LOG_LEVEL { error: 0, warn: 1, info: 2 }; const CURRENT_LEVEL LOG_LEVEL.warn; function log(level, ...args) { if (LOG_LEVEL[level] CURRENT_LEVEL) { console[level error ? error : level warn ? warn : log]( [quiz:${level}], ...args ); } }调试的时候把CURRENT_LEVEL调到info看全量日志正常跑的时候调到warn只看异常。这个习惯改一下只要十分钟但之后每次排查问题都能省下十几分钟。可视化这块我推荐用console.table代替console.log输出结构化数据。表格形式一眼就能看出哪一行分数异常比读几十行 JSON 快得多。还有一个技巧是在页面上直接标注匹配到auto的题在左上角画个绿点manual画个橙点miss画个红点。改版之后你只要扫一眼页面就知道哪块出了问题不用翻日志。5.3 性能与内存的几个注意点第一个注意点是别把大对象挂到 window 上。window.__qs questions在调试阶段很方便但如果题目有几百道、每题还带完整 options这个对象会一直占着内存而且不会被 GC 回收。调试完记得delete window.__qs。第二个是MutationObserver 要及时断开。它监听subtree: true的时候页面上任何一处 DOM 变化都会触发回调。如果你的回调里还做了比较重的操作页面会明显卡顿。拿到结果之后立刻mo.disconnect()别让它一直挂着。第三个是编辑距离的缓存。同一道题在一次运行里可能会被算多次相似度比如候选集里有重复用一个Map缓存结果能省掉不少重复计算const simCache new Map(); function cachedSimilarity(a, b) { const key a b ? a \u0000 b : b \u0000 a; if (simCache.has(key)) return simCache.get(key); const v similarity(a, b); if (simCache.size 5000) simCache.set(key, v); return v; }注意加了simCache.size 5000这个上限。缓存不设上限的话题库大的时候会一直涨最后把内存吃光。第四个是字符串拼接的代价。归一化和关键词抽取里会做大量字符串操作replace链式调用每次都会创建一个新字符串。题量小的时候无所谓题量上千的时候这块的耗时能占到总耗时的三成。优化办法是把多个replace合并成一个正则加回调const PUNCT_RE /[\s。、“”‘’《》【】,.!?;:()[\]{}]/g; const norm s s.replace(PUNCT_RE, ).toLowerCase();最后分享一个我自己用得很顺的小技巧给脚本加一个预演模式。传入一个dryRun: true参数脚本只输出匹配结果和将要执行的操作清单不做任何实际的点击。每次改完逻辑先跑一遍预演确认匹配结果没问题再跑真实模式。这个习惯帮我避掉了很多跑了一半发现匹配全错、页面已经被点乱了的尴尬情况。这套东西写下来大概四百行左右从第一版到能用差不多两个晚上的时间。真正花时间的不是写代码是搞清楚目标页面的 DOM 结构和调阈值。所以如果你打算动手我的建议是先花半小时把页面结构摸清楚画出题目容器的层级图再动手写第一行代码。这一步做扎实了后面的坑能少踩一半。
返回列表