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

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:

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