.античит для честной игры по передаче информации в интернете.
Античит для честной игры по передаче информации в интернете

Слово — это базовая единица в лингвистике.

Дисклеймер: текущие LLM‑возможности позволяют детектировать жирный слоп. Но оценка может быть субъективной, и возможны ошибки — никогда не доверяйте оценке слепо: это обоюдоострый меч. Оценить точно не всегда могут сами люди, кроме совсем очевидных примеров, и даже там среднестатистический житель планеты напряжётся. Этот метод — инструмент, призванный экономить время, необходимое для диагностики человеком. Именно эту плоскость и будет раскрывать статья.

Да будет свет. Год назад @vvzvlad руками показал, как опознавать слоп, и заметил главное: «детекторы ИИ бесполезны» — их обходят дообучением и сменой стиля, а иногда обходить и не надо: детектор меряет среднюю температуру по атмосфере Марса.

И при этом сам vvzvlad делает то, что обойти нельзя: проверяет не «писал ли ИИ», а является ли текст слопом (слоп — мусорная информация, не приносящая пользы читателю, а служащая payload'ом для цели автора или того, кто текст выплюнул на сайт) — вне зависимости от того, кожаный его автор или жестяной. Я это автоматизировал частично. Фильтр ловит множество классов, которые попсовые ИИ‑детекторы не видят, и ни сменой стиля, ни переписыванием его не обойти. Он экономит время человека и выдаёт аудит найденного, чтобы человек сам решал, слоп это или нет.

Аесли кто‑то обойдёт текст, к тому моменту из‑за всех попыток обойти детектор он постепенно будет дорабатываться как побочный процесс. Не факт, что он станет лучше. Но, по крайней мере, получит шансы.

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

Что тяжелее: килограмм пуха или килограмм слопа?
Что тяжелее: килограмм пуха или килограмм слопа?

Что тяжелее: килограмм пуха или килограмм слопа?

Кожаный это слоп или жестяной? Вопрос поставлен неправильно

Человеко‑минуты на прочтение оба сжигают одинаково в плане конечного результата.

Чтение статей — это дуэль: сколько своих ресурсов потратите вы на распознавание слопа (или пропустите и запомните слоп, не поняв, что это слоп) против того, сколько времени на стеганографию потратил оппонент, пытаясь обмануть вас как классификатор. Истина относительна.

vvzvlad утвердил следующее:

Использовать LLM для детекции LLM не имеет смысла. Как только вы выпускаете детектор в открытое пространство, любую сеть можно обучить его обходить.

Это правда, но только если использовать детектор, чтобы детектировать LLM. ИИ‑детекторы обходятся дообучением или простым «перефразируй, пока не замолчит».

Но есть вопрос, который обойти нельзя по построению: не «писал ли ИИ?», а несёт ли текст смысл, сходится ли он, если разобрать на куски и связи? Чтобы пройти такую проверку, текст обязан реально нести смысл.

«Перефразируй, пока не замолчит» здесь означает править текст (идеально не надо, но ИИ‑текст всегда будет иметь планку ниже на детекторе, чем человек, из‑за пустоты).

Проблема одна: руками это не масштабируется. vvzvlad сам пишет: «примеры не то чтобы закончились… но я устал». Один человек не вычитает Хабр, а LLM генерирует слоп килограммами и бесплатно, благо есть deepseek и прочее доброе, светлое и вечное.

Значит, автоматизировать.

Не ИИ-детектор, а пайплайн из того, что эксперт делает глазами и ртом, — и выносит готовый продукт на обозрение человека.

С компромиссом для автоматизации: итоговую оценку факторов будет выдавать человек.

Чего не видят AI‑детекторы: вода, пропаганда, манипуляции и прочее

Грубый SEO‑мусор и явную нейрогенерацию ловят и без меня (ну или я на это надеюсь). А вот другой класс — компетентная вода: человек начинает по делу, грамотно, а потом, не имея реального опыта, сходит с темы и льёт правдоподобные факты. Грамматика безупречна, ИИ‑вероятность низкая, статистический детектор молчит.

Любой такой инструмент ничего не стоит без внешней сверки — вот 3 конкретные статьи, которые vvzvlad в своём разборе пометил слопом руками.

Как это выглядит (глубокая проверка, под капотом GLM 5.2)

>>11/2 Почему батареи в некоторых телефонах живут так мало Опа, слоп. Причем того же автора, что и выше, About_it (Техногик, пишу: об айти, об этом и обо всем). Заходим к нему в профиль... Уууу. Чистейший слоп, прям квинтэссенция, каждая статья как пример.

Глубокая проверка о том же тексте: 0.42, «Сомнительно». Не голый балл — под оценкой лежит разбор по фрагментам, где именно сборка текста разваливается.

>>1/1 Как цифровые видеорекордеры меняли ТВ и почему не выжили (слоп, отсюда куча примеров в этой статье, автор techno_mot)

>>3/2 Toshiba разработала HDD с 12 пластинами (слоп. Автор TrexSelectel)

>>14/6 Учимся дружить Mikrotik с Nginx (не слоп, чувак реально ковыряет тему)

Ссылки на инструмент в конце статьи.

Архитектурные решения и выборы

Смотреть на графы и читать философию интересно, но аудитория Хабра ходит на него за другим.

Страница в браузере
 ├─ нормализованный URL
 ├─ отпечаток отображённого содержимого
 └─ извлечённый текст
        │
        ▼
Проверка на сервере
 ├─ сверка текста с источником
 ├─ построение полного вердикта
 └─ атомарная запись результата
        │
        ▼
Бесплатная выдача следующему читателю - только для этой версии страницы

Публикация проверки выполняется только с согласия пользователя. Клиент может сообщить URL, отпечаток страницы и просьбу поделиться результатом, но он не может самостоятельно продиктовать серверу оценку, модель или содержимое аудита. В общую базу попадает результат, сформированный самим сервером.

Перед записью сервер отдельно проверяет, что анализируемый текст действительно относится к указанному публичному источнику. Поэтому нельзя вставить случайный текст, указать адрес известного сайта и получить якобы подтверждённую проверку этой страницы.

В базе хранится не один балл, а ограниченный полный вердикт: итоговая оценка, режим проверки, модель, найденные фрагменты, пояснения и доступные основания. Если результат неполный или источник не подтверждён, публичная запись не создаётся. Старые строки, где сохранилось только число без аудита, не выдаются как полноценные проверки.

Идентичность страницы определяется одновременно адресом и отпечатком отображённого содержимого. Один URL недостаточен: статья может измениться, а разные страницы иногда живут под похожими адресами. Сам отпечаток тоже не считается доказательством и используется вместе с серверной сверкой источника.

Когда другой пользователь открывает ту же версию страницы, расширение запрашивает уже существующие проверки. Повторный вызов модели не требуется: человек бесплатно получает полный вердикт, а не отвязанный от текста рейтинг.

Проверки всей страницы, отдельного фрагмента и вставленного вручную текста хранятся как разные типы объектов. Проверка абзаца не может заменить оценку всей статьи, а произвольный вставленный текст никогда не приобретает происхождение конкретной веб‑страницы.

Если проверок несколько, система сохраняет каждую отдельно вместе с моделью и режимом. Поверх них может строиться отдельный синтез: что повторяется, где результаты расходятся и какие замечания подтверждаются несколькими проходами. Синтез не стирает исходные вердикты, поэтому читатель может открыть их и перепроверить вывод.

Публичный интерфейс не позволяет выгрузить всю базу, искать по произвольному тексту или перечислять накопленный корпус. Он отвечает только на вопрос: Какие полные проверки существуют для страницы, которую я сейчас действительно вижу? Исследовательский граф и массовые выборки остаются закрытой операторской поверхностью.

Это всё ещё централизованная архитектура, а не распределённый консенсус и не оракул. Сервер остаётся доверенной стороной и точкой отказа. Архитектура гарантирует привязку результата, его целостность, и возможность аудита, но не превращает вывод модели в установленный факт.

Почему общий индекс не является обычной голосовалкой

Долгосрочная задача ClearWater не только проверить текст для одного читателя, но и не заставлять следующего читателя снова платить за уже проделанную работу. Если пользователь согласился поделиться результатом, полная проверка сохраняется на сервере и становится доступна другим людям, открывшим ту же версию страницы.

При этом клиент не может прислать серверу готовый балл и подписать им произвольный текст. Он передаёт согласие на публикацию и сведения о странице, а сам результат берётся из только что завершённой серверной проверки. Для публикации страницы нужны нормализованный URL, отпечаток отображённого содержимого, независимая сверка источника и полный вердикт. Вставленный вручную текст хранится отдельно и не может стать оценкой статьи по подставленному адресу.

Система также не схлопывает разные проверки в безымянное среднее. Читателю остаются доступны отдельные полные вердикты с моделью, режимом, замечаниями и обоснованиями. Поверх них строится осторожный синтез: отдельно показываются совпавшие находки, уникальные находки каждой проверки, расхождения оценок и потенциальные слепые пятна. Согласие нескольких прогонов повышает вес свидетельства, но не превращает его в абсолют и не стирает несогласие.

Защита от накрутки тоже строится слоями. В основном публичном канале сервер публикует собственный завершённый артефакт проверки, а не присланный клиентом «голос». Частые отправки замедляются, подозрительные массовые всплески временно исключаются из текущего агрегата, а результаты модели учитываются как один ограниченный коррелированный класс свидетельств: они не зарабатывают пользовательскую репутацию и не изображают толпу независимых людей. Для отдельного контура прямых пользовательских голосов в коде предусмотрены замена повторного голоса той же личности, ограниченное влияние свежих идентичностей и анализ совместного поведения зрелых участников, чтобы синхронная группа аккаунтов не выглядела множеством независимых наблюдателей.

Здесь есть намеренная граница раскрытия. Публичны модель угроз и инварианты: нельзя подменить текст страницы, нельзя размножить один голос повторной отправкой, всплеск не должен мгновенно переписать рейтинг, а расхождение нельзя прятать средним числом. Конкретные веса, пороги, признаки координации и административные процедуры не публикуются, иначе описание защиты стало бы инструкцией по её обходу.

Текущая версия ещё не умеет надёжно отличать органический наплыв читателей на популярную статью от организованного рейда; для этого нужны отдельный путь пересмотра карантина, пользовательские жалобы и апелляции. Семантический синтез также пока сохраняет и группирует свидетельства, но не выносит новый мета‑вердикт по каждому содержательному конфликту. Поэтому общий результат по конкретной странице открыт читателю, а массовая выгрузка всего корпуса и внутренний граф остаются закрыты.

Как это устроено

ClearWater состоит из тонкого браузерного клиента и серверного конвейера. Расширение извлекает видимый текст, нормализует адрес страницы, вычисляет отпечаток содержимого и отправляет типизированный запрос. Долгой проверкой владеет service worker: popup можно закрыть, а прогресс и результат сохраняются локально.

Сервер ставит задачу в очередь и выполняет несколько изолированных проходов анализа. Проверка связности и структуры отделена от фактчекинга по внешним источникам. На выходе получается не анонимный балл, а полный вердикт: модель и режим, итоговая оценка, конкретные фрагменты, замечания, объяснения и доступные свидетельства.

Если пользователь явно согласился поделиться результатом, сервер сохраняет собственный завершённый и очищенный артефакт, привязанный к каноническому URL и отпечатку содержимого. Клиент не может прислать готовый балл и назначить его произвольной странице. Вставленный вручную текст остаётся отдельным типом данных и не становится публичной оценкой сайта.

Другой читатель той же страницы получает полный проверяемый вердикт, а не только число. Несогласие разных проверок сохраняется и показывается отдельно. Массовая выгрузка корпуса и внутренний граф при этом не публикуются.

Мы открываем поток данных, модель угроз, формат результата и системные инварианты. Закрытыми остаются точные промпты, веса, пороги финального допуска, маршрутизация моделей и признаки антиабьюза. Клиент всё равно считается публичным и потенциально враждебным: защита строится на серверной авторитетности и привязке результата к содержимому, а не на секретности расширения.

Так это просто модель с промптом?

Честный скепсис: «ты просто спросил нейросеть, слоп ли это».

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

А можно вообще без ИИ? Открытый пре‑фильтр в браузере

Справедливое возражение: «у тебя под капотом LLM — дорого и непрозрачно». Отвечу делом: часть дефектов ловится вообще без ИИ — чистой арифметикой в браузере, мгновенно и бесплатно. Это не смысловой движок (связность и логику простым софтом не измеришь, его я и оставляю закрытым). Это грубый открытый пре‑фильтр, который отсекает очевидное. Что считается локально:

  • взбитые ссылки

  • числа и статы без пруфа — число без ссылки или маркера источника рядом;

  • отсутствие источников — высокая плотность утверждений при нуле исходящих ссылок;

  • keyword‑stuffing — аномальная плотность одного слова или биграммы;

  • экстремумы читабельности — индекс и доля сверхдлинных предложений.

Ставите Tampermonkey, создаёте скрипт, вставляете код. На любой статье Alt+W (или кружок CW в углу) → панель дефектов. Ничего никуда не отправляется, весь анализ в браузере; ядро — чистые функции, гоняются в Node для тестов.

Полный код userscript (MIT), исполнение — Fable 5 xhigh + GLM 5.6 xhigh:

// ==UserScript==
// @name         ClearWater PreFilter — локальные сигналы качества текста
// @namespace    https://wecleantheinternet.com/
// @version      0.2.1
// @description  Локальный эвристический пре-фильтр: экстремумы читабельности, повторы, keyword-stuffing и количественные утверждения без видимой опоры. Анализ текста выполняется в браузере; проверка ссылок запускается отдельно и делает сетевые запросы. Результат предназначен для ручной проверки и не доказывает истинность, качество или авторство текста.
// @author       ClearWAter
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @connect      *
// @license      MIT
// ==/UserScript==

/* eslint-disable no-var */
(function () {
  "use strict";

  // ===================================================================
  //  ЯДРО — чистые функции анализа. Они не трогают DOM и сеть, поэтому их
  //  можно прогонять в Node. Каждый результат содержит наблюдаемый сигнал,
  //  а не предположение об авторстве текста. Отсутствие выбранных сигналов
  //  не доказывает, что материал качественный или истинный.
  // ===================================================================
  var Core = {};

  var RU_VOWELS = "аеёиоуыэюяАЕЁИОУЫЭЮЯ";
  var LAT_VOWELS = "aeiouyAEIOUY";
  var STOP = new Set(("и в во не что он на я с со как а то все она так его но да ты к у же вы за бы " +
    "по только ее мне было вот от меня еще нет о из ему теперь когда даже ну вдруг ли если уже или ни " +
    "быть был него до вас нибудь опять уж вам ведь там потом себя ничего ей может они тут где есть надо " +
    "ней для мы тебя их чем была сам чтоб без будто чего раз тоже себе под будет ж кто этот того потому " +
    "этого какой совсем ним здесь этом один почти мой тем чтобы нее сейчас были куда зачем всех никогда " +
    "можно при наконец два об другой хоть после над больше тот через эти нас про всего них какая много " +
    "разве три эту моя впрочем хорошо свою этой перейти нас это").split(/\s+/));

  // Аббревиатуры, после точки которых НЕ конец предложения.
  var ABBR = ("т е д п г гг см рис табл стр им ул др пр напр например тыс млн млрд трлн " +
    "руб долл коп ч мин сек кг км мм гб мб кб вкл см_рис").split(/\s+/);

  // -- токенизация --------------------------------------------------
  // Защищаем точки внутри версий, сокращений и инициалов от разбиения
  // предложения, затем восстанавливаем их после токенизации.
  var _SENT = ""; // временный маркер защищённой точки
  Core.splitSentences = function (text) {
    var s = String(text || "").replace(/\s+/g, " ");
    // 1) десятичные / версии: цифра.цифра  (3.11, 0.9.23 → обе точки)
    s = s.replace(/(\d)\.(?=\d)/g, "$1" + _SENT);
    // 2) известные аббревиатуры «слово.» перед пробелом.
    //    NB: JS \b — только ASCII и для кириллицы НЕ работает (probe это и
    //    поймал: "тыс.", "т.е." рвались). Используем lookbehind по классу
    //    «не буква/цифра/_», который корректен для кириллицы.
    var abbrRe = new RegExp("(?<![A-Za-zА-Яа-яЁё0-9_])(" + ABBR.join("|") + ")\\.(?=\\s)", "gi");
    s = s.replace(abbrRe, "$1" + _SENT);
    // 3) одиночная буква + точка перед буквой (инициалы, "т.е.", "И. Иванов")
    s = s.replace(/(?<![A-Za-zА-Яа-яЁё0-9_])([A-Za-zА-Яа-яЁё])\.(?=\s*[A-Za-zА-Яа-яЁё])/g, "$1" + _SENT);
    var raw = s.split(/(?<=[.!?…])\s+/);
    return raw
      .map(function (x) { return x.split(_SENT).join(".").trim(); })
      .filter(function (x) { return x.length > 0; });
  };

  Core.words = function (text) {
    var m = String(text || "").match(/[A-Za-zА-Яа-яЁё0-9]+/g);
    return m || [];
  };

  // Учитываем русские и латинские гласные, чтобы технические термины
  // не превращались в слова с нулём слогов.
  Core.syllables = function (word) {
    var n = 0, vowels = RU_VOWELS + LAT_VOWELS, prevV = false;
    for (var i = 0; i < word.length; i++) {
      var isV = vowels.indexOf(word[i]) !== -1;
      if (isV && !prevV) n++;   // соседние гласные = один слог (дифтонг-аппрокс)
      prevV = isV;
    }
    return n;
  };

  // -- 1. ЧИТАБЕЛЬНОСТЬ (экстремумы) --------------------------------
  // FRE-ru по Оборневой (адаптация Флеша): чем ниже, тем тяжелее текст.
  // Флагуем только ЭКСТРЕМУМЫ, а не нормальную сложную прозу.
  Core.readability = function (text) {
    var sents = Core.splitSentences(text);
    var ws = Core.words(text).filter(function (w) { return !/^\d+$/.test(w); }); // чистые числа не слова
    var nS = sents.length || 1, nW = ws.length || 1;
    var syll = 0;
    for (var i = 0; i < ws.length; i++) syll += Math.max(1, Core.syllables(ws[i]));
    var asl = nW / nS;            // средняя длина предложения (слов)
    var asw = syll / nW;          // средняя длина слова (слогов)
    var fre = 206.835 - 1.3 * asl - 60.1 * asw; // FRE-ru (Оборнева)
    var longCnt = 0;
    for (var j = 0; j < sents.length; j++) {
      if (Core.words(sents[j]).length > 40) longCnt++;
    }
    var longPct = sents.length ? longCnt / sents.length : 0;
    var flags = [];
    if (asl > 30) flags.push("средняя длина предложения " + asl.toFixed(1) + " слов (>30 — тяжело)");
    if (longPct > 0.15) flags.push((longPct * 100).toFixed(0) + "% предложений длиннее 40 слов");
    if (fre < 0) flags.push("индекс читабельности FRE-ru = " + fre.toFixed(0) + " (экстремально низкий)");
    return {
      fre: +fre.toFixed(1), avgSentLen: +asl.toFixed(1),
      avgWordSyll: +asw.toFixed(2), longSentPct: +(longPct).toFixed(2),
      n_sentences: nS, n_words: nW, flags: flags
    };
  };

  // -- вспом.: содержательные токены и n-gram счётчики ---------------
  function contentTokens(text) {
    return Core.words(text).map(function (w) { return w.toLowerCase(); })
      .filter(function (w) { return w.length > 3 && !STOP.has(w) && !/^\d+$/.test(w); });
  }
  function topNgram(tokens, n) {
    var f = {}, i, gram, best = { gram: "", count: 0 };
    for (i = 0; i + n <= tokens.length; i++) {
      gram = tokens.slice(i, i + n).join(" ");
      f[gram] = (f[gram] || 0) + 1;
      if (f[gram] > best.count) { best = { gram: gram, count: f[gram] }; }
    }
    return best;
  }

  // -- 2. ПОВТОРЫ / БОЙЛЕРПЛЕЙТ (внутренний n-gram оверлап) — INFO ----
  // Здесь считаются только внутренние повторы. Соседние совпадения могут
  // возникать из-за цитирования, поэтому показываем лишь разнесённые копии
  // длинного шингла. Это информационный сигнал, а не обвинение в плагиате.
  Core.repeatedShingles = function (text, n) {
    n = n || 8;
    var ws = Core.words(text).map(function (w) { return w.toLowerCase(); });
    var pos = {};
    for (var i = 0; i + n <= ws.length; i++) {
      var slice = ws.slice(i, i + n);
      var meaningful = slice.filter(function (w) { return w.length > 2 && !STOP.has(w); }).length;
      if (meaningful < Math.ceil(n / 2)) continue;
      var gram = slice.join(" ");
      (pos[gram] = pos[gram] || []).push(i);
    }
    var list = [];
    Object.keys(pos).forEach(function (g) {
      var ps = pos[g];
      if (ps.length < 3) return;
      var spread = ps.filter(function (p, k) { return k === 0 || p - ps[k - 1] > 4 * n; });
      if (spread.length < 3) return;
      list.push({ gram: g, count: spread.length, rawCount: ps.length });
    });
    list.sort(function (a, b) { return b.count - a.count; });
    return { n: n, distinctRepeated: list.length, examples: list.slice(0, 8) };
  };

  // -- 2b. KEYWORD-STUFFING (SEO-нагон) -----------------------------
  // Высокая частота одного тематического слова сама по себе нормальна.
  // Более сильный сигнал — дословное повторение одной содержательной фразы.
  Core.keywordStuffing = function (text) {
    var ct = contentTokens(text);
    var nC = ct.length || 1;
    var per1k = function (x) { return x / nC * 1000; };
    var t1 = topNgram(ct, 1);
    var b1 = topNgram(ct, 2);
    var tri = topNgram(ct, 3);
    var d1 = t1.count / nC;
    var flags = [];
    // сильный сигнал: дословный содержательный триграм ≥4 раз
    if (tri.count >= 4)
      flags.push("фраза-ключ «" + tri.gram + "» дословно ×" + tri.count + " (SEO-нагон)");
    // биграм — только при экстремальной плотности (>12 на 1000 сод. слов И ≥8 раз)
    if (b1.count >= 8 && per1k(b1.count) > 12)
      flags.push("биграм «" + b1.gram + "» ×" + b1.count + " (" + per1k(b1.count).toFixed(0) + "/1000 сод. слов)");
    // одиночный токен — только выше 9% И ≥12 раз (топикальность держится <8%)
    if (d1 > 0.09 && t1.count >= 12)
      flags.push("слово «" + t1.gram + "» — " + (d1 * 100).toFixed(1) + "% значимых слов ×" + t1.count);
    return {
      topToken: t1.gram, topTokenDensity: +d1.toFixed(3),
      topBigram: b1.gram, topBigramCount: b1.count,
      topTrigram: tri.gram, topTrigramCount: tri.count,
      flags: flags
    };
  };

  // -- 3. ДУБЛИ ПРЕДЛОЖЕНИЙ — INFO ----------------------------------
  Core.duplicateSentences = function (text) {
    var sents = Core.splitSentences(text);
    var norm = {}, dups = [];
    for (var i = 0; i < sents.length; i++) {
      var key = sents[i].toLowerCase().replace(/[^a-zа-яё0-9 ]/gi, "").trim();
      if (key.length < 25) continue;
      if (norm[key]) { if (norm[key] === 1) dups.push(sents[i]); norm[key]++; }
      else norm[key] = 1;
    }
    return { count: dups.length, examples: dups.slice(0, 5) };
  };

  // -- 4. СТАТ-ПРЕТЕНЗИИ БЕЗ ПРУФА -----------------------------------
  // Отделяем описательные числа (годы, версии, размеры, порты) от
  // количественных утверждений о внешнем мире: процентов, кратности,
  // рыночных оценок и заявлений о росте или снижении.
  var STAT_RE = new RegExp(
    "(" +
      "\\d[\\d.,\\s]*\\s*%" +                                  // 47%
      "|\\d[\\d.,\\s]*\\s*процент" +                           // 47 процентов
      "|(?<![А-Яа-яЁё])в\\s+\\d[\\d.,]*\\s*раз(а|)" +          // в 5 раз(а)  (Cyrillic \b мёртв в JS)
      "|(?<![А-Яа-яЁё])на\\s+\\d[\\d.,]*\\s*(раз|%|процент)" + // на 3 раза / на 5%
      "|[$€₽]\\s*\\d" +                                        // $12
      "|\\d[\\d.,]*\\s*(млрд|млн|тыс|трлн|миллион|миллиард)" +  // 12 млрд
      "|CAGR" +
      "|(вырос|выросл|упал|упа|снизил|снижени|увеличил|сократил|подорожал|подешевел|прирост|рост|падени)\\S*\\D{0,18}\\d" + // вырос на 12
      "|(состав|достиг|превыс|насчит|оценива)\\S*\\D{0,12}\\d" + // достиг 5 млн
      "|(больше|меньше|выше|ниже|быстрее|дешевле|дороже|эффективн)\\S*\\s+в?\\s*\\d[\\d.,]*\\s*(раз|%|процент)" +
    ")",
    "i");
  var NUM_ANY_RE = /(\d[\d\s.,]*\s*(%|процент|млрд|млн|тыс|раз|кратн|CAGR)|[$€₽]\s*\d|\b\d{4}\b|\d+[.,]\d+)/i;
  var SRC_RE = /(источник|по данным|согласно|исследовани|отчёт|отчет|статист|\[\d+\]|http|www\.|ссылк|документац|spec|RFC|ГОСТ|по словам)/i;
  var NEGATED_SOURCE_RE = /((без|нет|отсутств\w*|не\s+(указан\w*|привед\w*|дан\w*|найден\w*))\D{0,24}(источник\w*|ссыл\w*|доказательств\w*|данн\w*))|((источник\w*|ссыл\w*|доказательств\w*)\D{0,24}(не\s+(указан\w*|привед\w*|найден\w*)|отсутств\w*|нет))/i;
  function hasSourceMarker(sentence) {
    var s = String(sentence || "");
    return SRC_RE.test(s) && !NEGATED_SOURCE_RE.test(s);
  }
  Core.numbersWithoutProof = function (text, sentHasLink) {
    sentHasLink = sentHasLink || function () { return false; };
    var sents = Core.splitSentences(text);
    var bareStat = [], statSents = 0, numSents = 0;
    for (var i = 0; i < sents.length; i++) {
      if (NUM_ANY_RE.test(sents[i])) numSents++;
      if (!STAT_RE.test(sents[i])) continue;      // не количественная претензия — пропуск
      statSents++;
      if (hasSourceMarker(sents[i])) continue;     // есть положительный маркер источника
      if (sentHasLink(sents[i], i)) continue;      // есть ссылка рядом
      bareStat.push(sents[i]);
    }
    return {
      sentencesWithNumbers: numSents,   // INFO
      statClaims: statSents,
      bareStatClaims: bareStat.length,
      examples: bareStat.slice(0, 6).map(function (s) { return s.slice(0, 160); })
    };
  };

  // -- 5. ОТСУТСТВИЕ ИСТОЧНИКОВ --------------------------------------
  // Сигнал возникает, когда в тексте много количественных или проверяемых
  // утверждений, но ни одно из них не связано с видимым источником.
  var CLAIM_RE = /(показал|доказал|подтвержда|исследовани|эксперт|учёны|ученые|статист|по данным|известно, что|установлено|доказано)/i;
  Core.sourceBalance = function (text, nLinks, sentHasLink) {
    sentHasLink = sentHasLink || function () { return false; };
    var sents = Core.splitSentences(text);
    var claims = 0, supportedClaims = 0, srcMarks = 0;
    for (var i = 0; i < sents.length; i++) {
      var marker = hasSourceMarker(sents[i]);
      if (marker) srcMarks++;
      if (STAT_RE.test(sents[i]) || CLAIM_RE.test(sents[i])) {
        claims++;
        if (marker || sentHasLink(sents[i], i)) supportedClaims++;
      }
    }
    var flagged = claims >= 8 && supportedClaims === 0;
    return {
      claimSentences: claims, sourceMarkers: srcMarks,
      supportedClaimSentences: supportedClaims,
      unsupportedClaimSentences: Math.max(0, claims - supportedClaims),
      outboundLinks: nLinks || 0, flagged: flagged
    };
  };

  // -- СВОДКА --------------------------------------------------------
  // Сводный индекс сопровождается разложением по компонентам. Он нужен
  // для сортировки сигналов, а не для доказательства качества текста.
  // Повторы и дубли остаются только информационными наблюдениями.
  Core.analyze = function (text, opts) {
    opts = opts || {};
    var nLinks = opts.nLinks || 0;
    var sentHasLink = opts.sentHasLink;
    var nW = Core.words(text).length || 1;
    var per1k = function (x) { return x / nW * 1000; };
    var rb = Core.readability(text);
    var kw = Core.keywordStuffing(text);
    var rep = Core.repeatedShingles(text, 8);
    var dup = Core.duplicateSentences(text);
    var num = Core.numbersWithoutProof(text, sentHasLink);
    var src = Core.sourceBalance(text, nLinks, sentHasLink);
    var breakdown = {
      readability: +(rb.flags.length * 1.5).toFixed(2),
      keywordStuffing: +(kw.flags.length * 3).toFixed(2),
      bareStats: +Math.min(per1k(num.bareStatClaims) * 1.5, 8).toFixed(2),
      noSources: src.flagged ? 4 : 0
    };
    var score = breakdown.readability + breakdown.keywordStuffing +
      breakdown.bareStats + breakdown.noSources;
    return {
      defectScore: +score.toFixed(1),
      breakdown: breakdown,               // перепроверяемый вклад каждого сигнала
      n_words: nW,
      readability: rb,
      keywordStuffing: kw,
      repeats: rep,           // INFO
      duplicates: dup,        // INFO
      numbersWithoutProof: num,
      sources: src
    };
  };

  // ==== Экспорт для Node-тестов: если нет document — отдаём Core и выходим
  if (typeof module !== "undefined" && module.exports) {
    module.exports = Core;
    return;
  }
  if (typeof document === "undefined") { return; }

  // ===================================================================
  //  DOM-СЛОЙ — извлечение текста статьи и ссылок, сетевая проверка
  //  ссылок (единственная сетевая операция), отрисовка панели.
  // ===================================================================
  function pickBody() {
    var sels = ["div.article-formatted-body", "#post-content-body",
      "[class*=article-formatted-body]", "div.tm-article-body",
      "article", "main", "[role=main]"];
    for (var i = 0; i < sels.length; i++) {
      var el = document.querySelector(sels[i]);
      if (el && el.innerText && el.innerText.length > 400) return el;
    }
    return document.body;
  }

  function collectLinks(bodyEl) {
    var out = [], seen = {};
    var as = bodyEl.querySelectorAll("a[href]");
    for (var i = 0; i < as.length; i++) {
      var rawHref = as[i].getAttribute("href");
      if (!rawHref || /^(javascript:|mailto:|tel:)/i.test(rawHref)) continue;
      var href;
      try { href = new URL(rawHref, document.baseURI).href; }
      catch (e) { continue; }
      if (!/^https?:/i.test(href)) continue;
      href = href.replace(/#.*$/, "");
      if (seen[href]) continue; seen[href] = 1;   // дедуп: один URL — одна проверка
      out.push({
        href: href, text: (as[i].innerText || "").trim(),
        sentence: (as[i].closest("p,li,blockquote") || {}).innerText || ""
      });
    }
    return out;
  }

  function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }

  function render(report, links) {
    var old = document.getElementById("cw-prefilter"); if (old) old.remove();
    var box = document.createElement("div");
    box.id = "cw-prefilter";
    var css = "position:fixed;right:16px;bottom:16px;z-index:2147483600;width:370px;max-height:72vh;overflow:auto;" +
      "background:#fff;border:1px solid #0e7490;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.28);" +
      "font:13px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#0b1922;padding:12px 14px";
    box.setAttribute("style", css);
    function row(label, ok, detail) {
      return '<div style="margin:7px 0;padding:7px 8px;border-radius:8px;background:' +
        (ok ? "#f0fdf4" : "#fef2f2") + ';border:1px solid ' + (ok ? "#bbf7d0" : "#fecaca") + '">' +
        '<b style="color:' + (ok ? "#15803d" : "#b91c1c") + '">' + (ok ? "✓ " : "⚠ ") + esc(label) + '</b>' +
        (detail ? '<div style="font-size:12px;color:#334155;margin-top:3px">' + detail + '</div>' : '') + '</div>';
    }
    function info(label, detail) {
      return '<div style="margin:7px 0;padding:7px 8px;border-radius:8px;background:#f8fafc;border:1px solid #e2e8f0">' +
        '<b style="color:#475569">i ' + esc(label) + '</b>' +
        (detail ? '<div style="font-size:12px;color:#334155;margin-top:3px">' + detail + '</div>' : '') + '</div>';
    }
    var rb = report.readability, rep = report.repeats, dup = report.duplicates,
        num = report.numbersWithoutProof, src = report.sources, kw = report.keywordStuffing,
        bd = report.breakdown;
    var html = '<div style="font-size:15px;font-weight:700;color:#0e7490;margin-bottom:2px">ClearWater PreFilter</div>' +
      '<div style="font-size:11px;color:#64748b;margin-bottom:8px">Локальные эвристические сигналы, без ИИ. Сводный индекс: <b>' +
      report.defectScore + '</b> (0 = выбранные сигналы не найдены) · ' +
      'читаб.' + bd.readability + ' + стаффинг' + bd.keywordStuffing + ' + статы' + bd.bareStats + ' + источники' + bd.noSources + '</div>';
    html += row("Читабельность" + (rb.flags.length ? "" : ": экстремумы не найдены"),
      rb.flags.length === 0,
      "FRE-ru " + rb.fre + " · ср. предложение " + rb.avgSentLen + " слов" +
      (rb.flags.length ? "<br>" + rb.flags.map(esc).join("<br>") : ""));
    html += row("Keyword-stuffing" + (kw.flags.length ? "" : ": не найден"),
      kw.flags.length === 0,
      kw.flags.length ? kw.flags.map(esc).join("<br>")
        : "топ-слово «" + esc(kw.topToken) + "» " + (kw.topTokenDensity * 100).toFixed(1) + "% (топикальность, не спам)");
    html += row("Стат-претензии без пруфа: " + num.bareStatClaims + " из " + num.statClaims,
      num.bareStatClaims === 0,
      num.examples.map(function (e) { return "«" + esc(e) + "…»"; }).join("<br>"));
    html += row(src.flagged ? "Не найдена опора для проверяемых утверждений" :
      "Источниковые сигналы: " + src.supportedClaimSentences + " из " + src.claimSentences +
      " утверждений · " + src.outboundLinks + " ссылок",
      !src.flagged,
      src.flagged ? "Проверяемых утверждений: " + src.claimSentences +
        "; ни одно не связано с видимой ссылкой или положительным маркером источника." : "");
    // INFO-блоки (не штрафуют)
    html += info("Повторы-бойлерплейт: " + rep.distinctRepeated + " (INFO)",
      rep.examples.length ? rep.examples.map(function (e) {
        return "×" + e.count + " «" + esc(e.gram.slice(0, 60)) + "…»"; }).join("<br>") : "разнесённых повторов не найдено");
    if (dup.count) html += info("Дубли предложений: " + dup.count + " (INFO)",
      dup.examples.map(function (e) { return "«" + esc(e.slice(0, 70)) + "…»"; }).join("<br>"));
    html += '<button id="cw-pf-links" style="margin-top:6px;width:100%;cursor:pointer;border:1px solid #0e7490;' +
      'background:#0e7490;color:#fff;border-radius:8px;padding:7px;font-weight:600">Проверить доступность ' + links.length + ' ссылок (сетевой запрос)</button>';
    html += '<div id="cw-pf-deadout" style="font-size:12px;color:#334155;margin-top:6px"></div>';
    html += '<button id="cw-pf-close" style="margin-top:6px;width:100%;cursor:pointer;border:1px solid #e2e8f0;' +
      'background:#f8fafc;border-radius:8px;padding:6px">закрыть</button>';
    box.innerHTML = html;
    document.body.appendChild(box);
    document.getElementById("cw-pf-close").onclick = function () { box.remove(); };
    document.getElementById("cw-pf-links").onclick = function () { checkDeadLinks(links); };
  }

  // -- единственная сетевая операция: проверка ссылок --------------
  // HEAD используется как быстрый пробный запрос. Если сервер его не
  // поддерживает, выполняется ограниченный GET. Только 404/410 считаются
  // подтверждённым отсутствием; ошибки сети, авторизации и 5xx остаются
  // неопределёнными. Число одновременных запросов ограничено.
  function probe(url, method, cb) {
    var headers = method === "GET" ? { Range: "bytes=0-0" } : {};
    try {
      GM_xmlhttpRequest({
        method: method, url: url, timeout: 12000, headers: headers,
        onload: function (r) { cb({ status: r.status }); },
        onerror: function () { cb({ status: 0, err: true }); },
        ontimeout: function () { cb({ status: 0, timeout: true }); }
      });
    } catch (e) { cb({ status: 0, err: true }); }
  }
  function classify(url, done) {
    probe(url, "HEAD", function (h) {
      // однозначно битые по HEAD
      if (h.status === 404 || h.status === 410) return done("dead", url + " → " + h.status);
      // однозначно живые по HEAD
      if (h.status >= 200 && h.status < 400) return done("alive", url);
      // всё прочее (405/403/401/5xx/сеть/таймаут) — перепроверяем GET-ом
      probe(url, "GET", function (g) {
        if (g.status >= 200 && g.status < 400) return done("alive", url);
        if (g.status === 404 || g.status === 410) return done("dead", url + " → " + g.status);
        if (g.status >= 500) return done("unknown", url + " → " + g.status + " (временная ошибка сервера)");
        if (g.status === 0 && (h.status === 0)) return done("unknown", url + " → сеть/таймаут");
        // 401/403/405/opaque — судить не можем
        return done("unknown", url + " → " + (g.status || h.status || "403/405") + " (не проверить)");
      });
    });
  }
  function checkDeadLinks(links) {
    var out = document.getElementById("cw-pf-deadout");
    if (!links.length) { out.innerHTML = "ссылок в теле статьи нет"; return; }
    var dead = [], unknown = [], aliveN = 0, done = 0, total = links.length;
    var idx = 0, POOL = 6;
    out.innerHTML = "проверяю " + total + " ссылок…";
    function fin(kind, msg) {
      if (kind === "dead") dead.push(msg);
      else if (kind === "unknown") unknown.push(msg);
      else aliveN++;
      done++;
      if (done >= total) {
        var h = "";
        h += dead.length
          ? '<b style="color:#b91c1c">⚠ Ссылок с ответом 404/410: ' + dead.length + "</b><br>" + dead.slice(0, 8).map(esc).join("<br>")
          : '<b style="color:#15803d">✓ Ответов 404/410 нет; доступных: ' + aliveN + "</b>";
        if (unknown.length) h += '<div style="color:#64748b;margin-top:4px">не удалось проверить: ' +
          unknown.length + "<br>" + unknown.slice(0, 5).map(esc).join("<br>") + "</div>";
        out.innerHTML = h;
      } else { out.innerHTML = "проверяю… " + done + "/" + total; }
      pump();
    }
    function pump() { while (idx < total && (idx - done) < POOL) { classify(links[idx++].href, fin); } }
    pump();
  }

  function run() {
    var body = pickBody();
    var text = body.innerText || "";
    if (text.length < 400) return; // не статья
    var links = collectLinks(body);
    var linkSentences = links.map(function (l) { return (l.sentence || "").toLowerCase(); });
    var report = Core.analyze(text, {
      nLinks: links.length,
      sentHasLink: function (sentence) {
        var s = sentence.toLowerCase();
        for (var i = 0; i < linkSentences.length; i++) {
          if (linkSentences[i] && linkSentences[i].indexOf(s.slice(0, 40)) !== -1) return true;
        }
        return false;
      }
    });
    render(report, links);
  }

  // запуск по горячей клавише Alt+W (чтобы не дёргать на каждой странице)
  window.addEventListener("keydown", function (e) {
    if (e.altKey && (e.key === "w" || e.key === "W" || e.code === "KeyW")) { e.preventDefault(); run(); }
  });
  // и маленькая кнопка-якорь
  if (document.body) {
    var fab = document.createElement("div");
    fab.textContent = "CW";
    fab.title = "ClearWater PreFilter — Alt+W";
    fab.setAttribute("style", "position:fixed;right:16px;bottom:16px;z-index:2147483500;width:40px;height:40px;" +
      "border-radius:50%;background:#0e7490;color:#fff;display:flex;align-items:center;justify-content:center;" +
      "font:700 13px sans-serif;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.3)");
    fab.onclick = run;
    document.body.appendChild(fab);
  }
})();

Немного слов про ClearWater

Реальное предназначение плагина — помочь с проблемой мёртвого интернета. В тихом тёмном месте ведётся индексация всех проверенных страниц — постепенно накапливается база на собственный поисковик. Более того, проверки, сделанные одним пользователем, открыты для всех и показываются прямо при заходе на страницу, которую проверяли.

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

Это в целом моя первая статья с прицелом на серьёзный уровень для крупной аудитории. Буду рад фидбеку.

Аналогично с продуктом. Для меня это первый запуск своего продукта.

Попробуйте мой шоколад

Первоисточник, по которому можно сверяться: vvzvlad, «Вы не сможете это развидеть».

Вместо телеграм‑канала здесь будет сайт: wecleantheinternet.com

⚠ Важно: у некоторых пользователей сайт может не показываться без КВН — знаю об этом, работаю над доступностью (aws).

Плагин для проверки слопа скачивается по большой кнопке. Щедро даётся 3 бесплатные глубокие проверки в день с факт‑чекингом. Возможно, позже увеличу либо уменьшу лимиты — в зависимости от доходности или убытков продукта.

Попробуйте плагин и прогоните пару статей, на том, кто под руку подвернётся, хоть на этой статье. Буду благодарен обратной связи в комментариях: где фильтр попал, а где промахнулся. Промахи сейчас ценнее похвалы, именно из них собирается доработка. Ну а если вердикт совпал с вашим чутьём, поделитесь проверкой: следующему читателю она достанется бесплатно.