用户脚本自动填问卷: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 {Promise<Element>} */ 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[type=radio],也可能是伪装成单选的 div const inputs = node.querySelectorAll('input[type=radio]'); const fake = node.querySelectorAll('[role="radio"]'); 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[type=checkbox]')) .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 = ['test01@example.com', 'test02@example.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[type=radio], input[type=checkbox]') || option.querySelector('[role="radio"], [role="checkbox"]') || 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或[role="row"]分行遍历,每行独立抽取,不要在外层一次性取所有 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;" />