1. 油猴脚本的核心逻辑与适用场景
1.1 为什么选择油猴做问卷自动填表
先说结论:在市面上所有浏览器自动化方案里,油猴(Tampermonkey)是做问卷自动填表最轻量、成本最低、也最容易上手的路子,没有之一。
我这么说是有对比依据的。我见过不少朋友用按键精灵类的软件去做表单填充,思路是模拟鼠标键盘操作,看似通用,实际上对页面元素位置极其敏感,浏览器窗口一缩放、页面改个版式,整个脚本就废了;也有人用 Python 写 Selenium 脚本去控制浏览器,功能确实强大,但每次都要启动一个受控的浏览器实例,环境搭起来麻烦,对不懂编程的人来说门槛不低。而油猴脚本的运行机制不一样,它是寄生在浏览器里的,通过 JavaScript 直接操作页面里的 DOM 元素,页面怎么渲染、窗口怎么缩放、元素长什么样,都不影响脚本对"值"的赋值。换句话说,油猴是"直达数据层",而不是"模拟手指头"。
还有一个很现实的因素:问卷类页面通常不会频繁改版,但结构再稳定,也难免有登录态、验证码、动态加载这些环节。油猴脚本天然跑在你自己的浏览器里,天然继承了你的登录态、Cookie 和浏览器指纹,不需要额外处理登录问题,这一点比任何外挂式工具都省心。
我接触油猴是很多年前的事情了,当时只是装现成的脚本,后来发现现成脚本无法满足自己项目组里那套内部问卷的需求,才开始动手写自己的脚本。一路踩过不少坑,今天这篇就是把那些我用真金白银(主要是时间)换来的经验整理出来,从零开始完整讲清楚怎么用油猴写一个真正可靠的问卷自动填表脚本。最后还会专门讲一些边界问题——工具再好,也得用在正道上。
1.2 自动填写器的核心思路拆解
一个问卷自动填表脚本,拆开来看其实就解决三件事。
第一件事,找到要填的位置。问卷页面有单选、多选、下拉、填空、矩阵量表、排序题等七八种常见题型,每种题型在 HTML 里的表现形态都不一样。单选按钮是 input 标签,多选可能变成 checkbox 列表,下拉框是 select 标签,还有一些自定义组件是用 div 模拟的。所以"找到填的位置"这件事,本质上就是通过 DOM 选择器精准定位这些元素。
第二件事,告诉它填什么。这是整个脚本的灵魂:要填什么值、值从哪里来。最粗暴的做法是硬编码,把所有问卷答案写死,但实际场景里问卷大概率不是同一份,所以更合理的方式是把答案集中放在一个配置对象里,脚本运行时按题目类型去取对应的值,甚至可以做成配置文件单独维护,改答案时不用动脚本逻辑。
第三件事,触发表单的响应机制。很多时候你填完内容,页面并不是立刻就能提交的。有的问卷做了必填校验,没有触发 change 事件它就认为你什么都没填;有的问卷设计成选完自动翻页,你不触发事件它就一直卡在当前页;还有做了二次校验的,需要在填完后人为触发一下校验流程。这些"隐性"逻辑,才是自动填表脚本里最容易翻车的地方。
把这三点想明白了,写脚本就是顺水推舟的事。理解了这个拆解逻辑,你就会发现问卷自动填表本质上不是"新技术"问题,而是"如何优雅地操作 DOM"的问题。只要你懂一点点 HTML,懂一点点 JavaScript,完全可以从零写出自己的填表脚本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与脚本骨架搭建
2.1 安装 Tampermonkey 扩展
工欲善其事,必先利其器。在动手写脚本之前,得先把油猴这个容器装好。
Tampermonkey 是浏览器扩展程序,支持 Chrome、Edge、Firefox、Safari 等主流浏览器。安装路径很简单:打开你的浏览器扩展商店,搜索 Tampermonkey,点击安装即可。以 Chrome 为例,进入 Chrome 网上应用店,搜索框输入 Tampermonkey,出来的第一个结果通常就是官方版本,认准那个黑底蓝色图案的图标就行。
安装的时候有个细节要注意:国内访问扩展商店偶尔会卡住,这时候不要着急反复刷新,等一会儿就好。装完之后浏览器右上角会出现油猴的图标,点开可以看到菜单,里面有一个"添加新脚本"的入口,这就是写脚本的地方。如果你是 Edge 浏览器,也是一样,进 Edge 加载项商店搜 Tampermonkey,流程完全一致。
安装完我建议你做两件事。第一,打开"管理面板"看一眼设置项,把"配置模式"调到"高级",后面调试脚本时用得上。第二,随便找个现有的脚本网站,装一个使用人数多、评分高的脚本试试手,比如视频网站去广告类脚本,感受一下油猴脚本的运行效果。这能帮助你建立对脚本运行机制的基本认知。
还有一个很多初学者会忽略的点:Tampermonkey 有"扩展程序"模式(Chrome 专用)和"脚本管理器"模式(Firefox 专用)的差异,两者在权限模型上略有区别。日常写填表脚本基本不用关心这个差异,但如果你遇到"脚本明明装了却不生效"这种诡异问题,可以看看是不是扩展权限被限制了。排查方法很简单,打开浏览器的扩展管理页,看油猴的开关是不是打开状态,权限里是否允许访问所有网站。
2.2 认识用户脚本的头信息块
写完脚本的第一步,不是写代码,而是认识脚本头部那段信息块。这段信息块是油猴的"说明书",它决定了脚本在什么网站运行、叫什么名字、什么版本、需要什么权限。信息块写错了,脚本写得再漂亮也不会生效。
一个标准的油猴脚本头部信息块长这样:
javascript复制// ==UserScript==
// @name 调查问卷自动填表
// @namespace https://example.com/scripts
// @version 1.0.0
// @description 自动填写问卷并向指定字段赋值
// @author your-name
// @match https://survey.example.com/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// ==/UserScript==
逐个说重点。@match 是核心中的核心,它声明了这个脚本在哪些 URL 下生效。格式是 协议://域名/路径,* 是通配符。比如 https://survey.example.com/* 表示匹配该域名下所有路径,https://*/* 表示匹配所有 HTTPS 协议的网站——注意这样写是有风险的,在任意网站上运行你的脚本,可能出现奇怪的问题。建议你精确到具体域名,宁可多写几个 @match 行,也不要图省事写全站通配。
@grant 是权限声明。油猴脚本默认运行在沙盒环境里,页面内的 JavaScript 对象和油猴脚本的沙盒环境是隔离的。如果你的脚本需要调用油猴特有的 API,比如 GM_setValue、GM_getValue(用于持久化保存数据),就要在这里声明。如果不需要,建议写成 @grant none,这样脚本会直接运行在页面上下文里,访问 DOM 元素会更自然,避免一些跨作用域的问题。
@run-at 是运行时机。常见取值有 document-start、document-body、document-end、document-idle,分别对应页面生命周期的不同阶段。填表脚本我建议用 document-end,也就是页面 DOM 加载完后立即运行,比 document-idle 要早一些,比 document-start 要晚一些,刚好避开"元素还没渲染出来"的尴尬。
信息块写错了,最典型的症状就是脚本不生效,但油猴不会给你报错。你把脚本装好,打开网页,一脸懵地发现什么都没发生。这时候最快的排查方式是把脚本信息块的 @match 跟当前页面的 URL 比一遍,看是否匹配。
2.3 脚本的基本骨架
信息块写完后,紧接着就是脚本主体。我建议所有填表类脚本都按这个骨架来组织,后面扩展逻辑会非常顺畅:
javascript复制(function () {
'use strict';
// ========== 1. 配置区 ==========
const CONFIG = {
// 问卷答案配置
answers: {
q1: 'A',
q2: ['B', 'C'],
q3: '其他',
// ...
},
// 是否自动提交
autoSubmit: false,
// 是否开启调试日志
debug: true,
};
// ========== 2. 工具函数区 ==========
function log(...args) {
if (CONFIG.debug) {
console.log('[Tampermonkey 自动填表]', ...args);
}
}
// ========== 3. 主逻辑区 ==========
function main() {
// 等待页面元素出现
waitForElement('.survey-body', () => {
log('页面加载完成,开始填表');
// 这里写填表逻辑
});
}
// 等待元素出现的通用工具
function waitForElement(selector, callback) {
const target = document.querySelector(selector);
if (target) {
callback();
return;
}
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
callback();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ========== 4. 启动 ==========
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();
这里最值得说明的是 waitForElement 这个函数。问卷页面经常会用 AJAX 拉取问卷题目数据,页面壳先出现,题目后渲染出来。如果脚本一进去就找题目元素,大概率找不到。用 MutationObserver 监听 DOM 变化、等目标元素出现后再执行填表逻辑,是应对这种"动态渲染"场景最稳妥的方案。很多教程里用的是 setTimeout 固定延时,我不推荐,因为延时短了元素还没出来,延长了又拖慢效率,而且不同网速下表现不稳定。MutationObserver 才是正经解法。
3. 核心细节解析与实操要点
3.1 定位问卷元素的实用方法
现在到了最关键的环节:怎么在问卷页面里精准找到每个题目。这一步做不好,后面填值就是无源之水。
打开问卷页面,按 F12 打开开发者工具,用左上角的"选择元素"按钮点一下题目,右侧元素面板就会自动定位到对应的 HTML 节点。这个操作是基本功,但具体怎么从节点里提取出"稳定的选择器",我这里直接给一套方法论。
第一优先,看 id。id 在同一个页面里是唯一的,是最稳的锚点。但很多问卷系统不生成 id,或者 id 里带着动态变化的数字,那就得换策略。
第二优先,看 name。问卷系统里 name 往往就是题目的字段名,比如 q123、question_456,极大概率是稳定的。而且很重要的一点是,后端渲染后 name 不会变,这是填充后依赖的标签之一。
第三优先,用属性选择器。比如 input[value="A"]、div.option[data-index="2"]。某些组件型问卷(覆盖式、自绘的 div 列表)通常在选项上自定义了 data-* 属性,这些属性是开发者留的后门,也是我们写选择器的好帮手。
第四优先,用结构关系定位。比如"某个容器的第几个子元素"。这种写法比较脆弱,页面布局一变就失效,能不用就不用。实在要用,我会在脚本里写注释说明选择器依赖的页面结构,方便后期维护。
定位完之后,我强烈建议你在控制台里先把选择器 Test 一遍。方法是切换到 Console 面板,输入 document.querySelector('你的选择器'),看返回结果是不是你要的元素。多测几个,别怕麻烦。这一步能帮你省掉后面反复调试的功夫。
我举一个实际的例子。某问卷系统里单选题的结构是这样的:
html复制<div class="question-item" data-qid="q1">
<div class="question-title">您的年级是?</div>
<div class="options">
<label><input type="radio" name="q1" value="freshman">大一</label>
<label><input type="radio" name="q1" value="sophomore">大二</label>
<label><input type="radio" name="q1" value="junior">大三</label>
</div>
</div>
这种结构下最稳的选择器就是 input[name="q1"][value="junior"],点它一下就选中了大三。如果 options 是 li 标签 + 点击事件,那就要找这个 li 的 data 属性,或者直接调用它的 click() 方法。
3.2 处理不同类型字段的填表策略
问卷系统的题目类型五花八门,但归纳起来无非几大类。我逐个讲填表策略。
单选题。最常见的题型。Radio 按钮,策略很直接:把对应的 input 设置成 checked,然后触发 change 事件。注意不能只置 checked 不触发事件,很多框架是在 change 事件里记录值的,不触发就跟没选一样。
javascript复制function setRadio(name, value) {
const radio = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (radio) {
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
// 部分使用 React/Vue 的页面还需要触发 input 事件
radio.dispatchEvent(new Event('input', { bubbles: true }));
}
}
多选题。Checkbox 类型,本质上就是循环设置 checked。但这里有个坑:有的问卷系统对多选做了"至少选 N 项"的校验,选了少于 N 项会报错。所以脚本里最好直接配置好完整的选项集合,而不是只填一部分。
下拉选择题。Select 元素,处理方式略有不同。策略是先把 value 设置上去,然后触发 change。有些自定义下拉框其实是隐藏的 input + 展开的 div 列表,这种处理起来更麻烦,我在 3.3 里单独讲。
填空题/简答题。Textarea 和 input[type=text],直接设置 value 然后触发 input 事件。这里有一个历史遗留问题:在原生 JS 里直接设置 input.value 后,如果页面框架没有监听原生 setter,你是触发不了框架的 onChange 的。最稳妥的做法是用原生 setter 设置值,再派发事件。代码示例:
javascript复制function setInputValue(el, value) {
const proto = el.tagName === 'TEXTAREA' ? 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 }));
}
矩阵量表题。这通常是一排单选按钮,标题行是"很不满意 不满意 一般 满意 很满意",左列是各个评价维度。处理策略就是按题目 name 前缀 + 行索引定位。代码逻辑不复杂,麻烦的是选择器的拼装,需要一点耐心。
排序题。这类题是最难自动处理的,因为通常涉及拖拽排序,DOM 操作复杂而且依赖具体的前端实现。我的建议是:除非你的问卷场景固定且确认可以用鼠标事件模拟,否则人工填这种题。工具是为了省力,不是给自己添堵。
3.3 隐藏注意点与填表策略
自动填表脚本运行在真实的浏览器环境里,看似是"填表",实际上是在和各种前端框架做博弈。这里有几个隐藏得很深的注意点,我在实际写脚本时踩过不少次,分享出来希望你别重蹈覆辙。
第一个是 React/Vue 框架的受控组件问题。现代前端框架大多采用受控组件模式,输入框的值由框架的状态(State)驱动,而不是由 DOM 的 value 属性直接决定。如果你在控制台里手动改了一个 input 的 value,再用 document.querySelector 读回来,咦,值还在;但页面里其他逻辑读取的却是框架状态,根本没改。所以只改 DOM 是不够的,必须通过触发正确的原生 setter 和事件,让框架"感知"到值变了。这就是上面 setInputValue 函数存在的意义。这个方法被很多脚本玩家称为"填表的圣杯",不是什么高深技术,但确实是很多新手脚本失效的第一大原因。
第二个是必填校验的触发时机。很多问卷题目的必填校验不是在你填完那一刻立刻执行的,而是在提交时统一校验,或者在你离开当前区块时校验。如果你脚本填完直接点提交,校验逻辑一跑发现"字段没值",就会给你弹一个"xxx 题为必填项"。这里的坑在于:你以为填了,但框架的校验读的是状态,而不是 DOM。所以填完值之后,如果你不确定框架是否收到了事件,可以在控制台里手动读一下框架的实例状态。具体怎么读,跟框架的挂载方式有关,React 应用可以在 React DevTools 里查看组件的 props,Vue 应用可以查看 __vue__ 属性。说这些是为了让你明白:填表不只是"把值灌进去",还要"让框架消化这个值"。
第三个是页面里有多个填写入口时的选择器冲突。有些问卷页面为了兼容移动端和桌面端,同一道题渲染了两套 UI,CSS 选择器看起来都匹配。这种情况下要给选择器加限定条件,比如限定到可见的容器里。判断元素可见性的方式:
javascript复制function isVisible(el) {
return el && el.offsetWidth > 0 && el.offsetHeight > 0;
}
选元素的时候,优先选可见的那个容器,过滤掉隐藏副本。不然脚本一顿操作,哗啦啦填了隐藏的那套,页面上完全没反应,你能郁闷半天。
第四个是延迟加载的内容。问卷系统的选项有可能是异步加载的,选项数据是通过接口拉取后渲染的,所以你写选择器匹配选项时,可能在脚本运行的那一刻还匹配不到。应对策略还是 waitForElement,等到选项元素出现后再填。再极端一点,每个选项都可能在不同时间点渲染,这时候的稳妥做法是监听目标题目的容器元素,容器内出现预期选项后再填。
4. 实操过程与核心环节实现
4.1 写一个可用的自动填表脚本
理论铺垫了这么多,接下来来一个完整的实战。我们以一份模拟问卷为例,完整写一个脚本。这份问卷有四道题:一道单选、一道多选、一道下拉、一道简答,还有一个提交按钮。页面结构我按常见问卷系统的写法给出示例。
假设问卷 HTML 大致长这样(注意实际开发时你用开发者工具看到的结构会更复杂,但核心逻辑一致):
html复制<div class="survey-page">
<div class="question-item" data-qid="q1">
<div class="q-title">1. 你平时使用电脑的系统是?</div>
<label><input type="radio" name="q1" value="windows">Windows</label>
<label><input type="radio" name="q1" value="macos">macOS</label>
<label><input type="radio" name="q1" value="linux">Linux</label>
</div>
<div class="question-item" data-qid="q2">
<div class="q-title">2. 你常用的浏览器有哪些?(多选)</div>
<label><input type="checkbox" name="q2" value="chrome">Chrome</label>
<label><input type="checkbox" name="q2" value="edge">Edge</label>
<label><input type="checkbox" name="q2" value="firefox">Firefox</label>
<label><input type="checkbox" name="q2" value="safari">Safari</label>
</div>
<div class="question-item" data-qid="q3">
<div class="q-title">3. 你的职业是?</div>
<select name="q3">
<option value="">请选择</option>
<option value="student">学生</option>
<option value="engineer">工程师</option>
<option value="teacher">老师</option>
<option value="other">其他</option>
</select>
</div>
<div class="question-item" data-qid="q4">
<div class="q-title">4. 请简单描述你最近做的一个项目。</div>
<textarea name="q4" rows="3"></textarea>
</div>
<button class="submit-btn" type="button">提交问卷</button>
</div>
我现在写一个完整的脚本,把上面的填值策略集成进来。这个脚本不是最精简的,但结构清晰、逻辑完整,适合作为模板去改。你在实际使用中,重点替换 CONFIG.answers 里的内容,以及调整各题目的 name 和 value 即可。
完整脚本如下:
javascript复制// ==UserScript==
// @name 问卷自动填表模板
// @namespace https://example.com/scripts
// @version 1.0.0
// @description 一个通用的问卷自动填写脚本模板
// @author your-name
// @match https://your-survey-domain.com/*
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ===== 配置区:这里改答案 =====
const CONFIG = {
// 单选题设置:{ name: value }
radioAnswers: {
q1: 'windows',
},
// 多选题设置:{ name: [value, value] }
checkboxAnswers: {
q2: ['chrome', 'edge'],
},
// 下拉框设置:{ name: value }
selectAnswers: {
q3: 'engineer',
},
// 文本题设置:{ name: text }
textAnswers: {
q4: '这是一个自动填充的项目示例。',
},
// 是否自动点击提交
autoSubmit: false,
// 提交按钮选择器
submitSelector: '.submit-btn',
// 调试日志开关
debug: true,
};
function log(...args) {
if (CONFIG.debug) {
console.log('[自动填表]', ...args);
}
}
// 等待元素出现,避免动态渲染导致找不到
function waitForElement(selector, callback, timeout = 10000) {
const startTime = Date.now();
const check = () => {
const el = document.querySelector(selector);
if (el) {
callback(el);
return;
}
if (Date.now() - startTime > timeout) {
log('等待超时: ' + selector);
return;
}
setTimeout(check, 200);
};
check();
}
// 设置 input/textarea 值(兼容 React/Vue 受控组件)
function setNativeValue(el, value) {
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
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 }));
}
// 设置单选
function setRadio(name, value) {
const el = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (!el) {
log(`单选项未找到: ${name}=${value}`);
return;
}
setNativeValue(el, true);
el.checked = true;
el.dispatchEvent(new Event('change', { bubbles: true }));
log(`单选已设置: ${name} = ${value}`);
}
// 设置多选
function setCheckbox(name, values) {
values.forEach(value => {
const el = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (!el) {
log(`多选项未找到: ${name}=${value}`);
return;
}
el.checked = true;
el.dispatchEvent(new Event('change', { bubbles: true }));
log(`多选已设置: ${name} = ${value}`);
});
}
// 设置下拉框
function setSelect(name, value) {
const el = document.querySelector(`select[name="${name}"]`);
if (!el) {
log(`下拉框未找到: ${name}`);
return;
}
setNativeValue(el, value);
log(`下拉框已设置: ${name} = ${value}`);
}
// 设置文本
function setText(name, text) {
const el = document.querySelector(`textarea[name="${name}"], input[name="${name}"]`);
if (!el) {
log(`文本框未找到: ${name}`);
return;
}
setNativeValue(el, text);
log(`文本框已设置: ${name}`);
}
function fillForm() {
log('开始填写问卷...');
Object.entries(CONFIG.radioAnswers).forEach(([name, value]) => {
setRadio(name, value);
});
Object.entries(CONFIG.checkboxAnswers).forEach(([name, values]) => {
setCheckbox(name, values);
});
Object.entries(CONFIG.selectAnswers).forEach(([name, value]) => {
setSelect(name, value);
});
Object.entries(CONFIG.textAnswers).forEach(([name, text]) => {
setText(name, text);
});
log('所有题目填写完成');
if (CONFIG.autoSubmit) {
const submitBtn = document.querySelector(CONFIG.submitSelector);
if (submitBtn) {
setTimeout(() => submitBtn.click(), 500);
log('已触发提交');
} else {
log('提交按钮未找到');
}
}
}
function main() {
waitForElement('.survey-page', () => {
// 给所有题目留一点渲染时间,然后统一填写
setTimeout(fillForm, 300);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();
这个模板脚本,你把它粘到油猴的"添加新脚本"里,改掉 @match 的域名、CONFIG 里的答案、submitSelector 选择器,就能直接跑起来。用这份脚本前,记得先把页面结构看懂,确认各个题目的 name 属性值。
4.2 事件触发、提交与运行时机把控
脚本写好后,第一次运行大概率不会一次成功。这个时候不要慌,打开控制台看日志。这个模板脚本里我加了一大堆 log 输出,就是为了帮你定位是哪一步失败了。
先说事件触发。我在每个填值函数里都派发了 input 和 change 事件,这两个事件对现代前端框架是必须的。React 受控组件监听的通常是 input 事件,Vue 的 v-model 监听的是 input(对于 text input)和 change(对于 select)。但这里有个细节:有时 React 的 onChange 实际上是监听的原生 input 事件,你只派发 change 它根本不理你。所以我在 setNativeValue 里两个事件都派发,这是最保险的做法。
再说提交时机。如果你的 autoSubmit 开启了,脚本会在填完所有题目后 500ms 点击提交按钮。这个 500ms 的延时是有讲究的。第一,有些框架的校验是异步的,填完值之后要等一小会儿状态才更新;第二,如果问卷页面上有"必填项提示"之类的动态展示,等它渲染出来再点提交,能避免误点。当然,这个延时也不是越长越好,太长了用户体验不好。如果你发现某些题目填完后面板有联动校验(比如选择了某个选项后会弹出新问题),那提交延时就得加长,或者改成手动检测校验通过后再提交。
还有一个我踩过的坑:填表与页面路由的关系。有些问卷是多页模式,第一页填完要点击"下一题"跳到第二页,第二页的 DOM 是重新渲染的。这种情况下,单一页面的 main() 函数就不够用了,需要监听页面切换事件,或者用一个 MutationObserver 持续监听问卷容器,一旦有新题目渲染就继续填。我在模板里用的是"等 .survey-page 出现后一次性填充",只适合单页问卷。如果你遇到多页问卷,我建议你在 main() 里注册一个 MutationObserver,监听题目容器的变化,新增了 .question-item 就对新题目做填充。
4.3 核心实现里值得留意的两个细节
第一个细节,填表顺序。有些问卷存在联动逻辑,比如高中多选题里有"关注了哪些平台",然后下一题是"你在哪个平台最活跃",这种题目不是独立填的。如果你把每道题的值硬编码死,联动出来之后你填的值可能跟上一题完全不匹配,甚至选了一个选项后,另一个选项被置灰不可选了。我的建议是:碰到联动题,先手动画一个问卷逻辑图,把题目的依赖关系理清楚,然后脚本里按依赖顺序填充,后填的题目可以基于前面已填的值做动态计算。这个复杂度不算高,但需要你对问卷业务有充分了解。
第二个细节,防止脚本重复执行。油猴脚本在 SPA(单页应用)里,如果页面切换是路由级而不是整页刷新,脚本的主函数可能执行多次。虽然我们的填表逻辑是幂等的(值重复赋一遍没坏处),但如果你在脚本里加了提交逻辑,重复执行可能导致重复提交。最好做一个"已填写"标记:
javascript复制if (window.__autoFillDone) return;
window.__autoFillDone = true;
在 fillForm 开始的时候加这个判断,能有效防止 SPA 里路由切换导致的重复填充和重复提交。
5. 关键议题讨论:效率与合规边界
5.1 工具使用边界与注意事项
写到这个部分,我必须明确一个态度:任何工具都有它的使用边界,自动填表脚本也一样。技术本身是无倾向的,但用在哪里、怎么用,是有边界的。我在自己做填表脚本的时候,明确给自己划了几条线,分享出来供你参考。
第一条,只填真实数据。自动填表脚本最有价值的应用场景,是在大量重复性录入时帮你完成机械操作。比如你所在的业务团队要收集客户回访信息,客户信息在内部系统里都有,你需要把同一个信息在十几个问卷页面里重复录入——这种场景,脚本可以帮你节省大量时间,而且填进去的都是真实准确的业务数据。反过来,如果你用脚本在网络上大量批量提交虚假数据,或者用来刷某些活动的参与名额,这就突破了合理使用的边界。这不仅涉及账号封禁风险,更涉及数据真实性问题,影响的是平台方对真实用户的分析和决策。
第二条,不碰验证码。我在设计脚本时有一条铁律:任何涉及验证码的环节都不自动处理。验证码存在的意义就是为了区分人与机器,你费尽心思去绕过去,本质是在对抗平台的安全机制。而且从工程角度看,验证码的形态千变万化,滑块、点选、旋转等各不同,处理它们的成本极高,维护难度也大。真到那种程度,不如直接用官方提供的 API,或者联系问卷系统方做系统对接。
第三条,不做绕过限制的事。比如问卷系统设置了每台设备只能填写一次、每个账号只能提交一份,那这就是规则。脚本可以用来便利录入,但不能用来突破这个限制。技术无罪论在我这里不成立,主动去规避平台设定的防刷机制,无论用什么包装,都不是正当用途。
坦白讲,我在写这篇教程时一直有个顾虑:把自动填表的技术细节讲得这么透彻,可能被拿去用于不太正面的目的。但换个角度想,任何一个真实开发问卷系统的人,都应该懂前端字段校验、后端重复校验等手段,这些手段不会因为有人做自动填写器就不存在。所以更合理的态度是,把技术讲透,把边界讲清楚,让诚实的开发者拿到工具,也让维护系统的人知道"原来自动填表是这么实现的",从而更有针对性地做好防护。
5.2 技术向善的应用建议
如果你的工作需要大量处理问卷数据,我推荐几个可以合理应用自动填表能力的场景。
第一个场景是内部数据录入。我以前在项目组里做过一个需求:每周都要汇总组员的工作进度,用一张企业内部问卷来收集。问题是问卷只有一份,每个组员手动填要花几分钟时间,十个人的组就是几十分钟的重复劳动。后来我用油猴写了一个小工具,组员打开问卷后,脚本会读取本地的配置,自动把大概率不变的字段填好(比如工号、部门、项目编号),只有本周的实际进度留给人手填。这样一来,填写时间从几分缩短到了几十秒,还减少了手滑填错字段的概率。
第二个场景是测试环境的数据准备。如果你负责一套问卷系统的运维或测试,需要反复用不同的测试数据去验证系统功能,那自动填表脚本简直是神器。配置好几组测试数据,一键填入,测试效率翻几倍。这个场景下,填什么值、填多少份完全在你自己的可控范围里,不存在合规问题。
第三个场景是个人的数据整理。比如你经常需要把自己的通讯录信息、收货地址信息填写到不同平台的表单里,信息内容相同只是格式略有差异。这种情况用脚本自动填充自己的真实信息,既方便又安全。
我把这些场景写出来的用意是提醒你:自动填表脚本是"效率工具"而不是"作弊工具"。同一个功能,用对了地方能帮人省时间,用错了地方就是给别人添麻烦、给自己惹风险。工具本身不决定价值,使用意图才是决定因素。
6. 常见问题与排查技巧实录
6.1 脚本不执行怎么办
这是新手遇到最多的一个问题:脚本装好了,配置也改了,打开问卷页面,毛都没发生。
第一件事,确认脚本是否在页面上运行。打开油猴弹出菜单,看看当前网站下有没有显示你的脚本图标。没有的话,说明 @match 没匹配上。这时候回到脚本编辑页,把 @match 的域名跟当前页面 URL 逐字比一遍,哪怕是多了一个 http/https、少了一个斜杠,都会导致不匹配。
第二件事,确认 @run-at 的时机。如果你的页面在 document-start 阶段就执行了脚本,但代码里没有任何等待逻辑,那很可能会因为找不到目标元素而静默失败。我的建议是统一用 document-end,然后在代码里用 waitForElement 做兜底,这两个组合能覆盖绝大多数页面。
第三件事,看代码报错。打开浏览器控制台(F12 切到 Console 面板),过滤 Tampermonkey 关键词,看有没有红色的报错信息。脚本里任何一行 JS 抛异常,后续代码就不会执行。常见报错是 Cannot read properties of null,意思是 document.querySelector 返回了 null,你直接访问了它的属性。这个报错对照我模板里的 if (!el) 判空逻辑,基本能解决。
我总结了一个排查速查表,贴在这里:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 页面毫无反应 | @match 不匹配 | 检查脚本的 @match 与页面 URL 是否匹配 |
| 页面毫无反应 | @run-at 时机过早 | 改为 document-end |
| 页面毫无反应 | 浏览器扩展未正常启用 | 检查扩展管理页的开关和权限 |
| 控制台报 null 错误 | 元素选择器写错或元素未渲染 | 打开开发者工具确认选择器,用 waitForElement |
| 填了值但校验不过 | 事件未触发或触发错误 | 使用 setNativeValue 派发 input + change 事件 |
| 填了值但页面卡住 | 代码抛异常中断 | 看控制台完整报错堆栈 |
| 填完了但提交失败 | 提交按钮选择器不对 | 确认提交按钮的真实 class/id |
6.2 字段填不上、填了不生效的深层原因
字段填不上,通常不是"值没设置上",而是"框架根本不认你设置的值"。这个我在前面 3.2 里强调过,但因为是高频问题,值得再展开讲一遍。
React 的受控组件,value 是由 React 的虚拟 DOM 管理的。你直接改真实 DOM 的 value 属性,React 不知道,它下一次渲染还会按它的 state 覆盖回去。所以必须用原生属性描述符的 setter 来设置值,让 React 误以为是它自己触发的变化。这就是 setNativeValue 函数存在的意义。
Vue 2/3 的 v-model 则不同,它对 input 事件的依赖很重。我在调试中遇到过的情况是:只设置 value 不派发 input 事件,Vue 组件的数据模型不更新,提交时值为空;派发了 input 但要加 bubbles: true,因为 Vue 监听的是冒泡阶段的事件,不加 bubbles 事件传不上来。
还有一个隐藏很深的坑:jQuery 时代的页面。很多老问卷系统用的是 jQuery,对 change 事件的监听方式比较老派。如果模板里的做法不管用,尝试直接触发 jQuery 的 change:
javascript复制$(el).val(value).trigger('change');
但这要求页面里能访问到 jQuery,而且你需要在页面上下文里执行。油猴脚本默认是沙盒环境,如果 @grant none,就能直接访问页面里的 jQuery,这也是我建议 @grant none 的一个原因。如果你在沙盒环境里访问不到 jQuery,那就退回原生事件派发,或者用 unsafeWindow 访问页面全局对象。
6.3 弹窗、动态加载与其他老六情况
问卷页面里最常见的"老六"就是弹窗。比如填了几道题之后,页面底部弹出一个"确认当前身份"的 Modal 框,或者某个选项关联了必填的补充说明。这类弹窗的 DOM 通常也存在于页面上,只是默认隐藏。脚本操作完主表单后,需要检测弹窗是否出现,再决定要不要继续填充。
检测弹窗的方式有两种。一是监听 DOM 变化(MutationObserver),看到某个 Modal 容器从 display:none 变成显示状态,就触发对应的填充逻辑;二是轮询检测,每 500ms 检查一次弹窗是否可见。我实际使用中觉得轮询更简单粗暴,不容易漏,只是注意不要无限轮询,加一个最大次数限制。
另一个经常遇到的是翻页式问卷。第一页填完点"下一页",第二页内容刷新。这个问题我在 4.2 里提过,再给一个具体的方案。在脚本里注册一个全局的 MutationObserver,监听问卷容器(外层 div)的子树变化。一旦发现容器内的题目数量增加了,就对新出现的题目执行一遍填表逻辑。这里的关键是给已经填过的题目加一个标记(比如给容器设置一个 data-filled 属性),避免重复填。
第三种情况是页面里嵌入了 iframe。部分问卷系统会把题目区域放在 iframe 里,脚本要操作 iframe 内部的内容,需要先获取 iframe 的 document 对象:
javascript复制const iframeDoc = document.querySelector('iframe').contentDocument;
const field = iframeDoc.querySelector('input[name="q1"]');
注意 iframe 访问有同源限制,如果你和目标问卷域名不一致,这种操作就行不通。遇到同源限制的 iframe,基本上只能放弃自动填这部分,或者想办法跟问卷系统方协调一个更合理的实现方式。
我最后再分享一个自己长期坚持的习惯:给脚本写详细注释。特别是选择器的选取逻辑和为什么这么选,隔一个月再回来看脚本,你绝对会感谢当时认真写注释的自己。我这个模板脚本里的每个函数都有注释说明用途,实际改造时你也要保持这个习惯。脚本是自己跑的,代码是给自己看的,维护体验完全取决于你写的时候有多用心。
这套流程走下来,我自己从打开开发者工具到写完一份单页问卷的填表脚本,大概需要二十分钟,其中一半时间花在确认题目 name 上。熟练之后,你会形成一套自己的选择器风格,遇到不同类型的问卷都知道怎么快速下手。工具这东西,用熟了就成手艺了。
