In diesem Artikel erkläre ich Schritt für Schritt meinen Prozess zum Erstellen einer Browser-Erweiterung zum Blockieren von Websites und beschreibe die Herausforderungen, denen ich begegnet bin, und die Lösungen, die ich gefunden habe. Dies ist kein erschöpfender Leitfaden. Ich behaupte nicht, in irgendetwas ein Experte zu sein. Ich möchte nur meinen Denkprozess hinter der Erstellung dieses Projekts teilen. Nehmen Sie also alles hier mit Vorsicht. Ich werde nicht jede Zeile abdecken, sondern mich stattdessen auf die Kernpunkte des Projekts, Schwierigkeiten, interessante Fälle und Eigenheiten des Projekts konzentrieren. Sie können den Quellcode gerne selbst genauer erkunden.
Inhaltsverzeichnis:
Wie vielen Menschen fällt es mir schwer, mich auf verschiedene Aufgaben zu konzentrieren, insbesondere da das Internet der allgegenwärtige Ablenkungsfaktor ist. Glücklicherweise habe ich als Programmierer große Fähigkeiten zur Problemerstellung entwickelt und beschloss daher, statt nach einer besseren bestehenden Lösung zu suchen, eine eigene Browsererweiterung zu erstellen, die die Websites blockiert, auf die Benutzer den Zugriff beschränken möchten.
Lassen Sie uns zunächst die Anforderungen und Hauptfunktionen skizzieren. Die Erweiterung muss:
Hier ist zunächst der Hauptstapel, den ich ausgewählt habe:
Der Hauptunterschied zwischen der Erweiterungsentwicklung und der regulären Webentwicklung besteht darin, dass Erweiterungen auf Servicemitarbeitern basieren, die die meisten Ereignisse, Inhaltsskripte und Nachrichten zwischen ihnen verwalten.
Um die browserübergreifende Funktionalität zu unterstützen, habe ich zwei Manifestdateien erstellt:
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'", }
Eine interessante Sache hierbei ist, dass Chrome die Eigenschaft „incognito“: „split“ benötigte, damit sie ordnungsgemäß im Inkognito-Modus funktioniert, während Firefox ohne diese Eigenschaft einwandfrei funktionierte.
Hier ist die grundlegende Dateistruktur der Erweiterung:
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Lassen Sie uns nun darüber sprechen, wie die Erweiterung funktionieren soll. Der Benutzer sollte in der Lage sein, eine Art Formular auszulösen, um die URL einzureichen, die er blockieren möchte. Wenn er auf eine URL zugreift, fängt die Erweiterung die Anfrage ab und prüft, ob sie blockiert oder zugelassen werden soll. Es benötigt außerdem eine Art Optionsseite, auf der ein Benutzer die Liste aller blockierten URLs sehen und eine URL zur Liste hinzufügen, bearbeiten, deaktivieren oder löschen kann.
Das Formular wird angezeigt, indem HTML und CSS in die aktuelle Seite eingefügt werden, wenn der Benutzer auf das Erweiterungssymbol klickt oder die Tastenkombination eingibt. Es gibt verschiedene Möglichkeiten, ein Formular anzuzeigen, z. B. das Aufrufen eines Popups, aber die Anpassungsmöglichkeiten sind für meinen Geschmack begrenzt. Das Hintergrundskript sieht so aus:
background.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)); } }
ℹ Das Einfügen von HTML in jede Seite kann zu unvorhersehbaren Ergebnissen führen, da es schwer vorherzusagen ist, wie sich unterschiedliche Stile von Webseiten auf das Formular auswirken werden. Eine bessere Alternative scheint die Verwendung von Shadow DOM zu sein, da es einen eigenen Spielraum für Stile schafft. Auf jeden Fall eine potenzielle Verbesserung, an der ich in Zukunft gerne arbeiten würde.
Aus Gründen der Browserkompatibilität habe ich webextension-polyfill verwendet. Dadurch musste ich keine separaten Erweiterungen für verschiedene Manifestversionen schreiben. Mehr darüber, was es bewirkt, können Sie hier lesen. Damit es funktioniert, habe ich die Datei browser-polyfill.js vor anderen Skripten in die Manifestdateien eingefügt.
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 ] }], }
Der Prozess des Einfügens des Formulars ist eine einfache DOM-Manipulation. Beachten Sie jedoch, dass jedes Element einzeln erstellt werden muss, anstatt ein Vorlagenliteral auf ein Element anzuwenden. Obwohl ausführlicher und langwieriger, vermeidet diese Methode Warnungen wegen unsicherer HTML-Injektion, die wir andernfalls erhalten würden, wenn wir versuchen, den kompilierten Code im Browser auszuführen.
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); }
Jetzt ist es an der Zeit, sicherzustellen, dass das Formular im Browser angezeigt wird. Um den erforderlichen Kompilierungsschritt durchzuführen, habe ich Webpack wie folgt konfiguriert:
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'" }, }
Im Grunde nimmt es den Browsernamen aus der Umgebungsvariablen der Befehle, die ich ausführe, um zwischen zwei der Manifestdateien auszuwählen, und kompiliert den TypeScript-Code in das Verzeichnis dist/.
ℹ Ich wollte eigentlich richtige Tests für die Erweiterung schreiben, habe aber festgestellt, dass Puppeteer das Testen von Inhaltsskripten nicht unterstützt, sodass es unmöglich ist, die meisten Funktionen zu testen. Wenn Sie Problemumgehungen für das Testen von Inhaltsskripten kennen, würde ich sie gerne in den Kommentaren hören.
Meine Build-Befehle in package.json sind:
{ "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'", }
Also zum Beispiel, wenn ich laufe
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Die Dateien für Chrome werden im Verzeichnis dist/ kompiliert. Nach dem Auslösen eines Formulars auf einer beliebigen Website durch Klicken auf das Aktionssymbol oder Drücken der Verknüpfung sieht das Formular folgendermaßen aus:
Da das Hauptformular nun fertig ist, besteht die nächste Aufgabe darin, es einzureichen. Um die Blockierungsfunktion zu implementieren, habe ich die declarativeNetRequest-API und dynamische Regeln genutzt. Die Regeln werden im Speicher der Erweiterung gespeichert. Das Bearbeiten dynamischer Regeln ist nur in der Service-Worker-Datei möglich. Um Daten zwischen dem Service-Worker und den Inhaltsskripten auszutauschen, sende ich zwischen ihnen Nachrichten mit den erforderlichen Daten. Da für diese Erweiterung eine ganze Reihe von Operationstypen erforderlich sind, habe ich für jede Aktion Typen erstellt. Hier ist ein Beispiel für einen Operationstyp:
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)); } }
Da es sinnvoll ist, neue URLs sowohl vom Hauptformular als auch von der Optionsseite aus hinzufügen zu können, wurde die Übermittlung durch eine wiederverwendbare Funktion in einer neuen Datei ausgeführt:
helpers.ts:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
Ich rufe handleFormSubmission() in content.ts auf, das die bereitgestellte URL validiert und sie dann an den Servicemitarbeiter sendet, um sie zur Blacklist hinzuzufügen.
ℹ Dynamische Regeln haben eine maximale Größe festgelegt, die berücksichtigt werden muss. Die Übergabe einer zu langen URL-Zeichenfolge führt zu unerwartetem Verhalten beim Versuch, die dynamische Regel dafür zu speichern. Ich habe herausgefunden, dass in meinem Fall eine 75 Zeichen lange URL eine gute maximale Länge für eine Regel war.
So wird der Servicemitarbeiter die empfangene Nachricht verarbeiten:
background.ts:
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
Für die Übermittlung erstelle ich ein neues Regelobjekt und aktualisiere die dynamischen Regeln, um es einzuschließen. Mit einem einfachen bedingten regulären Ausdruck kann ich zwischen dem Blockieren der gesamten Domain oder nur der angegebenen URL wählen.
Nach Abschluss sende ich die Antwortnachricht an das Inhaltsskript zurück. Das Interessanteste an diesem Snippet ist die Verwendung von Nanoid. Durch Versuch und Irrtum habe ich herausgefunden, dass es eine Grenze für die Anzahl dynamischer Regeln gibt – 5.000 für ältere Browser und 30.000 für neuere. Ich habe das durch einen Fehler herausgefunden, als ich versuchte, einer Regel eine ID zuzuweisen, die größer als 5000 war. Ich konnte kein Limit für meine IDs auf unter 4999 erstellen, also musste ich meine IDs auf dreistellige Zahlen beschränken ( 0-999, also insgesamt 1000 eindeutige IDs). Das bedeutete, dass ich die Gesamtzahl der Regeln für meine Erweiterung von 5000 auf 1000 reduziert habe, was einerseits ziemlich wichtig ist, andererseits aber auch die Wahrscheinlichkeit, dass ein Benutzer so viele URLs zum Blockieren hatte, ziemlich gering war, und so habe ich Ich habe beschlossen, mich mit dieser nicht ganz so eleganten Lösung zufrieden zu geben.
Jetzt kann der Benutzer neue URLs zur Blacklist hinzufügen und den Blocktyp auswählen, den er ihnen zuweisen möchte. Wenn er versucht, auf eine blockierte Ressource zuzugreifen, wird er auf eine Blockierungsseite weitergeleitet:
Es gibt jedoch einen Randfall, der angegangen werden muss. Die Erweiterung blockiert alle unerwünschten URLs, wenn der Benutzer direkt darauf zugreift. Wenn es sich bei der Website jedoch um eine SPA mit clientseitiger Umleitung handelt, fängt die Erweiterung die dort verbotenen URLs nicht ab. Um diesen Fall zu lösen, habe ich meine Datei „background.ts“ aktualisiert, um den aktuellen Tab abzuhören und zu sehen, ob sich die URL geändert hat. Wenn es passiert, überprüfe ich manuell, ob die URL auf der Blacklist steht, und wenn ja, leite ich den Benutzer um.
background.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() ist eine Funktion, die die Methode declarativeNetRequest.getDynamicRules() verwendet, um die Liste aller dynamischen Regeln abzurufen, die ich in ein besser lesbares Format konvertiere.
Jetzt blockiert die Erweiterung korrekt URLs, auf die direkt und über SPAs zugegriffen wird.
Die Optionsseite verfügt über eine einfache Benutzeroberfläche, wie unten gezeigt:
Dies ist die Seite mit den meisten Funktionen wie Bearbeiten, Löschen, Deaktivieren und Anwenden des strengen Modus. So habe ich es verkabelt.
Das Bearbeiten war wahrscheinlich die komplexeste Aufgabe. Benutzer können eine URL bearbeiten, indem sie ihre Zeichenfolge ändern oder ihren Blockierungstyp ändern (die gesamte Domain oder nur eine bestimmte blockieren). Beim Bearbeiten sammle ich die IDs der bearbeiteten URLs in einem Array. Beim Speichern erstelle ich aktualisierte dynamische Regeln, die ich an den Servicemitarbeiter übergebe, um Änderungen anzuwenden. Nach jeder gespeicherten Änderung oder jedem Neuladen rufe ich die dynamischen Regeln erneut ab und rendere sie in der Tabelle. Unten ist die vereinfachte Version davon:
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'" }, }
Ich entscheide, ob eine bestimmte Regel blockiert oder zugelassen wird, indem ich einfach ihre isActive-Eigenschaft bedingt überprüfe. Die Regeln aktualisieren und die Regeln abrufen – das sind zwei weitere Vorgänge, die ich zu meinem Hintergrund-Listener hinzufügen kann:
background.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'", }
Es war etwas schwierig, die Aktualisierungsfunktion richtig hinzubekommen, da es einen Grenzfall gibt, wenn eine bearbeitete URL zu einem Duplikat einer vorhandenen Regel wird. Ansonsten ist es das gleiche Spiel – aktualisieren Sie die dynamischen Regeln und senden Sie nach Abschluss die entsprechende Nachricht.
URLs zu löschen war wahrscheinlich die einfachste Aufgabe. In dieser Erweiterung gibt es zwei Arten des Löschens: das Löschen einer bestimmten Regel und das Löschen aller Regeln.
options.ts:
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Und wie zuvor habe ich dem Service-Worker-Listener zwei weitere Aktionen hinzugefügt:
background.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)); } }
Wahrscheinlich ist das Hauptmerkmal der Erweiterung die Möglichkeit, die Blockierung deaktivierter (Zugriffserlaubter) Regeln automatisch für Personen zu erzwingen, die eine strengere Kontrolle über ihre Surfgewohnheiten benötigen. Die Idee dahinter ist, dass bei Deaktivierung des strikten Modus jede vom Benutzer deaktivierte URL deaktiviert bleibt, bis der Benutzer sie ändert. Wenn der strikte Modus aktiviert ist, werden alle deaktivierten Regeln nach einer Stunde automatisch wieder aktiviert. Um eine solche Funktion zu implementieren, habe ich den lokalen Speicher der Erweiterung verwendet, um ein Array von Objekten zu speichern, die jede deaktivierte Regel darstellen. Jedes Objekt enthält eine Regel-ID, ein Entsperrdatum und die URL selbst. Jedes Mal, wenn ein Benutzer auf eine neue Ressource zugreift oder die Blacklist aktualisiert, überprüft die Erweiterung zunächst den Speicher auf abgelaufene Regeln und aktualisiert diese entsprechend.
options.ts:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
isStrictModeOn boolean wird ebenfalls im Speicher gespeichert. Wenn es wahr ist, durchlaufe ich alle Regeln und füge dem Speicher diejenigen hinzu, die deaktiviert sind, mit einer neu erstellten Entsperrungszeit für sie. Dann überprüfe ich bei jeder Antwort den Speicher auf deaktivierte Regeln, entferne die abgelaufenen Regeln, falls vorhanden, und aktualisiere sie:
background.ts:
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
Damit ist die Website-Blockierungserweiterung abgeschlossen. Benutzer können beliebige URLs hinzufügen, bearbeiten, löschen und deaktivieren, teilweise oder ganze Domänensperren anwenden und den strikten Modus verwenden, um mehr Disziplin beim Surfen aufrechtzuerhalten.
Das ist der grundlegende Überblick über meine Erweiterung zum Blockieren von Websites. Es ist meine erste Erweiterung und es war eine interessante Erfahrung, insbesondere angesichts der Tatsache, dass die Welt der Webentwicklung manchmal alltäglich werden kann. Es gibt definitiv Raum für Verbesserungen und neue Funktionen. Suchleiste für URLs in der Blacklist, das Hinzufügen geeigneter Tests, die benutzerdefinierte Zeitdauer für den strikten Modus, die gleichzeitige Übermittlung mehrerer URLs – das sind nur einige Dinge, die mir durch den Kopf gehen und die ich eines Tages gerne zu diesem Projekt hinzufügen würde. Ich hatte ursprünglich auch geplant, die Erweiterung plattformübergreifend zu machen, konnte sie aber nicht auf meinem Telefon ausführen.
Wenn Ihnen die Lektüre dieser exemplarischen Vorgehensweise gefallen hat, Sie etwas Neues gelernt haben oder Sie sonstiges Feedback haben, freuen wir uns über Ihre Kommentare. Vielen Dank fürs Lesen.
Der Quellcode
Die Live-Version
Das obige ist der detaillierte Inhalt vonBrowserübergreifende Erweiterung zum Blockieren von Websites. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!