Как я написал расширение для Chrome, которое спасает от ночных рейдов на Twitch
Привет, Хабр
Предыстория
У многих из нас есть привычка засыпать под стримы или просто оставлять вкладку открытой. Любимый стример, спокойный голос, фоновая игра — идеальная атмосфера для сна. Но есть одна проблема: когда стример завершает эфир, он часто запускает рейд — массовое перенаправление зрителей на другой канал.
И вот вы просыпаетесь в 3 часа ночи от громкой музыки или незнакомой речи на каком-то случайном канале.
Можно, конечно, каждый раз вручную нажимать «Отменить» в окне рейда. А как это сделать, когда ты не успел нажать эту кнопку или вовсе спишь. Что если сделать это автоматически? Так появилось идея расширения «Twitch Raid Blocker».

В этой статье я расскажу:
Как устроены рейды на Twitch с технической точки зрения
Как расширение обнаруживает и блокирует их
Какие подводные камни встретились при разработке
Как работает архитектура расширения на Manifest V3
Что такое рейд на Twitch
Рейд (raid) — это механизм, позволяющий стримеру перенаправить своих зрителей на другой канал в момент завершения трансляции. Для стримеров это способ поддержать коллег и обменяться аудиторией. Но для зрителей, которые уснули или отошли от компьютера, это может стать неприятным сюрпризом.
С технической точки зрения, при начале рейда Twitch показывает модальное окно с двумя вариантами:
Присоединиться к рейду (перейти на другой канал)
Отменить (остаться на текущей странице)
Наша задача — найти это окно и автоматически нажать кнопку «Отменить».
Архитектура расширения

Расширение построено по стандартной схеме для Manifest V3 и состоит из четырёх основных компонентов:
├── manifest.json # Конфигурация ├── background.js # Service Worker (инициализация) ├── content.js # Content Script (основная логика) ├── popup.html/js # Интерфейс управления └── icons/ # Иконки
Manifest V3
Начнём с конфигурации. Manifest V3 — это третья версия спецификации расширений Chrome, которая принесла ряд изменений в целях безопасности и производительности.
{ "manifest_version": 3, "name": "Котикс Блочит — Twitch Raid Blocker", "version": "2.1.2", "description": "Автоматически отменяет рейды на Twitch", "permissions": ["storage"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["https://*.twitch.tv/*"], "js": ["content.js"], "run_at": "document_idle" } ], "action": { "default_popup": "popup.html" } }
Ключевые моменты:
manifest_version: 3— используем актуальную версиюpermissions: ["storage"]— минимальные разрешения, только для хранения статистикиcontent_scripts— скрипт внедряется на все страницы Twitchrun_at: "document_idle"— скрипт запускается после полной загрузки страницы
Обнаружение рейда: основная логика


Самая интересная часть — это content.js. Именно здесь происходит магия обнаружения и блокировки рейдов.
Шаг 1: Определение текстов рейда
Twitch поддерживает множество языков, но нам достаточно покрыть русский и английский. Создадим массивы с ключевыми фразами:
const RAID_TEXTS = [ "проводит рейд", "начинает рейд", "вы присоединяетесь к рейду", "присоединяетесь к рейду", "вас перенаправляют", "скоро начнется рейд", "raiding", "starting raid", "you are joining a raid", "joining raid" ]; const CANCEL_TEXTS = [ "отменить", "остаться", "покинуть", "не переходить", "cancel", "stay here", "stay on channel", "don't join", "dismiss" ];
Шаг 2: Нормализация текста
Текст на странице может содержать лишние пробелы, разные регистры и т.д. Приведём всё к единому виду:
function normalize(text) { return (text || "").replace(/\s+/g, " ").trim().toLowerCase(); } function textIncludesAny(text, patterns) { const t = normalize(text); return patterns.some(p => t.includes(normalize(p))); }
Шаг 3: Поиск модального окна
Нам нужно найти элемент, который:
Содержит текст о рейде
Имеет кнопку «Отменить»
Видим на странице
Имеет достаточный размер
function findRaidContainers() { const all = [...document.querySelectorAll("div, section")]; const result = []; for (const el of all) { if (processedContainers.has(el)) continue; if (!isVisible(el)) continue; const txt = normalize(el.innerText || el.textContent || ""); if (textIncludesAny(txt, RAID_TEXTS) && txt.length >= 15 && isLargeEnough(el) && isLikelyRaidModal(el)) { result.push(el); processedContainers.add(el); setTimeout(() => processedContainers.delete(el), CACHE_TTL); } } return result; }
Здесь используется Set для кэширования уже обработанных контейнеров — это предотвращает повторную обработку одних и тех же элементов. TTL (time-to-live) установлен в 15 секунд.
Шаг 4: Проверка видимости элемента
Важно убедиться, что элемент действительно виден пользователю:
function isVisible(el) { if (!el) return false; try { const style = window.getComputedStyle(el); if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } catch (e) { return false; } }
Шаг 5: Поиск кнопки отмены
Ищем кнопку внутри контейнера рейда:
function findCancelInside(container) { if (!container) return null; const buttons = container.querySelectorAll("button, [role='button']"); for (const btn of buttons) { const txt = normalize(btn.innerText || btn.textContent || btn.getAttribute("aria-label") || ""); if (!txt) continue; if (textIncludesAny(txt, CANCEL_TEXTS)) { return btn; } } return null; }
Обратите внимание: мы проверяем не только innerText, но и aria-label — это важно для доступности и случаев, когда текст кнопки скрыт визуально.
Шаг 6: Клик по кнопке
Финальный шаг — симуляция клика:
function clickElement(el, reason = "") { if (!el || !isVisible(el)) return false; try { el.click(); log("✅ Clicked:", reason); return true; } catch (e) { return false; } }
Observer и периодическая проверка
DOM на современных сайтах динамически обновляется. Рейд может появиться в любой момент, поэтому нам нужно постоянно мониторить изменения.
Используем два подхода:
MutationObserver — реагирует на изменения DOM
setInterval — страховочный опрос каждые 2 секунды
function startObserver() { if (observer) observer.disconnect(); if (scanInterval) clearInterval(scanInterval); try { observer = new MutationObserver(() => tryBlockRaid()); observer.observe(document.body || document.documentElement, { childList: true, subtree: true, attributes: false }); scanInterval = setInterval(() => tryBlockRaid(), 2000); console.log("[RAID BLOCK] ✅ Observer started"); } catch (e) { console.error("[RAID BLOCK] ❌ Observer failed:", e); } }
Почему оба метода? MutationObserver может пропустить некоторые изменения, особенно если они происходят очень быстро. Периодический опрос служит страховкой.
Защита от частых срабатываний
Чтобы не кликать по кнопке много раз подряд, введём ограничение:
let lastBlockTs = 0; function canBlockNow() { const now = Date.now(); if (now - lastBlockTs < 3000) return false; lastBlockTs = now; return true; }
Минимальный интервал между блокировками — 3 секунды. Этого достаточно, чтобы обработать один рейд и не создавать нагрузку.
Хранение статистики
Пользователям интересно знать, сколько рейдов было заблокировано. Используем chrome.storage.local:
function incrementCount() { raidBlockCount += 1; log("✅ Raid blocked! Count =", raidBlockCount); saveCount(); } function saveCount() { chrome.storage.local.set({ raidBlockCount }, () => { notifyPopup(); }); }
Данные сохраняются даже после перезагрузки браузера и синхронизируются с popup.
Popup: интерфейс управления
Интерфейс сделан минималистичным: переключатель включения/выключения, счётчик заблокированных рейдов и кнопка сброса.
HTML
<h1>🐱 КОТИКС БЛОЧИТ</h1> <div class="row"> <label for="toggleSwitch">Включить блокировку</label> <label class="switch"> <input type="checkbox" id="toggleSwitch"> <span class="slider"></span> </label> </div> <div class="status" id="statusText">Статус: ON</div> <div class="row counter"> RAID BLOCK: <span id="counterValue">0</span> </div> <button id="clearBtn">🗑️ Сбросить счётчик</button>
CSS (темная тема в стиле Twitch)
body { width: 280px; padding: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0e0e10; color: #efeff1; margin: 0; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 2px; bottom: 2px; background-color: white; border-radius: 50%; transition: 0.3s; } input:checked + .slider { background-color: #f8a0a7; }
JavaScript: реактивное обновление
chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; if (changes.raidBlockCount) { counterValue.textContent = String(changes.raidBlockCount.newValue || 0); } if (changes.raidBlockEnabled) { const enabled = changes.raidBlockEnabled.newValue !== false; toggle.checked = enabled; statusText.textContent = "Статус: " + (enabled ? "ON" : "OFF"); } });
Это обеспечивает мгновенное обновление интерфейса при изменении данных из любого компонента расширения.
Background Script: инициализация
Service Worker в Manifest V3 заменяет собой старый background page. Он запускается при установке расширения и инициализирует значения по умолчанию:
chrome.runtime.onInstalled.addListener(async () => { const data = await chrome.storage.local.get([ "raidBlockEnabled", "raidBlockCount" ]); const updates = {}; if (typeof data.raidBlockEnabled === "undefined") { updates.raidBlockEnabled = true; } if (typeof data.raidBlockCount === "undefined") { updates.raidBlockCount = 0; } if (Object.keys(updates).length > 0) { await chrome.storage.local.set(updates); } });
Подводные камни при разработке
1. Динамическая природа Twitch
Twitch — это SPA (Single Page Application) на React. Элементы постоянно перерисовываются, классы могут меняться. Поэтому мы не полагаемся на конкретные селекторы или классы, а используем текстовый анализ и семантические признаки (наличие кнопки отмены, размер элемента).
2. Ложные срабатывания
Первоначально расширение могло реагировать на любые упоминания слова «рейд» в чате. Решение: требовать наличие кнопки отмены внутри контейнера и минимальный размер элемента (150×50 пикселей).
3. Производительность
Первоначальная версия сканировала весь DOM каждую секунду. Это создавало нагрузку. Оптимизации:
Кэширование обработанных контейнеров (TTL 15 секунд)
Увеличение интервала опроса до 2 секунд
Ранний выход при отсутствии видимых элементов
4. Manifest V3 ограничения
В Manifest V3 background script работает как Service Worker и может быть остановлен в любой момент. Поэтому вся критичная логика вынесена в content script, который работает непосредственно на странице.
Безопасность и приватность
Расширение запрашивает минимальные разрешения:
storage— только для хранения локальной статистикиНе собирает никакие персональные данные
Не отправляет данные на внешние серверы
Весь код выполняется локально в браузере
Исходный код открыт и доступен для аудита.
Заключение
Если вы тоже страдаете от ночных рейдов — надеюсь, это расширение вам поможет. А если вы разработчик — возможно, некоторые приёмы из статьи пригодятся в ваших проектах.
Ссылка на репозиторий: GitHub
