用户脚本自动填问卷:DOM事件模拟与受控组件赋值实践 简介这是一款面向需要自动随机填写问卷星问卷的用户整理的浏览器JS脚本插件核心为可安装的用户脚本适合问卷逻辑测试、表单填写演示以及JavaScript脚本学习参考。压缩包共5个文件整体约9KB包含1个js脚本文件、1个txt说明文件以及3个url快捷方式其中js文件承载自动填写逻辑txt与url文件用于补充使用说明和参考入口。脚本目前支持单选题、多选题、比重题等题型的自动随机填写可在问卷测试与演示场景中减少重复手工点选便于快速验证题目跳转、选项分布、必填限制和提交流程对于前端自动化初学者也可借此观察随机取值、题型判断与页面元素定位等实现思路。已有9972人学习下载适合想了解浏览器用户脚本运行方式、需要批量测试问卷或研究自动填写实现思路的读者参考。1. 把一份二十题的问卷手动走完要四分钟我决定让脚本替我点上个月帮一个做用户调研的朋友验证问卷逻辑一份二十题的问卷光手动走一遍就得四分钟十几种分支组合跑下来一个下午就没了人还会点错。后来我写了个浏览器端的 JS 用户脚本挂在页面上自动扫题、按权重随机选项、模拟真实点击一份问卷从加载到提交压到六秒左右。这类小工具本质是一个跑在页面里的网络软件不依赖后端、不需要接口权限靠 DOM 扫描和事件派发把整条链路走完。它适合两类人一是自己问卷上线前要做分支覆盖测试的开发者二是想学前端自动化填表、事件模拟这套东西的人。边界得先说清楚只跑在自己有权限的问卷页上做链路自测拿它去伪造别人的调研数据不在讨论范围。2. 脚本骨架用户脚本怎么注入、怎么和页面共存自动填表脚本最容易翻车的地方不是随机算法是“脚本已经跑完页面上什么都没发生”。原因是注入时机、选择器、事件模型三件事只要有一件对不上脚本就像在黑匣子里空转。所以骨架阶段我一般先把“什么时候注入、注进去扫什么、扫完按什么顺序处理”这三件事定死再谈随机。2.1 用户脚本头与注入时机脚本本体是一个标准用户脚本头部元数据决定了它挂在哪、什么时候挂。// UserScript // name 问卷页自动填充自测用 // namespace local.survey.autofill // version 0.4.2 // description 扫描页面题型按权重随机作答仅用于自有问卷链路自测 // match https://survey-demo.local/* // run-at document-idle // grant none // /UserScript几个字段的取舍要说清楚。match一定要写死域名和路径不要图省事写通配一旦把匹配范围放宽到所有站点脚本会在任意页面上尝试扫描并派发点击行为完全不可控。grant none表示不申请任何特权 API脚本跑在页面上下文里这样document、window都是原生的避免沙箱环境导致的instanceof判断失效。run-at document-idle是血泪经验document-end只是 DOM 解析完成前端渲染型页面此时题目容器还是空的脚本扫出来 0 道题然后安静退出看起来就像没生效。光靠document-idle也不保险SPA 页面首次渲染可能还要等接口。我一般再包一层等待函数把“等容器出现”做成显式逻辑而不是靠setTimeout猜时间。/** * 轮询等待元素出现 * param {string} selector 目标选择器 * param {number} timeout 最长等待毫秒数默认 8 秒 * param {number} interval 轮询间隔默认 200ms * returns {PromiseElement} */ function waitFor(selector, timeout 8000, interval 200) { return new Promise((resolve, reject) { const start Date.now(); const timer setInterval(() { const el document.querySelector(selector); if (el) { clearInterval(timer); resolve(el); } else if (Date.now() - start timeout) { clearInterval(timer); reject(new Error(等待超时未找到: ${selector})); } }, interval); }); }timeout默认 8 秒是按“问卷页首屏接口最慢两三秒”估的留了三倍余量interval不要低于 100ms轮询太密在低端设备上会和页面自身渲染抢主线程。这个函数返回 Promise后面主流程用await串起来避免回调套回调。2.2 题型路由表把“长什么样”映射到“怎么处理”一份问卷里题型就那么几类但每类的 DOM 结构不同写成一堆if-else很难维护。我的做法是维护一张声明式路由表把选择器和处理函数绑定在一起。// 题型 - 选择器 处理器 的映射 const QUESTION_TYPES { single: { selector: .q-single, handler: fillSingle }, multi: { selector: .q-multi, handler: fillMulti }, text: { selector: .q-text, handler: fillText }, scale: { selector: .q-scale, handler: fillScale }, matrix: { selector: .q-matrix, handler: fillMatrix }, dropdown: { selector: .q-select, handler: fillDropdown }, }; /** * 扫描页面并按声明顺序构建待处理队列 * returns {Array{type: string, node: Element, handler: Function}} */ function buildRouteMap() { const map []; for (const [type, cfg] of Object.entries(QUESTION_TYPES)) { const nodes document.querySelectorAll(cfg.selector); if (!nodes.length) continue; nodes.forEach((node) { // 已处理过的题目打标跳过防止同一节点被多个类型重复消费 if (node.dataset.autofilled 1) return; map.push({ type, node, handler: cfg.handler }); }); } return map; }这张表有两个隐含约定。一是顺序即优先级Object.entries的遍历顺序等于定义顺序所以单向选择必须排在矩阵前面因为矩阵题的容器里常常也带单选按钮反过来写会出现矩阵被当成一组普通单选处理、行错位的问题。二是dataset.autofilled标记它是幂等性的保证页面如果有重渲染或脚本被重复触发处理过的题不会被二次点击。这个标记打在题目的容器节点上而不是选项上原因是选项节点在 React 重渲染时会被整个替换掉标记会丢。2.3 主循环串行处理、节流、留出页面反应时间扫描完不能一口气全点完。页面上很多逻辑是“上一题选了 A下一题才展开附加项”一次性并发点击会绕过分支逻辑填出来的数据自相矛盾。const sleep (ms) new Promise((r) setTimeout(r, ms)); const randomBetween (min, max) Math.floor(Math.random() * (max - min 1)) min; async function run() { await waitFor(.survey-body); // 等问卷主体挂载 const routes buildRouteMap(); // 扫题型 for (const { type, node, handler } of routes) { try { await handler(node); // 串行处理等待每个处理器内部的异步 node.dataset.autofilled 1; node.scrollIntoView({ block: center, behavior: smooth }); await sleep(randomBetween(120, 380)); // 题与题之间留间隔 } catch (err) { console.warn([autofill] ${type} 处理失败, err, node); } } }sleep(randomBetween(120, 380))这段间隔不是玄学它解决的是两个具体问题一是页面自身的题间过渡动画或懒加载还没完太快点会造成点击落空二是连续几十次同步 DOM 变更会挤在同一个事件循环里页面自己的重渲染被推到后面选择器的状态读出来是旧的。scrollIntoView是给某些虚拟滚动容器用的不滚动到视口内节点虽然存在但点击坐标算不出来。单个题目try/catch包住是一道保险某一道题结构异常不应该让整份问卷中断日志里能定位到具体类型和节点就行。3. 题型识别与随机策略别把“随机”做成“全选第一个”随机填表最常被诟病的一点是“随机出来的数据一眼假”比如所有单选都落在同一个选项上或者矩阵题整列同值。这不是随机算法的问题是策略太单薄。我一般把随机拆成三层先识别题型和约束再按权重抽值最后过一遍校验规则。3.1 从 DOM 里读出选项和约束处理函数的第一件事永远是“把可选项和限制条件读出来”而不是直接点。/** * 提取单选题的可选项与约束 * param {Element} node 题目容器 */ function fillSingle(node) { // 选项可能是 input[typeradio]也可能是伪装成单选的 div const inputs node.querySelectorAll(input[typeradio]); const fake node.querySelectorAll([roleradio]); const options inputs.length ? Array.from(inputs).filter((i) !i.disabled) : Array.from(fake).filter((i) i.getAttribute(aria-disabled) ! true); if (!options.length) throw new Error(未找到可选项); const required node.dataset.required true; if (!required) { // 非必答题有一定概率主动跳过让样本更接近真实分布 if (Math.random() 0.05) return; } const picked weightedPick(options, readWeights(node)); pickOption(picked); }readWeights从题干或选项上的>/** * 按权重从数组中抽取一项 * param {Array} items 候选元素 * param {number[]} weights 与 items 等长的权重数组 */ function weightedPick(items, weights) { const total weights.reduce((a, b) a b, 0); if (total 0) return items[Math.floor(Math.random() * items.length)]; let r Math.random() * total; for (let i 0; i items.length; i) { r - weights[i]; if (r 0) return items[i]; } return items[items.length - 1]; } /** * 多选题先定命中数量再不放回抽取 */ function fillMulti(node) { const options Array.from(node.querySelectorAll(input[typecheckbox])) .filter((i) !i.disabled); const min Number(node.dataset.min || 1); const max Number(node.dataset.max || Math.min(3, options.length)); const count randomBetween(min, Math.max(min, max)); const pool options.slice(); for (let i 0; i count pool.length; i) { const idx Math.floor(Math.random() * pool.length); pickOption(pool[idx]); pool.splice(idx, 1); // 不放回避免重复点同一个 } }weightedPick用累积权重法复杂度 O(n)选项数量级下完全够用。fillMulti的关键在“先定数量再抽取”和“不放回”两点先定数量保证了选中个数落在[min, max]区间内这是很多问卷的硬校验不放回则避免重复点击导致实际只选中了一个。max的默认值取min(3, options.length)经验值五个选项全选在多选题里是异常样本。3.3 文本题校验规则比随机内容更容易翻车文本题不通过的原因基本都是格式校验而不是内容本身。const TEXT_POOL [还行, 比较满意, 没什么特别意见, 希望继续保持, 整体不错]; const EMAIL_POOL [test01example.com, test02example.com]; function fillText(node) { const input node.querySelector(input, textarea); if (!input) throw new Error(未找到输入框); const maxLen Number(input.getAttribute(maxlength) || 200); const minLen Number(node.dataset.minlength || 0); const type input.type || input.tagName.toLowerCase(); let value; if (type email) { value EMAIL_POOL[Math.floor(Math.random() * EMAIL_POOL.length)]; } else if (type number || input.inputMode numeric) { const min Number(input.min || 0); const max Number(input.max || 100); value String(randomBetween(min, max)); } else { value TEXT_POOL[Math.floor(Math.random() * TEXT_POOL.length)]; // 补齐到最小长度避免“不少于 5 个字”这类校验失败 while (value.length minLen) value 整体体验良好; if (value.length maxLen) value value.slice(0, maxLen); } setNativeValue(input, value); }这里有个容易被忽略的点maxlength是硬限制超了浏览器会直接截断但minlength在多数实现里只是校验提示脚本不能指望它自动补。数值题必须读input.min和input.max用randomBetween而不是Math.random() * 100否则会出现负数评分或者超出量表范围的值提交时被拦下来。文本池的内容要贴近真实表达不要出现“test”“abc”这种即便只是自测数据回看时也能快速分辨哪些是脚本填的。4. 受控组件赋值为什么 .value 改了页面不认写到这一步脚本在纯静态页面上已经能跑了但一放到现代前端框架的问卷页上就会卡住输入框里明明有字提交时却提示“该题未作答”。这不是脚本没执行是赋值方式绕过了框架的状态管理。4.1 原生 setter绕过框架对 value 的劫持React 这类框架会在 input 元素上重写value属性的 getter/setter直接input.value x只改了 DOM 属性框架内部的 state 没变提交读的还是 state。解决办法是拿原型链上的原生 setter 来赋值。/** * 绕过框架对 value 的劫持写入真实值并派发 input 事件 * param {HTMLInputElement|HTMLTextAreaElement} el * param {string} value */ function setNativeValue(el, value) { const proto el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor Object.getOwnPropertyDescriptor(proto, value); if (descriptor descriptor.set) { descriptor.set.call(el, value); // 调原生 setter } else { el.value value; // 兜底 } // 顺序不能反先 input 再 change框架靠 input 同步 state el.dispatchEvent(new Event(input, { bubbles: true })); el.dispatchEvent(new Event(change, { bubbles: true })); }Object.getOwnPropertyDescriptor(proto, value)拿到的是原型上的原生访问器descriptor.set.call(el, value)相当于以元素为 this 执行原生赋值框架在元素实例上挂的拦截器就被跳过了。bubbles: true必须带很多框架是在外层容器上做事件委托监听的不冒泡等于没派发。input必须在change前面input触发 state 同步change触发校验逻辑顺序反了会出现“值有了但校验没跑”的情况。单选框和复选框不走这套它们要的是click()因为框架监听的是点击而不是checked赋值。4.2 点击目标与事件链点错一层就白点选项的点击是最容易出错的一环。页面上能点的可能有三层原生 input、包裹它的 label、label 里面的自定义图标。点哪一层结果完全不同。function pickOption(option) { // 1) 原生 input直接 click浏览器会自动处理 label 关联 if (option.tagName INPUT) { option.click(); return; } // 2) 自定义组件找内部真正可点击的目标 const target option.querySelector(input[typeradio], input[typecheckbox]) || option.querySelector([roleradio], [rolecheckbox]) || option; if (target.tagName INPUT) { target.click(); } else { // 自定义元素只认指针事件时补一套完整事件链 [pointerdown, mousedown, pointerup, mouseup, click].forEach((type) { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); }); } // 3) 校验结果点完再读一次状态确认选中 const checked option.querySelector(input:checked) || option.getAttribute(aria-checked) true; if (!checked) throw new Error(点击后未选中可能命中了错误节点); }事件链的顺序是固定的pointerdown → mousedown → pointerup → mouseup → click少任何一个某些组件库的内部状态机就不会推进。cancelable: true是为了让组件能正常调用preventDefault不然它会认为事件不可取消而走异常分支。最关键的是最后那步“点完回读”不要假设点击成功用:checked或aria-checked验证一次失败了直接抛错比提交时才发现某题没答要好排查得多。5. 避坑与排查脚本跑通之前踩过的五个坑这套脚本我改过三版前两版基本都在“看起来跑了但结果不对”的状态里打转。下面五条是按出现频率排的每条都能对应到具体的日志现象。5.1 扫描与识别阶段坑一脚本执行了控制台没有任何输出页面也没变化。现象是加了一堆console.log但一条都不打印。原因是run-at document-end前端渲染的问卷此时容器还没挂载querySelector返回 null脚本在第一个if里就静默返回了。改成document-idle并把等待逻辑显式写成waitFor同时给超时抛错而不是静默 return问题就不会被吞掉。坑二题目扫出来了但数量比页面上看到的少一半。现象是第 6 题之后全部漏填。原因是问卷做了分页或懒加载第一页只有 5 道题后面的节点在翻页之后才注入。解决方式是把“扫描”从一次性动作改成循环动作每处理完当前批次检查有没有“下一页”按钮点掉之后重新执行buildRouteMap()用dataset.autofilled保证已处理的题不被重复消费。坑三矩阵题整行整列错位。现象是矩阵题本该每行选一个结果第一行被选了两次、第二行空着。原因是矩阵的容器里同时匹配了.q-single被普通单选处理器先消费了。解决方式是调整路由表的声明顺序让.q-matrix排在.q-single前面并且在矩阵处理器内部按tr或[rolerow]分行遍历每行独立抽取不要在外层一次性取所有 radio。5.2 交互与提交阶段坑四输入框里有字提交时提示“未作答”。现象就是前面说的受控组件问题满屏文字但框架 state 是空的。判断方法很简单在控制台执行document.querySelector(.q-text input).value有值但打开框架开发者工具看到的 state 是空字符串。解决就是setNativeValue那套原生 setter 加input/change双事件注意bubbles: true不能省。坑五点击返回成功但选项视觉上没变化。现象是代码没报错日志也打了“已选中”但截图里选项还是灰的。原因多数是点在了 label 上而组件库监听的是内部隐藏 input 的 click或者是只派发了click没有前置的pointerdown组件的按下态没建立click被自身的状态机忽略。解决办法是按 4.2 里的顺序派发完整事件链并且在点击后回读:checked/aria-checked做断言不通过就抛出带节点信息的错误。提示排查这类问题的通用手段是先手动操作一遍同时在控制台用monitorEvents(el)观察真实触发了哪些事件、顺序如何再让脚本照着复现。比猜事件名靠谱得多。6. 二次开发把写死的随机改成配置驱动写到能跑之后最值得改的一件事是把散落在各处理器里的魔法值抽出来做成一份外部配置。我最初的版本里跳过概率 0.05、题间间隔 120 到 380、多选题上限 3、文本池内容全是硬编码在函数体里的改一个参数要翻四个文件。后来改成从localStorage读一份 JSON 配置用的时候合并默认值二次开发成本立刻降下来。const DEFAULTS { skipRate: 0.05, // 非必答题跳过概率 delayRange: [120, 380], // 题间间隔毫秒 multiMax: 3, // 多选题命中上限 textPool: [还行, 比较满意, 没什么特别意见], dryRun: false, // 只扫描不点击用于校验选择器 }; function loadConfig() { try { const raw localStorage.getItem(autofill:config); return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS }; } catch (e) { console.warn([autofill] 配置解析失败回退默认值, e); return { ...DEFAULTS }; } } const CONFIG loadConfig();这里dryRun是我后来加的价值比想象中大。打开它之后所有处理器只做扫描和日志输出不派发任何事件等于给脚本加了一个“结构体检”模式。问卷页改版、选择器失效的时候先用dryRun跑一遍看每类题型扫到几个节点、匹配到哪些约束比直接跑完整流程再从头查日志高效得多。验证方式我一般分三步走。第一步dryRun扫结构确认每类题型的命中数量跟肉眼看到的一致。第二步关掉dryRun但把提交那一步手动拦住跑完看控制台有没有处理失败的告警以及所有题目容器的style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />