在本文中,我将解释构建用于阻止网站的浏览器扩展的分步过程,并描述我遇到的挑战和我提出的解决方案。这并不是一份详尽的指南。我并不声称自己是任何方面的专家。我只是想分享我构建这个项目背后的思考过程。因此,对这里的一切都持保留态度。我不会涵盖每一行,而是重点关注项目的关键点、困难、有趣的案例和项目的怪癖。欢迎您自己更详细地探索源代码。
目录:
就像很多人一样,我很难专注于不同的任务,尤其是在互联网成为无处不在的干扰因素的情况下。幸运的是,作为一名程序员,我已经培养了出色的解决问题的技能,因此我决定,我不去寻找更好的现有解决方案,而是创建自己的浏览器扩展程序来阻止用户想要限制访问的网站。
首先,让我们概述一下要求和主要特征。扩展必须:
首先,这是我选择的主堆栈:
扩展开发与常规 Web 开发的主要区别在于,扩展依赖于处理大多数事件、内容脚本以及它们之间的消息传递的服务工作者。
为了支持跨浏览器功能,我创建了两个清单文件:
manifest.chrome.json:
{ "manifest_version": 3, "action": { "default_title": "Click to show the form" }, "incognito": "split", "permissions": [ "activeTab", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs" ], "host_permissions": ["*://*/"], // get access to all URLs "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"] }], "web_accessible_resources": [ { "resources": ["blocked.html", "options.html", "about.html", "icons/*.svg"], "matches": ["<all_urls>"] } ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, }
manifest.firefox.json:
{ "manifest_version": 2, "browser_action": { "default_title": "Click to show the form" }, "permissions": [ "activeTab", "declarativeNetRequest", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs", "*://*/" ], "background": { "scripts": [ "background.js" ], "persistent": false }, "content_scripts": [{ "matches": ["<all_urls>"], "js": [ "options.js", "blocked.js", "about.js" ] }], "web_accessible_resources": [ "blocked.html", "options.html", "icons/*.svg" ], "content_security_policy": "script-src 'self'; object-src 'self'", }
这里一件有趣的事情是 Chrome 需要“incognito”:“split”,该属性指定为在隐身模式下正常工作,而 Firefox 没有它也可以正常工作。
这是扩展的基本文件结构:
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
现在让我们来谈谈扩展应该如何工作。用户应该能够触发某种表单来提交他想要阻止的 URL。当他访问 URL 时,扩展程序将拦截该请求并检查是否应该阻止或允许该请求。它还需要某种选项页面,用户可以在其中查看所有被阻止 URL 的列表,并能够从列表中添加、编辑、禁用或删除 URL。
当用户单击扩展图标或键入键盘快捷键时,通过将 HTML 和 CSS 注入当前页面来显示表单。显示表单的方式有多种,例如调用弹出窗口,但根据我的口味,它的自定义选项有限。后台脚本如下所示:
背景.ts:
import browser, { DeclarativeNetRequest } from 'webextension-polyfill'; // on icon click const action = chrome.action ?? browser.browserAction; // Manifest v2 only has browserAction method action.onClicked.addListener(tab => { triggerPopup(tab as browser.Tabs.Tab); }); // on shortcut key press browser.commands.onCommand.addListener(command => { if (command === 'trigger_form') { browser.tabs.query({ active: true, currentWindow: true }) .then((tabs) => { const tab = tabs[0]; if (tab) { triggerPopup(tab); } }) .catch(error => console.error(error)); } }); function triggerPopup(tab: browser.Tabs.Tab) { if (tab.id) { const tabId = tab.id; browser.scripting.insertCSS(({ target: { tabId }, files: ['global.css', './popup.css'], })) .then(() => { browser.scripting.executeScript ? browser.scripting.executeScript({ target: { tabId }, files: ['./content.js'], // refer to the compiled JS files, not the original TS ones }) : browser.tabs.executeScript({ file: './content.js', }); }) .catch(error => console.error(error)); } }
ℹ 将 HTML 注入每个页面可能会导致不可预测的结果,因为很难预测不同样式的网页将如何影响表单。更好的替代方案似乎是使用 Shadow DOM,因为它创建了自己的样式范围。这绝对是我未来想要努力的潜在改进。
我使用 webextension-polyfill 来实现浏览器兼容性。通过使用它,我不需要为不同版本的清单编写单独的扩展。您可以在此处阅读有关其功能的更多信息。为了使其正常工作,我在清单文件中的其他脚本之前包含了 browser-polyfill.js 文件。
manifest.chrome.json:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
manifest.firefox.json:
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
注入表单的过程是一种简单的 DOM 操作,但请注意,每个元素必须单独创建,而不是将一个模板文字应用于元素。虽然更加冗长乏味,但此方法避免了我们在浏览器中尝试运行编译后的代码时收到的不安全 HTML 注入警告。
content.ts:
import browser from 'webextension-polyfill'; import { maxUrlLength, minUrlLength } from "./globals"; import { GetCurrentUrl, ResToSend } from "./types"; import { handleFormSubmission } from './helpers'; async function showPopup() { const body = document.body; const formExists = document.getElementById('extension-popup-form'); if (!formExists) { const msg: GetCurrentUrl = { action: 'getCurrentUrl' }; try { const res: ResToSend = await browser.runtime.sendMessage(msg); if (res.success && res.url) { const currUrl: string = res.url; const popupForm = document.createElement('form'); popupForm.classList.add('extension-popup-form'); popupForm.id = 'extension-popup-form'; /* Create every child element the same way as above */ body.appendChild(popupForm); popupForm.addEventListener('submit', (e) => { e.preventDefault(); handleFormSubmission(popupForm, handleSuccessfulSubmission); // we'll discuss form submission later }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (popupForm) { body.removeChild(popupForm); } } }); } } catch (error) { console.error(error); alert('Something went wrong. Please try again.'); } } } function handleSuccessfulSubmission() { hidePopup(); setTimeout(() => { window.location.reload(); }, 100); // need to wait a little bit in order to see the changes } function hidePopup() { const popup = document.getElementById('extension-popup-form'); popup && document.body.removeChild(popup); }
现在是时候确保表单显示在浏览器中了。为了执行所需的编译步骤,我像这样配置了 Webpack:
webpack.config.ts:
{ "manifest_version": 3, "action": { "default_title": "Click to show the form" }, "incognito": "split", "permissions": [ "activeTab", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs" ], "host_permissions": ["*://*/"], // get access to all URLs "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"] }], "web_accessible_resources": [ { "resources": ["blocked.html", "options.html", "about.html", "icons/*.svg"], "matches": ["<all_urls>"] } ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, }
基本上,它从我运行的命令的环境变量中获取浏览器名称,以在 2 个清单文件之间进行选择,并将 TypeScript 代码编译到 dist/ 目录中。
ℹ 我本来打算为扩展编写适当的测试,但我发现 Puppeteer 不支持内容脚本测试,因此无法测试大多数功能。如果您知道内容脚本测试的任何解决方法,我很乐意在评论中听到它们。
package.json 中我的构建命令是:
{ "manifest_version": 2, "browser_action": { "default_title": "Click to show the form" }, "permissions": [ "activeTab", "declarativeNetRequest", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs", "*://*/" ], "background": { "scripts": [ "background.js" ], "persistent": false }, "content_scripts": [{ "matches": ["<all_urls>"], "js": [ "options.js", "blocked.js", "about.js" ] }], "web_accessible_resources": [ "blocked.html", "options.html", "icons/*.svg" ], "content_security_policy": "script-src 'self'; object-src 'self'", }
所以,例如,每当我跑步时
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Chrome 的文件被编译到 dist/ 目录中。通过单击操作图标或按快捷键在任何网站上触发表单后,表单如下所示:
现在主表单已准备就绪,接下来的任务是提交它。为了实现阻止功能,我利用了 declarativeNetRequest API 和动态规则。这些规则将存储在扩展程序的存储中。操作动态规则只能在服务工作线程文件中进行,因此为了在服务工作线程和内容脚本之间交换数据,我将在它们之间发送带有必要数据的消息。由于此扩展需要相当多类型的操作,因此我为每个操作创建了类型。这是一个示例操作类型:
types.ts:
import browser, { DeclarativeNetRequest } from 'webextension-polyfill'; // on icon click const action = chrome.action ?? browser.browserAction; // Manifest v2 only has browserAction method action.onClicked.addListener(tab => { triggerPopup(tab as browser.Tabs.Tab); }); // on shortcut key press browser.commands.onCommand.addListener(command => { if (command === 'trigger_form') { browser.tabs.query({ active: true, currentWindow: true }) .then((tabs) => { const tab = tabs[0]; if (tab) { triggerPopup(tab); } }) .catch(error => console.error(error)); } }); function triggerPopup(tab: browser.Tabs.Tab) { if (tab.id) { const tabId = tab.id; browser.scripting.insertCSS(({ target: { tabId }, files: ['global.css', './popup.css'], })) .then(() => { browser.scripting.executeScript ? browser.scripting.executeScript({ target: { tabId }, files: ['./content.js'], // refer to the compiled JS files, not the original TS ones }) : browser.tabs.executeScript({ file: './content.js', }); }) .catch(error => console.error(error)); } }
由于能够从主表单和选项页面添加新 URL 是合理的,因此提交是由新文件中的可重用函数执行的:
helpers.ts:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
我在 content.ts 中调用 handleFormSubmission() 来验证提供的 URL,然后将其发送到 Service Worker 以将其添加到黑名单中。
ℹ 动态规则设置了需要考虑的最大大小。尝试为其保存动态规则时,传递太长的 URL 字符串将导致意外行为。我发现就我而言,75 个字符长的 URL 是规则的最佳最大长度。
以下是 Service Worker 将如何处理收到的消息:
背景.ts:
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
为了提交,我创建了一个新的规则对象并更新动态规则以包含它。一个简单的条件正则表达式允许我选择阻止整个域或仅阻止指定的 URL。
完成后,我将响应消息发送回内容脚本。这段代码中最有趣的是 nanoid 的使用。通过反复试验,我发现动态规则的数量是有限制的 - 较旧的浏览器为 5000 个,较新的浏览器为 30000 个。当我尝试将 ID 分配给大于 5000 的规则时,我发现了一个错误。我无法为我的 ID 创建低于 4999 的限制,因此我必须将我的 ID 限制为 3 位数字( 0-999,即总共 1000 个唯一 ID)。这意味着我将扩展程序的规则总数从 5000 条减少到 1000 条,一方面这非常重要,但另一方面 - 用户拥有这么多 URL 进行阻止的可能性非常低,所以我决定接受这个不太优雅的解决方案。
现在,用户可以将新的 URL 添加到黑名单中,并选择他想要分配给它们的阻止类型。如果他尝试访问被阻止的资源,他将被重定向到阻止页面:
但是,有一个边缘情况需要解决。如果用户直接访问该扩展程序,该扩展程序将阻止任何不需要的 URL。但如果该网站是具有客户端重定向的 SPA,则扩展程序将无法捕获那里的禁止 URL。为了处理这种情况,我更新了 background.ts 以侦听当前选项卡并查看 URL 是否已更改。发生这种情况时,我会手动检查该 URL 是否在黑名单中,如果是,我会重定向用户。
背景.ts:
{ "manifest_version": 3, "action": { "default_title": "Click to show the form" }, "incognito": "split", "permissions": [ "activeTab", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs" ], "host_permissions": ["*://*/"], // get access to all URLs "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"] }], "web_accessible_resources": [ { "resources": ["blocked.html", "options.html", "about.html", "icons/*.svg"], "matches": ["<all_urls>"] } ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, }
getRules() 是一个利用 declarativeNetRequest.getDynamicRules() 方法来检索所有动态规则列表的函数,我将其转换为更易读的格式。
现在,扩展程序可以正确阻止直接访问和通过 SPA 访问的 URL。
选项页面界面简洁,如下图:
这是具有主要功能的页面,例如编辑、删除、禁用和应用严格模式。这是我的接线方式。
编辑可能是最复杂的任务。用户可以通过修改字符串或更改其阻止类型(阻止整个域或仅阻止特定域)来编辑 URL。编辑时,我将编辑的 URL 的 ID 收集到一个数组中。保存后,我创建更新的动态规则,并将其传递给服务工作人员以应用更改。每次保存更改或重新加载后,我都会重新获取动态规则并将其呈现在表中。下面是它的简化版本:
options.ts:
{ "manifest_version": 3, "action": { "default_title": "Click to show the form" }, "incognito": "split", "permissions": [ "activeTab", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs" ], "host_permissions": ["*://*/"], // get access to all URLs "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"] }], "web_accessible_resources": [ { "resources": ["blocked.html", "options.html", "about.html", "icons/*.svg"], "matches": ["<all_urls>"] } ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, }
我决定是否阻止或允许特定规则的方法只是有条件地检查其 isActive 属性。更新规则和检索规则 - 这是添加到我的后台侦听器中的另外 2 个操作:
背景.ts:
{ "manifest_version": 2, "browser_action": { "default_title": "Click to show the form" }, "permissions": [ "activeTab", "declarativeNetRequest", "declarativeNetRequestWithHostAccess", "scripting", "storage", "tabs", "*://*/" ], "background": { "scripts": [ "background.js" ], "persistent": false }, "content_scripts": [{ "matches": ["<all_urls>"], "js": [ "options.js", "blocked.js", "about.js" ] }], "web_accessible_resources": [ "blocked.html", "options.html", "icons/*.svg" ], "content_security_policy": "script-src 'self'; object-src 'self'", }
更新功能要正确执行有点棘手,因为当编辑的 URL 成为现有规则的重复时,会出现边缘情况。除此之外,都是同样的内容 - 更新动态规则并在完成后发送适当的消息。
删除 URL 可能是最简单的任务。此扩展中有两种删除类型:删除特定规则和删除所有规则。
选项.ts:
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
并且,就像以前一样,我向 Service Worker 监听器添加了另外 2 个操作:
背景.ts:
import browser, { DeclarativeNetRequest } from 'webextension-polyfill'; // on icon click const action = chrome.action ?? browser.browserAction; // Manifest v2 only has browserAction method action.onClicked.addListener(tab => { triggerPopup(tab as browser.Tabs.Tab); }); // on shortcut key press browser.commands.onCommand.addListener(command => { if (command === 'trigger_form') { browser.tabs.query({ active: true, currentWindow: true }) .then((tabs) => { const tab = tabs[0]; if (tab) { triggerPopup(tab); } }) .catch(error => console.error(error)); } }); function triggerPopup(tab: browser.Tabs.Tab) { if (tab.id) { const tabId = tab.id; browser.scripting.insertCSS(({ target: { tabId }, files: ['global.css', './popup.css'], })) .then(() => { browser.scripting.executeScript ? browser.scripting.executeScript({ target: { tabId }, files: ['./content.js'], // refer to the compiled JS files, not the original TS ones }) : browser.tabs.executeScript({ file: './content.js', }); }) .catch(error => console.error(error)); } }
该扩展程序的主要功能可能是能够为需要更严格控制其浏览习惯的人自动强制执行禁用(允许访问)规则阻止。这个想法是,当严格模式关闭时,用户禁用的任何 URL 将保持禁用状态,直到用户更改它。启用严格模式后,任何禁用的规则将在 1 小时后自动重新启用。为了实现这样的功能,我使用扩展的本地存储来存储代表每个禁用规则的对象数组。每个对象都包含规则 ID、解除阻止日期和 URL 本身。每当用户访问新资源或刷新黑名单时,扩展程序都会首先检查存储中是否有过期规则并进行相应更新。
选项.ts:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
isStrictModeOn 布尔值也存储在存储中。如果这是真的,我会循环所有规则,并将那些被禁用的规则添加到存储中,并为它们创建新的解锁时间。然后,对于每个响应,我都会检查存储中是否有任何禁用的规则,删除过期的规则(如果存在),然后更新它们:
背景.ts:
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
完成后,网站拦截扩展就完成了。用户可以添加、编辑、删除和禁用他们想要的任何 URL,应用部分或整个域阻止,并使用严格模式来帮助他们在浏览中保持更多纪律。
这是我的网站阻止扩展的基本概述。这是我的第一个扩展,这是一次有趣的经历,特别是考虑到 Web 开发的世界有时会变得平凡。肯定有改进的空间和新功能。黑名单中 URL 的搜索栏、添加适当的测试、严格模式的自定义持续时间、一次提交多个 URL - 这些只是我脑海中的一些事情,我想有一天添加到这个项目中。我最初也计划使扩展程序跨平台,但无法使其在我的手机上运行。
如果您喜欢阅读本演练、学到了新东西或有任何其他反馈,我们非常感谢您的评论。感谢您的阅读。
源代码
现场版
以上是构建网站拦截跨浏览器扩展的详细内容。更多信息请关注PHP中文网其他相关文章!