javascript

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

  • суббота, 26 сентября 2026 г. в 00:00:09
https://habr.com/ru/articles/1086296/

Бывает, что блокировщик рекламы не справляется: всплывающее окно подписки, баннер «мы используем cookies», чат‑виджет в углу, липкая шапка на пол‑экрана. Для этого было маленькое расширение ekill: жмёшь кнопку, наводишь на элемент, кликаешь — элемент исчез. Оно не обновлялось с 2019 года и перестало работать в Chrome, когда Google отключил Manifest V2.

Я переписал его под Manifest V3 для Firefox, Chrome и Edge. Получился SkullClick (GitHub). Задача кажется тривиальной, но у неё неожиданно много граней:

  • сайты, которые перехватывают клики;

  • CSS‑селекторы, которые ломаются на современных именах классов;

  • элементы, которые должны исчезать сами при следующем визите.

Разберу по порядку.

SkullClick
SkullClick

Проблема 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(). Отказаться от этого пришлось по двум причинам:

  1. Отмену так не сделать. Удалённый узел можно вставить обратно, но фреймворк страницы к этому времени может перерисовать соседей.

  2. Селекторы разъезжаются. Запомненные правила используют позицию :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 (как у оригинала).

Код и релизы: https://github.com/Perruer/skullclick