Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Дайджесты новостей
Иллюстрация архитектуры браузерного расширения SkullClick, перехватывающего события мыши на capture-фазе поверх оверлея в обход изоляции DOM.

Архитектура блокировщика элементов на Manifest V3: кейс SkullClick

Удаление раздражающих элементов на страницах — всплывающих баннеров, плашек с куки или липких шапок — кажется простой задачей. Однако с переходом на платформу Chrome Manifest V3 и усложнением современных интерфейсов (Notion, Jira, Miro) прежние подходы перестали работать. Популярная утилита ekill 2019 года устарела, а ее прямое портирование наткнулось на защитные ограничения браузеров.

Разработчик Владимир (@Perruer) переписал инструмент под Manifest V3, выпустив расширение с открытым исходным кодом SkullClick (лицензия MIT). В проекте решен комплекс задач: от перехвата событий мыши в обход изоляции до надежной генерации CSS-селекторов.

Перехват кликов и поиск элементов сквозь оверлей

Главная проблема расширений-«пипеток» — перехват кликов. Сервисы вроде Notion вешают обработчики на уровне document и вызывают e.stopPropagation(). Расширение, слушающее события на фазе всплытия (bubble), кликов попросту не получает.

SkullClick решает задачу через оверлей и раннюю фазу погружения (capture phase):

  1. Интерфейс подсветки изолируется в Shadow DOM, чтобы стили страницы не ломали разметку инструмента.
  2. Обработчики событий мыши регистрируются на объекте window с флагом { capture: true, passive: false }.
  3. Поскольку прозрачная подложка перекрывает экран, целевой узел под курсором находится методом document.elementsFromPoint(x, y) с фильтрацией элементов самого расширения.
// Перехват событий на ранней фазе capture
const POINTER_EVENTS = ['mousedown', 'mouseup', 'click', 'pointerdown'];

for (const type of POINTER_EVENTS) {
  window.addEventListener(type, onPointerEvent, { capture: true, passive: false });
}

function onPointerEvent(e) {
  if (!e.composedPath().includes(uiOverlayElement)) return;

  e.preventDefault();
  e.stopPropagation();

  const target = getElementUnderCursor(e.clientX, e.clientY);
  if (target && e.type === 'click') {
    hideElement(target);
  }
}

function getElementUnderCursor(x, y) {
  const elements = document.elementsFromPoint(x, y);
  for (const el of elements) {
    if (isExtensionElement(el)) continue;
    if (el === document.documentElement || el === document.body) return null;
    return el;
  }
  return null;
}

Почему нельзя вызывать remove()

В ранних утилитах элемент удалялся вызовом element.remove(). В SPA-приложениях это создает две проблемы:

  • Физическое удаление узла нарушает отмену действия (Ctrl+Z), если приложение перерисовало состояние.
  • Удаление узла сдвигает индексы :nth-child(n) у соседей, из-за чего персистентные правила бьют по чужим блокам.

В SkullClick элемент скрывается правилом display: none !important. Для медиаплееров вызывается .pause(), а для встроенных фреймов очищается адрес (iframe.src = "about:blank"), исключая фоновое воспроизведение звука.

Генерация селекторов с экранированием

Для долговременного сокрытия расширение сохраняет CSS-селектор. В современных проектах классы Tailwind CSS (md:flex, hover:bg-red-500) и БЭМ-модификаторы (u-hidden@desktop-max) содержат спецсимволы, вызывающие синтаксические ошибки в document.querySelector().

SkullClick экранирует каждый класс методом CSS.escape():

// Генерация однозначного CSS-селектора с экранированием классов
function buildSafeSelector(el) {
  if (el.id && !/^\d/.test(el.id)) {
    return `#${CSS.escape(el.id)}`;
  }

  const parts = [];
  let current = el;

  while (current && current !== document.body) {
    let selector = current.tagName.toLowerCase();

    if (current.classList.length > 0) {
      const classes = Array.from(current.classList)
        .map((cls) => `.${CSS.escape(cls)}`)
        .join('');
      selector += classes;
    }

    if (document.querySelectorAll(selector).length === 1) {
      parts.unshift(selector);
      return parts.join(' > ');
    }

    const parent = current.parentElement;
    if (parent) {
      const index = Array.from(parent.children).indexOf(current) + 1;
      selector += `:nth-child(${index})`;
    }

    parts.unshift(selector);
    current = parent;
  }

  return parts.join(' > ');
}

Стили внедряются через chrome.scripting.insertCSS, не блокируясь строгими политиками Content Security Policy (CSP).

Установка и горячие клавиши

Расширение доступно в репозитории https://github.com/Perruer/skullclick. В Chrome и Edge его можно подключить через chrome://extensions, включив «Режим разработчика» и выбрав распакованный архив.

Пипетка вызывается сочетанием Alt+Shift+K или иконкой на панели. Клавиши ↑ и ↓ расширяют или сужают область выбора в дереве DOM, Shift+клик запускает серийное скрытие, а Ctrl+Z отменяет последнее действие.