空间手机版网站目录建设进wordpress根目录
web/
2025/10/6 4:32:15/
文章来源:
空间手机版网站目录建设,进wordpress根目录,天猫分销平台,安徽省住房城乡建设部网站从零实现的浏览器Web脚本
在之前我们介绍了从零实现Chrome扩展#xff0c;而实际上浏览器级别的扩展整体架构非常复杂#xff0c;尽管当前有统一规范但不同浏览器的具体实现不尽相同#xff0c;并且成为开发者并上架Chrome应用商店需要支付5$的注册费#xff0c;如果我们只…从零实现的浏览器Web脚本
在之前我们介绍了从零实现Chrome扩展而实际上浏览器级别的扩展整体架构非常复杂尽管当前有统一规范但不同浏览器的具体实现不尽相同并且成为开发者并上架Chrome应用商店需要支付5$的注册费如果我们只是希望在Web页面中进行一些轻量级的脚本编写使用浏览器扩展级别的能力会显得成本略高所以在本文我们主要探讨浏览器Web级别的轻量级脚本实现。
描述
在前边的从零实现Chrome扩展中我们使用了TS完成了整个扩展的实现并且使用Rspack作为打包工具来构建应用那么虽然我们实现轻量级脚本是完全可以直接使用JS实现的但是毕竟随着脚本的能力扩展会变得越来越难以维护所以同样的在这里我们依旧使用TS来构建脚本并且在构建工具上我们可以选择使用Rollup来打包脚本本文涉及的相关的实现可以参考个人实现的脚本集合https://github.com/WindrunnerMax/TKScript。
当然浏览器是不支持我们直接编写Web级别脚本的所以我们需要一个运行脚本的基准环境当前有很多开源的脚本管理器:
GreaseMonkey: 俗称油猴最早的用户脚本管理器为Firefox提供扩展能力采用MIT license协议。TamperMonkey: 俗称篡改猴最受欢迎的用户脚本管理器能够为当前主流浏览器提供扩展能力开源版本采用GPL-3.0 license协议。ViolentMonkey: 俗称暴力猴完全开源的用户脚本管理器同样能够为当前主流浏览器提供扩展能力采用MIT license协议。ScriptCat: 俗称脚本猫完全开源的用户脚本管理器同样能够为当前主流浏览器提供扩展能力采用 GPL-3.0 license协议。
此外还有很多脚本集合网站可以用来分享脚本例如GreasyFork。在之前我们提到过在研究浏览器扩展能力之后可以发现扩展的权限实在是太高了那么同样的脚本管理器实际上也是通过浏览器扩展来实现的选择可信的浏览器扩展也是很重要的例如在上边提到的TamperMonkey在早期的版本是开源的但是在18年之后仓库就不再继续更新了也就是说当前的TamperMonkey实际上是一个闭源的扩展虽然上架谷歌扩展是会有一定的审核但是毕竟是闭源的开源对于类似用户脚本管理器这类高级用户工具来说是一个建立信任的信号所以在选择管理器时也是需要参考的。
脚本管理器实际上依然是基于浏览器扩展来实现的通过封装浏览器扩展的能力将部分能力以API的形式暴露出来并且提供给用户脚本权限来应用这些API能力实际上这其中涉及到很多非常有意思的实现例如脚本中可以访问的window与unsafeWindow那么如何实现一个完全隔离的window沙箱环境就值的探索再比如在Web页面中是无法跨域访问资源的如何实现在Inject Script中跨域访问资源的CustomEvent通信机制也可以研究一下以及如何使用createElementNS在HTML级别实现Runtime以及Script注入、脚本代码组装后//# sourceURL的作用等等所以如果有兴趣的同学可以研究下ScriptCat这是国内的同学开发的脚本管理器注释都是中文会比较容易阅读。那么本文还是主要关注于应用我们从最基本的UserScript脚本相关能力到使用Rollup来构建脚本再通过实例来探索脚本的实现来展开本文的讨论。
UserScript
在最初GreaseMonkey油猴实现脚本管理器时是以UserScript作为脚本的MetaData也就是元数据块描述并且还以GM.开头提供了诸多高级的API使用例如可跨域的GM.xmlHttpRequest实际上相当于实现了一整套规范而后期开发的脚本管理器大都会遵循或者兼容这套规范以便复用相关的生态。其实对于开发者来说这也是个麻烦事因为我们没有办法控制用户安装的浏览器扩展而我们的脚本如果用到了某一个扩展单独实现的API那么就会导致脚本在其他扩展中无法使用特别是将脚本放在脚本平台上之后没有办法构建渠道包去分发所以平时还是尽量使用各大扩展都支持的Meta与API来开发避免不必要的麻烦。
此外在很久之前我一直好奇在GreasyFork上是如何实现用户脚本的安装的因为实际上我并没有在那个安装脚本的按钮之后发现什么特殊的事件处理以及如何检测到当前已经安装脚本管理器并且实现通信的之后简单研究了下发现实际上只要用户脚本是以.user.js结尾的文件就会自动触发脚本管理器的脚本安装功能并且能够自动记录脚本安装来源以便在打开浏览器时检查脚本更新同样的后期这些脚本管理器依然会遵循这套规范既然我们了解到了脚本的安装原理在后边实例一节中我会介绍下我个人进行脚本分发的最佳实践。那么在本节我们主要介绍常见的Meta以及API的使用一个脚本的整体概览可以参考https://github.com/WindrunnerMax/TKScript/blob/gh-pages/copy-currency.user.js。
Meta
元数据是以固定的格式存在的主要目的是便于脚本管理器能够解析相关属性比如名字和匹配的站点等每一条属性必须使用双斜杠//开头不得使用块注释/* */与此同时所有的脚本元数据必须放置于// UserScript和// /UserScript之间才会被认定为有效的元数据即必须按照以下格式填写:
// UserScript
// 属性名 属性值
// /UserScript常用的属性如下所示:
name: 脚本的名字在namespace级别的脚本的唯一标识符可以设置语言例如// name:zh-CN 文本选中复制(通用)。author: 脚本的作者例如// author Czy。license: 脚本的许可证例如// license MIT License。description: 脚本功能的描述在安装脚本时会在管理对话框中呈现给用户同样可以设置语言例如// description:zh-CN 通用版本的网站复制能力支持。namespace: 脚本的命名空间用于区分脚本的唯一标识符例如// namespace https://github.com/WindrunnerMax/TKScript。version: 脚本的版本号脚本管理器启动时通常会对比改字段决定是否下载更新例如// version 1.1.2。updateURL: 检查更新地址在检查更新时会首先访问该地址来对比version字段来决定是否更新该地址应只包含元数据而不包含脚本内容。downloadURL: 脚本更新地址(https协议)在检查updateURL后需要更新时则会请求改地址获取最新的脚本若未指定该字段则使用安装脚本地址。include: 可以使用*表示任意字符支持标准正则表达式对象脚本中可以有任意数量的include规则例如// include http://www.example.org/*.barexclude: 可以使用*表示任意字符支持标准正则表达式对象同样支持任意数量的规则且exclude的匹配权限比include要高例如// exclude /^https?://www\.example\.com/.*$/。match: 更加严格的匹配模式根据Chrome的Match Patterns规则来匹配例如// match *://*.google.com/foo*bar。icon: 脚本管理界面显示的图标几乎任何图像都可以使用但32x32像素大小是最合适的资源大小。resource: 在安装脚本时每个resource都会下载一次并与脚本一起存储在用户的硬盘上这些资源可以分别通过GM_getResourceText和GM_getResourceURL访问例如// resource name https://xxx/xxx.png。require: 脚本所依赖的其他脚本通常为可以提供全局对象的库例如引用jQuery则使用// require https://cdn.staticfile.org/jquery/3.7.1/jquery.min.js。run-at: 用于指定脚本执行的时机可用的参数只能为document-start页面加载前、document-end页面加载后资源加载前、document-idle页面与资源加载后默认值为document-end。noframes: 当存在时该命令会限制脚本的执行。该脚本将仅在顶级文档中运行而不会在嵌套框架中运行不需要任何参数默认情况下此功能处于关闭状态即允许脚本在iframe中运行。grant: 脚本所需要的权限例如unsafeWindowGM.setValueGM.xmlHttpRequest等如果没有指定grant则默认为none即不需要任何权限。
API
API是脚本管理器提供用来增强脚本功能的对象通过这些脚本我们可以实现针对于Web页面更加高级的能力例如跨域请求、修改页面布局、数据存储、通知能力、剪贴板等等甚至于在Beta版的TamperMonkey中还有着允许用户脚本读写HTTP Only的Cookie的能力。同样的使用API也有着固定的格式在使用之前必须要在Meta中声明相关的权限以便脚本将相关函数动态注入否则会导致脚本无法正常运行此外还需要注意的是相关函数的命名可能不同在使用时还需要参考相关文档。
// UserScript
// grant unsafeWindow
// /UserScriptGM.info: 获取当前脚本的元数据以及脚本管理器的相关信息。GM.setValue(name: string, value: string | number | boolean): Promisevoid: 用于写入数据并储存数据通常会存储在脚本管理器本体维护的IndexDB中。GM.getValue(name: string, default?: T): : Promisestring | number | boolean | T | undefined: 用于获取脚本之前使用GM.setValue赋值储存的数据。GM.deleteValue(name: string): Promisevoid: 用于删除之前使用GM.setValue赋值储存的数据。GM.getResourceUrl(name: string): Promisestring: 用于获取之前使用resource声明的资源地址。GM.notification(text: string, title?: string, image?: string, onclick?: () void): Promisevoid: 用于调用系统级能力的窗口通知。GM.openInTab(url: string, open_in_background?: boolean ): 用于在新选项卡中打开指定的URL。GM.registerMenuCommand(name: string, onclick: () void, accessKey?: string): void: 用于在脚本管理器的菜单中添加一个菜单项。GM.setClipboard(text: string): void: 用于将指定的文本数据写入剪贴板。GM.xmlHttpRequest(options: { method?: string, url: string, headers?: Recordstring, string, onload?: (response: { status: number; responseText: string , ... }) void , ... }): 用于与标准XMLHttpRequest对象类似的发起请求的功能但允许这些请求跨越同源策略。unsafeWindow: 用于访问页面原始的window对象在脚本中直接访问的window对象是经过脚本管理器封装过的沙箱环境。
单看这些常用的API其实并不好玩特别是其中很多能力我们也可以直接换种思路借助脚本来实现当然有一些例如unsafeWindow和GM.xmlHttpRequest我们必须要借助脚本管理器的API来完成。那么在这里我们还可以聊一下脚本管理器中非常有意思的实现方案首先是unsafeWindow这个非常特殊的API试想一下如果我们完全信任用户当前页面的window那么我们可能会直接将API挂载到window对象上听起来似乎没有什么问题但是设想这么一个场景假如用户访问了一个恶意页面然后这个网页又恰好被类似https://*/*规则匹配到了那么这个页面就可以获得访问我们的脚本管理器的相关API这相当于是浏览器扩展级别的权限例如直接获取用户磁盘中的文件内容并且可以直接将内容跨域发送到恶意服务器这样的话我们的脚本管理器就会成为一个安全隐患再比如当前页面已经被XSS攻击了攻击者便可以借助脚本管理器GM.cookie.get来获取HTTP Only的Cookie并且即使不开启CORS也可以轻松将请求发送到服务端。那么显然我们本身是准备使用脚本管理器来Hook浏览器的Web页面此时反而却被越权访问了更高级的函数这显然是不合理的所以GreaseMonkey实现了XPCNativeWrappers机制也可以理解为针对于window对象的沙箱环境。
那么我们在隔离的环境中可以得到window对象是一个隔离的安全window环境而unsafeWindow就是用户页面中的window对象。曾经我很长一段时间都认为这些插件中可以访问的window对象实际上是浏览器拓展的Content Scripts提供的window对象而unsafeWindow是用户页面中的window以至于我用了比较长的时间在探寻如何直接在浏览器拓展中的Content Scripts直接获取用户页面的window对象当然最终还是以失败告终这其中比较有意思的是一个逃逸浏览器拓展的实现因为在Content Scripts与Inject Scripts是共用DOM的所以可以通过DOM来实现逃逸当然这个方案早已失效。
var unsafeWindow;
(function() {var div document.createElement(div);div.setAttribute(onclick, return window);unsafeWindow div.onclick();
})();此外在FireFox中还提供了一个wrappedJSObject来帮助我们从Content Scripts中访问页面的的window对象但是这个特性也有可能因为不安全在未来的版本中被移除。那么为什么现在我们可以知道其实际上是同一个浏览器环境呢除了看源码之外我们也可以通过以下的代码来验证脚本在浏览器的效果可以看出我们对于window的修改实际上是会同步到unsafeWindow上证明实际上是同一个引用。
unsafeWindow.name 111111;
console.log(window unsafeWindow); // false
console.log(window); // Proxy {Symbol(Symbol.toStringTag): Window}
console.log(window.onblur); // null
unsafeWindow.onblur () 111;
console.log(unsafeWindow); // Window { ... }
console.log(unsafeWindow.name, window.name); // 111111 111111
console.log(window.onblur unsafeWindow.onblur); // true
const win new Function(return this)();
console.log(win unsafeWindow); // true实际上在grant none的情况下脚本管理器会认为当前的环境是安全的同样也不存在越权访问的问题了所以此时访问的window就是页面原本的window对象。此外如果观察仔细的话我们可以看到上边的验证代码最后两行我们突破了这些扩展的沙盒限制从而可以在未grant unsafeWindow情况下能够直接访问unsafeWindow当然这并不是什么大问题因为脚本管理器本身也是提供unsafeWindow访问的而且如果在页面未启用unsafe-eval的CSP情况下这个例子就失效了。只不过我们也可以想一下其他的方案是不是直接禁用Function函数以及eval的执行就可以了但是很明显即使我们直接禁用了Function对象的访问也同样可以通过构造函数的方式即(function(){}).constructor来访问Function对象所以针对于window沙箱环境也是需要不断进行攻防的例如小程序不允许使用Function、eval、setTimeout、setInterval来动态执行代码那么社区就开始有了手写解释器的实现对于我们这个场景来说我们甚至可以直接使用iframe创建一个about:blank的window对象作为隔离环境。
那么我们紧接着可以简单地讨论下如何实现沙箱环境隔离其实在上边的例子中也可以看到直接打印window输出的是一个Proxy对象那么在这里我们同样使用Proxy来实现简单的沙箱环境我们需要实现的是对于window对象的代理在这里我们简单一些我们希望的是所有的操作都在新的对象上不会操作原本的对象在取值的时候可以做到首先从我们新的对象取取不到再去window对象上取写值的时候只会在我们新的对象上操作在这里我们还用到了with操作符主要是为了将代码的作用域设置到一个特定的对象中在这里就是我们创建的的context在最终结果中我们可以看到我们对于window对象的读操作是正确的并且写操作都只作用在沙箱环境中。
const context Object.create(null);
const global window;
const proxy new Proxy(context, {// Proxy使用in操作符号判断是否存在属性has: () true,// 写入属性作用到context上set: (target, prop, value) {target[prop] value;return true;},// 特判特殊属性与方法 读取属性依次读context、windowget: (target, prop) {switch (prop) {// 重写特殊属性指向case globalThis:case window:case parent:case self:return proxy;default:if (prop in target) {return target[prop];}const value global[prop];// alert、setTimeout等方法作用域必须在window下if (typeof value function !value.prototype) {return value.bind(global);}return value;}},
});window.name 111;
with (proxy) {console.log(window.name); // 111window.name 222;console.log(name); // 222console.log(window.name); // 222
}
console.log(window.name); // 111
console.log(context); // { name: 222 }那么现在到目前为止我们使用Proxy实现了window对象隔离的沙箱环境总结起来我们的目标是实现一个干净的window沙箱环境也就是说我们希望网站本身执行的任何不会影响到我们的window对象比如网站本体在window上挂载了$$对象我们本身不希望其能直接在开发者的脚本中访问到这个对象我们的沙箱环境是完全隔离的而用户脚本管理器的目标则是不同的比如用户需要在window上挂载事件那么我们就应该将这个事件处理函数挂载到原本的window对象上那么我们就需要区分读或者写的属性是原本window上的还是Web页面新写入的属性显然如果想解决这个问题就要在用户脚本执行之前将原本window对象上的key记录副本相当于以白名单的形式操作沙箱。由此引出了我们要讨论的下一个问题如何在document-start即页面加载之前执行脚本。
实际上document-start是用户脚本管理器中非常重要的实现如果能够保证脚本是最先执行的那么我们几乎可以做到在语言层面上的任何事情例如修改window对象、Hook函数定义、修改原型链、阻止事件等等等等。当然其本身的能力也是源自于浏览器拓展而如何将浏览器扩展的这个能力暴露给Web页面就是需要考量的问题了。首先我们大概率会写过动态/异步加载JS脚本的实现类似于下面这种方式:
const loadScriptAsync (url: string) {return new PromiseEvent((resolve, reject) {const script document.createElement(script);script.src url;script.async true;script.onload e {script.remove();resolve(e);};script.onerror e {script.remove();reject(e);};document.body.appendChild(script);});
};那么现在就有一个明显的问题我们如果在body标签构建完成也就是大概在DOMContentLoaded时机再加载脚本肯定是达不到document-start的目标的甚至于在head标签完成之后处理也不行很多网站都会在head内编写部分JS资源在这里加载同样时机已经不合适了。那么对于整个页面来说最先加载的必定是html这个标签那么很明显我们只要将脚本在html标签级别插入就好了配合浏览器扩展中background的chrome.tabs.executeScript动态执行代码以及content.js的run_at: document_start建立消息通信确认注入的tab这个方法是不是看起来很简单但就是这么简单的问题让我思索了很久是如何做到的。此外这个方案目前在扩展V2中是可以行的在V3中移除了chrome.tabs.executeScript替换为了chrome.scripting.executeScript当前的话使用这个API可以完成框架的注入但是做不到用户脚本的注入因为无法动态执行代码。
(function () {const script document.createElementNS(http://www.w3.org/1999/xhtml, script);script.setAttribute(type, text/javascript);script.innerText console.log(111);;script.className injected-js;document.documentElement.appendChild(script);script.remove();
})();此外我们可能纳闷为什么脚本管理器框架和用户脚本都是采用这种方式注入的而在浏览器控制台的Sources控制面板下只能看到一个userscript.html?namexxxxxx.user.js却看不到脚本管理器的代码注入实际上这是因为脚本管理器会在用户脚本的最后部分注入一个类似于//# sourceURLchrome.runtime.getURL(xxx.user.js)的注释其中这个sourceURL会将注释中指定的URL作为脚本的源URL并在Sources面板中以该URL标识和显示该脚本这对于在调试和追踪代码时非常有用特别是在加载动态生成的或内联脚本时。
window[xxxxxxxxxxxxx] function (context, GM_info) {with (context)return (() {// UserScript// name TEST// description TEST// version 1.0.0// match http://*/*// match https://*/*// /UserScriptconsole.log(window);//# sourceURLchrome-extension://xxxxxx/DEBUG.user.js})();
};还记得我们最初的问题吗即使我们完成了沙箱环境的构建但是如何将这个对象传递给用户脚本我们不能将这些变量暴露给网站本身但是又需要将相关的变量传递给脚本而脚本本身就是运行在用户页面上的否则我们没有办法访问用户页面的window对象所以接下来我们就来讨论如何保证我们的高级方法安全地传递到用户脚本的问题。实际上在上边的source-map我们也可以明显地看出来我们可以直接借助闭包以及with访问变量即可并且在这里还需要注意this的问题所以在调用该函数的时候通过如下方式调用即可将当前作用域的变量作为传递给脚本执行。
script.apply(proxyContent, [ proxyContent, GM_info ]);我们都知道浏览器会有跨域的限制但是为什么我们的脚本可以通过GM.xmlHttpRequest来实现跨域接口的访问而且我们之前也提到了脚本是运行在用户页面也就是作为Inject Script执行的所以是会受到跨域访问的限制的。那么解决这个问题的方式也比较简单很明显在这里发起的通信并不是直接从页面的window发起的而是从浏览器扩展发出去的所以在这里我们就需要讨论如何做到在用户页面与浏览器扩展之间进行通信的问题。在Content Script中的DOM和事件流是与Inject Script共享的那么实际上我们就可以有两种方式实现通信首先我们常用的方法是window.addEventListener window.postMessage只不过这种方式很明显的一个问题是在Web页面中也可以收到我们的消息即使我们可以生成一些随机的token来验证消息的来源但是这个方式毕竟能够非常简单地被页面本身截获不够安全所以在这里通常是用的另一种方式即document.addEventListener document.dispatchEvent CustomEvent自定义事件的方式在这里我们需要注意的是事件名要随机通过在注入框架时于background生成唯一的随机事件名之后在Content Script与Inject Script都使用该事件名通信就可以防止用户截获方法调用时产生的消息了。
// Content Script
document.addEventListener(xxxxxxxxxxxxx content, e {console.log(From Inject Script, e.detail);
});// Inject Script
document.addEventListener(xxxxxxxxxxxxx inject, e {console.log(From Content Script, e.detail);
});// Inject Script
document.dispatchEvent(new CustomEvent(xxxxxxxxxxxxx content, {detail: { message: call api },}),
);// Content Script
document.dispatchEvent(new CustomEvent(xxxxxxxxxxxxx inject, {detail: { message: return value },}),
);脚本构建
在构建Chrome扩展的时候我们是使用Rspack来完成的这次我们换个构建工具使用Rollup来打包主要还是Rspack更适合打包整体的Web应用而Rollup更适合打包工具类库我们的Web脚本是单文件的脚本相对来说更适合使用Rollup来打包当然如果想使用Rspack来体验Rust构建工具的打包速度也是没问题的甚至也可以直接使用SWC来完成打包实际上在这里我并没有使用Babel而是使用ESBuild来构建的脚本速度也是非常不错的。
此外之前我们也提到过脚本管理器的API虽然都对GreaseMonkey兼容但实际上各个脚本管理器会出现特有的API这也是比较正常的现象毕竟是不同的脚本管理器完全实现相同的功能是意义不大的至于不同浏览器的差异还不太一样浏览器之间的API差异是需要运行时判断的。那么如果我们需要全平台支持的话就需要实现渠道包这个概念在Android开发中是非常常见的那么每个包都由开发者手写显然是不现实的使用现代化的构建工具除了方便维护之外对于渠道包的支持也更加方便利用环境变量与TreeShaking可以轻松地实现渠道包的构建再配合脚本管理器以及脚本网站的同步功能就可以实现分发不同渠道的能力。
Rollup
这一部分比较类似于各种SDK的打包假设在这里我们有多个脚本需要打包而我们的目标是将每个工程目录打包成单独的包Rollup提供了这种同时打包多个输入输出能力我们可以直接通过rollup.config.js配置一个数组通过input来指定入口文件通过output来指定输出文件通过plugins来指定插件即可我们输出的包一般需要使用iife立执行函数也就是能够自动执行的脚本适合作为script标签这样的输出格式。
[{input: ./packages/copy/src/index.ts,output: {file: ./dist/copy.user.js,format: iife,name: CopyModule,},plugins: [ /* ... */ ],},// ...
];如果需要使用updateURL来检查更新的话我们还需要单独打包一个meta文件打包meta文件与上边同理只需要提供一个空白的blank.js作为input之后将meta数据注入就可以了这里需要注意的一点是这里的format要设置成es因为我们要输出的脚本不能带有自执行函数的(function () {})();包裹。
[{input: ./meta/blank.js,output: {file: ./dist/meta/copy.meta.js,format: es,name: CopyMeta,},plugins: [{ /* ... */}],},// ...
];前边我们也提到了渠道包的问题那么如果想打包渠道包的话主要有以下几个需要注意的地方首先是在命令执行的时候我们要设置好环境变量例如在这里我设置的环境变量是process.env.CHANNEL其次在打包工具中我们需要在打包的时候将定义的整个环境变量替换掉实际上这里也是个非常有意思的事情虽然我们认为process是个变量但是在打包的时候我们是当字符串处理的利用rollup/plugin-replace将process.env.CHANNEL字符串替换成执行命令的时候设置的环境变量之后在代码中我们需要定义环境变量的使用在这里特别要注意的是要写成直接表达式而不是函数的形式因为如果写成了函数我们就无法触发TreeShakingTreeShaking是静态检测的方式我们需要在代码中明确指明这个表达式的Boolean值最后再通过环境变量来设置文件的输出最终将所有的文件打包出来即可。
// package.json scripts
// build:special: cross-env CHANNELSPECIAL rollup -c// index.ts
const isSpecialEnv process.env.CHANNEL SPECIAL;
if (isSpecialEnv) {console.log(IS IN SPECIAL ENV);
}// rollup/plugin-replace
replace({process.env.NODE_ENV: JSON.stringify(process.env.NODE_ENV),process.env.CHANNEL: JSON.stringify(process.env.CHANNEL),preventAssignment: true,
})// rollup.config.js
if(process.env.CHANNEL SPECIAL){config.output.file ./dist/copy.special.user.js;
}此外我们不能使用rollup-plugin-terser等模块去压缩打包的产物特别是要分发到GreasyFork等平台中因为本身脚本的权限也可以说是非常高的所以配合代码审查是非常有必要的。同样的也因为类似的原因类似于jQuery这种包我们是不能够直接打包到项目中的一般是需要作为external配合require外部引入的类似于GreasyFork也会采取白名单机制审查外部引入的包。大部分情况下我们需要使用document-start去前置执行代码但是在此时head标签是没有完成的所以在这里还需要特别关注下CSS注入的时机如果脚本是在document-start执行的话通常就需要自行注入CSS而不能直接使用rollup-plugin-postcss的默认注入能力。那么到这里实际上Rollup打包这部分并没有特别多需要注意的能力基本就是我们普通的前端工程化项目完整的配置可以参考https://github.com/WindrunnerMax/TKScript/blob/master/rollup.config.js。
// Plugins Config
const buildConfig {postcss: {minimize: true,extensions: [.css],},esbuild: {exclude: [/node_modules/],sourceMap: false,target: es2015,minify: false,charset: utf8,tsconfig: path.resolve(__dirname, tsconfig.json),},
};// Script Config
const scriptConfig [{name: Copy,meta: {input: ./meta/blank.js,output: ./dist/meta/copy.meta.js,metaFile: ./packages/copy/meta.json,},script: {input: ./packages/copy/src/index.ts,output: ./dist/copy.user.js,injectCss: false,},},// ...
];export default [// Meta...scriptConfig.map(item ({input: item.meta.input,output: {file: item.meta.output,format: es,name: item.name Meta,},plugins: [metablock({ file: item.meta.metaFile })],})),// Script...scriptConfig.map(item ({input: item.script.input,output: {file: item.script.output,format: iife,name: item.name Module,},plugins: [postcss({ ...buildConfig.postcss, inject: item.script.injectCss }),esbuild(buildConfig.esbuild),// terser({ format: { comments: true } }),metablock({ file: item.meta.metaFile }),],})),
];Meta
在上边虽然我们完成了主体包的构建但是似乎我们遗漏了一个大问题也就是脚本管理器脚本描述Meta的生成幸运的是在这里有Rollup的插件可以让我们直接调用当然实现类似于这种插件的能力本身并不复杂首先是需要准备一个meta.json的文件在其中使用json的形式将各种配置描述出来之后便可以通过遍历的方式生成字符串在Rollup的钩子函数中讲字符串注入到输出的文件中即可。当然这个包还做了很多事情例如对于字段格式的检查、输出内容的美化等等。
{name: {default: 文本选中复制(通用),en: Text Copy Universal,zh-CN: 文本选中复制(通用)},namespace: https://github.com/WindrunnerMax/TKScript,version: 1.1.2,description: {default: 文本选中复制通用版本适用于大多数网站,en: Text copy general version, suitable for most websites.,zh-CN: 文本选中复制通用版本适用于大多数网站},author: Czy,match: [http://*/*,https://*/*],supportURL: https://github.com/WindrunnerMax/TKScript/issues,license: GPL License,installURL: https://github.com/WindrunnerMax/TKScript,run-at: document-end,grant: [GM_registerMenuCommand,GM_unregisterMenuCommand,GM_notification]
}实例
那么在这部分我们实现用户脚本的实例虽然我们平时可能Ctrl CV代码比较多但是Ctrl CV也不是仅仅用来搞代码的平时抄作业抄报告也是很需要用到的尤其是当时我还是学生党的时候要是不能复制粘贴纯自己写报告那简直要了命。那么问题来了总有一些网站不想让我们愉快地进行复制粘贴所以在这里我们来实现解除浏览器复制限制的通用方案具体代码可以参考https://github.com/WindrunnerMax/TKScript文本选中复制-通用这部分。
CSS
某些网站会会通过CSS来禁用复制粘贴具体表现为文字无法直接选中特别是很多文库类的网站例如随便在百度上搜索一下实习报告那么很多搜出来的网站都是无法复制的当然我们可以直接使用F12看到这部分文本但是当他是这种嵌套层次很深并且分开展示的数据使用F12复制起来还是比较麻烦的当然可以直接使用$0.innerText来获取文本但是毕竟过于麻烦不如让我们来看看CSS是如何禁用的文本选中能力。
那么平时如果我们写过一些文本类操作的能力比如富文本Void块元素的时候很容易就可以了解到use-select这个CSS属性user-select属性用于控制用户是否可以选择文本这不会对作为浏览器用户界面的一部分的内容加载产生任何影响除非是在文本框中。
user-select: none; /* 元素及其子元素的文本不可选中 */
user-select: auto; /* 具体取值取决于一系列条件 */
user-select: text; /* 元素及其子元素的文本内容可选中 */
user-select: contain; /* 元素的子元素的文本可选中 但元素本身的文本不可选中 */
user-select: all; /* 元素及其子元素的文本内容可选中 */那么我们在这些网站中检索一下就可以很明显的看到user-select: none;那么如果想解除这个限制我们可以很轻松地想到CSS的优先级利用优先级来强行覆盖所有属性的值即可这也是比较通用的实现方案可以轻松适配绝大多数利用这种方式禁止复制的页面。
const style document.createElement(style);
style.type text/css;
style.innerText *{user-select: auto !important; -webkit-user-select: auto !important;};
document.head.appendChild(style);Event
在大部分时候网站都不仅仅是使用CSS来禁止用户复制行为的特别是使用Canvas绘制的内容当然这种方式不在本文讨论的范围在这里我们要讨论利用事件来限制用户复制的方式那么能够影响到用户复制行为的事件主要有onCopy、onSelectStart事件。onCopy事件很明显我们在触发复制例如使用Ctrl C或者右键复制的时候就会触发在这里我们只要将其截获就可以做到阻止复制了同样的onSelectStart事件也是只要阻止其默认行为就可以阻止用户的文本选中自然也就无法复制了。在这里为了简单直接使用DOM0事件如果在控制输入这段代码就可以发现无法正常复制了。
document.oncopy event event.preventDefault();
document.onselectstart event event.preventDefault();在研究如何处理这些事件的行为之前我们先来看一下getEventListeners方法Chrome浏览器提供的getEventListeners方法来获取所有的事件监听但是这毕竟是在控制台中才能使用的函数不具有通用性只是方便我们调试用。
console.log(getEventListeners(document));
// {
// click: Array(4),
// DOMContentLoaded: Array(3),
// // ...
// }那么既然不具有通用性我们为什么要聊这个方法呢这其中涉及一个问题对于这些事件监听如果我们想解除这些事件处理函数对于DOM0级的事件而言我们只需要将属性设置为null即可但是对于DOM2级的事件而言我们需要使用removeEventListener来移除事件处理函数那么问题来了使用removeEventListener函数我们必须要获取当时addEventListener时的函数引用但是我们并没有保存这个引用那么我们如何获取这个引用呢这就是我们讨论的getEventListeners方法的作用了我们可以通过这个方法获取到所有的事件监听之后再通过removeEventListener来移除事件处理函数即可当然在这里我们只能进行事件判定的调试用并不能实现一个通用的方案。
const listeners getEventListeners(document);
Object.keys(listeners).forEach(key {console.log(key);listeners[key].forEach(item {document.removeEventListener(item.type, item.listener);});
});那么我们是不是可以换个思路非得移除事件监听是比较钻牛角尖了俗话说得好最高端的食物往往只需要最简单的烹饪方式既然移除不了我们就不让他执行就完事了既然不想让他执行那就很自然的联想到了JS的事件流模型那就给他阻止冒泡呗。
document.body.addEventListener(copy, e e.stopPropagation());
document.body.addEventListener(selectstart, e e.stopPropagation());看似这个方式是没有问题的那么假如此时Web页面本身监听的事件是在body上的话那么很明显在document上去阻止冒泡就已经太晚了并不能达到效果所以这就很尴尬那说明这个方案不够通用。那既然冒泡不行我们直接在捕获阶段给他干掉就ok了并且配合上脚本管理器的document-start来保证我们的事件捕获是最先执行的这样不光能够解决这类DOM0事件的问题对于DOM2级的事件也同样有效果。
document.body.addEventListener(copy, e e.stopPropagation(), true);
document.body.addEventListener(selectstart, e e.stopPropagation(), true);这个方案已经是一个比较通用的复制方案了我们可以解决大多数网站的限制但通过直接在捕获阶段拦截事件也是可能有一定的副作用的例如我们在捕获阶段就阻止了键盘的事件然后在编辑语雀的文档的时候就会出现问题因为语雀的文档也跟飞书类似都是按行处理文本然后猜测他是阻止了contenteditable的默认行为然后编辑器完全接管了键盘的事件所以会导致其无法换行和处理快捷启动菜单。同理如果直接阻止了onCopy的冒泡就可能导致编辑器复制采用了默认行为而通常编辑器会对于复制文本的格式进行一些处理所以在有编辑功能的时候还是要慎重完全作为展览端倒是就问题不大了整体来说是收益更大。
前一段时间我发现了另一种非常有意思的事情onFocus、onBlur事件也可以做到限制用户文本选中随便找个页面然后将下边的代码在控制台执行我们可以惊奇地发现我们无法正常选中文本了。
const button document.createElement(button);
button.onblur () button.focus();
button.textContent BUTTON;
document.body.appendChild(button);
button.focus();那么实际上这里的原理也很简单通常在HTMLInputElement、HTMLSelectElement、HTMLTextAreaElement、HTMLAnchorElement、HTMLButtonElement等元素会有焦点这个概念而文本的选中也有焦点这个行为那么既然焦点不能够同时聚焦在一起我们就直接强行将焦点聚焦在其他的地方比如上边的例子就是将焦点强行聚焦在了按钮上这样因为文本内容无法获取焦点就无法正常选中了。
那么我们同样可以使用捕获阶段阻止事件执行的方式解决这个问题分别将onFocus、onBlur事件处理掉即可只不过这种方式可能会导致页面的焦点控制出现一些问题所以在这里我们还有另一种方式通过在document-start执行MutationObserver在发现类似的DOM节点的时候直接将其移出让其无法插入到DOM树中自然也就不会有相关问题了只不过这就不是一个通用的解决方案通常需要case by case地处理才可以。
const handler mutationsList {for (const mutation of mutationsList) {const addedNodes mutation.addedNodes;for (let i 0; i addedNodes.length; i) {const target addedNodes[i];if (target.nodeType ! 1) return void 0;if (target instanceof HTMLButtonElement target.textContent BUTTON) {target.remove();}}}
};
const observer new MutationObserver(handler);
observer.observe(document, { childList: true, subtree: true });脚本分发
那么基于上述方式我们完成了脚本的编写与打包在这里也分享一个脚本分发的最佳实践GreasyFork等脚本网站通常会有源代码同步的能力我们可以直接填入一个脚本链接就可以自动同步脚本更新就不需要我们到处填写了那么这里还有一个问题这个脚本链接应该从哪里来呢那么同样我们可以借助GitHub的 GitPages来生成脚本链接并且GitHub还有GitAction可以帮助我们自动构建脚本。
那么整个流程就是这样的我们首先在GitHub配置好GitAction当我们推送代码的时候就可以触发自动构建流程在构建完成后我们可以将代码自动地推送到GitPages之后我们就可以手动获取GitPages的脚本链接并且填入到各个脚本网站了并且如果打了渠道包也可以分别分发不同的脚本链接这样就完成了整个流程的自动化并且借助GitHub还可以将jsDelivr作为CDN使用下面就是完整的GitAction的配置。
name: publish gh-pageson:push:branches:- masterjobs:build-and-deploy:runs-on: ubuntu-lateststeps:- name: checkoutuses: actions/checkoutv2with:persist-credentials: false- name: install and buildrun: |npm install -g pnpm6.24.3pnpm installpnpm run build- name: deployuses: JamesIves/github-pages-deploy-actionreleases/v3with:GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}BRANCH: gh-pagesFOLDER: dist每日一题
https://github.com/WindrunnerMax/EveryDay参考
https://wiki.greasespot.net/Security
https://docs.scriptcat.org/docs/dev/api/
https://en.wikipedia.org/wiki/Greasemonkey
https://wiki.greasespot.net/Metadata_Block
https://juejin.cn/post/6844903977759293448
https://www.tampermonkey.net/documentation.php
https://wiki.greasespot.net/Greasemonkey_Manual:API
https://learn.scriptcat.org/docs/%E7%AE%80%E4%BB%8B/
http://jixunmoe.github.io/gmDevBook/#/doc/intro/gmScript
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/87740.shtml
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!