在本文中,我將解釋建立用於阻止網站的瀏覽器擴充功能的逐步過程,並描述我遇到的挑戰和我提出的解決方案。這並不是一份詳盡的指南。我並不聲稱自己是任何方面的專家。我只是想分享我建構這個專案背後的思考過程。因此,對這裡的一切都持保留態度。我不會涵蓋每一行,而是專注於專案的關鍵點、困難、有趣的案例和專案的怪癖。歡迎您自己更詳細地探索原始碼。
目錄:
就像許多人一樣,我很難專注於不同的任務,尤其是在網路成為無所不在的干擾因素的情況下。幸運的是,作為一名程式設計師,我已經培養了出色的解決問題的技能,因此我決定,我不去尋找更好的現有解決方案,而是創建自己的瀏覽器擴充功能來阻止用戶想要限制訪問的網站。
首先,讓我們概述一下要求和主要特徵。擴充必須:
首先,這是我選擇的主堆疊:
擴充功能開發與常規 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中文網其他相關文章!