简介:问卷星自动随机填写脚本js插件是一份面向需要批量测试问卷、演示表单或进行问卷数据模拟的用户整理的小工具,核心为可在浏览器中运行的JavaScript用户脚本,支持单选题、多选题和比重题等题型的自动随机填写,适合具备基础脚本安装能力的使用者。压缩包共5个文件,以js主脚本和txt说明为主,并附带若干url快捷入口,整体约9KB,体积轻便,便于保存和按说明部署。资源已有9972人学习,说明其在问卷自动填写场景中具有一定关注度。读者可获得核心填写逻辑脚本、浏览器脚本安装指引及使用入口,便于快速理解脚本运行方式,并在符合平台规则与授权的前提下用于表单测试、流程演示或重复性录入操作。
1. 自动随机填写脚本:一份 js 插件替你干了哪几步活
做表单回归测试的人都撞见过这种场面:一份二十来题的问卷,逻辑跳转、必填校验、多选上下限全都要验,手动点完一轮七八分钟,一天验十个版本就是纯体力活。我最初也不信这类自动随机填写脚本能省多少事,直到某次改了一个多选题的互斥逻辑,靠手点漏掉了三条分支。这类 js 插件属于很典型的轻量网络软件:几百行浏览器脚本,不依赖后端,靠解析页面结构生成合规答案,再模拟点击与输入。它适合自建表单的前端测试、埋点联调和本地样本构造,不适合往真实数据采集场景里塞。下面按“结构怎么拆、脚本怎么跑、参数怎么调、哪里会翻车”四段讲。
2. 拆解插件内核:三类表单骨架与四种题型的识别链
这份资源的体积很小,真正的价值全在识别链上。同样一份问卷,换一套前端模板,DOM 结构可能完全不同,脚本要能活下来,靠的不是某一个选择器,而是三层降级:先找题目容器,再判题型,最后才生成答案。顺序错了就会连锁翻车——容器抓大了,矩阵题的所有行会被当成一道题;题型判错了,多选题按单选处理,必填校验直接卡死。
2.1 三种 DOM 骨架,决定脚本能不能通用
实际遇到的表单骨架基本就三类。第一类是老式表格布局,题干在td里,选项在相邻td的input里;第二类是div+label布局,题干有独立的标题节点,选项被label包着;第三类是前端框架渲染出来的受控组件,外层是自定义标签,input可能是隐藏的原生控件,真正被点的是一层span或div。前两类靠querySelectorAll就能拿下,第三类必须结合事件派发,否则值改了框架也不认。
不管哪一类,第一步都是把页面切成“题目数组”。这里最容易犯的错是直接document.querySelectorAll('input')平铺处理,结果把隐藏域、模板节点、搜索框全拉进来。我的做法是先抓候选容器,再做父子去重,只保留最外层,最后过滤掉不可交互的控件。
// 把任意一种表单骨架归一化成 [{ index, stem, inputs, required }] function collectQuestions(root = document) { // 1) 候选容器:类名特征 + fieldset,覆盖大部分模板 const blocks = [ ...root.querySelectorAll('[class*="question"], [class*="field"], [class*="item"], fieldset') ].filter(el => el.querySelector('input, textarea, select')); // 2) 父子去重:只留最外层容器,避免一道题被数两次 const top = blocks.filter(el => !blocks.some(o => o !== el && o.contains(el))); return top.map((el, i) => { const stemEl = el.querySelector('[class*="title"], [class*="label"], legend'); const stem = (stemEl?.innerText || el.innerText || '').trim().slice(0, 160); const inputs = [...el.querySelectorAll('input, textarea, select')] .filter(n => { if (n.type === 'hidden' || n.disabled) return false; const r = n.getBoundingClientRect(); // 用尺寸判可见,比 offsetParent 稳 return r.width > 0 && r.height > 0; }); // 3) 必填判定:题干带星号,或容器上有 required 类名 const required = /\*|必填/.test(stem) || /required/i.test(el.className); return { index: i, stem, inputs, required }; }).filter(q => q.inputs.length > 0); }这段代码里三个参数值得说明。stem截断到 160 字符,是因为后面要靠题干文案兜底解析“最多选 N 项”这类约束,太长会把选项文本也吃进去。可见性判断没用offsetParent !== null,原因是position: fixed元素的offsetParent恒为null,会误杀浮动在底部的提交条。required做成双通道,是因为很多模板把星号画在 CSS 伪元素里,innerText根本取不到。
2.2 题型识别链路:从 input type 到题干文案兜底
归一化之后,每道题手上只有一堆原生控件和一段题干。识别顺序必须是“结构特征优先、type 次之、文案兜底”,因为矩阵题和量表题的原生控件全都是 radio,只按 type 判会把它们全归成单选。
矩阵题的判定特征是:同一个容器内存在多个不同的name,且这些name对应的 radio 数量一致。量表题更简单,通常是一排 radio 配数字文案,但它的语义和单选不同,取答案的策略也不同。排序题和拖拽题没有原生控件,识别结果必然是unknown,这时候正确动作是记录日志后跳过,而不是硬点。
function detectType(q) { const list = q.inputs; const types = new Set(list.map(n => (n.type || n.tagName).toLowerCase())); const names = new Set(list.map(n => n.name).filter(Boolean)); // 矩阵题:容器内有多个分组,每组都是 radio if (types.size === 1 && types.has('radio') && names.size > 1) return 'matrix'; if (types.has('checkbox')) return 'multi'; if (types.has('radio')) return 'single'; if (types.has('select-one') || types.has('select')) return 'single'; if (types.has('textarea')) return 'open'; if (['text', 'tel', 'number', 'email'].some(t => types.has(t))) return 'fill'; return 'unknown'; }names.size > 1这一句是矩阵题的命门。有些模板会把整道矩阵题的 radio 全部命名成同一个name,这时候结构上它和普通单选无差别,只能退回去看题干里有没有“请对以下各项打分”“每行选择一项”这类描述。我在实际用的时候会给unknown加一条告警,跑完一轮先看日志里有多少条未识别,超过两道就说明这套模板的骨架超出了脚本的覆盖范围,得手动补规则,而不是直接提交。
还有一个细节:select的type在部分浏览器里返回select-one,在另一些环境返回select,所以两个都要判。这类跨浏览器的琐碎差异不写进代码,换台机器就跑不通。
2.3 答案生成器:约束优先,随机在后
真正决定这份资源好不好用的,不是随机函数,而是约束解析。多选题的题干里经常藏着“最多选 3 项”“至少选 2 项”,这类约束不在 DOM 属性里,只在文案里;如果脚本不知道上下限,就会出现两种情况:一是只选一个,提交时提示“请至少选择 2 项”;二是全选,撞上“最多选 3 项”的红线。两种都会让整轮测试白跑。
我的处理是把约束解析做成独立的一层,先扫题干和容器的>// 从题干文案里抽多选上下限,抽不到就用保守默认值 function parseBounds(q) { const text = q.stem.replace(/\s/g, ''); const pick = (re) => { const m = text.match(re); return m ? parseInt(m[1], 10) : null; }; const max = pick(/(?:最多|至多|不超过)选?择?(\d+)项?/); const min = pick(/(?:最少|至少|不少于)选?择?(\d+)项?/); const total = new Set(q.inputs.map(n => n.value)).size; return { min: min ?? 1, max: Math.min(max ?? 3, total) }; }
replace(/\s/g, '')是必须的,模板渲染出来的题干经常夹着换行和全角空格,“最多选 3 项”被拆成三行的情况我见过不止一次。total用new Set去重,是因为有些模板会给“其他”选项配一个额外的输入框,value重复会让上限算大。
约束拿到之后才谈随机。单选按权重抽,多选先定数量再按权重不放回抽,填空按input的type和maxlength生成对应格式,开放题走模板池。这一层的输出统一成一个picked结构,后面写回页面和做快照都用它,好处是排查问题时能直接对着快照看“这道题脚本到底选了什么”,不用去翻 DOM。
3. 跑通一次完整填写:从脚本注入到提交的六步
结构拆明白之后,剩下的是工程问题:脚本挂在哪里、配置怎么给、主流程怎么串、失败怎么收场。这一段给的是能直接抄的骨架,改三个参数就能在本地的自建表单上跑起来。
3.1 运行环境与两种加载方式
两种加载方式各有适用场景。临时验证用浏览器开发者工具的 Console,粘贴即跑,缺点是刷新就没了,而且长脚本粘贴容易丢字符;反复用就写成用户脚本,交给浏览器的用户脚本管理器托管,靠@match控制生效范围。我一般先用 Console 跑通,再固化成用户脚本,顺序反过来会浪费很多时间在“到底是脚本没注入还是逻辑写错了”上。
// ==UserScript== // @name FormAutoFill-Test // @namespace local.sandbox // @version 0.1 // @description 自建表单的自动化填写测试脚本(仅用于本地/测试环境) // @match https://your-test-domain.example/* // @run-at document-idle // @grant none // ==/UserScript==@match一定要收窄到测试域名,写成*://*/*的后果是打开任何页面都可能触发填写逻辑,这个坑我踩过一次,页面上的搜索框被填进了随机文本。@run-at document-idle比document-start稳,因为框架渲染的题目容器在 DOMContentLoaded 时往往还不存在,太早执行只会抓到空数组。
Console 方式要注意的是执行上下文。部分页面有 iframe 嵌套的表单,Console 默认在顶层窗口执行,document抓到的是外壳页面,题目数组直接为空。这时候得先在控制台的上下文下拉里切到对应的 frame,再执行脚本。
3.2 配置对象:七个必须改的参数
配置集中在一个对象里,不要散落在各处。下面这张表是我实际用的默认值,每一项都对应一个具体后果,不是拍脑袋定的:
| 参数 | 默认值 | 作用 | 调错的后果 |
|---|---|---|---|
durationMin/durationMax | 90 / 240 秒 | 整份表单的目标耗时区间 | 太短会撞上提交频控,太长测试跑不完 |
thinkMin/thinkMax | 800 / 3500 毫秒 | 单题之间的间隔 | 间隔为 0 时页面滚动和懒加载跟不上 |
typingDelay | 60 毫秒 | 开放题逐字输入间隔 | 太小会触发输入框的防抖校验 |
maxOpenLen | 60 字符 | 开放题截断长度 | 超过maxlength会被截断或报错 |
fillRate | 0.92 | 非必填题的作答概率 | 设成 1 会让答案分布过于整齐 |
seed | null | 随机种子,null表示每次不同 | 需要复现时忘了设,问题复现不了 |
fillRate这一项容易被忽略。非必填题如果每道都答,跑出来的样本在分布上会明显偏离真实填写,做埋点联调时看不出“用户跳过某题”这条分支。留 8% 的跳过率,顺便把“未作答”的展示逻辑也覆盖了。
3.3 主流程代码与逐段说明
主流程就是一个串行循环,中间夹着等待。不写成并发是有意的:表单的滚动加载、焦点切换、埋点上报都有时序依赖,并发点选很容易出现“元素已存在但被遮挡”的报错。
const CONFIG = { durationMin: 90, durationMax: 240, thinkMin: 800, thinkMax: 3500, typingDelay: 60, maxOpenLen: 60, fillRate: 0.92, seed: null }; const sleep = ms => new Promise(r => setTimeout(r, ms)); const rand = (a, b) => a + Math.random() * (b - a); (async function main() { // 单例保护:防止脚本被注入两次导致定时器叠加 if (window.__FORM_FILLING__) return; window.__FORM_FILLING__ = true; const questions = collectQuestions(); console.log(`[fill] 识别到 ${questions.length} 个题目容器`); const snapshot = []; for (const q of questions) { const type = detectType(q); if (type === 'unknown') { console.warn(`[fill] #${q.index} 题型未识别,跳过:${q.stem.slice(0, 30)}`); continue; } // 非必填题按 fillRate 抽样跳过 if (!q.required && Math.random() > CONFIG.fillRate) continue; const bounds = type === 'multi' ? parseBounds(q) : null; const picked = answerFor(q, type, bounds); // 生成答案,见 2.3 / 4.2 applyAnswer(q, type, picked); // 写回页面,见 5.2 snapshot.push({ index: q.index, type, picked }); await sleep(rand(CONFIG.thinkMin, CONFIG.thinkMax)); } window.__FORM_SNAPSHOT__ = snapshot; // 留给回放与断言 console.log('[fill] 完成,快照已挂到 window.__FORM_SNAPSHOT__'); window.__FORM_FILLING__ = false; })();逐段说。单例保护用的是挂在window上的布尔标记,看起来土,但比闭包变量可靠,因为用户脚本重复注入时会创建新的闭包,而window是共享的。answerFor和applyAnswer拆成两个函数是有讲究的:前者纯计算、不碰 DOM,可以单独跑单测;后者只负责事件派发,出错也只影响一道题。
最后一步不自动点提交,是我刻意留的。提交按钮的文案五花八门,抓错了可能触发“保存草稿”或者“退出”,而且在测试环境里自动提交会污染后端数据。正确做法是跑完之后人工确认一遍高亮的题目,再手动提交,把最后一公里留给眼睛。
4. 参数调优:时长、权重、文本长度这三处最容易翻车
默认配置能跑通,但跑出来的数据像不像“人填的”,全看这三个参数。它们之间还有耦合:时长压短了,逐题间隔就必须跟着缩,间隔一缩,开放题的逐字输入就会露馅。
4.1 时长参数:整卷节奏与逐题间隔怎么配
durationMin/durationMax是目标,thinkMin/thinkMax是实现手段,两者必须能对上,否则脚本要么跑得比目标慢一倍,要么快到触发频控。反算的公式很朴素:可用总时长除以题数,得到平均单题间隔,再上下浮动 50% 作为区间。
// 由目标总时长反算单题间隔,避免两个参数互相打架 function planTiming(questionCount, cfg) { const avgTotal = (cfg.durationMin + cfg.durationMax) / 2 * 1000; const avgThink = avgTotal / Math.max(questionCount, 1); return { thinkMin: Math.max(200, Math.round(avgThink * 0.5)), thinkMax: Math.round(avgThink * 1.5), estimatedSeconds: Math.round(avgThink * questionCount / 1000) }; }Math.max(200, ...)这个下限是血泪经验。题量大的表单(比如四十题的满意度调查)反算出来的平均间隔可能只有一百多毫秒,实际跑起来页面滚动动画还没结束就点下一题,报错率飙升。两百毫秒是滚动和焦点切换的底线,低于这个值就别指望稳定。
题量特别少的时候反过来要小心:三道题、目标 90 到 240 秒,平均单题间隔接近一分钟,跑一轮要等太久。这种场景我一般把目标时长改成“题数 × 5 秒”,让时长跟着题量走,而不是死守一个固定区间。
4.2 选项权重:让随机结果贴近你要验证的分布
均匀随机在测试里经常不够用。想验证“选了 A 之后 B 题才出现”的跳转逻辑,就要让 A 的出现概率明显偏高;想压测某个选项对应的长文本分支,就得给它加权。权重表的价值在于把“随机”变成“可控的随机”,跑完一轮能确认目标分支确实被覆盖到了。
// 按权重不放回抽取 n 个选项,n 由 min/max 约束决定 function pickMulti(options, bounds, weights = [], random = Math.random) { const n = Math.min( options.length, Math.max(bounds.min, Math.floor(random() * (bounds.max - bounds.min + 1)) + bounds.min) ); const pool = options.map((opt, i) => ({ opt, w: weights[i] ?? 1 })); const picked = []; while (picked.length < n && pool.length) { const total = pool.reduce((s, p) => s + p.w, 0); let r = random() * total, k = 0; while (r > pool[k].w) { r -= pool[k].w; k++; } picked.push(pool.splice(k, 1)[0].opt); } return picked; }weights[i] ?? 1的空值兜底很关键:题干选项数量是动态的,权重表往往是手写的,长度对不上就会取到undefined,reduce出来的total变成NaN,整个循环静默退出,现象是“多选题一个都没选”。这类静默失败最难查,因为控制台不报错。
权重设 0 表示“这一轮永不选它”,用来排除掉“以上都不是”这类破坏分支覆盖的选项。但要注意和min的冲突:如果min=2,而权重为 0 的选项占了大多数,采样器会陷入死循环。稳妥做法是在采样前先过滤掉权重为 0 的项,再判一次可用数量是否满足min,不满足就放宽。
4.3 开放题文本:长度、模板与重复率控制
开放题是最容易露馅的地方。随机字符串一眼假,同一段模板文本重复出现十次同样一眼假。我的做法是准备一个模板池,模板里留数字占位,填进去之后按maxOpenLen截断,再用一个Set记录已用文本,命中重复就换模板或加后缀。
const OPEN_POOL = [ '整体流程比较顺畅,第{n}步的提示可以再明确一些', '页面加载速度还可以,第{n}步的按钮位置希望调整', '功能基本满足需求,第{n}步的操作路径偏长', '体验还行,第{n}步的必填项说明不够清楚' ]; function makeOpenText(used = new Set(), maxLen = 60) { for (let i = 0; i < 24; i++) { const tpl = OPEN_POOL[Math.floor(Math.random() * OPEN_POOL.length)]; const text = tpl.replace('{n}', 1 + Math.floor(Math.random() * 6)); const cut = text.slice(0, maxLen); if (!used.has(cut)) { used.add(cut); return cut; } } // 兜底:模板池耗尽时加短后缀,保证不出现完全相同的两条 return `${OPEN_POOL[0].replace('{n}', 1)}-${Math.random().toString(36).slice(2, 6)}`.slice(0, maxLen); }slice(0, maxLen)一定要在去重之前做,因为截断本身会产生新的重复——两条不同的模板截到 20 字符可能变得一模一样。顺序反了的话,used里存的是截断前的文本,判重就失效了。
还有一个坑是输入法事件。部分表单会监听compositionstart/compositionend,只派发input事件时这些表单认为输入未完成,提交时报“请完成输入”。遇到这种情况不必伪造输入法事件,把逐字输入改成一次性setNativeValue写入整段文本再派发change就行,绝大多数表单能接受。
5. 避坑与排查:五类翻车现象的原因与解决
下面这五条是我在这类脚本上真实撞过的,按发生频率排序。前两条属于识别层,中间两条属于交互层,最后一条属于流程层。
5.1 识别层翻车:选错、漏选、矩阵题串行
现象一:多选题永远只勾一个选项。控制台看快照,picked数组长度恒为 1。原因是parseBounds没抽到约束,max退回默认值 3,但采样时random() * (max - min + 1)算出来的n有时候确实是 1——这不是 bug,是均匀分布的正常表现。真正的问题是题干里明明写了“最多选 3 项”,说明正则没匹配上。解决方式是把题干预处理再狠一点:去掉全角空格、把“选择”和“选”统一、兼容“不超过三项”这种中文数字。中文数字我一般直接映射成阿拉伯数字再跑正则,比写一堆分支省事。
现象二:矩阵题所有行都选了第一个选项。快照里只有一个index,但页面上有五行。原因是detectType判成了single,answerFor只返回一个值,写回时querySelector('input[type=radio]')命中了第一行的第一个。修复的关键在分组:按tr或者按name把inputs拆成多组,每组单独抽一个,再统一写回。分组时优先用最近的tr祖先,取不到再退化到name,因为有些矩阵题用的是div网格布局,压根没有table。
5.2 交互层翻车:值填了但校验不认
现象三:输入框里明明有文字,提交时提示“该项为必填”。这是最经典的一个。原因是表单用的是框架受控组件,直接el.value = 'xxx'只改了 DOM 属性,框架内部的 state 还是空字符串。解决方式是走原生 setter,再补派input和change。
// 绕过框架对 value 的劫持:先拿原型上的原生 setter,再派发事件 function setNativeValue(el, value) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }bubbles: true不能省,很多框架把监听器挂在表单根节点上而不是元素本身,不冒泡就收不到。单选和多选反过来要用el.click(),不要直接设checked = true,因为click()会连带触发框架的选中逻辑和联动题目的显隐,手写checked只会让页面看起来选中了,实际状态没变。
现象四:隐藏域被填了随机值。现象是提交后后端报“参数非法”。原因是可见性过滤失效。getBoundingClientRect()对display:none的元素返回全 0,这个没问题;但对visibility:hidden的元素返回的还是有尺寸的矩形,会被误判为可见。补充一个getComputedStyle(n).visibility !== 'hidden'的判断就行,代价很小。
5.3 流程层翻车:分页、频控与定时器泄漏
现象五:分页表单只填了第一页就结束。日志显示识别到 5 道题,实际页面有三页共 15 道。原因是脚本在document-idle时执行一次,翻页后 DOM 重建,新一页的题目容器根本没被扫到。解决方式是把主流程包成一个可重入的函数,用MutationObserver监听容器变化,翻页按钮点下去之后等新容器出现再跑一轮。这里要注意加一个“本页已处理”的标记,否则 DOM 每次微调都会触发重跑,同一道题被反复改写。
现象六:跑第二轮时报错“元素已被移除”,或者点两下才生效。八成是脚本被注入了两次。用户脚本管理器在页面内路由切换(单页应用)时可能重复注入,window.__FORM_FILLING__只挡住了执行中的并发,挡不住两轮串行。更稳的做法是在window上存一个带版本号的标记,发现版本不一致就先清掉旧的定时器和监听器再启动新的。
提示:以上所有排查都以快照为准,不要靠肉眼盯页面。快照里记下每道题的
index、type和picked,出错时能直接定位到是哪一层的问题。
6. 进阶用法:用种子随机把脚本变成可复现的测试数据源
随机脚本最大的软肋是不可复现。周五跑出一个诡异的分支报错,周一换了台机器重跑,随机走的是另一条路,问题再也复现不了。解决办法是把Math.random换掉,用一个带种子的伪随机数生成器,种子记进快照里,出问题拿种子回放就行。
// mulberry32:32 位种子,实现短、分布够用、结果完全可复现 function makeRandom(seed) { let s = seed >>> 0; return function () { s = (s + 0x6D2B79F5) >>> 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const random = makeRandom(CONFIG.seed ?? Date.now()); // 之后所有采样都传这个 random,包括 pickMulti 的第三个参数改造点只有一处:把pickMulti、answerFor里所有的Math.random()换成传入的random。这件事必须一次性改干净,漏掉一处就会出现“大部分题可复现、个别题每次都变”的诡异现象,排查成本比不改还高。改完之后,快照里补上seed和脚本自身的标识,回放时用同一个种子跑一遍,逐题比对picked数组,不一致就是逻辑改动引入的回归。
| 题型 | 生成器 | 回放校验点 |
|---|---|---|
single | 加权抽取一个 | 选项value相等 |
multi | 约束内不放回抽取 | 集合相等,不看顺序 |
matrix | 按行分组各抽一个 | 每行一组,逐行比对 |
open | 模板池 + 截断去重 | 文本长度与去重标记 |
这张表的价值在于“校验点”那一列:多选不能用数组相等去比,因为抽取顺序不同结果就判失败,必须转成集合。矩阵题要按行拆开比,整体比会漏掉“行序错位”这种问题。
从那以后我每次动这类脚本,都强制先跑一遍固定种子、把快照存成文件,改完再跑一遍同样的种子,diff 干净了才敢往测试环境推。这个习惯比任何调试技巧都省时间,因为大部分回归问题不是逻辑写错了,而是某次顺手改了个选择器,把一道题的识别结果悄悄改掉了——没有快照比对,这种改动根本发现不了。希望帮到你。
本文还有配套的精品资源,点击获取