油猴(Tampermonkey)是我在浏览器里用得最顺手的一款脚本管理工具,它能在不修改网站源代码的前提下,往网页里注入自定义 JS 代码,从而实现各种自动操作。问卷自动填表就是其中非常典型的一种应用场景:批量提交调研问卷、日常信息登记、活动报名页面,手动一遍遍选择选项、填文本框、点提交,实在太浪费时间。实际动手做过的人都知道,问卷平台五花八门,表单项也不是一成不变,靠临时复制粘贴撑不了几次,真正靠谱的方案是写一个能自动识别表单结构并完成填写的油猴脚本。这篇文章我会从安装扩展开始,讲到脚本基础结构、表单定位与赋值、动态页面处理,最后给你一份可以直接抄作业的完整示例,并把实际调试中踩过的坑一并整理出来。圈子里习惯把 Tampermonkey 叫“油猴”,也有人戏称“油泼猴”,不管哪种叫法,指的都是它。适合两类人看:一是完全没写过油猴脚本、想快速上手的新手;二是会一点 JS,但卡在表单定位、动态加载、批量提交这些细节上的朋友。
1. 这脚本到底解决了什么问题
1.1 表单填写不是“体力活”那么简单
很多人觉得填问卷就是个重复动作,写个脚本往里面塞数据就行。真上手之后才发现,问题没有想象中简单。问卷平台为了提高数据有效性,往往会设置各种交互逻辑:某些选项选了“其他”之后才出现文本框,某些字段只有鼠标移过去才加载数据,提交前还有验证码或者滑块校验。单纯用固定代码去 set value,很容易出现“框里填了字,但平台不认”的情况。
我在做自动填表前,习惯先在控制台手动跑一遍常用 DOM 操作,看看页面用了什么框架、事件绑定方式是什么。比如有些页面用的是 Vue 或 React,直接给 input 的 value 赋值不行,必须触发对应的 input 事件或者设置原生值描述符,才能让框架感知到变化。如果不注意这些细节,脚本填完表面上看是空的,提交自然就报错了。
换句话说,自动填表的核心不是“往输入框里塞值”,而是“让页面框架认为你是一个真实用户在操作”。这句话我后面会反复提到,所有关键技巧都围绕它展开。
1.2 油猴是自动化方案里最轻量的一档
浏览器自动化方案其实不少:有专门做端到端测试的 Playwright、Puppeteer,有传统自动化工具 Selenium,也有各类浏览器外的按键精灵。但如果你只是想在访问某个问卷页面时,自动把当前这份表单填掉,引入一整套自动化框架显然太重了。
油猴脚本的优势在于它和浏览器是同进程运行,直接操作真实页面 DOM,不需要额外驱动,也不需要后台维护一套浏览器实例。只要浏览器能打开这个页面,脚本就能跑。而且 Tampermonkey 提供了脚本管理、定时执行、跨域请求等能力,正好覆盖问卷填表的大部分需求。
我自己的习惯是:项目涉及大量页面交互、需要截图验证的时候,用 Playwright;但只是日常快速填表、改页面样式、辅助走通表单流程,就直接写油猴脚本。两者不冲突,油猴负责轻量执行,专业工具负责重活。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:先把 Tampermonkey 装明白
2.1 安装扩展与脚本管理器的作用
以 Chrome 系浏览器为例,去应用商店搜 Tampermonkey 安装即可。如果你用的是 Edge,也可以直接在 Edge 扩展市场装。Firefox 同样支持。安装完成以后,浏览器工具栏会出现一个带有两个节点图案的图标,点开就能进入管理面板。
一个常见的误区是:把“油猴”和“油猴脚本”当成同一个东西。实际上油猴是一个脚本管理器,本身不包含任何自动填表逻辑;真正干活的,是运行在它里面的那些 // ==UserScript== 开头的 JS 文件。你可以把油猴理解成插座,脚本才是插上去的电器。
脚本来源不限于 GreasyFork 这类第三方脚本库。自己写的脚本也完全可以直接导入。在管理面板里点“添加新脚本”,会进入一个编辑器界面,默认带一段模板代码,我们接下来的操作都以这个编辑器为基础。
2.2 理解脚本头:@match、@run-at 是门槛
油猴脚本的头部注释不是普通注释,它声明了脚本的运行环境和匹配规则。常见的几个字段优先级很高,我列一下:
| 字段 | 作用 | 备注 |
|---|---|---|
| @name | 脚本名称 | 面板里显示的名字 |
| @namespace | 命名空间 | 用来区分同名脚本 |
| @version | 版本号 | 每次更新建议递增 |
| @match | 匹配哪些网址 | 可以写 *://*.example.com/* |
| @include | 另一种匹配方式 | 老写法,优先级低于 @match |
| @run-at | 执行时机 | 多选 document-end |
| @grant | 授权哪些油猴 API | 用不到特殊 API 就写 none |
@match 必须写对,否则脚本在目标页面毫无反应。比如目标问卷网址是 https://survey.example.com/fill?page=1,那么 @match 至少写成 https://survey.example.com/*。如果脚本要同时跑在多个域名下,可以用多个 @match 行,一行一个域名。这个字段是新手最容易漏的地方,脚本不生效时先看它。
@run-at 也很关键。默认情况下 Tampermonkey 会等 DOM 加载完再执行,但如果页面是单页应用,路由跳转后 DOM 会重新生成,单纯靠 document-end 也不够。这个时候往往要配合 MutationObserver 或者定时器,后面会详细说。
2.3 从空白脚本到控制台输出
我建议新项目不要一上来就写完整填表逻辑,先写一个最小脚本跑通链路。新建脚本,头部写:
javascript复制// ==UserScript==
// @name 问卷填表练习
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 测试最小脚本
// @author you
// @match https://survey.example.com/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
console.log('脚本运行成功');
})();
保存之后,打开匹配的页面,按 F12 打开控制台,如果能看到 脚本运行成功,说明 Tampermonkey 已经正确加载并执行了脚本。这一步很重要,因为它把“脚本安装问题”和“填表逻辑问题”切分开,之后调试时定位范围就小很多。
我习惯在脚本开头打印一个时间戳和 location.href,这样能确认脚本是在哪里跑的。如果页面有 iframe,还要特别留意脚本默认跑在顶层窗口,不会自动进入子 iframe。
3. 核心实现:问卷自动填表的完整拆解
3.1 定位表单控件的三种方式
问卷页面上的元素,最终都要通过 DOM 定位找到。定位方式无外乎三种,按推荐程度排序:
document.querySelector/querySelectorAll:最推荐,通过 CSS 选择器精确锁定元素。document.getElementById/getElementsByName:兼容性最好的老 API,适合简单的单元素定位。- XPath:复杂结构里偶尔用,但可读性差,非必要不选。
问卷里的文本框一般有 name、id 或 placeholder 属性,比如:
html复制<input type="text" name="username" placeholder="请输入姓名">
这时候用:
javascript复制const nameInput = document.querySelector('input[name="username"]');
if (nameInput) {
nameInput.value = '张三';
}
单选按钮和复选框的定位稍微特殊。一个单选组往往是多个同名 input:
html复制<input type="radio" name="gender" value="male">
<input type="radio" name="gender" value="female">
要选中某一项,用:
javascript复制const maleRadio = document.querySelector('input[name="gender"][value="male"]');
if (maleRadio) {
maleRadio.click();
}
注意:模拟用户点击用 .click(),而不是直接改 checked 属性。直接改 checked 通常不会触发表单状态更新,提交时依然可能被判定为空。
下拉框 select 的赋值逻辑稍不同:
javascript复制const select = document.querySelector('select[name="city"]');
select.value = 'Beijing';
select.dispatchEvent(new Event('change', { bubbles: true }));
对 select 来说,只设置 value 还不够,最好主动触发一个 change 事件,很多页面正是在这个事件里联动后续逻辑的,比如城市选择的下一级“区县选项”。
3.2 给控件赋值的三个关键细节
第一个细节是触发事件。普通的原生 input,赋值后基本能带出内容,但很多现代前端框架(Vue、React)监听的是合成事件,直接改 value 不会触发框架的更新。常见做法是:赋值之后,再 dispatchEvent(new Event('input', { bubbles: true })),有些框架还需要 change 事件。我写脚本时通常都触发,确保两种事件都覆盖到。
第二个细节是隐藏控件。部分问卷会把某些字段做成隐藏输入框,比如统计用的 token、用户 id 等,这类字段可能需要赋值,但不应该被用户看到。用 querySelector 一样能取到,赋值方式相同,只是不必担心模态框遮挡。
第三个细节是处理文本域。textarea 也是拿 value 赋值,但没有标准的 input 事件,需要触发 input 事件来模拟用户输入。同理,contenteditable 的富文本区域会比较麻烦,它没有 value 属性,得拿到 innerText 或者操作 selection 去赋值,这种通常不建议强行自动化,容易踩坑。
实际调试时,第一步赋值后立刻在控制台里看对应元素的值是否变化,确认框架有没有正确感知。
3.3 等待页面就绪与动态内容处理
问卷页面经常有“逐步加载”的逻辑,比如先选择省份,再异步加载城市列表,最后再加载行业类型。如果用一次性执行的方式写脚本,大概率会遇到“元素不存在”的报错。
一个基础方案是定时轮询:
javascript复制function waitForElement(selector, timeout = 10000) {
return new Promise((resolve) => {
const startTime = Date.now();
const timer = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(timer);
resolve(element);
} else if (Date.now() - startTime > timeout) {
clearInterval(timer);
resolve(null);
}
}, 200);
});
}
这个函数会每隔 200ms 检测一次目标元素,最多等待 10 秒。页面异步加载数据后,DOM 更新,下一次轮询就能找到。这个方案虽然不是最优雅的,但胜在稳定可靠,完全够用。
进阶方案是使用 MutationObserver 监听 DOM 变化。触发条件更即时,也不会有轮询带来的资源浪费。但代码复杂度高一些,而且容易因为页面其他节点频繁变化导致回调被反复触发。如果你想快速交付,直接用轮询;想打磨到专业级,再上 MutationObserver。
3.4 自动提交与批量跑批的实现
填完表单后自动提交,是最容易出问题的地方。我的经验是:不要直接调用 form.submit(),除非你确定不需要前端校验。更接近用户行为的方式是点击“提交”按钮:
javascript复制const submitBtn = document.querySelector('#submitBtn');
if (submitBtn) {
submitBtn.click();
}
但是很多问卷平台在点击提交后会弹出二次确认框,比如“请确认填写信息是否完整”,这时候需要再等一会儿,确认弹窗出现后再点一次。可以用和上面一样的 waitForElement 来等待弹窗里的确认按钮。
批量跑批时,我习惯把“填表数据”从脚本主体中抽离出来,单独放在一个对象里:
javascript复制const dataPool = [
{ name: '张三', gender: 'male', city: 'Beijing', hobby: ['coding', 'reading'] },
{ name: '李四', gender: 'female', city: 'Shanghai', hobby: ['movie'] }
];
const currentIndex = sessionStorage.getItem('surveyIndex') || 0;
const current = dataPool[currentIndex];
配合 sessionStorage 记录当前进度,刷新页面后还能继续,避免手动定位到上次填到哪一条。这是我在实际批量跑批过程中觉得最省心的小设计。
3.5 一份可以直接改的完整脚本
下面给一份稍完整的示例,把上面几个点的核心逻辑综合起来。匹配地址、选择器、数据部分需要按实际页面改。
javascript复制// ==UserScript==
// @name 问卷自动填表完整示例
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 自动填写问卷并提交
// @author you
// @match https://survey.example.com/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 工具函数:等待元素出现
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve) => {
const startTime = Date.now();
const timer = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(timer);
resolve(element);
} else if (Date.now() - startTime > timeout) {
clearInterval(timer);
resolve(null);
}
}, 200);
});
}
// 模拟输入:兼容 React/Vue 等框架
function setNativeValue(input, value) {
const valueSetter = Object.getOwnPropertyDescriptor(input, 'value').set;
valueSetter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
async function fillSurvey() {
// 1. 等待表单出现
const form = await waitForElement('.survey-form, #surveyForm');
if (!form) {
console.error('未找到表单');
return;
}
// 2. 填文本框
const nameInput = await waitForElement('input[name="username"]');
if (nameInput) {
setNativeValue(nameInput, '张三');
}
// 3. 单选
const maleRadio = document.querySelector('input[name="gender"][value="male"]');
if (maleRadio) {
maleRadio.click();
}
// 4. 下拉框
const citySelect = document.querySelector('select[name="city"]');
if (citySelect) {
citySelect.value = 'Beijing';
citySelect.dispatchEvent(new Event('change', { bubbles: true }));
}
// 5. 复选框
const codingCheckbox = document.querySelector('input[name="hobby"][value="coding"]');
if (codingCheckbox) {
codingCheckbox.click();
}
// 6. 点击提交
const submitBtn = await waitForElement('#submitBtn');
if (submitBtn) {
setTimeout(() => submitBtn.click(), 500);
}
// 7. 处理二次确认弹窗
const confirmBtn = await waitForElement('.confirm-btn, .dialog-confirm', 5000);
if (confirmBtn) {
setTimeout(() => confirmBtn.click(), 300);
}
}
fillSurvey();
})();
脚本结构里我特意把事件触发和点击拆分成独立步骤,是为了调试时能分别验证。如果用一段很长的连续代码跑下来,一旦中间某一步失败,很难确定是哪一行出了问题。
4. 高频问题与排查心得
4.1 脚本不生效:先查匹配规则
脚本安装好但页面没反应,十有八九是 @match 写错了。我遇到最多的情况是域名带了 www 前缀,而脚本只匹配了不带 www 的版本,或者反过来。Tampermonkey 的匹配规则是前缀匹配,https://example.com/* 并不会自动覆盖 https://www.example.com/*。
另一个容易被忽略的问题是单页应用内路由跳转。如果你访问的是 /fill/a,脚本已经执行过一次,然后在应用内部跳转到 /fill/b,脚本不会自动再执行。这种情况下要么用 location.hash 监听,要么在全局用定时器持续检测表单是否出现。
排查思路很简单:在脚本第一行加 console.log,看能不能打印出来。能打印说明匹配规则没问题,不能打印就检查 @match 和 @include,确认页面地址和规则一致。
4.2 赋值没反应:事件的坑
填完值,页面上看起来还是空的,或者提交的时候提示“该项未填写”,这是动态框架最常见的坑。最典型的场景是 React 的受控组件,它内部维护自己的状态,外部往 DOM 里设置 input.value,框架并不知道状态变了。
解决办法是用原生 value 描述符的 setter,也就是前面示例里的 setNativeValue:
javascript复制function setNativeValue(input, value) {
const valueSetter = Object.getOwnPropertyDescriptor(input, 'value').set;
valueSetter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
这个写法在一些框架版本里有效,但在另一些版本里仍然可能失败。如果还不行,还可以试试直接调用 React 内部属性的方法。不过这种方案依赖框架内部实现,稳定性差一些,我的建议是优先用原生 setter 配合 input/change 事件,如果不行,再用 .focus() 后 document.execCommand('insertText', false, value) 来模拟真实输入。后者虽然老,但对某些框架意外有效。
识别问题到底出在哪一步,最直接的办法是赋值后打断点,在控制台查看元素状态和框架内部状态是否同步。我是靠这招排查出了大部分问题。
4.3 找不到元素:动态加载与 iframe 问题
问卷里很多内容不是在页面加载时就直接渲染好的。比如下拉选项是点击“省份”后异步请求接口才拿到的,如果你的脚本在页面加载完就立刻查那个 select,结果是 null。
解决方案就是前面提到的 waitForElement 轮询。还有一个隐藏问题:如果表单项在 iframe 里,主文档的 querySelector 是找不到的。需要先拿到 iframe 的 contentDocument:
javascript复制const frame = document.querySelector('iframe#surveyFrame');
const frameDoc = frame.contentDocument || frame.contentWindow.document;
const inputInsideFrame = frameDoc.querySelector('input[name="username"]');
注意跨域 iframe 在这种情况下是取不到内部 DOM 的,浏览器出于安全限制不允许访问。这种只能另想他法,比如在 iframe 内部再挂一个脚本。
4.4 批量填写时的频率控制
批量跑批的目标是快速提交,但如果完全不控制节奏,提交间隔太短,有些平台会做风控限制,轻则要求输入验证码,重则直接封禁一段时间。
我的经验是每次填表后加一个随机延迟,比如在 3 到 6 秒之间。这个延迟不是为了骗过系统,而是给自己留出动态调整的时间。如果某个问卷页面加载比较慢,上一个脚本已经触发提交,下一个页面还没出来,这时候随机延迟能降低竞态问题的概率。
同时建议在脚本里增加一个“是否自动提交”的开关。先关掉自动提交,跑一轮看填表是否正确,确认无误后再打开自动提交。否则批量填到一半,发现某个字段赋值方式有误,所有数据都得重来。
5. 进阶扩展思路
5.1 结合本地数据做个性化填充
固定写死一批数据虽然快,但不同问卷的字段不一样,很多场景也更需要差异化填充。油猴配合 Tampermonkey 的 GM_setValue 和 GM_getValue,可以把已经填过的历史数据存到本地,下次遇到类似字段直接补全。
javascript复制// @grant GM_setValue
// @grant GM_getValue
const savedName = GM_getValue('lastName', '');
if (savedName && !nameInput.value) {
setNativeValue(nameInput, savedName);
}
GM_setValue('lastName', nameInput.value);
这种方式适合“同一用户多次提交不同问卷”的场景,既保留个性化数据,又免去重复填写。要注意 @grant 需要明确声明,否则调用这些 API 会被拒绝。
油猴脚本还能在前端读取本地 CSV 或 JSON 数据文件,配合本地文件选择器,把整批数据导入页面。这个玩法等于是把油猴变成了一个轻量级“数据录入工具”,对于产品运营岗位经常要在后台某系统里批量录入信息的朋友来说很实用。
5.2 从单一问卷到一套流程
问卷自动填表只是油猴的用法之一。把思路放大一点,凡是网页上重复性、规则明确的交互,都可以用类似方式改写:自动登录、自动签到、自动翻页抓取表格数据、工作后台批量审核等等。核心能力无非是“定位元素 + 模拟操作 + 事件触发”,差别只在于业务逻辑不同。
我在实际项目里,还会把自动填表脚本和后台数据接口的调用分开:填表脚本负责前端交互,如果需要提交后的数据回传,再用 GM_xmlhttpRequest 做跨域请求。Tampermonkey 允许脚本跨域请求,只要在脚本头声明 @grant GM_xmlhttpRequest 和对应域名。这里比较容易踩坑,注意跨域请求要带完整 URL,有些接口还要配合 token 鉴权。
写完一个稳定脚本后,记得在脚本编辑器里设置一个快捷键开关,这样每次上班打开问卷页面时,可以手动决定要不要立即执行自动填表。我一般用快捷键 Alt + Shift + F 来触发填表逻辑,而不是让脚本在页面加载时自动执行所有操作。这样更可控,也方便测试阶段反复调整。
最后再分享一个小技巧:脚本写完后,不要急着删掉 console.log。把它们保留在函数里,但用环境变量控制输出级别,以后页面改版、脚本失效时,打开控制台就能看到卡在哪一步,比自己重新推测要快得多。我的脚本里最常用的就是 log('【步骤1】等待表单...') 这样的输出,配合时间戳,一眼就能看出整个流程走到哪里断掉了。
