Userscript 油猴脚本

该文介绍了油猴脚本的使用和 Tampermonkey API,包括头部元信息、GM_XXX函数的使用、插件编写等。此外,还提供了使用 esbuild 构建油猴脚本的示例。

油猴脚本:是什么、能干嘛?

油猴脚本,正式的叫法是用户脚本(user script)。之所以叫做「油猴」,是因为第一个制作这个浏览器扩展的作者 Aaron Boodman 起名叫做 Greasymonkey,中文直译就是「油腻的猴子」;后面其他脚本开发的时候,基本都在沿用 Greasymonkey 的一些基本规范,这些脚本也就统称为「油猴脚本」了。

你可以将油猴脚本理解为一种可以根据我们的实际需求,为网页「加料」的手段。

油猴脚本 vs. 浏览器扩展

同样一个需求,如果浏览器扩展和油猴脚本都能实现,我们应该如何选择?

一方面,浏览器扩展相比用户脚本诞生的时间其实更晚,各家的浏览器扩展后发制人,的确也有了比油猴脚本强得多的功能实现;

但另一方面,虽然脚本能力有限,但是它们占用的系统资源和内存又更少一点。

另外,从安全性角度上来说,油猴脚本虽然也爆出过不少窃取个人信息、替换返利链接甚至挖矿的负面新闻,但油猴脚本的源代码审查相比浏览器扩展更为直接透明,选择合适的油猴脚本获取渠道、留意脚本的权限请求,有基础的用户也可以多多留意、检查脚本内容,一般就能规避大部分风险。

因此我自己的解决方案是,对于轻量一些的场景,通过用户脚本+用户样式(user style)解决大部分浏览需求,重一些的场景则会选择浏览器扩展。

当然了,如果你的设备对保密性和安全等级有着较高的要求,我还是不建议你安装任何第三方油猴脚本。

油猴脚本原理浅析

如果要用严谨一点的定义来说,油猴脚本其实是一种注入式的 JavaScript 程序,在网页本身的程序之外,通过一些手段,将用户需要的数据和逻辑注入到当前的网页中,达到修改界面、增加功能等等的目的。

换句话说油猴脚本也是 JavaScript。JavaScript 能实现的能力,油猴脚本基本也能做,比如操作页面元素,可以给页面中增加、删减、修改页面元素,最常见的去广告脚本就是这么实现的。

不过油猴脚本能提供一些普通 JavaScript 实现不了的能力。Greasymonkey 在最早的 0.25 版本中就带来了两个基本的功能:

  • GM_xmlhttpRequest:用于发起跨域请求

    为了安全起见,浏览器在页面加载的时候会有一个同源策略,如果页面中的 javaScript 来自另一个域名,浏览器就会认为这个不安全不让其加载运行,但有的时候用户可能会有一些别的需求。

    举个例子:比如说在京东或者当当上买书的时候,想看一下豆瓣上用户的评分,这种情况下就需要用到油猴脚本的这个能力了。在京东的页面中,我们就可以借助油猴脚本调用这个 GM_xmlhttpRequest 的 API 去访问豆瓣平台的查询接口。

  • GM_registerMenuCommand:当用户操作菜单时,触发一个行为

    很多情况下,油猴脚本不需要自动执行,而是需要使用者来手动运行,这时就需要 GM_registerMenuCommand 了,在点击之后,触发一个写好的函数,就可以完成改变页面数据,或者发起某些请求的情况。

    举个例子:我在页面中看到一个不认识的单词,想要查询一下,这时候选中这个单词,然后触发这个接口,就可以实现查询的效果(当然也有很多的别的能力可以实现划词查询)。

:bulb: 除了这两个功能之外,目前的油猴脚本,大多采用了 Greasemonkey 制定的 V4 API 规范。通过这个规范,我们就能知道用户脚本可以做什么了。

脚本管理器

绝大部分情况下我都推荐 Tampermonkey

另外,GitHub - scriptscat/scriptcat: 脚本猫,一个可以执行用户脚本的浏览器扩展 脚本猫,一个国产开源可以执行用户脚本的浏览器扩展。

万物皆可脚本化,让你的浏览器可以做更多的事情!

脚本源

当然,直接在 GitHubuserscript · GitHub Topics · GitHub / tampermonkey · GitHub Topics · GitHub / GitHub - MrLeo/Leo.UserScript / https://github.com/MrLeo/userscript)上去找脚本也是个不错的选择。

脚手架

使用 Vite 开发油猴脚本

后端集成 | Vite 官方中文文档

https://github.com/asadahimeka/vite-plugin-tm-userscript

https://github.com/lisonge/vite-plugin-monkey

油猴脚本中引用的 localhost 域下脚本,一般情况下是不能在一个正常网页下运行的,会违反浏览器与网站的安全策略(CSP)

安装浏览器扩展以绕过目标网页安全策略(出于安全考虑,开发结束务必关闭扩展!):

Disable Content-Security-Policy - Chrome 网上应用店 (google.com)

Content Security Policy Override - Chrome 网上应用店 (google.com)

开发模式

开发模式下所使用的油猴脚本:

// ==UserScript==
// name         Your Script (dev mode)
// namespace    https://your.site/
// version      0.1.0
// description  What does your script do
// author       You
// include      /https://match\.site/
// grant        GM_addElement
// noframes
// require      https://cdn.jsdelivr.net/npm/vue3.2.6/dist/vue.global.min.js
// ==/UserScript==

(function() {
    'use strict';
		// source: https://cn.vitejs.dev/guide/backend-integration.html
		// 注意端口是否正确,以及是否修改了默认入口文件
    GM_addElement('script', {
        src: 'http://localhost:3000/vite/client',
        type: 'module'
    });
    GM_addElement('script', {
        src: 'http://localhost:3000/src/main.ts',
        type: 'module'
    });
})();

编辑选项:

[
    ["https://targetsite\\.com/some/.*/url/", [
        ["script-src", "script-src http://localhost:3000"],
        ["connect-src", "connect-src ws://localhost:3000/"]
    ]]
]

修改HMR(默认情况下hmr会使用基于 window.location 的相对地址):

// vite.config.js

import ...

export default defineConfig({
	...,

	server: {
		...,

    hmr: {
      protocol: 'ws',
      host: 'localhost',
    },
  },
})

项目开发像正常开发一样即可,main.ts/js 即为脚本入口(根据vite设置修改)

根据脚本功能可能需要手动添加挂载元素:

// ./src/App.vue

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

const appRoot = document.createElement('div')
appRoot.id = 'us-appRoot'
document.body.appendChild(appRoot)

app.mount('#us-appRoot')

如须对原有页面元素进行监控操作,可考虑 MutationObserver、vue 3.0 teleport 标签 等

生产模式

修改设置文件在选项对象中添加以下内容:

// vite.config.js

import ...

export default defineConfig({
	build: {
    lib: {
      entry: path.resolve(__dirname, 'src/main.ts'),
      name: 'userscript',
      formats: ['iife'], // 自运行打包格式,与默认模版一致
      fileName: format => `yourscript.${format}.user.js`, // 非函数的常量会自动添加后缀
    },
    rollupOptions: {
      external: ['vue'], // 分离库以降低最终代码体积
      output: {
        globals: {
          vue: 'Vue',
          GM_addStyle: 'GM_addStyle', // 油猴脚本API,用于添加样式到页面
        },
        inlineDynamicImports: true, // 库构建模式下不能进行代码分割,开启此功能可将本应分割的代码整合在一起避免报错(代码分割可能由其他插件引起)
      },
    },
    minify: 'terser',
    terserOptions: {
      mangle: false, // 关闭名称混淆,遵守Greasefork规则
      format: {
        beautify: true, // 美化代码开启缩进,遵守Greasefork规则
      },
    },
  },
	...
	plugins: [
		...,
		// custom plugin
    (() => {
		 /**
			* 如果用到了额外的 GM_functions,需要添加对应 grant
			* 虽然可以全部不添加,但只有TamperMonkey会自动推断,其他扩展不一定
			* 在上面 extenral 声明的库,此处需要添加对应的 require 要注意全局变量名称
			*/
      const headers = `\
// ==UserScript==
// name         Your Script (prod mode)
// namespace    https://your.site/
// version      0.1.0
// description  What does your script do
// author       You
// include      /https://match\.site/
// grant        GM_addStyle
// noframes
// require      https://cdn.jsdelivr.net/npm/vue3.2.6/dist/vue.global.min.js
// ==/UserScript==
`

      return {
        name: 'inject-css',
        apply: 'build', // 仅在构建模式下启用
        enforce: 'post', // 在最后处理
        generateBundle(options, bundle) {
					// 从 bundle 中提取 style.css 内容,并加入到脚本中
          const keyword = 'user.js'
          if (!bundle['style.css'] || bundle['style.css'].type !== 'asset') return
          const css = bundle['style.css'].source
          const [, target] = Object.entries(bundle).find(([name]) => {
            return name.includes(keyword)
          }) ?? []
          if (!target || target.type !== 'chunk') return
          target.code = `${headers}\nGM_addStyle(\`${css}\`)\n${target.code}`
        },
      } as Plugin
    })(),
	]
})

编译后的js文件应在 /dist 目录下,像普通油猴脚本一样使用即可

若使用了 tailwindcss/windicss 等,内置的css reset影响了原本元素,可以在对应设置文件中关闭 preflight

使用 esbuild 构建油猴脚本

esbuild - An extremely fast JavaScript bundler

https://github.com/evanw/esbuild

const fs = require('fs')
const path = require('path')
const { build, analyzeMetafile } = require('esbuild')

const srcdir = path.resolve('./src')
const outdir = path.resolve('./dist')

function extractHeaders(contents) {
  return contents.match(/^.*?\/\/ ==\/UserScript==/s)?.[0]
}

async function insertHeader(filename, header) {
  const filePath = path.resolve(outdir, filename)
  const content = await fs.readFile(filePath, 'utf8')
  fs.writeFile(filePath, [header, content].join('\n\n'))
}

const plugin = {
  name: 'sf-script-plugin',
  setup(build) {
    build.headers = {}
    build.onLoad({ filter: /src[\\/][^/\\]+\.js$/ }, async (args) => {
      const contents = await fs.readFile(args.path, 'utf8')
      build.headers[path.relative(srcdir, args.path)] = extractHeaders(contents)
      return { contents }
    })
    build.onEnd((result) => {
      if (result.errors.length) {
        return
      }
      Object.entries(build.headers).forEach(([filename, header]) => insertHeader(filename, header))
    })
  },
}

const entryNames = (await fs.readdir(srcdir, { withFileTypes: true }))
  .filter((entry) => entry.isFile() && /\.js$/.test(entry.name))
  .map(({ name }) => name)

const result = await build({
  logLevel: 'info',
  outdir,
  entryPoints: entryNames.map((filename) => path.resolve(srcdir, filename)),
  bundle: true,
  metafile: true,
  watch: true,
  target: ['chrome77'],
  plugins: [plugin],
})
  .then((result) => {
    console.log('watching...')
    // setTimeout(() => {
    //   result.stop()
    //   console.log('stopped watching')
    // }, 10 * 1000)
  })
  .catch(() => process.exit(1))

const analyzeResult = await analyzeMetafile(result.metafile)
console.log(analyzeResult)

Tampermonkey API

油猴脚本由头部和核心逻辑两部分组成

// ==UserScript==
// name         New Userscript
// namespace    http://tampermonkey.net/
// version      0.1
// description  try to take over the world!
// author       You
// match        https://www.tampermonkey.net/documentation.php?ext=dhdg
// icon         https://www.google.com/s2/favicons?domain=tampermonkey.net
// grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Your code here...
})();

:bulb: 完整的说明文档:Tampermonkey documentation

头部是脚本的一些元信息、更新方式、指定运行页面、权限声明,逐一解释一下

配置名 作用 使用技巧
name 脚本的显示名称 加后缀实现国际化,例如,name:zh-CN 指定在浏览器语言为中文时显示的名称
namespace 脚本的命名空间,可以理解为脚本的标识 为了避免冲突一般使用 github 仓库地址
version 与更新相关,当前版本
updateURL 检查脚本是否更新地址 配合 version 和自动更新使用
downloadURL 检测到更新时,去哪下载脚本
supportURL 遇到问题时,用户去哪反馈
include 脚本在哪些页面运行 可使用正则,不支持 hashtag,多个页面的地址声明多个 include 即可
match 与 include 类似
exclude 脚本禁止在哪些页面运行,优先于 include
require 在脚本运行前引入外部 JavaScript 文件 例如,引入 jQuery
resource 声明外部资源文件,搭配 GM_getResourceText 使用 例如引入 html、icon
connect 声明 GM_xmlhttpRequest 可访问的域 必须指定才能正常请求
grant 声明 GM_xxx 函数的使用列表 必须先指定权限才能正常使用
run-at 指定脚本运行时机 document-start: 尽快执行; document-body: 当 body 挂载时执行;document-end: DOMContentLoaded 触发时执行; document-idle: DOMContentLoaded 触发后执行,也是默认设置项; context-menu: 右键菜单项被点击时执行
author 作者名
description 简短介绍 同样可以加后缀实现国际化
homepage 主页地址 如果未设置并且 namespace 是仓库地址,默认导向仓库地址
icon 脚本 icon
icon64 64x64像素的脚本 icon
antifeature 脚本是否有广告、挖矿、数据收集等商业行为
noframes 声明脚本不在 iframe 中运行

核心逻辑通过一个立即执行函数包裹,避免和全局作用域相互干扰。Tampermonkey 将浏览器的部分能力封装为 GM_XXX 函数以供调用。

API 作用 使用技巧
unsafeWindow 访问页面的 Window 对象
GM_addStyle(css) 创建全局样式的快捷方式,向页面插入 style 元素 也可以用 DOM 操作手动创建
GM_addElement(tag_name, attributes)
GM_addElement(parent_node, tag_name, attributes) 向 DOM 新建元素的快捷方式 也可以用 DOM 操作手动创建
GM_log(message) 在 console 中打印信息 console.log 的快捷方式
GM_setValue(name, value) 持续化存储数据
GM_getValue(name, defaultValue) 从存储体中获取数据
GM_deleteValue(name) 从存储体中删除数据
GM_listValues() 列举存储体中所有数据项
GM_addValueChangeListener 监听数据更新 例如要使 Tab 间数据同步,可以用监听 value 达成同步
GM_removeValueChangeListener 移除监听
GM_getResourceText(name) 获取 resource 中已声明的资源
GM_getResourceURL(name) 获取 resource 中已声明的资源(base64 URI 形式)
GM_registerMenuCommand(name, fn, accessKey) 在 Tampermonkey 的 popup 中增加选项
GM_unregisterMenuCommand(menuCmdId) 移除选项
GM_openInTab(url, options) 新开一个 tab 页
GM_xmlhttpRequest(details) 使用后台脚本进行请求,自动带上 cookie,无跨域问题,目标域需要在 connect 中提前声明
GM_download(details) 下载资源到本地
GM_getTab(callback) 获取当前 tab 的 object 对象
GM_saveTab(tab) 通过 tab 的 object 对象重新打开一个 tab
GM_getTabs(callback) 获取当前存活的所有 tab 的对象,以便和其他脚本实例偶同学
GM_notification 使用插件 notification API 弹出桌面通知
GM_setClipboard 复制内容到剪贴板
GM_info 获取脚本的油猴插件的信息

资料

油猴脚本开发

强大的油猴Tampermonkey脚本开发环境搭建

Content scripts - Mozilla 产品与私有技术 | MDN

Build software better, together

Build software better, together

52 个赞

cy油猴脚本学习

3 个赞

这个论坛上油猴是大行其道啊

7 个赞

牛牛牛

4 个赞

不错,写得挺详尽,感谢分享

3 个赞

造就了论坛插件和外挂满天飞的效果

3 个赞

有几把刷子

3 个赞

小白正在学习油猴脚本

3 个赞

为了水论坛 无所不用其极

3 个赞

学习学习

3 个赞

mark mark

3 个赞

上网必备

5 个赞

收藏了,备用

4 个赞

现在是已是无油猴,不开网页

3 个赞

感谢分享(之前不知道有dom快捷创建手搓了一堆

3 个赞

加入书签

3 个赞

收藏再说.

2 个赞

Tampermonkey和Tampermonkey beta两个有啥区别,我看有的脚本只能在beta版本用

2 个赞

技术贴,马克

权限更高

1 个赞