Когда антибот притворяется РКН: ложные заглушки и локальный Web UI в rkn-block-checker 0.6.0

от автора

Проблема: Когда Авито притворяется Роскомнадзором В белом списке (контрольные сайты, которые должны работать всегда: Госуслуги, Яндекс и тд.) внезапно загорелся красным Авито:

avito   ✗ HTTP_STUB    HTTP 429 (37ms)  └ response body matches a known ISP stub-page marker

Скрипт на полном серьезе утверждал: “На Авито висит заглушка провайдера о блокировке РКН, уверенность: HIGH” Откуда на Авито взялась заглушка РКН? Всё дело в наивном поиске подстрок. В коде был массив маркеров STUB_MARKERS, характерных для страниц провайдеров:

STUB_MARKERS = (    "доступ ограничен",    "решению роскомнадзора",    "заблокирован",    "единый реестр",    # ...)

Авито известен защитой от авто парса. Увидев сетевой запрос без накопленных кук от стандартной библиотеки (даже несмотря на имитацию Chrome User-Agent), WAF/антибот выдал стандартный статус 429 Too Many Requests.

А на самой HTML-странице ошибки 429 крупным шрифтом было написано: «Доступ с вашего IP временно ограничен». Скрипт скачал тело ответа, нашел вхождение подстроки “доступ ограничен” и радостно сообщил пользователю, что ресурс заблокирован цензурой на магистрали

Решение: Провайдерские заглушки в России имеют характерную особенность: они почти всегда маскируются под статус 200 OK (перехват с подменой тела) либо возвращают специализированный статус 451 Unavailable For Legal Reasons. HTTP 429 (Rate Limit) и 403 с капчей — это реакция самого целевого сервера, а не фильтра ТСПУ на магистрали. В core.py пришлось явно развести понятия цензуры и антибот-защиты:

    if http_mod.looks_like_stub(probe.body_snippet):        if res.status_code == 429:            res.verdict = Verdict.DOWN            res.confidence = Confidence.LOW            res.notes.append("http 429 (antibot rate limit) contained words similar to a block stub")            return res        res.verdict = Verdict.HTTP_STUB        res.confidence = Confidence.HIGH        res.notes.append(            "response body matches a known ISP stub-page marker"        )        return res

Web UI: Зачем консольной утилите браузер?

Изначально проект задумывался строго как CLI. Но когда начинаешь разбирать спорные кейсы (“почему конкретный сервис то работает, то нет”), сухого текста в консоли становится мало. Хочется:

  1. Кликнуть по сайту и увидеть детальный таймлайн: на сколько миллисекунд задержался TCP, как ответил TLS, что именно вернул DNS.

  2. Дать инструмент тем, кто не привык работать в терминале.

  3. Добавлять кастомные домены прямо на лету и перепроверять их в один клик.

Тащить ради этого тяжелые зависимости вроде FastAPI, Flask или Uvicorn не хотелось категорически. Инструмент должен ставиться за секунду через pip install в чистый системный Python.

Поэтому веб-интерфейс был написан максимально легковесно:

  • Бэкенд: встроенный http.server.ThreadingHTTPServer из стандартной библиотеки Python.

  • Фронтенд: один файл index.html на ванильном JS и CSS без сборщиков, node_modules и внешних CDN.

Запускается одной командой:

rkn-check startwebилиrkn-check startweb --port 8080

Сервер по умолчанию слушает 127.0.0.1:7777. Как устроен реалтайм без WebSocket Чтобы интерфейс не висел мертвым во время проверки нескольких десятков сайтов, результаты должны прилетать по мере готовности (как в CLI через as_completed()). Вместо того чтобы городить WebSocket или постоянный polling, бэкенд отдает результаты обычным HTTP-стримингом в формате NDJSON (Newline Delimited JSON):

self.send_response(200)self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")self.end_headers()for res in iter_check_urls(targets, max_workers=workers, timeout=timeout):    line = json.dumps(res.to_dict()) + "\n"    self.wfile.write(line.encode("utf-8"))    self.wfile.flush() # Выталкиваем чанк в сокет сразу

А в браузере стандартный fetch читает поток чанков через ReadableStreamDefaultReader:

const response = await fetch('/api/scan', { method: 'POST', body: JSON.stringify(payload) });const reader = response.body.getReader();const decoder = new TextDecoder();let buffer = '';while (true) {    const { value, done } = await reader.read();    if (done) break;        buffer += decoder.decode(value, { stream: true });    let lines = buffer.split('\n');    buffer = lines.pop(); // Неполную строку оставляем в буфере    for (let line of lines) {        if (!line.trim()) continue;        const result = JSON.parse(line);        updateTableRow(result); // Обновляем конкретную строчку в таблице на лету    }}

Что в итоге (релиз 0.6.0)

В версию 0.6.0 вошли:

  1. Полноценный локальный Web UI (rkn-check startweb) с графиками, мультиязычностью (RU/EN) и ручным добавлением произвольных URL

  2. Фильтрация ложных срабатываний на антибот-заглушки (HTTP 429)

  3. Возможность экспорта полного отчета в JSON прямо из браузера в один клик

Код по-прежнему полностью открыт под MIT:

Буду рад любому фидбеку!

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