跳到主要内容

Tampermonkey 进阶

基础篇能让你写出一个「能跑起来」的油猴脚本;进阶篇解决的是那些真正会卡住人的问题:

  • 页面在沙箱里,你在沙箱外,变量互相看不见怎么办
  • 想抓接口返回的数据,而不是抓 DOM
  • 网站是 SPA(单页应用),元素是异步渲染的,脚本有时跑对有时跑不到
  • 脚本要发布、更新、回滚
  • 写多了以后性能、重复注入、内存泄漏怎么控制

目录​

  1. 先理解沙箱模型
  2. 沙箱 ⇄ 页面通信桥
  3. 在页面主世界执行代码
  4. Hook fetch / XMLHttpRequest 抓接口数据
  5. SPA 路由监听与动态元素等待
  6. DOM 监听:MutationObserver
  7. 实战:把接口数据存下来并导出
  8. 实战:在页面上加一个悬浮工具面板
  9. GM API 完整速查表
  10. GM 数据面板(跨页面共享状态)
  11. 性能、稳定性与重复注入
  12. 版本管理与发布更新
  13. 调试进阶
  14. 与浏览器自动化联调
  15. 常见坑速查

1. 先理解沙箱模型​

油猴的沙箱是理解一切诡异问题的钥匙。

┌─ 页面环境(主世界 / main world)──────────────────────┐
│ 页面自己的 JS:Vue、React、jQuery、fetch、页面变量 │
│ window === page window │
└────────────────────────────────────────────────────┘
↕ 只能通过消息或 unsafeWindow 交流
┌─ 油猴沙箱(isolated world)─────────────────────────┐
│ 你的脚本:GM_* API 可用;window !== 页面 window │
│ 页面在 window 上的东西,这里默认全都看不到 │
└────────────────────────────────────────────────────┘

两种模式对照​

模式元数据能用 GM API能看到页面变量典型场景
页面上下文(无沙箱)@grant none❌✅ 直接用 window改样式、hook fetch、注入 <style>
油猴沙箱@grant GM_xxx✅只能用 unsafeWindow跨域请求、GM 存储、右键菜单、@require 库
混合两者都写✅靠桥接需要 GM API 又要改页面

关于 @sandbox​

Tampermonkey 4.18 之后新增了 @sandbox 元数据,用来更精细地指定运行上下文,取值常见为:

// @sandbox raw 直接跑在页面上下文(等同于 @grant none 的效果)
// @sandbox DOM 独立的 JS 环境 + 共享 DOM(能改页面,读不到页面变量)
// @sandbox JavaScript 纯独立 JS 环境(连 DOM 都共享不了)
// @sandbox JavaScript:unsafeWindow 独立 JS 环境,但暴露 unsafeWindow

@sandbox 是较新的特性,不同版本行为可能有差异;除非你确实需要精确控制,否则用 @grant none / @grant GM_xxx 这套经典写法就足够了。需要时以你所用版本的官方文档为准。

暴力猴(Violentmonkey) 用的是另一个元数据 @inject-into page / content / auto,效果与上表类似,移植脚本时注意这个差异。

判断当前处于哪种模式​

// 在脚本开头打一行日志,什么都清楚了
console.log('[沙箱检测]', {
isSandbox: typeof unsafeWindow !== 'undefined' && window !== unsafeWindow,
hasGM: typeof GM_xmlhttpRequest !== 'undefined',
jquery: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.jQuery : 'N/A',
});

2. 沙箱 ⇄ 页面通信桥​

沙箱和页面之间最稳的通信方式是 DOM 事件(CustomEvent / postMessage):两边都能操作 document,所以可以互相传 JSON。

约定一个协议​

// ============ 沙箱侧:用户脚本 ============
// @grant GM_setValue
// @grant GM_getValue
// @match https://example.com/*

// 页面 -> 脚本
window.addEventListener('tmRequest', (e) => {
const { id, action, payload } = e.detail || {};
const data = handle(action, payload);
// 回给页面
unsafeWindow.dispatchEvent(
new CustomEvent('tmResponse', { detail: { id, data } })
);
});

function handle(action, payload) {
switch (action) {
case 'getPageInfo':
return { title: unsafeWindow.document.title, url: unsafeWindow.location.href };
case 'getConfig':
return GM_getValue('config', {});
default:
return { error: 'unknown action: ' + action };
}
}

// 脚本 -> 页面(主动发一条)
unsafeWindow.dispatchEvent(new CustomEvent('tmNotify', { detail: { hello: 'from userscript' } }));
// ============ 页面侧:页面自己的 JS ============

// 页面调用沙箱里的脚本
function callUserScript(action, payload) {
return new Promise((resolve) => {
const id = Math.random().toString(36).slice(2);
const onResp = (e) => {
if (e.detail?.id === id) {
unsafeWindow.removeEventListener('tmResponse', onResp);
resolve(e.detail.data);
}
};
unsafeWindow.addEventListener('tmResponse', onResp);
document.dispatchEvent(new CustomEvent('tmRequest', { detail: { id, action, payload } }));
});
}

callUserScript('getPageInfo').then(console.log);

三种通信方式怎么选​

方式优点缺点适用
unsafeWindow 直接读写简单直接沙箱与页面互相污染;页面自己改了你会漏掉单向读取页面状态
CustomEvent 事件桥解耦、可序列化、异步友好需要约定协议双向通信(推荐)
window.postMessage跨窗口/跨 iframe 也能用同源判断要自己写、格式是字符串与 iframe 内容脚本通信

postMessage 版:

// 沙箱 -> 页面
unsafeWindow.postMessage({ __tm: true, type: 'ping', data: { a: 1 } }, '*');

// 页面 -> 沙箱
window.addEventListener('message', (e) => {
if (e.data?.__tm) console.log('收到消息', e.data);
});

postMessage 的第二个参数(targetOrigin)尽量别用 *,写具体域名更安全。


3. 在页面主世界执行代码​

有时候你必须让代码「以页面的身份」运行:hook fetch、包裹第三方 SDK、给 Vue 组件打补丁。经典做法是插入一个 <script> 标签。

// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-start
// @match https://example.com/*

// 让页面创建一个普通的 <script>,代码就在页面上下文里执行
function runInPage(code) {
const s = document.createElement('script');
s.textContent = code;
(document.head || document.documentElement).appendChild(s);
s.remove(); // 执行完就删掉,避免污染 DOM
return true;
}

runInPage(`
window.__tm_hooks = [];
const _f = window.fetch;
window.fetch = function (...args) {
window.__tm_hooks.push({ type: 'fetch', url: String(args[0]) });
return _f.apply(this, args);
};
`);

console.log(unsafeWindow.__tm_hooks);

三个必须注意的点:

  1. CSP 会拦住内联脚本。如果页面设置了 Content-Security-Policy: script-src 'self',textContent 注入的 <script> 不会执行。绕过方式(按可行性排序):
    • 改用事件桥:把「要暴露到页面上的东西」通过 CustomEvent 传出去,让页面自己装上(推荐,无需绕过)
    • 把函数挂到 unsafeWindow 上,再让页面已有的入口去调它
    • 用 GM_addElement 挂自定义标签,部分情况下不受内联 CSP 限制
  2. @run-at document-start 是前提。晚了页面早就把原始 fetch 绑定到组件上了,hook 不到。
  3. CSP 绕过相关的高级技巧在新版 Chrome 上成功率越来越低,别把它当主要方案。

4. Hook fetch / XMLHttpRequest 抓接口数据​

DOM 里的东西可能是渲染过的、可能是加密混淆的,接口返回的 JSON 才是原始数据。这是油猴最 killer 的用法之一。

4.1 Hook fetch​

// @grant none
// @run-at document-start
// @match https://example.com/*

(() => {
const captured = (window.__tm_captured = []);
const origFetch = window.fetch;

window.fetch = async function (...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const res = await origFetch.apply(this, args);

// 必须 clone(),否则会把 body 读空,导致页面自己拿不到数据
try {
const clone = res.clone();
const text = await clone.text();
captured.push({
url: res.url,
status: res.status,
method: args[1]?.method || 'GET',
body: text,
time: new Date().toISOString(),
});
} catch (e) { /* 二进制响应忽略 */ }

return res;
};
})();

res.clone() 是关键,不 clone 就会消耗掉响应流,页面会报 body used already。

4.2 Hook XMLHttpRequest​

老一点的网站(大量后台系统、jQuery 时代的站点)还是 XHR:

// @grant none
// @run-at document-start

(() => {
const captured = (window.__tm_xhr = []);
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;

XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__tm_info = { method, url, time: Date.now() };
return open.apply(this, [method, url, ...rest]);
};

XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('load', function () {
// 200 且能解析成 JSON 才记录,避免一堆静态资源
if (this.status === 200) {
captured.push({
...this.__tm_info,
status: this.status,
body: this.responseText,
});
}
});
return send.apply(this, [body]);
};
})();

4.3 用 Fetch/XHR 拦截 API 的方式重放​

抓到 URL 和参数后,可以在控制台或脚本里重放:

// 在页面上下文
const list = window.__tm_captured.find(x => x.url.includes('/api/list'));
const data = JSON.parse(list.body);
console.table(data.list);

配合油猴的跨域能力,写成定时轮询 + 差异提醒:

// @grant GM_xmlhttpRequest
// @connect api.example.com
// @match https://example.com/*
// @grant GM_notification
// @grant GM_setValue
// @grant GM_getValue

const API = 'https://api.example.com/task/list';
const seen = new Set(GM_getValue('seenIds', []));

GM_xmlhttpRequest({
url: API,
method: 'GET',
responseType: 'json',
onload: (res) => {
const items = res.response?.data ?? [];
const fresh = items.filter((it) => !seen.has(it.id));
if (fresh.length) {
GM_notification({ title: '有 ' + fresh.length + ' 条新任务' });
fresh.forEach((it) => seen.add(it.id));
GM_setValue('seenIds', [...seen]);
}
},
});

⚠️ 合规提醒:抓取和重放接口数据前,务必确认目标网站的服务条款与相关法规允许这样做。仅用于自己的账号、自己的数据、授权范围内的测试。


5. SPA 路由监听与动态元素等待​

现代站点基本都是 SPA,URL 变了但页面不刷新,@run-at 只在首次加载时触发一次。必须自己做监听。

5.1 监听路由变化​

// @grant none
// @match https://example.com/*

// 把 history 的三个方法打补丁,统一派发 locationchange
(function () {
const fire = () => window.dispatchEvent(new Event('locationchange'));
const orig = {
pushState: history.pushState,
replaceState: history.replaceState,
};
history.pushState = function () { orig.pushState.apply(this, arguments); fire(); };
history.replaceState = function () { orig.replaceState.apply(this, arguments); fire(); };
window.addEventListener('popstate', fire);
})();

// 然后统一在这里做「每到一个页面就执行一次」的事
let lastPath = location.pathname;
window.addEventListener('locationchange', () => {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
onRouteChange(location.pathname);
}
});
window.addEventListener('popstate', () => onRouteChange(location.pathname));

function onRouteChange(path) {
console.log('进入新页面:', path);
// 重新找元素、重新注入面板
}

5.2 等待元素出现的通用工具​

// @grant none

/**
* 等某个选择器出现(可选:等可见),超时抛错
* @param {string} selector
* @param {number} timeout 毫秒
*/
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);

const timer = setTimeout(() => {
observer.disconnect();
reject(new Error('等待元素超时: ' + selector));
}, timeout);

const observer = new MutationObserver(() => {
const found = document.querySelector(selector);
if (found) {
clearTimeout(timer);
observer.disconnect();
resolve(found);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
});
}

waitForElement('.comment-list')
.then((el) => console.log('列表出来了,共', el.children.length, '条'))
.catch((e) => console.warn(e.message));

5.3 等「文本出现」而不是「元素出现」​

SPA 里骨架屏常常是元素已存在但内容还是空的,这时要等文本变化:

function waitForText(selector, minLength = 1, timeout = 10000) {
return new Promise((resolve, reject) => {
const check = () => {
const el = document.querySelector(selector);
return el && el.textContent.trim().length >= minLength;
};
if (check()) return resolve(document.querySelector(selector));

const timer = setTimeout(() => { obs.disconnect(); reject(new Error('等待文本超时')); }, timeout);
const obs = new MutationObserver(() => {
if (check()) { clearTimeout(timer); obs.disconnect(); resolve(document.querySelector(selector)); }
});
obs.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
});
}

6. DOM 监听:MutationObserver​

MutationObserver 是现代的「DOM 变化监听器」,比老式 DOMNodeInserted 快得多,任何要「页面上出现什么就做什么」的脚本都该用它。

// @grant none

// 只监听「新增子节点」,并只看 body 下的目标容器
const target = document.body;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1 && node.matches('.ad-item, .banner')) {
node.remove(); // 命中就删掉
}
}
}
});

observer.observe(target, { childList: true, subtree: true });

// 不用了就断开,脚本重跑时不会叠加
// observer.disconnect();

三种监听配置的取舍​

配置含义用在哪
{ childList: true }只看子节点增删大多数情况,够用且最省性能
{ childList: true, subtree: true }递归监听整棵树元素可能在任意深层出现
{ attributes: true, attributeFilter: ['class'] }只看 class/style 等属性变化等待「组件进入激活状态」
{ characterData: true }只看文本变化等内容渲染完

别在回调里做重活。MutationObserver 是微任务,重活(比如抓大接口)会拖慢页面渲染,建议用 setTimeout 或 requestIdleCallback 推出去。


7. 实战:把接口数据存下来并导出​

一个完整闭环的示例:自动收集 → 本地持久化 → 一键导出 CSV。

// ==UserScript==
// @name 数据收集助手
// @namespace https://example.com
// @version 1.2.0
// @description 自动收集列表页数据,存到油猴,右键菜单可导出 CSV
// @match https://example.com/list*
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @grant GM_notification
// @grant GM_addStyle
// @grant unsafeWindow
// @connect api.example.com
// ==/UserScript==

const KEY = 'collected_rows';
const MAX = 5000;

function getRows() { return GM_getValue(KEY, []); }
function setRows(rows) { GM_setValue(KEY, rows.slice(-MAX)); }

// —— 1. Hook 页面自己的 fetch 抓数据 ——
(function hookFetch() {
const origFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
if (res.url.includes('/api/list')) {
res.clone().json().then((json) => {
const items = json?.data ?? [];
const rows = getRows();
const known = new Set(rows.map((r) => r.id));
const added = items.filter((it) => !known.has(it.id));
if (added.length) {
setRows(rows.concat(added));
GM_notification({ text: `新增 ${added.length} 条,已收集 ${getRows().length} 条` });
}
}).catch(() => {});
}
return res;
};
})();

// —— 2. 页面右下角显示收集状态 ——
unsafeWindow.addEventListener('load', () => {
GM_addStyle(`
#tm-counter{position:fixed;right:12px;bottom:12px;z-index:2147483647;
background:rgba(0,0,0,.8);color:#fff;padding:8px 12px;border-radius:8px;
font:12px/1.6 system-ui;cursor:pointer}
`);
const box = document.createElement('div');
box.id = 'tm-counter';
const render = () => { box.textContent = `已收集 ${getRows().length} 条(点击导出)`; };
render();
box.addEventListener('click', exportCsv);
document.body.appendChild(box);
});

// —— 3. 右键菜单 ——
GM_registerMenuCommand('导出 CSV', exportCsv);
GM_registerMenuCommand('清空已收集数据', () => {
if (confirm('确定清空?')) { setRows([]); location.reload(); }
});
GM_registerMenuCommand('查看原始 JSON', () => {
GM_setClipboard(JSON.stringify(getRows(), null, 2));
alert('已复制到剪贴板');
});

// —— 4. 导出 ——
function exportCsv() {
const rows = getRows();
if (!rows.length) return alert('还没有数据');
const keys = Object.keys(rows[0]);
const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const csv = [keys.join(','), ...rows.map((r) => keys.map((k) => esc(r[k])).join(','))].join('\n');
GM_setClipboard('' + csv); // 加 BOM,Excel 打开中文不乱码
alert(`已导出 ${rows.length} 条到剪贴板,直接粘到 Excel 即可`);
}

这个例子里体现了几个进阶技巧:

  • @run-at document-start 保证 hook 能赶在页面脚本之前
  • res.clone() 避免消耗响应流
  • 数据存在 GM_setValue,不依赖页面刷新状态,关掉浏览器也在
  • GM_registerMenuCommand 提供维护入口
  • 导出加 UTF-8 BOM,Excel 不乱码
  • 状态角标让用户知道脚本在跑

8. 实战:在页面上加一个悬浮工具面板​

油猴的另一半价值是「给页面加个自己的操作面板」,让复杂操作变成点一下。

// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @match https://example.com/*

const PANEL_ID = 'tm-panel';
GM_registerMenuCommand('显示/隐藏工具面板', toggle);

function toggle() {
const el = document.getElementById(PANEL_ID);
if (el) el.remove();
else build();
}

function build() {
GM_addStyle(`
#${PANEL_ID}{position:fixed;right:16px;top:80px;z-index:2147483647;width:240px;
background:#fff;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.18);
font:13px/1.7 system-ui;color:#222}
#${PANEL_ID} h4{margin:0;padding:8px 10px;background:#2f6feb;color:#fff;
border-radius:10px 10px 0 0;cursor:move;user-select:none}
#${PANEL_ID} .body{padding:10px;display:flex;flex-direction:column;gap:6px}
#${PANEL_ID} button{padding:6px 10px;border:1px solid #ddd;background:#fafafa;
border-radius:6px;cursor:pointer}
#${PANEL_ID} button:hover{background:#2f6feb;color:#fff;border-color:#2f6feb}
`);

const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<h4>数据工具</h4>
<div class="body">
<button data-act="copy">复制当前页 URL</button>
<button data-act="links">复制本页所有链接</button>
<button data-act="text">复制正文纯文本</button>
<button data-act="img">下载全部图片地址</button>
<button data-act="close">关闭</button>
</div>`;

panel.addEventListener('click', (e) => {
const act = e.target.dataset.act;
if (!act) return;
const txt = {
copy: () => location.href,
links: () => [...document.querySelectorAll('a[href]')].map((a) => a.href).join('\n'),
text: () => document.body.innerText,
img: () => [...document.querySelectorAll('img[src]')].map((i) => i.src).join('\n'),
close: () => panel.remove(),
}[act]?.();
if (txt) GM_setClipboard(txt);
});

// 拖动标题栏
const head = panel.querySelector('h4');
let sx, sy, ox, oy;
head.addEventListener('mousedown', (e) => {
const r = panel.getBoundingClientRect();
sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', onMove);
}, { once: true });
});
function onMove(e) {
panel.style.left = ox + e.clientX - sx + 'px';
panel.style.top = oy + e.clientY - sy + 'px';
panel.style.right = 'auto';
}

document.body.appendChild(panel);
}

要点:

  • z-index 要开得足够大(2147483647),否则被网站的悬浮层盖住。
  • 面板本身要在 @grant none 下创建,或用 unsafeWindow.document,否则创建的节点可能进不了页面。
  • 提供一个开关入口(右键菜单 / 控制台函数),别让用户只能删脚本。
  • 拖动、once: true 清理事件,避免事件监听越堆越多。

9. GM API 完整速查表​

存储​

API说明
GM_setValue(key, value)同步保存,值可以是对象
GM_getValue(key, defaultValue)同步读取
GM_deleteValue(key)同步删除
GM_listValues()返回所有 key 的数组
GM_setValue 配额默认每个脚本约 1MB,超了会写失败

网络​

API说明
GM_xmlhttpRequest(options)跨域请求,需 @connect
GM_xmlhttpRequest(url)Promise 简化版(GM.xmlhttpRequest)
GM_webRequestTampermonkey 专有,需 @run-at document-start,用于拦截/修改请求

页面与元素​

API说明
GM_addStyle(css)注入 CSS
GM_addElement(tagName, props)插入自定义标签,返回元素
unsafeWindow页面的 window

用户交互​

API说明
GM_setClipboard(text, type)写剪贴板,type 可为 text/html
GM_openInTab(url, active)开新标签
GM_registerMenuCommand(label, fn, icon)注册菜单项
GM_notification(options)系统级通知
GM_download(options)下载文件
alert / confirm / prompt油猴重载的原生弹窗,不易被页面样式干扰
GM_info油猴专有,脚本信息与版本号

资源​

API说明
GM_getResourceText(name)取 @resource 的文本内容
GM_getResourceURL(name, asBlob)取 @resource 的 URL,asBlob=true 转 blob:

存储 / 会话​

API说明
GM_getTab(cb)读取当前标签页在数据面板里的数据
GM_saveTab(data, cb)保存当前标签页数据到数据面板
GM_getTabs(cb)读取所有标签页数据
GM_log(...args)输出到油猴日志面板

新旧两套 API:以 GM. 开头的写法(GM.setValue、GM.xmlHttpRequest、GM.getResourceText)是较新的 Promise 风格,需要显式授权 @grant GM.setValue 这样的名称;旧的 GM_xxx 是同步风格。两者可以混用,但授权要对应。


10. GM 数据面板(跨页面共享状态)​

GM_saveTab / GM_getTab 提供了一套「按标签页隔离」的数据存储,很适合做「一个标签页里慢慢收集,多个标签页共享进度」的场景。

// @grant GM_saveTab
// @grant GM_getTab
// @grant GM_getTabs
// @grant GM_registerMenuCommand
// @grant GM_setClipboard

function load() {
return new Promise((resolve) => GM_getTab((data) => resolve(data || { items: [] })));
}
function save(data) {
return new Promise((resolve) => GM_saveTab(data, resolve));
}

GM_registerMenuCommand('导出全部标签页数据', () => {
GM_getTabs((all) => {
GM_setClipboard(JSON.stringify(all, null, 2));
alert('已导出 ' + Object.keys(all).length + ' 个标签页的数据');
});
});

和 GM_setValue 的区别:

GM_setValueGM_saveTab
作用域脚本级,所有页面共享标签页级,每个标签页一份
适合配置项、长期状态会话内收集、页面间传参
清理手动 GM_deleteValue关闭标签页即消失

11. 性能、稳定性与重复注入​

防止重复注入​

SPA 里同一个脚本逻辑可能被多次触发(路由变了、组件重渲染),必须加幂等保护:

// 顶层守卫:同一个页面里只执行一次
if (window.__myScriptLoaded) return;
window.__myScriptLoaded = true;

框架环境(Vue/React)里更推荐用框架的挂载判断:

const app = document.querySelector('#app')?.__vue_app__;
if (app?.__myPluginInstalled) return;
if (app) app.__myPluginInstalled = true;

元素存在就别重复加​

function ensurePanel() {
if (document.getElementById(PANEL_ID)) return;
build();
}

防抖与节流​

// 防抖:连续触发只在停止后执行一次
function debounce(fn, wait = 300) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
}

// 节流:固定间隔最多执行一次
function throttle(fn, wait = 1000) {
let last = 0, timer;
return (...args) => {
const now = Date.now();
if (now - last >= wait) { last = now; fn(...args); }
else { clearTimeout(timer); timer = setTimeout(() => { last = Date.now(); fn(...args); }, wait - (now - last)); }
};
}

window.addEventListener('resize', throttle(() => console.log('窗口尺寸变了'), 500));
window.addEventListener('scroll', debounce(() => console.log('滚动停了'), 200));

别在 MutationObserver 里干重活​

const obs = new MutationObserver((muts) => {
if (!muts.length) return;
// 推到下一个宏任务,避免阻塞渲染
setTimeout(() => handle(muts), 0);
});

一定要清理​

  • 路由监听、setInterval、MutationObserver、全局事件监听,如果脚本可能在同一页面里跑多次,都要保证只有一个实例
  • 长期驻留脚本提供「停止」入口:
    GM_registerMenuCommand('停止运行', () => {
    observer?.disconnect();
    clearInterval(timer);
    console.log('已停止,刷新页面后重新启用');
    });

12. 版本管理与发布更新​

脚本一旦分享出去,就必须能更新。三件套:

// @version 1.3.0
// @downloadURL https://raw.githubusercontent.com/you/my-scripts/main/xxx.user.js
// @updateURL https://raw.githubusercontent.com/you/my-scripts/main/xxx.user.js
// ==/UserScript==

规则​

规则说明
@version 一定要随改动递增油猴靠它判断有没有新版本
URL 必须能返回脚本源码纯文本GitHub Raw 页面带 HTML 外壳是不行的
URL 必须以 / 或 *.user.js 结尾否则油猴直接拒绝更新
离线脚本不写 updateURL内网脚本不需要检查更新
走 CDNraw.gitmirror.com、ghproxy 之类的镜像在部分网络环境下更稳

本地版本自动化​

手动改版本号很容易忘,用脚本处理(需 npm 或 Python):

# bump.sh —— 自动提升 patch 版本号
VERSION=$(grep -m1 '@version' tampermonkey.user.js | awk '{print $3}')
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(($(echo $VERSION | cut -d. -f3) + 1))
NEW="$MAJOR.$MINOR.$PATCH"

sed -i "s/@version.*/@version $NEW/" tampermonkey.user.js
echo "版本更新为 $NEW"
git add tampermonkey.user.js
git commit -m "chore: bump version to $NEW"
git push

发布渠道​

渠道特点
Greasy Fork油猴官方脚本站,人多、有评论和代码审查,搜索友好。发布后把脚本地址填进 @updateURL
GitHub Gist / Raw自己掌控,更新即时,适合内部/私有脚本
自建静态地址完全可控,可加访问控制
油猴本身导入分享时直接把 .user.js 文件发给朋友,对方拖进浏览器即可安装

开源共享时请移除任何写死的 token、账号、内网地址。Greasy Fork 的代码是公开的。

写 changelog​

很多成熟的脚本会在脚本顶部写明更新历史,这既是给人看的,也是给自己看的:

// ==UserScript==
// @name 我的脚本
// @version 1.3.0
// @description 干这件事
// ==/UserScript==

/*
* 1.3.0 2026-09-27 新增导出 CSV,修复表格换页后停止收集
* 1.2.0 2026-09-20 增加右键菜单,改为 GM_setValue 存储
* 1.0.0 2026-09-01 首个可用版本
*/

13. 调试进阶​

沙箱里看 console​

网页 F12 的控制台能看到沙箱脚本的 console.log。如果什么都看不到:

  • 检查「允许用户脚本」开关
  • 检查脚本是否被禁用 / 站点是否在排除名单
  • 在脚本第一行写 console.log('script loaded') 逐步二分定位

用油猴自己的日志面板​

GM_log 和 GM_info 的输出会进油猴管理页的日志面板,不会和页面的 log 混在一起:

GM_log('这是一条油猴日志', { a: 1 });
console.table([{ id: 1 }, { id: 2 }]);

断言和错误捕获​

// @grant GM_notification

try {
doSomethingRisky();
} catch (e) {
console.error('[我的脚本] 出错了:', e);
GM_notification({ title: '脚本出错', text: String(e && e.message || e) });
}

反复调试脚本​

编辑 → 保存 → 刷新页面。开 DevTools 的「停用缓存」再刷新,避免拿到旧脚本。

判断是不是自己脚本的锅​

油猴「实用工具」里有排除模式(黑名单):打开它会阻止所有页面加载,让你先确认「不开脚本时网站正不正常」。


14. 与浏览器自动化联调​

油猴脚本适合人工长期使用;如果要做批量、无人值守的任务,逻辑应该写进自动化脚本(DrissionPage / ruyipage / Selenium)里。两者有三种配合方式:

方式一:直接注入脚本代码(最快)​

@grant none 的脚本本质就是一段 JS,可以直接塞进页面执行:

# DrissionPage
with open('my-script.user.js', encoding='utf-8') as f:
code = f.read()

page.get('https://example.com/list')
page.run_js(code) # 立即执行

方式二:导航前注入(推荐)​

页面的 JS 跑得比你晚,尤其是要 hook fetch 时必须在文档之前注入。各库的接口名不同(add_init_js / add_preload_script / CDP Page.addScriptToEvaluateOnNewDocument),含义都是「每次导航前先执行这段代码」。

# 思路示意
page.add_init_js(code) # DrissionPage(4.x)
page.add_preload_script(code) # ruyipage / WebDriver BiDi

方式三:装进自动化浏览器的用户目录​

用油猴的真实扩展环境跑,能力最全(含 GM_* API):

  1. 给自动化浏览器准备一个独立用户目录(不要用你日常的 profile)。
  2. 在该 profile 里打开一次 chrome://extensions,安装油猴并打开「允许用户脚本」。
  3. 自动化启动时指定这个用户目录,再把脚本文件直接装进去(或预置到扩展的存储里)。
  4. 页面加载后脚本自动执行,Python 侧通过 CDP / BiDi 读取脚本产出的数据。
# DrissionPage 指定用户目录示例
from DrissionPage import ChromiumOptions, ChromiumPage

co = ChromiumOptions()
co.set_user_data_path(r'D:\browser_profile_tm')
co.add_extension('油猴扩展目录或crx')
page = ChromiumPage(co)
page.get('https://example.com/list')
print(page.run_js('return window.__tm_captured'))

注意:扩展方式启动时,有些自动化库会切到无头模式,无头下的行为和真实浏览器有差异,做验收要以有头模式为准。

怎么分工​

需求放哪
个人每天打开页面顺手用一下油猴脚本
页面上的个人偏好、UI 增强油猴脚本
每天定时跑 100 个页面Python 自动化
需要登录态 + 复杂交互 + 结果入库Python 自动化
站点结构复杂、选择器天天变油猴脚本先探路,再把结论写进 Python

15. 常见坑速查​

现象原因解决
脚本完全不执行Chrome 没开「允许用户脚本」chrome://extensions/ 打开该开关
改了脚本没反应没刷新页面刷新;SPA 里还要触发路由或重启浏览器
GM_xxx is not defined写了 @grant none 却用了 GM API二选一:有沙箱就写 @grant,或用事件桥把活交给页面
xxx is not defined(页面变量)沙箱访问不到页面变量用 unsafeWindow.xxx
页面功能坏了脚本改坏了页面的原型/全局排除模式确认;hook 一定要保存原函数并透传参数、this
页面接口报 body used already读 res 没 clone()res.clone().json()
Hook 到了但没反应@run-at 太晚改 document-start
元素时有时无SPA 异步渲染MutationObserver / waitForElement 重试
内联 <script> 不执行页面 CSP改用事件桥或 GM_addElement
样式覆盖不了页面用了 !important / 内联 style你的 CSS 也加 !important;内联样式需逐个改
GM_xmlhttpRequest 直接静默失败缺 @connect加上 @connect 域名
换浏览器脚本失效@if 条件注释没覆盖补条件分支
视频网站播放器没声音/报错脚本动了 HTMLMediaElement.prototype检查是否 hook 了原型,别动它
更新检查一直失败updateURL 返回的是 HTML 页面用 Raw 地址;确认以 / 或 .user.js 结尾
油猴里数据写不进去超出存储配额清理数据;大文件改存 IndexedDB
标签页关闭后数据没了用的是 GM_saveTab改用 GM_setValue(脚本级)

写在最后​

  • 写脚本先跑通再加功能,每加一段就刷新验证,别一次写 200 行再排错。
  • 页面会变,选择器要写得有冗余(多个备选选择器依次尝试),别只依赖 nth-child。
  • 用户脚本是「在别人页面上跑自己的代码」,克制很重要:不要影响正常功能,不要收集上传无关数据。
  • 调试自动化时,把油猴脚本当普通 JS 来看待,能省掉一半的「魔法感」。