Maison >interface Web >js tutoriel >Création d'une extension multi-navigateurs bloquant le site
Dans cet article, je vais expliquer mon processus étape par étape de création d'une extension de navigateur pour bloquer les sites Web et décrire les défis que j'ai rencontrés et les solutions que j'ai proposées. Il ne s’agit pas d’un guide exhaustif. Je ne prétends pas être un expert en quoi que ce soit. Je veux juste partager mon processus de réflexion derrière la construction de ce projet. Alors prenez tout ici avec des pincettes. Je ne couvrirai pas chaque ligne mais me concentrerai plutôt sur les points clés du projet, les difficultés, les cas intéressants et les bizarreries du projet. Vous êtes invités à explorer le code source plus en détail par vous-même.
Table des matières :
Comme beaucoup de gens, j'ai du mal à me concentrer sur différentes tâches, en particulier avec Internet qui est le distraction omniprésent. Heureusement, en tant que programmeur, j'ai développé de grandes compétences en matière de création de problèmes. J'ai donc décidé qu'au lieu de chercher une meilleure solution existante, je créerais ma propre extension de navigateur qui bloquerait les sites Web auxquels les utilisateurs souhaitent restreindre l'accès.
Tout d’abord, décrivons les exigences et les principales fonctionnalités. L'extension doit :
Tout d'abord, voici la pile principale que j'ai choisie :
La principale distinction entre le développement d'extensions et le développement Web classique est que les extensions s'appuient sur des techniciens de service qui gèrent la plupart des événements, des scripts de contenu et de la messagerie entre eux.
Pour prendre en charge la fonctionnalité multi-navigateurs, j'ai créé deux fichiers manifeste :
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'", }
Une chose intéressante ici est que Chrome nécessitait "incognito": "split", propriété spécifiée pour fonctionner correctement en mode navigation privée alors que Firefox fonctionnait bien sans elle.
Voici la structure de base des fichiers de l'extension :
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Parlons maintenant de la façon dont l'extension est censée fonctionner. L'utilisateur devrait pouvoir déclencher une sorte de formulaire pour soumettre l'URL qu'il souhaite bloquer. Lorsqu'il accède à une URL, l'extension intercepte la demande et vérifie si elle doit être bloquée ou autorisée. Il a également besoin d'une sorte de page d'options où un utilisateur peut voir la liste de toutes les URL bloquées et pouvoir ajouter, modifier, désactiver ou supprimer une URL de la liste.
Le formulaire apparaît en injectant du HTML et du CSS dans la page actuelle lorsque l'utilisateur clique sur l'icône d'extension ou tape le raccourci clavier. Il existe différentes manières d'afficher un formulaire, comme appeler une fenêtre contextuelle, mais les options de personnalisation sont limitées à mon goût. Le script d'arrière-plan ressemble à ceci :
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)); } }
ℹ Injecter du HTML dans chaque page peut conduire à des résultats imprévisibles car il est difficile de prédire comment les différents styles de pages Web vont affecter le formulaire. Une meilleure alternative semble utiliser Shadow DOM car il crée sa propre portée pour les styles. C'est certainement une amélioration potentielle sur laquelle j'aimerais travailler à l'avenir.
J'ai utilisé webextension-polyfill pour la compatibilité du navigateur. En l'utilisant, je n'ai pas eu besoin d'écrire des extensions distinctes pour différentes versions du manifeste. Vous pouvez en savoir plus sur ce qu'il fait ici. Pour que cela fonctionne, j'ai inclus le fichier browser-polyfill.js avant les autres scripts dans les fichiers manifestes.
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 ] }], }
Le processus d'injection du formulaire est une manipulation simple du DOM, mais notez que chaque élément doit être créé individuellement au lieu d'appliquer un modèle littéral à un élément. Bien que plus verbeuse et fastidieuse, cette méthode évite les avertissements d'injection HTML non sécurisée que nous recevrions autrement en essayant d'exécuter le code compilé dans le navigateur.
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); }
Il est maintenant temps de vous assurer que le formulaire s'affiche dans le navigateur. Pour effectuer l'étape de compilation requise, j'ai configuré Webpack comme ceci :
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'" }, }
Fondamentalement, il prend le nom du navigateur de la variable d'environnement des commandes que j'exécute pour choisir entre 2 des fichiers manifestes et compile le code TypeScript dans le répertoire dist/.
ℹ J'allais écrire des tests appropriés pour l'extension, mais j'ai découvert que Puppeteer ne prend pas en charge les tests de scripts de contenu, ce qui rend impossible le test de la plupart des fonctionnalités. Si vous connaissez des solutions de contournement pour les tests de scripts de contenu, j'aimerais les entendre dans les commentaires.
Mes commandes de build dans package.json sont :
{ "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'", }
Donc, par exemple, chaque fois que je cours
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
les fichiers pour Chrome sont compilés dans le répertoire dist/. Après avoir déclenché un formulaire sur n'importe quel site en cliquant sur l'icône d'action ou en appuyant sur le raccourci, le formulaire ressemble à ceci :
Maintenant que le formulaire principal est prêt, la tâche suivante consiste à le soumettre. Pour implémenter la fonctionnalité de blocage, j'ai exploité l'API déclarativeNetRequest et les règles dynamiques. Les règles vont être stockées dans le stockage de l'extension. La manipulation de règles dynamiques n'est possible que dans le fichier du service Worker, donc pour échanger des données entre le Service Worker et les scripts de contenu, j'enverrai des messages entre eux avec les données nécessaires. Comme de nombreux types d'opérations sont nécessaires pour cette extension, j'ai créé des types pour chaque action. Voici un exemple de type d'opération :
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)); } }
Comme il est raisonnable de pouvoir ajouter de nouvelles URL à la fois depuis le formulaire principal et depuis la page d'options, la soumission a été exécutée par une fonction réutilisable dans un nouveau fichier :
helpers.ts:
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
J'appelle handleFormSubmission() dans content.ts qui valide l'URL fournie, puis l'envoie au service worker pour l'ajouter à la liste noire.
ℹ Les règles dynamiques ont fixé la taille maximale qui doit être prise en compte. La transmission d'une chaîne d'URL trop longue entraînera un comportement inattendu lorsque vous tenterez d'enregistrer la règle dynamique correspondante. J'ai découvert que dans mon cas, une URL de 75 caractères était une bonne longueur maximale pour une règle.
Voici comment le service worker va traiter le message reçu :
background.ts :
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
Pour la soumission, je crée un nouvel objet de règle et mets à jour les règles dynamiques pour l'inclure. Une simple expression régulière conditionnelle me permet de choisir entre bloquer l'intégralité du domaine ou uniquement l'URL spécifiée.
Une fois terminé, je renvoie le message de réponse au script de contenu. La chose la plus intéressante dans cet extrait est l’utilisation du nanooïde. Par essais et erreurs, j'ai découvert qu'il existe une limite pour le nombre de règles dynamiques : 5 000 pour les anciens navigateurs et 30 000 pour les plus récents. J'ai découvert cela grâce à un bug lorsque j'ai essayé d'attribuer un identifiant à une règle supérieure à 5 000. Je ne pouvais pas créer de limite pour que mes identifiants soient inférieurs à 4 999, j'ai donc dû limiter mes identifiants à des nombres à 3 chiffres ( 0-999, soit 1000 identifiants uniques au total). Cela signifiait que j'avais réduit le nombre total de règles pour mon extension de 5 000 à 1 000, ce qui d'une part est assez significatif, mais d'autre part, la probabilité qu'un utilisateur ait autant d'URL à bloquer était assez faible, et j'ai donc a décidé de se contenter de cette solution pas si gracieuse.
L'utilisateur peut désormais ajouter de nouvelles URL à la liste noire et choisir le type de blocage qu'il souhaite leur attribuer. S'il tente d'accéder à une ressource bloquée, il sera redirigé vers une page de blocage :
Cependant, il y a un cas limite qui doit être résolu. L'extension bloquera toutes les URL indésirables si l'utilisateur y accède directement. Mais si le site Web est un SPA avec redirection côté client, l'extension n'y interceptera pas les URL interdites. Pour gérer ce cas, j'ai mis à jour mon background.ts pour écouter l'onglet actuel et voir si l'URL a changé. Lorsque cela se produit, je vérifie manuellement si l'URL est dans la liste noire, et si c'est le cas, je redirige l'utilisateur.
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() est une fonction qui utilise la méthode declarativeNetRequest.getDynamicRules() pour récupérer la liste de toutes les règles dynamiques que je convertis dans un format plus lisible.
Désormais, l'extension bloque correctement les URL accessibles directement et via les SPA.
La page d'options a une interface simple, comme indiqué ci-dessous :
Il s'agit de la page contenant l'essentiel des fonctionnalités telles que l'édition, la suppression, la désactivation et l'application du mode strict. Voici comment je l'ai câblé.
Le montage était probablement la tâche la plus complexe. Les utilisateurs peuvent modifier une URL en modifiant sa chaîne ou en changeant son type de blocage (bloquer le domaine entier ou uniquement un domaine spécifique). Lors de l'édition, je collecte les identifiants des URL modifiées dans un tableau. Lors de l'enregistrement, je crée des règles dynamiques mises à jour que je transmets au service worker pour appliquer les modifications. Après chaque modification ou rechargement enregistré, je récupère les règles dynamiques et les affiche dans le tableau. Vous en trouverez ci-dessous la version simplifiée :
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'" }, }
La façon dont je décide de bloquer ou d'autoriser une règle particulière consiste simplement à vérifier conditionnellement sa propriété isActive. Mettre à jour les règles et récupérer les règles - ce sont 2 opérations supplémentaires à ajouter à mon écouteur en arrière-plan :
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'", }
La fonctionnalité de mise à jour était un peu difficile à mettre en œuvre car il existe un cas extrême où une URL modifiée devient un double d'une règle existante. A part ça, c'est le même baratin : mettez à jour les règles dynamiques et envoyez le message approprié une fois terminé.
La suppression d'URL était probablement la tâche la plus simple. Il existe 2 types de suppression dans cette extension : suppression d'une règle spécifique et suppression de toutes les règles.
options.ts :
dist/ node_modules/ src/ |-- background.tsc |-- content.ts static/ |-- manifest.chrome.json |-- manifest.firefox.json package.json tsconfig.json webpack.config.js
Et, comme avant, j'ai ajouté 2 actions supplémentaires à l'auditeur du service worker :
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)); } }
La principale caractéristique de l'extension est probablement la possibilité d'appliquer automatiquement le blocage des règles désactivées (autorisations d'accès) pour les personnes qui ont besoin d'un contrôle plus rigide sur leurs habitudes de navigation. L'idée est que lorsque le mode strict est désactivé, toute URL désactivée par l'utilisateur restera désactivée jusqu'à ce que l'utilisateur la modifie. Avec le mode strict activé, toutes les règles désactivées seront automatiquement réactivées après 1 heure. Pour implémenter une telle fonctionnalité, j'ai utilisé le stockage local de l'extension pour stocker un tableau d'objets représentant chaque règle désactivée. Chaque objet comprend un ID de règle, une date de déblocage et l'URL elle-même. Chaque fois qu'un utilisateur accède à une nouvelle ressource ou actualise la liste noire, l'extension vérifie d'abord le stockage pour les règles expirées et les met à jour en conséquence.
options.ts :
{ "content_scripts": [{ "js": ["browser-polyfill.js"] }], }
isStrictModeOn boolean est également stocké dans le stockage. Si c'est vrai, je boucle sur toutes les règles et ajoute au stockage celles qui sont désactivées avec un temps de déblocage nouvellement créé pour elles. Ensuite, à chaque réponse, je vérifie le stockage pour les règles désactivées, supprime celles expirées si elles existent et les mets à jour :
background.ts :
{ "background": { "scripts": [ "browser-polyfill.js", // other scripts ], }, "content_scripts": [{ "js": [ "browser-polyfill.js", // other scripts ] }], }
Une fois cela fait, l'extension de blocage de sites Web est terminée. Les utilisateurs peuvent ajouter, modifier, supprimer et désactiver toutes les URL de leur choix, appliquer des blocages de domaine partiels ou entiers et utiliser le mode strict pour les aider à maintenir plus de discipline dans leur navigation.
C'est l'aperçu de base de mon extension de blocage de sites. C'est ma première extension, et ce fut une expérience intéressante, surtout compte tenu du fait que le monde du développement Web peut parfois devenir banal. Il y a certainement place à l'amélioration et à de nouvelles fonctionnalités. Barre de recherche d'URL dans la liste noire, ajout de tests appropriés, durée personnalisée pour le mode strict, soumission de plusieurs URL à la fois - ce ne sont là que quelques éléments qui me préoccupent et que j'aimerais ajouter un jour à ce projet. J'avais également initialement prévu de rendre l'extension multiplateforme, mais je n'ai pas pu la faire fonctionner sur mon téléphone.
Si vous avez aimé lire cette procédure pas à pas, appris quelque chose de nouveau ou si vous avez d'autres commentaires, vos commentaires sont appréciés. Merci d'avoir lu.
Le code source
La version live
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!