Удалить что угодно со страницы одним кликом: как устроен «пипеточный» блокировщик на Manifest V3

Бывает, что блокировщик рекламы не справляется: всплывающее окно подписки, баннер «мы используем cookies», чат-виджет в углу, липкая шапка на пол-экрана. Для этого было маленькое расширение ekill: жмёшь кнопку, наводишь на элемент, кликаешь — элемент исчез. Оно не обновлялось с 2019 года и перестало работать в Chrome, когда Google отключил Manifest V2.
Я переписал его под Manifest V3 для Firefox, Chrome и Edge. Получился SkullClick (GitHub). Задача кажется тривиальной, но у неё неожиданно много граней:
сайты, которые перехватывают клики;
CSS-селекторы, которые ломаются на современных именах классов;
элементы, которые должны исчезать сами при следующем визите.
Разберу по порядку.
Проблема 1: сайт съедает клики
Наивная «пипетка» вешает mousemove и click на document и подсвечивает e.target. На обычных сайтах это работает. На Notion и подобных приложениях — нет: страница сама слушает события на фазе перехвата, вызывает stopPropagation(), и до расширения ничего не доходит. Это issue ekill#35.
Решение в SkullClick — прозрачная полноэкранная подложка внутри shadow root, плюс все обработчики вешаются на window в фазе capture. Это самая ранняя точка, раньше любого обработчика страницы на document или элементах:
for (const type of Object.keys(POINTER_EVENTS)) {
window.addEventListener(type, onPointerEvent, { capture: true, passive: false });
}
function onPointerEvent(e) {
if (!e.composedPath().includes(ui.overlay)) return; // событие не по нашей подложке
POINTER_EVENTS[e.type](e); // mousedown/up, pointer*, dblclick... — проглатываем
}
Но если всё закрыто подложкой, как понять, что под курсором? Для этого есть document.elementsFromPoint(x, y). Он возвращает все элементы в точке, сверху вниз. Мы пропускаем свои и берём первый элемент страницы:
function elementAt(x, y) {
for (const el of document.elementsFromPoint(x, y)) {
if (isOurs(el)) continue;
if (el === document.documentElement || el === document.body) return null;
return el;
}
return null;
}
Побочный плюс: так же подсвечиваются iframe. Старый подход требовал отдельной подложки для каждого фрейма, здесь это не нужно. А DOM и классы страницы во время выбора не трогаются вообще.
Минус подложки в том, что она перехватывает и колесо мыши. Основная страница прокручивается сама, а для вложенных прокручиваемых блоков (модалки со своим скроллом) пришлось вручную найти ближайшего прокручиваемого предка и вызвать scrollBy.
Проблема 2: выбрать не то
Кликаешь по окну подписки, а удаляется только кнопка внутри него. Или удаляется диалог, но тёмный полупрозрачный фон остаётся. Поэтому в SkullClick:
↑ расширяет выделение до родителя, ↓ возвращает к ребёнку (стек запоминает путь);
Shift+клик удаляет и оставляет пипетку включённой;
Ctrl+Z — отмена.
Скрывать, а не удалять
ekill делал element.remove(). Отказаться от этого пришлось по двум причинам:
Отмену так не сделать. Удалённый узел можно вставить обратно, но фреймворк страницы к этому времени может перерисовать соседей.
Селекторы разъезжаются. Запомненные правила используют позицию
:nth-child. Удалишь один элемент — индексы соседей сдвинутся, и правило для следующего элемента начнёт бить мимо (ekill#22).
Поэтому элемент просто скрывается через display: none !important, а прежнее значение display запоминается для отмены. Скрытое видео при этом продолжает играть, так что у видео и аудио вызывается pause(), а iframe (встроенные плееры, чаты) получает src="about:blank". При отмене всё восстанавливается.
Проблема 3: селекторы для «злопамятного» режима
Необязательный режим Grudge («злопамятный») запоминает удалённые элементы и сам скрывает их при следующих визитах на эту страницу или весь сайт. Для этого нужен CSS-селектор, однозначно указывающий на элемент.
В ekill селектор собирался из #id и .class склейкой строк. На современной вёрстке это ломается. Классы вида u-hidden@desktop-max (BEM с модификаторами), ver-5.7, md:flex из Tailwind или id, начинающийся с цифры, дают невалидный селектор, и querySelector бросает исключение (ekill#30, #34). Лечится через CSS.escape():
CSS.escape("u-hidden@desktop-max") // "u-hidden\\@desktop-max"
CSS.escape("5col") // "\\35 col"
Генератор идёт от элемента вверх и на каждом шаге проверяет через querySelectorAll, что селектор уже однозначен. Если нет, добавляет следующего предка или :nth-child.
Как применять правила: insertCSS, а не JavaScript
Скрыть запомненные элементы можно двумя способами:
JavaScript. Найти элементы и поставить
display: none. Но элемент может появиться позже (модалки часто всплывают через 5 секунд), и тогда нуженMutationObserverили опрос.CSS-правило. Оно действует и на элементы, которые появятся позже, без всякого наблюдения. Но
<style>на странице с жёсткой CSP заблокируют.
Выход — chrome.scripting.insertCSS. Стили, вставленные расширением, под CSP страницы не попадают:
// Одно правило на селектор: один кривой селектор не отключит остальные
const css = selectors.filter(isSafeSelector).map((s) => `${s} { display: none !important; }`).join("\n");
await chrome.scripting.insertCSS({ target: { tabId, frameIds: [frameId] }, css });
Здесь две защиты. Каждое правило отдельное: если один селектор окажется невалидным, браузер отбросит только его. А isSafeSelector не пропускает селекторы с {, } и /*. Список правил можно импортировать из файла, и без этой проверки из такого файла можно было бы вставить на страницу произвольный CSS.
Для одностраничных приложений есть ещё один нюанс: при навигации без перезагрузки меняется путь, и правила прошлой страницы нужно снять через removeCSS, а для новой — вставить.
Меньше прав, чем у оригинала
ekill внедрял скрипт в каждую страницу, то есть требовал доступ ко всем сайтам всегда. В SkullClick по умолчанию только activeTab и scripting: скрипт внедряется в текущую вкладку в момент нажатия кнопки. Доступ ко всем сайтам (optional_host_permissions) запрашивается только если пользователь включит режим Grudge. Тогда контент-скрипт регистрируется динамически:
if (settings.grudge && (await chrome.permissions.contains({ origins: ["<all_urls>"] }))) {
await chrome.scripting.registerContentScripts([
{ id: "skullclick-grudge", matches: ["<all_urls>"], js: CONTENT_FILES, runAt: "document_start" },
]);
}
Выключили режим — скрипт снимается. Для расширения, которое работает на любой странице, это существенная разница в доверии.
Тестирование
Юнит-тесты на jsdom покрывают генератор селекторов и правила, включая реальные случаи из issues ekill: классы u-hidden@desktop-max и js-comp-ver-5.7.
E2E на Puppeteer гоняются в Chrome, Firefox и Edge на локальных тестовых страницах:
обычная страница с модалкой;
страница, которая глушит все события мыши через
stopPropagation();чат-виджет в iframe.
Проверяются удаление, отмена, выбор родителя, запоминание и автоматическое скрытие при следующем визите.
Итог
Кликнул — элемент исчез. Работает на всплывающих окнах, баннерах, липких шапках, чатах, iframe.
Отмена, выбор родителя и ребёнка стрелками, удаление нескольких элементов подряд.
Необязательный режим «помнить и скрывать всегда», список правил с экспортом и импортом.
Работает на сайтах, которые перехватывают клики, и со строгой CSP.
Firefox, Chrome, Edge. Без сети и аналитики, MIT (как у оригинала).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.