Бесплатный блокировщик рекламы: 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 приходит через сеть, а плеер стартует раньше. Это место я чинил четыре раза.

  1. Первая версия. Проверка висела на событии timeupdate, которое браузер шлёт примерно 4 раза в секунду, а запрос к базе уходил, когда плеер уже появился. Первые 5–10 секунд рекламы человек успевал увидеть — ровно то, от чего хотел избавиться.

  2. Вторая. Запрос к базе ушёл в момент открытия страницы, ещё до плеера. Если ролик стартует с нуля, а ответа нет, плеер держался на паузе до 350 мс, дальше проверка шла на каждом кадре через requestVideoFrameCallback. У меня всё перематывалось до первого кадра, но под VPN реклама всё равно шла 5 секунд: столько занимал сам ответ базы.

  3. Третья. Спрашивать ещё раньше и ждать дольше, пока это незаметно. Соединение с SponsorBlock открывается сразу через preconnect — на медленном VPN одно TLS-рукопожатие легко занимает секунду. Ответы кэшируются, а если навести мышь на ролик в ленте и задержаться на 150 мс, расширение спрашивает про него заранее — к клику ответ уже лежит в кэше. Уходят те же 4 символа хеша. Пока кадра ещё нет и плеер сам грузится, жду до 1,5 секунды.

  4. Четвёртая. Пауза спорила с плеером 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.

Если знаете сайты, где реклама пролезает или что-то ломается, — киньте ссылку в комментарии, добавлю правило.

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

А тем, кто привык зарабатывать на навязчивых баннерах и платных стенах там, где можно обойтись без них, — привыкайте: пока у меня есть время, этих мест будет становиться всё меньше.

ссылка на оригинал статьи https://habr.com/ru/articles/1098988/