Ошибка линтера стоит пяти минут. Ошибка сканера, который проверяет сайты на соответствие 152-ФЗ, выглядит иначе: «ваш сайт нарушает закон». Если сайт при этом в порядке, а отчёт уже ушёл дальше, расплачивается чужая репутация.
Я пишу такой сканер. Его отчёт читает не только разработчик: он уходит веб‑студии, а от неё владельцу сайта. То есть за выдуманную находку краснеть будет не код, а люди, которые ему поверили.
Так что перед запуском я отложил новые проверки и месяц занимался противоположным: искал, где сканер врёт. Написал три инструмента, которые ищут ошибки в моём же коде, и прогнал через них полсотни самых неудобных сайтов Рунета. Трижды.
Нашлось восемь ловушек. Ни одну из них не увидел бы никто, кроме меня, но увидели бы все, выкати я сканер как есть.
Чем искал?
Обычный прогон отвечает на вопрос «что нашли». Мне был нужен другой: «можно ли этому верить».
Первый инструмент сайтов не открывает вообще. Он берёт готовые результаты и ищет в них следы вранья:
Противоречия внутри одного сайта. Если правило говорит «баннера нет», а соседнее «в баннере нет кнопки Отклонить», одно из двух ошиблось.
Правило, которое срабатывает почти всегда. Либо это правда про весь Рунет, либо правило слишком широкое.
Правило, которое не срабатывает никогда. На полусотне живых сайтов безупречность подозрительнее поломки.
Одинаковая уверенность у всех находок. Если везде стоит 1.0, поле
confidenceне значит ничего, а полоса риска в отчёте рисует неправду.Находки на сайтах, где юристы точно есть.
Последний пункт оказался самым полезным. У Госуслуг и крупных банков документы обычно в порядке, и находка там — почти всегда моя ошибка.
Набор сайтов подбирал так, чтобы было больно: госсервисы, налоговая, банки, операторы связи, хостинги, магазины за антибот‑защитой. Плюс заведомо несуществующий домен для контроля: на нём сканер обязан сказать «не удалось проверить», а не найти нарушения.
1. Госуслуги без политики конфиденциальности
Пять нарушений, включая «политики нет». Политика у Госуслуг, разумеется, есть.
Разбираться пришлось долго, потому что воспроизводилось через раз. В одном заходе главная отдала 39 ссылок, в следующем ноль: подвал со ссылкой на политику не успел отрисоваться. А ещё через заход пришла страница с кодом 200, правильным заголовком в <title> и текстом «Во время обработки запроса произошла ошибка» внутри. Две ссылки: «Вернуться назад» и «Перейти на главную».
Формально всё хорошо. Сайта сканер так и не увидел.
Теперь страница проходит входной контроль:
def _refused_or_broken(data: PageData) -> str: if data.status and data.status >= 400: return refusal.reason(data.status) text = data.dom.get("bodyText", "").strip() if len(text) < 60: return "страница пустая, вероятно, не догрузилась" # Служебная страница, отданная с кодом 200. Сервер бодро отвечает # «всё хорошо» и показывает извинения. if len(text) < STUB_MAX_TEXT and contains_any(text, STUB_WORDS): return "сервер отдал служебную страницу вместо содержимого" # Текст есть, а ссылок нет ни одной. Так выглядит приложение, успевшее # нарисовать заставку и не успевшее меню с подвалом. if not data.dom.get("links"): return "страница открылась, но не отрисовалась" return ""
Порог по ссылкам именно ноль, а не «мало». Тут я долго колебался: хотелось поставить три или пять. Но у живого сайта‑одностраничника бывает ровно одна ссылка, телефон в шапке, и порог побольше начал бы глушить настоящие находки.
2. «У РЖД просрочен сертификат»
РЖД, Т‑Банк и Timeweb получили «срок действия сертификата истёк». Уверенность 0.9, и английский текст ошибки OpenSSL прямо в теле находки.
С сертификатами у них всё хорошо. Их выпустил российский удостоверяющий центр, корневого сертификата которого нет в стандартном наборе доверенных. Сканер не отличил «не могу проверить» от «сломано».
Сообщить РЖД, что у неё битый сертификат, — так себе результат для инструмента, который проверяет соблюдение российского закона.
Разобрал отказ в доверии по видам. Для российских УЦ отдельная ветка: уверенность 0.4, то есть жёлтое «посмотрите», и пояснение по‑русски вместо вывода OpenSSL:
в цепочке есть корневой сертификат, которого нет в наборе доверенных у нашего робота, так выглядят российские удостоверяющие центры
Совсем молчать тоже нельзя: посетитель, у которого этого корня нет, увидит в браузере предупреждение. Проблема настоящая. Просто не та, о которой кричал сканер.
3. Политика, которую рисует JavaScript
Мэрия Москвы: «ссылка на политику битая, страница почти пустая».
Открыл руками — страница на месте. Это витрина на JavaScript, а сами документы приложены файлами PDF. Сканер качал её обычным HTTP‑запросом, и в сыром HTML текста там действительно почти нет.
Пришлось признать, что экономия не окупается: перед тем как выписать нарушение, страница открывается настоящим браузером в отдельной вкладке. Дорого, поэтому только в этом месте.
4. «Политика» на Яндексе — это новости
На yandex ссылка с подписью «Политика» ведёт в раздел политических новостей Дзена: dzen.ru/topic/politika.
Эвристика складывает вес из признаков. Слово «политик» в подписи даёт +2, politika в адресе ещё +2, порог для «уверенно политика» равен трём. Новостной раздел набрал четыре балла и дальше не проверялся вовсе.
Плохо это в обе стороны. Сайт без политики получает «всё в порядке». А окажись новостная страница пустой — получает выдуманное «ссылка битая».
Просто выкинуть двусмысленные слова из словаря не выйдет: на них держатся настоящие адреса вида /politika-konfidencialnosti/. Рядом такая же мина: personal в адресе. На каждом втором Битриксе /personal/ — это личный кабинет, а вовсе не персональные данные.
5. Код 503 значит «зайдите позже»
Страница политики Аэрофлота ответила 503, и сканер записал «ссылка на политику есть, но не открывается» с уверенностью 0.8. Сервер был перегружен, только и всего.
Случаи 3, 4 и 5 закрылись одним циклом. Перебираем несколько кандидатов и берём первый, который и открывается, и написан про персональные данные:
for _, url, text in policy_candidates(links)[:MAX_TRIES]: result = _check_link_opens(page, url, text) if not result["opens"]: отказ = отказ or result continue # about is False: страница открылась, но она не про персональные данные. # None: посмотреть не вышло, тогда верим подписи ссылки. if result.get("about") is not False or _named_policy(text): return result
На живом Яндексе этот цикл отбросил новости и нашёл настоящую политику по запасному пути: dzen.ru/persdata.
Ту же логику я применил ко всему сайту. «Зайдите позже» больше не отказ: ждём и повторяем, уважая Retry-After, с верхним пределом, чтобы один упрямый сайт не встал поперёк ночного прогона. Аэрофлот и Ситилинк после этого стали проверяться нормально.
6. Проверка молчала, а данные исчезли
Разборщик показал, что две проверки не сработали ни разу на сорока сайтах: «оператор не зарегистрирован в РКН» и «сайт размещён за пределами РФ». Обе живут не на коде, а на внешних данных.
С геолокацией всё оказалось в порядке: сервис не отвечал только на машине разработчика, а на боевом сервере работал всегда. Полдня на это ушло, и урок тут не про код, а про среду, в которой воспроизводишь баг.
А вот со вторым интереснее. Выгрузка реестра операторов раньше лежала в открытых данных Роскомнадзора, и я собирал из неё список ИНН. Пошёл обновить — выгрузки нет. Двадцать три набора: лицензии, СМИ, новостные агрегаторы. Реестра операторов среди них не осталось. На портале персональных данных доступен только поиск по одному оператору, файла на скачивание нет нигде.
Если кто‑то знает, куда он делся и вернётся ли, напишите в комментариях. Я не нашёл ни объявления, ни обсуждения.
Переписал на живой запрос по одному ИНН. Сведения из реестра общедоступны, ч. 4 ст. 22 152-ФЗ говорит об этом прямо. Вышло даже лучше прежнего: реестр меняется каждый день, и квартальный снимок всё равно устаревал бы.
Как устроено:
Поля формы не зашиты в код, а забираются с живой страницы при первом запросе. Переделают портал — сканер подстроится. Не сможет разобрать — уйдёт в молчание.
Вердикт «не зарегистрирован» выдаётся, только если портал явно ответил «ничего не найдено». Непонятный ответ, изменившаяся вёрстка, обрыв связи — это
None, и проверка молчит.Нагрузка на портал минимальная: пауза не меньше секунды, один запрос на компанию, кеш, остановка при первом признаке капчи. На двадцать сайтов это двадцать запросов в неделю.
Отрицательный ответ живёт в кеше месяц, а не вечно. Компания могла подать уведомление вчера.
Но главное здесь не реестр. Молчащее правило хуже отсутствующего, потому что отсутствующее хотя бы никого не обманывает. Проверка без данных выглядит в отчёте ровно так же, как проверка, которая всё осмотрела и ничего не нашла. Тринадцать проверок могли незаметно стать одиннадцатью, и заметил бы это не разработчик, а тот, кто читает отчёт.
Теперь об этом говорят вслух в трёх местах: сводка в конце прогона, предполётная проверка перед выкладкой и ночная задача, которая обходит все внешние источники и пишет письмо, если что‑то отвалилось.
# Эти проверки НЕ отработали, их находок в отчёте нет вовсе: operator_not_registered реестр не читается: портал не ответил hosting_outside_ru страну узнали у 1 хостинга из 40: timed out
7. Сайты видели в сканере робота
Сразу оговорюсь, потому что вопрос предсказуемый: капчу сканер не разгадывает, прокси не подбирает, ограничения по частоте не обходит. Задача была ровно одна — чтобы сканер видел ту же страницу, что и обычный посетитель. Иначе вердикт получается про чужую страницу.
Сайты малого бизнеса открылись все: ноль отказов из восемнадцати. Среди крупных и государственных не открылось одиннадцать из тридцати двух.
Прежде чем ругать чужую защиту, я замерил, чем представляется сам сканер:
Что отправляли | Что видит сайт |
|---|---|
| «я браузер‑робот», прямым текстом |
| версия не сходится с предыдущей строкой |
| у живого Chrome их пять |
| у живого Chrome он есть всегда |
| Chrome шлёт список с весами |
Первые две строки противоречат друг другу, и любая проверка на стороне сайта видит это одним сравнением. Никакой хитрой защиты тут не было: сканер сам махал флагом.
Двое грабель по дороге, на каждые ушло по часу.
navigator.userAgentData отдаёт новый объект при каждом обращении. Поэтому defineProperty на самом объекте молча теряется, и выглядит это так, будто метод не работает. Править надо прототип.
Параметр locale в Playwright портит Accept-Language. С locale="ru-RU" уходит голый ru-RU без весов. А если передать готовый список с весами, Playwright его продублирует, и получится ru;q=0.9;q=0.9. В итоге язык задаю заголовком, а navigator.language и Intl подгоняю скриптом.
Сайт за настоящей защитой так и остаётся непроверенным. ТАСС, например, отдаёт 1767 байт с JS‑загадкой вместо страницы — и в отчёте это написано прямым текстом, а не спрятано.
Кстати о тексте. «Сервер ответил ошибкой 403» читатель отчёта увидит в PDF и не поймёт ни что сломалось, ни к кому идти. Переписал все отказы: теперь там сказано, что произошло и что делать, и поломка сайта отличается от антибот‑защиты.
8. Уверенность 0.9 там, где выбор всё‑таки есть
Баннер с кнопками «Принять все» и «Настройки cookie» получал «нет кнопки отказа» с уверенностью 0.9. Столько же, сколько баннер с единственным «Принять».
Но отказаться там можно, просто на клик глубже. Европейские регуляторы считают, что отказ должен быть так же прост, как согласие, и по этой логике находка верна. Только спорить об этом должен юрист, а не сканер.
Снизил до 0.5. Это «требует внимания», а не «вы нарушаете закон», и в улике теперь сказано, какую именно кнопку сканер увидел. Шкала уверенности затем и нужна, чтобы ей пользоваться.
Улика важнее находки
Эту претензию разборщик выкатил уже не к коду, а к формулировкам. Находка policy_missing уходила на восьми сайтах с буквально одинаковым текстом: «ссылок на политику на главной нет».
По такой улике читатель не может ни проверить сканер, ни возразить. А первый вопрос того, кто получил отчёт, именно этот — где это у меня.
Стало так:
осмотрели 103 ссылки на главной, ни одна не ведёт на политику; по обычным адресам (/privacy, /policy) её тоже нет
Числа проверяются руками за минуту. Проверил: 103 и 118 ссылок на двух сайтах сошлись.
Чем мерил, что стало лучше?
Разборщик ловит подозрительное, но сайтов он не открывает. Смотреть находки глазами надёжнее всего, но это час работы на тридцать находок, и делать так каждый релиз никто не станет.
Третий инструмент встал между ними. Он заново открывает сайт и проверяет заявленный факт другим кодом — не тем, который находку породил. Правило говорит «у формы нет галочки согласия», а проверяльщик самостоятельно считает формы и галочки и сравнивает.
Первый прогон дал 93,9% совпадений и два расхождения. Оба оказались дырами в самом проверяльщике: на одном сайте формы жили на внутренних страницах, а он смотрел только главную; на другом находка была про галочку, отмеченную заранее, а он проверял только её наличие. После правки расхождений не осталось.
Честная оговорка: инструмент написан тем же человеком, что и правила, значит наследует его слепые пятна. Он не заменяет проверку глазами. Он сокращает ей работу раз в десять.
Как обнаружилось, что тесты ходят в интернет
После того как проверка реестра научилась спрашивать портал, полный прогон тестов стал занимать 46 секунд вместо 25. Вот эта разница в двадцать секунд и стала сигналом.
Оказалось, тесты, которым до реестра нет никакого дела, дёргали pd.rkn.gov.ru через общее правило. Запретил походы в сеть на уровне папки, отдельным conftest.py.
Тесты обязаны проходить без интернета. Иначе они падают у коллеги, в поезде и на сборке, а виноватым каждый раз выглядит не тот код.
Что из этого следует?
Оглядываясь на все восемь случаев, вижу одну общую причину: сканер делал выводы о том, чего не видел. О подвале, который не загрузился. О тексте, который рисует JavaScript. О данных, которых не было. О странице, которую ему просто не показали.
Правила, которые из этого выросли, довольно скучные, но теперь они вшиты в код и закрыты тестами:
«Сайт не открылся» — это не нарушение, а отсутствие проверки, и отчёт обязан сказать об этом прямо.
Сомнительное идёт как «требует внимания». Категоричность стоит дорого, а шкала уверенности есть, и пользоваться ею ничего не стоит.
Проверка, которая не смогла отработать, обязана крикнуть. Молчание в отчёте неотличимо от чистого результата — и это, пожалуй, самое неочевидное, что я вынес за месяц.
Что в итоге? Три прогона по полусотне сайтов: противоречий внутри сайта ноль, находок без проверяемой улики ноль, уверенность разложилась по девяти значениям от 0,4 до 0,95, причины недоступности написаны по‑русски без единого net::ERR_. Тестов 616, и одиннадцать из них стерегут не код, а текст писем, которые уходят людям.
Технический признак сканер не выдаёт за юридический вывод. Пока ссылки на статьи закона не сверены юристом, API отдаёт law_verified: false, и в отчёте их нет вообще.
Если вы делаете похожее или знаете места, где такие сканеры садятся в лужу, — напишите в комментариях. Соберу и проверю.

