Бесплатный блокировщик рекламы: DNS-парсер без библиотек, VPN, который всё ломает, и реклама блогеров с нулевой секунды

Недавно писал про Качалку — программу, которая скачивает видео. Следующая боль ещё проще: реклама. На сайтах — баннеры и всплывашки. На YouTube из России обычной рекламы от самого YouTube почти не осталось, зато блогеры вшивают её прямо в ролик: минута про банк, маркетплейс, сервис объявлений. Её не вырезать никаким блокировщиком, потому что она часть видео.
Так появилось «Чисто»: одна ссылка, на ней кнопка под каждое устройство. Бесплатно, без регистрации и без аккаунта, код открыт. Ниже — как это устроено и на какие грабли я наступил.

Знак у «Чисто» — мангуст против кобры. Мангуст славится тем, что змеиный яд на него почти не действует. Реклама, трекеры и принудительные подписки — такой же яд для привычного сайта, а «Чисто» должно вести себя как мангуст: спокойно делать своё и не бояться.
Как это устроено

Одной программы на iPhone, Android и компьютер не бывает: у каждой платформы свой способ влезть в сеть. Общее у всех — DNS. Телефон спрашивает у моего сервера, где находится ad.doubleclick.net. Домен в списке — отвечаю 0.0.0.0, и баннер просто не грузится. Иначе пересылаю вопрос публичному DNS. Через сервер идут только имена сайтов, сами страницы — напрямую.
iPhone — профиль DNS: скачал, ввёл код-пароль, готово. Работает во всех приложениях.
Android — «Безопасный DNS» в Chrome: вставил адрес, три касания.
Компьютер — расширение для Chrome, Яндекс Браузера, Edge и Firefox.
С VPN — отдельная вкладка, про неё ниже.
Telegram — бот с теми же кнопками, расширение присылает файлом.
DNS видит только домен. Рекламу с адреса самого сайта (site.ru/ads/banner.js) он не отличит от статьи — это работа расширения: оно режет по пути и типу файла и прячет пустые места.
Стена №1: 10 мс на запрос
У бесплатного воркера Cloudflare на один запрос 10 мс процессора. А в списке рекламных доменов 222 980 строк. Разбирать такой список из хранилища на каждом холодном старте — не успеть.
Решение — вшить список прямо в код. Это JSON на 1,7 МБ в сжатом виде при лимите 3 МБ. Он разбирается один раз при запуске, дальше проверка домена — пара обращений к Set:
import LIST from './blocklist.json';
const BLOCK = new Set(LIST.block);
// ads.tracker.example.com → проверяю сам домен и всех родителей
function isBlocked(name) {
for (let d = name; d; d = d.slice(d.indexOf('.') + 1 || d.length)) {
if (BLOCK.has(d)) return true;
}
return false;
}
Отвечает воркер в среднем за 127 мс, Cloudflare напрямую — за 98 мс. Разница только на первом заходе на сайт: дальше телефон берёт ответ из своей памяти. А так как реклама перестаёт грузиться, страницы часто открываются даже быстрее.
Грабли с маскировкой. Трекеры давно прячутся под поддоменом самого сайта: stat.site.ru на самом деле ведёт на site.tracker.net. По имени такой не поймать. Поэтому сервер разбирает ответ публичного DNS и проверяет, куда ведёт цепочка. Сначала я проверял только CNAME и забыл, что тот же трюк делается через DNAME и через запись HTTPS — теперь смотрю все три.
Стена №2: DNS-пакет, который читается по кругу
DNS-ответы я собираю руками по RFC 1035, без библиотек. Потом сел и прогнал по коду проверку, как будто его писал кто-то другой. Нашлось три дыры.
Петля. В DNS имя может ссылаться на кусок, который уже был в пакете раньше. Ссылка на саму себя давала петлю. Код упирался в счётчик и возвращал половину имени, которая шла дальше как нормальная. Теперь ссылка принимается только назад:
const ptr = ((len & 0x3f) << 8) | buf[off + 1];
if (ptr >= off) return null; // вперёд или на себя — мусор
Точка внутри имени. Кусок имени в DNS — это просто байты, и в нём может стоять точка. Один кусок
ad.doubleclickсклеивался в строку, неотличимую от двух. Это либо ложная блокировка, либо обход списка. Такие запросы теперь отклоняются.Два вопроса в одном запросе. Я отвечал на первый, а счётчик вопросов копировал из запроса — ответ получался битым. Теперь принимаю ровно один вопрос.
На каждую дыру — тест.
Стена №3: кнопка, которая отключила бы интернет
В Android-приложении была удобная кнопка «Включить Частный DNS» — без VPN, во всех приложениях сразу. Я перечитывал код перед выкладкой APK и понял, что она сделала бы.
«Частный DNS» в Android работает по отдельному протоколу на порту 853. А Cloudflare Workers этот порт не умеет вообще. Человек нажал бы кнопку, Android начал бы ждать ответа, которого никогда не будет, — и не открылся бы ни один сайт. Причём человек даже не понял бы, что виновато моё приложение.
До людей это не дошло: APK ещё не был на сайте. Теперь блок «Частный DNS» появляется, только если сервер его поддерживает.
Вывод тот же, что с Качалкой: проверять надо не «работает ли у меня», а «что будет у человека, который нажмёт».
Стена №4: у всех включён VPN
Это я недооценил сильнее всего. Почти у каждого, кто сидит в YouTube из России, постоянно включён VPN: Shadowrocket, Happ, sing-box, v2rayN. А VPN-клиент забирает DNS себе. Профиль на iPhone в лучшем случае молча перестаёт работать, в худшем — сеть подвисает, потому что два механизма спорят, кто отвечает на вопрос «где сайт».
Времена такие: VPN и блокировщик у людей теперь стоят одновременно, и они не должны спорить. Бороться с туннелем бессмысленно, поэтому «Чисто» встраивается в него. На сайте появилась вкладка «Использую VPN»: выбираешь клиент — Shadowrocket, sing-box, Happ, V2Ray/Xray или Clash — и получаешь две вещи:
адрес DoH, который вставляется в настройки DNS самого клиента, копируется в одно касание;
там, где клиент это умеет, — готовый список доменов для правила REJECT и кусок конфига. Реклама режется внутри туннеля, и DNS вообще не участвует.
Списка два: облегчённый на 46 тысяч доменов и полный на 223 тысячи. По умолчанию предлагаю облегчённый — полный на iPhone заметно тяжелее для клиента. Профиль тем, кто выбрал VPN, сайт не предлагает вообще.
Стена №5: бот, которому Telegram повторяет одно и то же

Бот живёт в том же воркере. Если он отвечает Telegram ошибкой, тот считает сообщение недоставленным и присылает его снова, и снова. Кривое сообщение о платеже роняло код — и начинался круг.
Правило простое: Telegram всегда получает «ок», даже на сломанном сообщении, а ошибку я разбираю у себя. Заодно бот перестал отвечать в группах на обычный текст — там он слушает только свои команды.
Стена №6: а если сервер угонят?
Расширение умеет больше, чем помещается внутрь. В Chrome лимит — 30 000 встроенных правил, а в списках их около 130 тысяч. Поэтому внутри лежит 29 499 правил, а ещё 28 999 расширение раз в 12 часов забирает с сервера. Новое правило доезжает до всех без новой версии расширения.
Удобно, пока сервер мой. Но правила с сервера умеют не только блокировать: они могут перенаправлять страницы и снимать защиту сайта. А хитрый CSS вроде input[value^="a"]{background:url(...)} по одной букве утаскивает то, что человек вводит в поле. Если бы мой аккаунт Cloudflare угнали, это ударило бы по всем сразу.
Поэтому всё, что приходит с сервера, проходит фильтр: только «заблокировать», «пропустить» и подмена на пустышки внутри самого расширения. CSS без url(, @import и селекторов по значению поля. Проверил на настоящих правилах: из 28 999 фильтр не выкинул ни одного.
Мелкие грабли: регулярка на 2 КБ. После выкладки на странице расширений в Chrome появилась ошибка: правило 262 из списка AdGuard пропущено, потому что \.com\/jsonp\.php\?[A-Za-z0-9]{300,} после компиляции не влезает в 2 КБ памяти. Расширение работало, но красная надпись пугает. Теперь при сборке выкидываю регулярки с повтором от 100, а все оставшиеся прогоняю через declarativeNetRequest.isRegexSupported в самом браузере: 13 из 13 проходят.
Стена №7: блокировщик, который тормозит сильнее рекламы
Сетевые правила в расширении — это DeclarativeNetRequest: их применяет сам браузер, и они почти ничего не стоят. А вот скрытие пустых мест — CSS, и общий файл весил 600 КБ. Он вставлялся в каждую страницу в момент открытия, и на тяжёлых сайтах это было видно глазом.
Самыми дорогими оказались селекторы по подстроке вроде [class*="ad-"]: браузер проверяет их на каждом элементе при каждом изменении страницы. Из общего файла я убрал 532 таких селектора — по подстроке теперь прячу только на конкретных сайтах. Правила конкретного сайта вставляются сразу, общий файл — после DOMContentLoaded. А 2 МБ правил по сайтам разбиты на 32 файла: расширение читает один-два, а не всё целиком.
Главное: реклама внутри роликов
Ради этого всё и затевалось. Вырезать интеграцию блогера нельзя, но её можно промотать. Где она, расширение узнаёт из трёх мест.
База SponsorBlock. Это открытая база, где люди уже отметили миллионы рекламных кусков в роликах YouTube. Подвох: если спросить «где реклама в ролике abc123», база узнает, что ты смотришь. Поэтому спрашиваю только по первым 4 символам хеша от номера ролика. Под них подходят тысячи разных видео, а нужное выбираю у себя:
const prefix = (await sha256Hex(id)).slice(0, 4);
const list = await fetch(`${API}/skipSegments/${prefix}`).then((r) => r.json());
const hit = list.find((v) => v.videoID === id);
Таймкоды в описании. По закону рекламу обязаны помечать, и многие блогеры сами пишут «02:15 Реклама» или «05:00 Партнёр выпуска». Строка с таким словом — начало, следующий таймкод — конец. Если следующего нет или кусок длиннее 10 минут, не трогаю: лучше не промотать рекламу, чем промотать половину ролика.
Своя отметка. Нет вставки нигде — в углу плеера есть «Отметить рекламу». Нажал в начале, нажал в конце, и у тебя она пропускается сразу. А по кнопке уходит в SponsorBlock и пропускается у всех.
Грабли: реклама с нулевой секунды. Многие начинают ролик сразу с интеграции. Ответ SponsorBlock приходит через сеть, а плеер стартует раньше. Это место я чинил четыре раза.
Первая версия. Проверка висела на событии
timeupdate, которое браузер шлёт примерно 4 раза в секунду, а запрос к базе уходил, когда плеер уже появился. Первые 5–10 секунд рекламы человек успевал увидеть — ровно то, от чего хотел избавиться.Вторая. Запрос к базе ушёл в момент открытия страницы, ещё до плеера. Если ролик стартует с нуля, а ответа нет, плеер держался на паузе до 350 мс, дальше проверка шла на каждом кадре через
requestVideoFrameCallback. У меня всё перематывалось до первого кадра, но под VPN реклама всё равно шла 5 секунд: столько занимал сам ответ базы.Третья. Спрашивать ещё раньше и ждать дольше, пока это незаметно. Соединение с SponsorBlock открывается сразу через
preconnect— на медленном VPN одно TLS-рукопожатие легко занимает секунду. Ответы кэшируются, а если навести мышь на ролик в ленте и задержаться на 150 мс, расширение спрашивает про него заранее — к клику ответ уже лежит в кэше. Уходят те же 4 символа хеша. Пока кадра ещё нет и плеер сам грузится, жду до 1,5 секунды.Четвёртая. Пауза спорила с плеером YouTube: в логах он то снимал её сам, то ставил обратно, и был риск, что ролик так и останется стоять. Теперь паузы нет: пока ждём ответа, ролик прозрачный и без звука, а выглядит это как обычная загрузка. Как только ответ пришёл и вставка начинается не позже 0,5 секунды, перематываю на её конец до первого кадра.
Замерил на ролике, где интеграция идёт с 0:00 до 0:20, подменяя задержку ответа базы. Было: 5–10 секунд рекламы. Стало: при ответе до 1,5 секунды — ни одного кадра, плеер сразу открывается на 0:20; при ответе через 3,5 секунды — видно меньше половины секунды. На ещё трёх роликах того же автора с рекламой с нуля (0:00–0:31, 0:00–0:20, 0:00–0:17) плеер открывается сразу после неё. Вставку в середине ролика плеер перепрыгивает сразу. Честная оговорка: YouTube иногда сам возвращает звук, так что при медленной базе звук рекламы может проскочить на секунду.
Проверил на живом ролике: на 22-й секунде плеер сам перепрыгнул на 81-ю, а в углу появилось «Пропущена реклама 0:22–1:21 · база SponsorBlock · Вернуть». Кнопка «Вернуть» на случай, если промотало лишнее.
Сколько это стоит и почему оно не упадёт
Бесплатный тариф Cloudflare Workers — 100 000 запросов в сутки на весь аккаунт. Звучит много, пока не посчитаешь: телефон делает тысячи DNS-запросов в день, и лимит закончится на нескольких десятках телефонов. После этого фильтр перестал бы отвечать у всех сразу — а вместе с ним и интернет у тех, кто поставил профиль.
Что я сделал:
TTL. Заблокированный адрес раньше жил в кэше телефона 10 секунд, и телефон переспрашивал про один и тот же трекер шесть раз в минуту. Теперь — час: реклама за час никуда не переедет. Обычным адресам поднял нижнюю границу до 5 минут — CDN любят отдавать 20–60 секунд.
Лимит на адрес. Было 600 запросов в минуту с одного IP — при таком лимите один человек сжигал суточную норму за три минуты. Стало 120: обычному телефону с кэшем хватает с запасом.
Свой сервер для DNS. Телефоны теперь ходят на отдельный сервер, а воркер остался для сайта, бота и как запасной вход. Код тот же самый —
worker.jsзапускается под Deno, снаружи его видно только через Cloudflare Tunnel: входящих портов нет, IP сервера не светится. Служба ограничена одним ядром и 400 МБ памяти, чтобы не мешать соседям по серверу, а раз в минуту сторож проверяет, что DNS отвечает и сам, и через туннель, и перезапускает то, что упало.Кэш на сервере. Ответы публичного DNS держу в памяти столько, сколько разрешает их TTL: повторный вопрос про
habr.com— 1 мс вместо 500.Сколько выдерживает. Нагрузочный тест на одном ядре: 4 400 запросов в секунду, медиана 1 мс, 95% — 7 мс, памяти 80 МБ. Без кэша — 2 600 в секунду. Телефон в среднем делает меньше одного запроса в секунду, так что это тысячи телефонов. Цена — лишний прыжок через туннель: с тестового сервера в Европе ответ приходит за 136 мс против 67 мс у воркера. Телефон кэширует ответы, так что разницу видно только на первом заходе на сайт.
Расширению сервер не нужен. Все правила внутри, сервер нужен только раз в 12 часов за свежими.
Что хранится
На сервере — ничего. Нет журналов, нет базы, отладочные логи Cloudflare выключены. Лимит запросов Cloudflare держит в памяти, адреса никуда не пишутся.
В боте — ничего. Он не ведёт ни списков пользователей, ни истории.
В расширении всё лежит в браузере. Наружу уходят только 4 символа хеша ролика к SponsorBlock.
Чего я не могу обещать: через сервер проходят имена сайтов, и дальше их видит публичный DNS, которому я пересылаю вопрос, — сейчас это Cloudflare и Google. Если это неприемлемо, сервер поднимается у себя, адрес пересылки меняется одной строкой.
Чего «Чисто» не умеет
Официальные приложения YouTube, TikTok и Instagram — их реклама идёт с тех же серверов, что и видео. Пропуск интеграций работает в браузере на компьютере.
Свежий ролик, который ещё никто не отметил, — первые зрители рекламу увидят.
Android «Частный DNS» — пока нет: нужен отдельный порт, а он есть не везде.
Расширения пока нет в магазинах: ставится из архива с сайта или файлом из бота.
Если однажды я перестану этим заниматься
Бесплатно — пока я жив и сервер работает. Если перестанет — код открыт целиком: сайт, сервер, расширение, списки. Поднять свой «Чисто» — одна команда docker compose up -d на любом сервере или wrangler deploy на своём аккаунте Cloudflare. Инструкция в README.
Что поменялось с первой версии
Версия | Что исправлено |
|---|---|
0.1.0 | Запуск: DNS на воркере, профиль iPhone, Chrome на Android, расширение, бот |
0.2.0 | Дыры в DNS-парсере, бот без повторов от Telegram, фильтр правил с сервера, Android без «Частного DNS», пропуск интеграций на YouTube |
0.2.1 | Свой сервер для DNS с кэшем и сторожем, TTL и лимиты, вкладка «Использую VPN», лёгкий общий CSS, реклама с нуля под медленным VPN |
0.2.2 | Старт ролика без паузы, тяжёлые регулярки больше не попадают в правила |
Чтобы не проверять руками
После каждого изменения GitHub сам прогоняет 32 теста и собирает APK для Android. Своего Android SDK у меня нет, так что APK я ни разу не собирал сам — как и exe для Качалки. А раз в 6 часов отдельная проверка стучится в живой сервер: реклама должна блокироваться, обычные сайты — открываться. Если что-то упадёт, придёт письмо, а не сообщение от друга со скрином.
Поставить: chisto.flownote.cc. Бот — @chisto_ads_bot, исходники — github.com/kozliq/chisto.
Если знаете сайты, где реклама пролезает или что-то ломается, — киньте ссылку в комментарии, добавлю правило.
Качалка и «Чисто» — не последние. Есть ещё пара вещей, которые в Рунете почему-то принято делать с подписками на каналы и баннерами. Расскажу, когда будет что показать.
А тем, кто привык зарабатывать на навязчивых баннерах и платных стенах там, где можно обойтись без них, — привыкайте: пока у меня есть время, этих мест будет становиться всё меньше.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.