SafeTopic

Антижалоба для оффтопа: проверка темы перед публикацией

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         SafeTopic
// @namespace    https://zelenka.guru/
// @version      1.0
// @description  Антижалоба для оффтопа: проверка темы перед публикацией
// @license      MIT
// @author       keymastr
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @match        https://lolz.guru/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    var CFG = {
        v: '1.0',
        nodeIds: [8],
        btn: 'st-btn',
        wrap: 'st-wrap',
        panel: 'st-panel',
        style: 'st-style',
        toast: 'st-toast'
    };

    var URL_RE = /https?:\/\/[^\s<>'"]+/ig;
    var FORUM_RE = /^https?:\/\/(?:www\.)?(?:zelenka\.guru|lolz\.live|lolz\.guru)\//i;
    var SHORT_RE = /\b(?:bit\.ly|goo\.gl|tinyurl\.com|cutt\.ly|is\.gd|t\.co|clck\.ru|vk\.cc|shorturl\.at)\/\S+/i;

    var lastUrl = location.href;
    var retryTimer = null;
    var retryLeft = 0;

    addStyle();
    cleanupLegacy();
    boot();
    exposeApi();

    function boot() {
        retryInit(12);

        setInterval(function () {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                closePanel();
                removeButton();
                cleanupLegacy();
                retryInit(12);
            }
        }, 1500);
    }

    function retryInit(count) {
        retryLeft = count || 8;
        clearInterval(retryTimer);

        retryTimer = setInterval(function () {
            retryLeft--;
            init();

            if (retryLeft <= 0 || document.getElementById(CFG.btn)) {
                clearInterval(retryTimer);
            }
        }, 650);

        init();
    }

    function init() {
        if (!isComposerPage()) return;
        if (!isOfftopPage()) return;
        addButton();
    }

    function cleanupLegacy() {
        [
            'sp-lite-panel',
            'sp-lite-wrap',
            'sp-lite-btn',
            'spg-panel',
            'spg-wrap',
            'spg-btn',
            'spg-offtop-panel',
            'spg-offtop-check-wrap',
            'spg-offtop-check-btn'
        ].forEach(function (id) {
            var el = document.getElementById(id);
            if (el) el.remove();
        });
    }

    function addStyle() {
        if (document.getElementById(CFG.style)) return;

        var css = `
#sp-lite-panel,#sp-lite-wrap,#sp-lite-btn,#spg-panel,#spg-wrap,#spg-btn,#spg-offtop-panel,#spg-offtop-check-wrap,#spg-offtop-check-btn{display:none!important}
#${CFG.wrap}{margin:10px 0 0}
#${CFG.btn}{background:#168a52;color:#fff;border:0;border-radius:6px;padding:9px 14px;font:700 13px Arial,sans-serif;cursor:pointer}
#${CFG.btn}:hover{background:#1d9b61}
#${CFG.btn}:disabled{opacity:.65;cursor:wait}
#${CFG.panel}{position:fixed;right:18px;top:86px;width:390px;max-height:550px;z-index:2147483647;background:#111;color:#ddd;border:1px solid #303030;border-radius:8px;box-shadow:0 14px 38px rgba(0,0,0,.55);font:13px Arial,sans-serif;overflow:hidden}
.st-head{padding:10px 12px;background:#181818;border-bottom:1px solid #303030;display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:move}
.st-name{font-weight:800;color:#f2f2f2;font-size:14px}
.st-by{font-size:11px;color:#888;margin-top:2px}
.st-icons{display:flex;gap:6px;flex-shrink:0}
.st-icon{width:28px;height:26px;background:#242424;color:#aaa;border:1px solid #444;border-radius:6px;cursor:pointer;font-size:14px}
.st-icon:hover{background:#2c2c2c;color:#fff}
.st-body{padding:10px;max-height:490px;overflow:auto}
.st-box{background:#151515;border:1px solid #292929;border-radius:7px;padding:10px;margin-bottom:8px;line-height:1.45;word-break:break-word}
.st-ok{border-color:rgba(22,138,82,.75)}
.st-manual{border-color:rgba(90,135,190,.8)}
.st-review{border-color:rgba(180,130,50,.85)}
.st-warn{border-color:rgba(190,95,45,.9)}
.st-block{border-color:rgba(190,60,60,.95);background:rgba(80,20,20,.22)}
.st-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
.st-title{font-weight:800;color:#f2f2f2;font-size:15px}
.st-score{font-size:27px;font-weight:900;color:#f2f2f2;line-height:1}
.st-small{font-size:12px;color:#aaa;margin-top:4px}
.st-note{font-size:11px;color:#777;margin-top:5px}
.st-bar{height:7px;background:#242424;border:1px solid #303030;border-radius:999px;overflow:hidden;margin-top:9px}
.st-fill{height:100%;background:#168a52;border-radius:999px}
.st-manual .st-fill{background:#4f80c7}
.st-review .st-fill{background:#b88432}
.st-warn .st-fill{background:#c46a2e}
.st-block .st-fill{background:#c44848}
.st-advice{border-color:#333;background:#171717}
.st-advice b{color:#f2f2f2}
.st-item{border-top:1px solid #282828;margin-top:7px;padding-top:7px}
.st-item:first-child{border-top:0;margin-top:0;padding-top:0}
.st-tag{float:right;font-size:10px;color:#aaa;background:#222;border:1px solid #444;border-radius:5px;padding:2px 6px;margin-left:8px}
.st-action{width:100%;border:1px solid #444;background:#202020;color:#ddd;border-radius:6px;padding:9px;cursor:pointer;font-weight:700;font-family:inherit}
.st-action:hover{background:#292929}
.st-muted{color:#888;font-size:11px;margin-top:6px}
#${CFG.toast}{position:fixed;right:18px;bottom:18px;z-index:2147483647;background:#181818;border:1px solid #333;border-radius:7px;color:#ddd;padding:9px 12px;box-shadow:0 8px 28px rgba(0,0,0,.45);font-size:12px}
@media(max-width:900px){#${CFG.panel}{left:10px;right:10px;width:auto;top:70px;max-height:72vh}.st-body{max-height:calc(72vh - 70px)}}`;

        var style = document.createElement('style');
        style.id = CFG.style;
        style.textContent = css;
        document.head.appendChild(style);
    }

    function addButton() {
        var old = document.getElementById(CFG.btn);

        if (old) {
            old.textContent = 'Проверить';
            return;
        }

        var editor = getEditor();
        if (!editor) return;

        var btn = document.createElement('button');
        btn.id = CFG.btn;
        btn.type = 'button';
        btn.textContent = 'Проверить';
        btn.onclick = function () {
            runCheck(false);
        };

        var wrap = document.createElement('div');
        wrap.id = CFG.wrap;
        wrap.appendChild(btn);

        var box = editor.closest('.fr-box') ||
            editor.closest('.fr-wrapper') ||
            editor.closest('.block-container') ||
            editor.closest('form') ||
            editor.parentElement;

        if (box && box.parentElement) {
            box.insertAdjacentElement('afterend', wrap);
        } else {
            editor.insertAdjacentElement('afterend', wrap);
        }
    }

    function removeButton() {
        var wrap = document.getElementById(CFG.wrap);
        if (wrap) wrap.remove();

        var btn = document.getElementById(CFG.btn);
        if (btn) btn.remove();
    }

    function runCheck(silent) {
        var btn = document.getElementById(CFG.btn);
        var oldText = btn ? btn.textContent : '';

        if (btn && !silent) {
            btn.disabled = true;
            btn.textContent = 'Проверяю...';
        }

        try {
            var data = collectData();
            renderPanel(data, analyze(data));
        } catch (e) {
            console.error('[SafeTopic]', e);

            renderPanel(fallbackData(), {
                status: 'warn',
                score: 50,
                title: 'Ошибка проверки',
                subtitle: String(e && e.message ? e.message : e),
                advice: 'открой консоль браузера и пришли ошибку.',
                items: [
                    item(
                        'warn',
                        'Ошибка',
                        'Скрипт упал.',
                        'открой консоль браузера и пришли ошибку.'
                    )
                ]
            });
        }

        if (btn && !silent) {
            btn.disabled = false;
            btn.textContent = oldText || 'Проверить';
        }
    }

    function collectData() {
        var title = getTitle();
        var body = getBody();
        var media = hasRealMedia();

        return {
            title: title,
            body: body,
            text: title + '\n' + stripIgnored(body),
            hasMedia: media.yes,
            mediaName: media.name,
            nodeId: getNodeId(),
            section: getSectionName()
        };
    }

    function fallbackData() {
        try {
            return collectData();
        } catch (e) {
            return {
                title: '',
                body: '',
                text: '',
                hasMedia: false,
                mediaName: '',
                nodeId: getNodeId(),
                section: getSectionName()
            };
        }
    }

    function analyze(data) {
        var hard = [];
        var fix = [];
        var review = [];
        var note = [];
        var manual = [];

        add(hard, ruleFamilyTeam(data));
        add(hard, ruleMinorAdult(data));

        add(fix, ruleSensitive(data));
        add(fix, ruleAdult(data));
        add(fix, ruleAds(data));
        add(fix, ruleBegging(data));
        add(fix, ruleGiveaway(data));
        add(fix, ruleTrash(data));
        add(fix, ruleDirectedInsult(data));

        add(review, ruleTitleQuality(data));
        add(review, ruleRipContext(data));
        add(review, ruleContext(data));
        add(review, ruleHeavyRoughness(data));

        add(note, ruleLightRoughness(data));

        if (data.hasMedia) {
            add(manual, item(
                'manual',
                'Вложение',
                'Есть файл/картинка/видео. Скрипт не читает содержимое.',
                'проверь вручную: 18+, реклама, личные данные, политика, оскорбления.'
            ));
        }

        var status = 'ok';
        var items = note.slice();
        var title = 'Можно публиковать';
        var subtitle = 'Локальная проверка текста не нашла явных рисков.';
        var advice = note.length ?
            'текст грубоват. Если грубость не нужна по смыслу — смягчи формулировку.' :
            'можно публиковать. Перед отправкой глазами проверь смысл темы.';

        if (hard.length) {
            status = 'block';
            items = hard.concat(fix, review, manual, note);
            title = 'Не публиковать';
            subtitle = 'Есть прямой красный риск по правилам.';
            advice = firstFix(items, 'убери красный риск и проверь заново.');
        } else if (fix.length) {
            status = 'warn';
            items = fix.concat(review, manual, note);
            title = 'Исправить';
            subtitle = 'Есть риск по правилам оффтопа.';
            advice = firstFix(items, 'исправь отмеченный пункт и проверь заново.');
        } else if (review.length) {
            status = 'review';
            items = review.concat(manual, note);
            title = 'Лучше уточнить';
            subtitle = 'Явного красного риска нет, но формулировка слабая или спорная.';
            advice = firstFix(items, 'добавь деталей или перепроверь тему через Claude.');
        } else if (manual.length) {
            status = 'manual';
            items = manual.concat(note);
            title = 'Текст нормальный';
            subtitle = 'По тексту всё ок, но вложение надо проверить вручную.';
            advice = 'проверь вложение глазами. Если там нет 18+, рекламы, данных людей, политики и оскорблений — можно публиковать.';
        }

        return {
            status: status,
            score: calcScore(status, items, note.length),
            title: title,
            subtitle: subtitle,
            advice: advice,
            items: items
        };
    }

    function add(arr, x) {
        if (x) arr.push(x);
    }

    function item(level, title, text, fix, found) {
        return {
            level: level,
            title: title,
            text: text,
            fix: fix || '',
            found: found || []
        };
    }

    function firstFix(items, fallback) {
        for (var i = 0; i < items.length; i++) {
            if (items[i].fix) return items[i].fix;
        }

        return fallback;
    }

    function calcScore(status, items, notes) {
        var n = items.length;

        if (status === 'block') return Math.max(5, 16 - n * 2);
        if (status === 'warn') return Math.max(35, 62 - n * 8);
        if (status === 'review') return Math.max(66, 80 - n * 5);
        if (status === 'manual') return notes ? 84 : 90;
        if (notes) return 88;

        return 96;
    }

    function ruleFamilyTeam(data) {
        var t = normText(data.text);

        var family = word('(мамк[а-яё]*|мать|маму|мамаш[а-яё]*|батю|батя|отец|отца|отцу|отцом|родител[а-яё]*|родн[а-яё]*|семь[яи]|сестру|сестра|брата|брат)');
        var team = word('(команд[аы] форум[а-яё]*|админ|администратор|модер|модератор|куратор|разработчик|саппорт|staff)');
        var direct = /(ты|тебя|тебе|твой|твоя|твою|твоего|твоей|твоим|твоих|у\s+тебя)/i;
        var insult = word('(хуесос|пидор|пидар|долбо[её]б|еблан|уебан|мразь|тварь|шлюх[а-яё]*|кончен[а-яё]*|даун|чмо|гнид[а-яё]*|дебил|идиот|урод|сука)');
        var dirty = /(трах[а-яё]*|вы[её]б[а-яё]*|за[её]б[а-яё]*|у[её]б[а-яё]*|ебал|[её]бать|сосал[а-яё]*|сосать|отсос[а-яё]*|отсоси|шлюх[а-яё]*|проститут[а-яё]*|спал\s+с|имел\s+тво[а-яё]*|сдох[а-яё]*|умер[а-яё]*|дохл[а-яё]*|могил[а-яё]*|похорон[а-яё]*)/i;

        if (hasNear(t, family, dirty, 90) && (direct.test(t) || insult.test(t) || /тво[а-яё]*/i.test(t))) {
            return item(
                'block',
                'Родня',
                'Есть грязный выпад или оскорбление в сторону родственников.',
                'полностью убери фразу про родню.'
            );
        }

        if (family.test(t) && insult.test(t) && direct.test(t)) {
            return item(
                'block',
                'Родня',
                'Похоже на оскорбление родственников.',
                'убери упоминание/оскорбление родных полностью.'
            );
        }

        if (team.test(t) && insult.test(t)) {
            return item(
                'block',
                'Команда форума',
                'Похоже на оскорбление модеров/админов.',
                'убери оскорбление команды форума.'
            );
        }

        return null;
    }

    function ruleDirectedInsult(data) {
        var t = normText(data.text);

        var direct = /(ты|тебя|тебе|твой|твоя|твою|твоего|твоей|твоим|у\s+тебя)/i;
        var insult = word('(хуесос|пидор|пидар|долбо[её]б|еблан|уебан|мразь|тварь|кончен[а-яё]*|даун|чмо|дебил|идиот|урод)');
        var found = findWords(t, /(хуесос|пидор|пидар|долбо[её]б|еблан|уебан|мразь|тварь|кончен[а-яё]*|даун|чмо|дебил|идиот|урод)/gi, 6);

        if (direct.test(t) && insult.test(t)) {
            return item(
                'warn',
                'Оскорбление',
                'Похоже на прямой наезд на пользователя.',
                'убери прямое оскорбление или сформулируй мягче.',
                found
            );
        }

        return null;
    }

    function ruleLightRoughness(data) {
        var t = normText(data.text);
        var found = findWords(t, roughRe(), 8);

        if (found.length) {
            return item(
                'note',
                'Грубость',
                'Есть грубые слова, но без явного красного риска.',
                'если грубость не нужна по смыслу — смягчи текст.',
                found
            );
        }

        return null;
    }

    function ruleHeavyRoughness(data) {
        var t = normText(data.text);
        var found = findWords(t, roughRe(), 10);
        var count = countMatches(t, [roughRe()]);

        if (count >= 4) {
            return item(
                'review',
                'Много грубости',
                'Текст выглядит слишком грубым или токсичным.',
                'сократи мат и грубые формулировки.',
                found
            );
        }

        return null;
    }

    function ruleSensitive(data) {
        var t = normText(data.text);

        if (hasAny(t, [
            word('(политик[а-яё]*|выборы|путин|зеленск[а-яё]*|навальн[а-яё]*|сво|войн[ауы]|украин[а-яё]*|религи[а-яё]*|верующ[а-яё]*|ислам|христиан[а-яё]*|евре[а-яё]*|наци[а-яё]*|мигрант[а-яё]*)'),
            word('(чурк[а-яё]*|хач[а-яё]*|жид[а-яё]*|хохл[а-яё]*)')
        ])) {
            return item(
                'warn',
                'Запрещённая тема',
                'Похоже на политику, религию, межнационалку или розжиг.',
                'убери спорную тематику из оффтопа.'
            );
        }

        if (/черн[а-яё]*\s+юмор|жестк[а-яё]*\s+юмор|аморал[а-яё]*|за\s+гранью/i.test(t)) {
            return item(
                'review',
                'Спорная формулировка',
                'Может выглядеть как провокация.',
                'сформулируй нейтральнее.'
            );
        }

        return null;
    }

    function ruleGiveaway(data) {
        if (hasAny(data.text, [
            word('(раздача|розыгрыш|конкурс|победител[а-яё]*|рандомайзер|участвуй[а-яё]*)'),
            /разыгра[юе][а-яёa-z0-9_]*/i
        ])) {
            return item(
                'warn',
                'Раздача / розыгрыш',
                'Раздачи и розыгрыши не для оффтопа.',
                'перенеси в подходящий раздел.'
            );
        }

        return null;
    }

    function ruleTrash(data) {
        var full = clean(data.title + ' ' + data.body);
        var t = data.text;

        if (/(.)\1{7,}/i.test(full) || /[!?.,;:]{8,}/.test(full)) {
            return item(
                'warn',
                'Флуд',
                'Слишком много повторов или знаков.',
                'убери повторы/лишние знаки.'
            );
        }

        if (/кто\s+онлайн|кто\s+тут|ап\s+тему|добить\s+символ|фарм\s+(актива|сообщений|симпат)/i.test(t)) {
            return item(
                'warn',
                'Фарм / флуд',
                'Похоже на фарм активности или мусор.',
                'добавь смысл темы или убери фарм.'
            );
        }

        var toxic = countMatches(t, [
            word('(долбо[её]б[а-яё]*|еблан[а-яё]*|уебан[а-яё]*|хуесос[а-яё]*|пидор[а-яё]*|мраз[а-яё]*|твар[а-яё]*|дебил[а-яё]*)')
        ]);

        if (toxic >= 3) {
            return item(
                'warn',
                'Токсичность',
                'Слишком много оскорблений.',
                'смягчи формулировки.'
            );
        }

        return null;
    }

    function ruleAdult(data) {
        var t = normText(data.text);

        if (hasAny(t, [
            /18\+/i,
            word('(эротик[а-яё]*|порно|porn|nsfw|нсфв|интим[а-яё]*|нюдс[а-яё]*)'),
            /слив\s+фото|голые\s+фото/i
        ])) {
            return item(
                'warn',
                '18+',
                'Похоже на 18+, эротику или порно.',
                'убери 18+ из темы.'
            );
        }

        return null;
    }

    function ruleMinorAdult(data) {
        var t = normText(data.text);

        if (/18\+|эротик|порно|porn|nsfw|нсфв|интим|нюдс|слив\s+фото/i.test(t) &&
            /детск[а-яё]*|несовершеннолет[а-яё]*|малолет[а-яё]*|школьни[а-яё]*|подростк[а-яё]*/i.test(t)) {
            return item(
                'block',
                'Запрещённый 18+ риск',
                'Есть связка 18+ и несовершеннолетних.',
                'не публикуй такую тему.'
            );
        }

        return null;
    }

    function ruleAds(data) {
        var t = data.text;
        var urls = urlsIn(t).filter(function (u) {
            return !FORUM_RE.test(u);
        });
        var words = wordsIn(removeUrls(t));

        if (hasAny(t, [
            word('(продам|куплю|услуг[а-яё]*|сделаю|заказ|цена|стоимость|магазин|аккаунт|прокси)'),
            /мой\s+(канал|телеграм|telegram|сайт|проект|магазин|сервис)/i,
            /моя\s+(ссылка|группа|тема|услуга)/i,
            /пиши(те)?\s+в\s+(лс|личк[а-яё]*|тг|tg|telegram|телеграм|discord)/i,
            /[?&](ref|refid|invite|promo|partner|aff|affiliate|r)=\S+/i,
            /реф(ерал|ка|ссылк)/i,
            SHORT_RE
        ]) || (urls.length && words.length < 18)) {
            return item(
                'warn',
                'Реклама / ссылки',
                'Похоже на рекламу, услугу, рефку, ЛС или голую ссылку.',
                'убери рекламу/рефки/ЛС или добавь нормальный контекст.'
            );
        }

        return null;
    }

    function ruleBegging(data) {
        if (hasAny(data.text, [
            /скинь(те)?\s+(денег|руб|рублей|копеек|на\s+карт)/i,
            /кинь(те)?\s+(денег|руб|рублей|на\s+карт)/i,
            word('(донат|задонать)'),
            /постав(ь|ьте|ить)?.{0,35}(симпат|лайк|реп|плюс)/i,
            /сыгра(й|йте).{0,30}куб/i,
            /добить\s+(лайк|симпат|трофе|реп)/i
        ])) {
            return item(
                'warn',
                'Попрошайничество',
                'Похоже на просьбу денег, реакций, куба или трофеев.',
                'убери просьбы денег/лайков/симпатий/куба.'
            );
        }

        return null;
    }

    function ruleTitleQuality(data) {
        var title = clean(data.title);
        var body = clean(data.body);
        var titleWords = wordsIn(title).length;
        var bodyWords = wordsIn(body).length;

        if (!title) {
            return item(
                'review',
                'Нет заголовка',
                'Заголовок пустой или не найден.',
                'добавь понятный заголовок темы.'
            );
        }

        if (titleWords <= 2 && bodyWords <= 10) {
            return item(
                'review',
                'Слабый заголовок',
                'Заголовок и текст слишком короткие.',
                'сделай заголовок понятнее и добавь контекст.'
            );
        }

        if (/^(привет|ку|хелп|помогите|вопрос|жесть|лол|кек)$/i.test(norm(title)) && bodyWords <= 18) {
            return item(
                'review',
                'Слабый заголовок',
                'Заголовок слишком общий.',
                'напиши в заголовке конкретно, о чём тема.'
            );
        }

        return null;
    }

    function ruleRipContext(data) {
        var t = normText(data.text);
        var rip = /(rip|r\.i\.p|press\s*f|пресс\s*f|умер[а-яё]*|помер[а-яё]*|погиб[а-яё]*|скончал[а-яё]*|ушел\s+из\s+жизни|ушла\s+из\s+жизни)/i;

        if (!rip.test(t)) return null;

        var hasLink = urlsIn(data.text).length > 0;
        var hasSourceWord = /(источник|пруф|новост[а-яё]*|ссылка|сообщил[а-яё]*|подтвердил[а-яё]*|по\s+данным|известн[а-яё]*\s+тем|известен\s+как|известна\s+как|кто\s+это|для\s+тех\s+кто\s+не\s+знает)/i.test(t);
        var bodyWords = wordsIn(data.body).length;

        if (!hasLink && !hasSourceWord) {
            return item(
                'review',
                'RIP / новость без пруфа',
                'Тема похожа на новость о смерти, но нет источника или объяснения кто это.',
                'добавь источник новости и пару слов, кто это и почему тема.'
            );
        }

        if (bodyWords < 18) {
            return item(
                'review',
                'Слабый контекст новости',
                'Новостная/RIP-тема выглядит слишком короткой.',
                'добавь 2–3 предложения контекста и повод для обсуждения.'
            );
        }

        return null;
    }

    function ruleContext(data) {
        var full = clean(data.title + ' ' + data.body);
        var body = clean(data.body);
        var totalWords = wordsIn(full).length;
        var bodyWords = wordsIn(body).length;
        var hasUrl = urlsIn(data.text).length > 0;

        if (!body) {
            return item(
                'review',
                'Пустой текст',
                'Нет текста темы.',
                'добавь текст темы.'
            );
        }

        if (bodyWords <= 8 && totalWords < 18) {
            return item(
                'review',
                'Слабый контекст',
                'Текст слишком короткий и похож на малосодержательную тему.',
                'добавь 2–3 предложения: что случилось, почему это интересно и что обсуждать.'
            );
        }

        if (totalWords <= 9 && !hasQuestion(full)) {
            return item(
                'review',
                'Мало контекста',
                'Не видно понятного повода для обсуждения.',
                'добавь вопрос или короткое пояснение.'
            );
        }

        if (totalWords <= 18 && hasQuestion(full) && !hasUrl) {
            return item(
                'review',
                'Короткий вопрос',
                'Есть вопрос, но мало сути для обсуждения.',
                'добавь деталей, пример или пояснение.'
            );
        }

        if (clean(full).length < 90 && !hasUrl) {
            return item(
                'review',
                'Мало текста',
                'Тема выглядит слишком короткой.',
                'добавь контекст, чтобы это не выглядело как флуд.'
            );
        }

        return null;
    }

    function renderPanel(data, result) {
        var panel = document.getElementById(CFG.panel);

        if (!panel) {
            panel = document.createElement('div');
            panel.id = CFG.panel;
            document.body.appendChild(panel);
            dragPanel(panel);
        }

        var cls = result.status === 'ok' ? 'st-ok' :
            result.status === 'manual' ? 'st-manual' :
            result.status === 'review' ? 'st-review' :
            result.status === 'warn' ? 'st-warn' : 'st-block';

        var itemsHtml = result.items.length ?
            result.items.map(renderItem).join('') :
            '<div class="st-small">Рисков нет.</div>';

        panel.innerHTML =
            '<div class="st-head"><div><div class="st-name">SafeTopic</div><div class="st-by">by keymastr</div></div><div class="st-icons"><button class="st-icon" type="button" data-reload="1" title="Перепроверить">↻</button><button class="st-icon" type="button" data-close="1" title="Закрыть">×</button></div></div>' +
            '<div class="st-body"><div class="st-box ' + cls + '"><div class="st-row"><div><div class="st-title">' + esc(result.title) + '</div><div class="st-small">Оценка SafeTopic</div></div><div class="st-score">' + result.score + '/100</div></div><div class="st-bar"><div class="st-fill" style="width:' + result.score + '%"></div></div><div class="st-small">' + esc(result.subtitle) + '</div><div class="st-note">Эвристика по тексту, не гарантия модерации.</div></div>' +
            '<div class="st-box st-advice"><b>Совет: ' + esc(result.advice) + '</b></div><div class="st-box">' + itemsHtml + '</div><div class="st-box"><button class="st-action" type="button" data-copy="1">Скопировать для Claude</button><div class="st-muted">Панель не обновляется сама. После правок нажми ↻ или кнопку «Проверить».</div></div></div>';

        panel.querySelector('[data-close="1"]').onclick = closePanel;

        panel.querySelector('[data-reload="1"]').onclick = function () {
            runCheck(true);
            toast('Перепроверено.');
        };

        panel.querySelector('[data-copy="1"]').onclick = function () {
            copyText(makeClaudePrompt(data, result));
        };
    }

    function renderItem(x) {
        var found = x.found && x.found.length ?
            '<div class="st-small"><b>Найдено:</b> ' + esc(x.found.join(', ')) + '</div>' :
            '';

        return '<div class="st-item"><span class="st-tag">' + esc(levelLabel(x.level)) + '</span><b>' + esc(x.title) + '</b><br><span class="st-small">' + esc(x.text) + '</span>' + found + (x.fix ? '<div class="st-small">' + esc(x.fix) + '</div>' : '') + '</div>';
    }

    function makeClaudePrompt(data, result) {
        var risks = result.items.length ? result.items.map(function (x) {
            return x.title + ': ' + x.text;
        }).join('; ') : 'нет';

        return clip([
            'Проверь тему по актуальным правилам раздела "Вишенка на торте" / оффтопа. Ответь кратко, без эмодзи.',
            'Заголовок: ' + clip(oneLine(data.title || '(пусто)'), 140),
            'Текст: ' + clip(oneLine(data.body || '(пусто)'), 1700),
            data.hasMedia ? 'Вложение: есть, проверь вручную.' : 'Вложение: нет.',
            'SafeTopic: ' + result.title + ', оценка ' + result.score + '/100, риски: ' + clip(oneLine(risks), 260),
            'Совет: ' + clip(oneLine(result.advice), 220),
            'Формат: СТАТУС одобрено/спорно/отклонено; ПРИЧИНА коротко; ИСПРАВИТЬ коротко/не требуется.'
        ].join('\n'), 3900);
    }

    function levelLabel(level) {
        if (level === 'block') return 'стоп';
        if (level === 'warn') return 'исправить';
        if (level === 'review') return 'уточнить';
        if (level === 'manual') return 'вручную';
        if (level === 'note') return 'заметка';

        return 'ок';
    }

    function roughRe() {
        return /(бля[а-яё]*|нахер|хрен|хуй|хуя|хуе[а-яё]*|еба[а-яё]*|ебу|уеб[а-яё]*|заеб[а-яё]*|выеб[а-яё]*|пизд[а-яё]*)/gi;
    }

    function findWords(text, re, max) {
        var out = [];
        var seen = {};
        var m;
        var flags = re.flags.indexOf('g') === -1 ? re.flags + 'g' : re.flags;
        var r = new RegExp(re.source, flags);

        while ((m = r.exec(text || '')) !== null) {
            var word = clean(m[0]).toLowerCase();

            if (!word || seen[word]) continue;

            seen[word] = true;
            out.push(word);

            if (out.length >= (max || 8)) break;
        }

        return out;
    }

    function hasAny(text, patterns) {
        for (var i = 0; i < patterns.length; i++) {
            patterns[i].lastIndex = 0;

            if (patterns[i].test(text || '')) return true;
        }

        return false;
    }

    function countMatches(text, patterns) {
        var count = 0;

        for (var i = 0; i < patterns.length; i++) {
            var p = patterns[i];
            var flags = p.flags.indexOf('g') === -1 ? p.flags + 'g' : p.flags;
            var re = new RegExp(p.source, flags);
            var m;

            while ((m = re.exec(text || '')) !== null) {
                count++;
            }
        }

        return count;
    }

    function word(body) {
        return new RegExp('(^|[^a-zа-яё0-9])' + body + '(?=$|[^a-zа-яё0-9])', 'i');
    }

    function hasNear(text, a, b, distance) {
        var pa = positions(text, a);
        var pb = positions(text, b);

        for (var i = 0; i < pa.length; i++) {
            for (var j = 0; j < pb.length; j++) {
                if (Math.abs(pa[i] - pb[j]) <= distance) return true;
            }
        }

        return false;
    }

    function positions(text, re) {
        var flags = re.flags.indexOf('g') === -1 ? re.flags + 'g' : re.flags;
        var r = new RegExp(re.source, flags);
        var out = [];
        var m;

        while ((m = r.exec(text || '')) !== null) {
            out.push(m.index);
        }

        return out;
    }

    function hasRealMedia() {
        var editor = getEditor();
        var root = editor ? editor.closest('form') || document : document;
        var attach = root.querySelector('.attachmentList .attachment, .js-attachmentList .attachment, .js-attachmentList [data-attachment-id], [data-attachment-id], a[href*="/attachments/"], a[href*="/attach/"]');

        if (attach && !isUi(attach) && visible(attach)) {
            return { yes: true, name: 'file' };
        }

        if (!editor) {
            return { yes: false, name: '' };
        }

        var imgs = editor.querySelectorAll('img');

        for (var i = 0; i < imgs.length; i++) {
            if (isRealImage(imgs[i])) {
                return { yes: true, name: 'image' };
            }
        }

        if (editor.querySelector('video, embed, object')) {
            return { yes: true, name: 'video' };
        }

        return { yes: false, name: '' };
    }

    function isRealImage(img) {
        if (!img || isUi(img) || !visible(img)) return false;

        var meta = [
            img.className || '',
            img.alt || '',
            img.title || '',
            img.src || ''
        ].join(' ').toLowerCase();

        if (/smilie|smiley|emoji|emoticon|xf-smilie|sprite|styles\/|\/smilies\//.test(meta)) return false;
        if (img.closest('[data-attachment-id], .attachment, .js-attachmentList, .attachmentList')) return true;
        if (/\/attachments\/|\/attach\//i.test(img.src || '')) return true;

        var w = img.naturalWidth || img.width || img.offsetWidth || 0;
        var h = img.naturalHeight || img.height || img.offsetHeight || 0;

        return (w > 48 || h > 48) && (w >= 80 || h >= 80);
    }

    function visible(el) {
        var r = el.getBoundingClientRect();
        return r.width > 0 && r.height > 0;
    }

    function isUi(el) {
        return !!(el && el.closest('#' + CFG.panel + ', .fr-toolbar, .fr-popup, .fr-command, .fr-dropdown-menu, .fr-second-toolbar, .tooltip, .menu, .button, .icon, .avatar, .p-nav, .p-header'));
    }

    function isComposerPage() {
        if (!getEditor()) return false;

        var t = document.body.innerText || '';

        return location.pathname.indexOf('/create-thread') !== -1 ||
            t.indexOf('Создать тему') !== -1 ||
            (t.indexOf('Заголовок') !== -1 && t.indexOf('Сформулируйте') !== -1);
    }

    function isOfftopPage() {
        if (CFG.nodeIds.indexOf(Number(getNodeId())) !== -1) return true;

        var t = norm(location.pathname + ' ' + getSectionName() + ' ' + document.title);

        return t.indexOf('vishenka') !== -1 ||
            t.indexOf('vishenka-na-torte') !== -1 ||
            t.indexOf('вишенка') !== -1 ||
            t.indexOf('оффтоп') !== -1;
    }

    function getTitleInput() {
        var s = [
            'input[name="title"]',
            'input[name="thread_title"]',
            'input[name="title_"]',
            'input[placeholder*="Заголовок"]',
            'input[placeholder*="Название"]',
            'input[placeholder*="Сформулируйте"]'
        ];

        for (var i = 0; i < s.length; i++) {
            var el = document.querySelector(s[i]);
            if (el) return el;
        }

        return null;
    }

    function getTitle() {
        var el = getTitleInput();
        return el && el.value ? el.value.trim() : '';
    }

    function getEditor() {
        var s = [
            '.fr-element[contenteditable="true"]',
            '.fr-element',
            '.ProseMirror[contenteditable="true"]',
            '[contenteditable="true"]',
            'textarea[name="message"]',
            'textarea[name="message_html"]',
            'textarea.input'
        ];

        for (var i = 0; i < s.length; i++) {
            var el = document.querySelector(s[i]);

            if (el && !el.closest('#' + CFG.panel)) {
                return el;
            }
        }

        return null;
    }

    function getBody() {
        var el = getEditor();

        if (!el) return '';

        if (String(el.tagName).toUpperCase() === 'TEXTAREA') {
            return el.value.trim();
        }

        return (el.innerText || el.textContent || '').trim();
    }

    function getSectionName() {
        var links = Array.prototype.slice.call(document.querySelectorAll('.p-breadcrumbs a, .breadcrumb a, .breadcrumbs a, [class*="breadcrumb"] a'));

        return links.map(function (a) {
            return clean(a.textContent);
        }).filter(Boolean).join(' > ') || document.title || '';
    }

    function getNodeId() {
        var c = document.querySelector('[data-container-key^="node-"]');

        if (c && c.dataset.containerKey) {
            var m1 = c.dataset.containerKey.match(/node-(\d+)/);
            if (m1) return Number(m1[1]);
        }

        var m2 = location.pathname.match(/\/forums\/[^\/]*\.(\d+)(?:\/|$)/i) ||
            location.pathname.match(/\/forums\/(\d+)(?:\/|$)/i);

        if (m2) return Number(m2[1]);

        var input = document.querySelector('input[name="node_id"], input[name="forum_id"]');

        if (input && /^\d+$/.test(input.value || '')) {
            return Number(input.value);
        }

        return null;
    }

    function stripIgnored(text) {
        return String(text || '')
            .replace(/\[quote[\s\S]*?\[\/quote\]/gi, ' ')
            .replace(/\[code[\s\S]*?\[\/code\]/gi, ' ')
            .replace(/^>.*$/gm, ' ');
    }

    function clean(text) {
        return String(text || '').replace(/\s+/g, ' ').trim();
    }

    function norm(text) {
        return clean(text).toLowerCase().replace(/ё/g, 'е');
    }

    function normText(text) {
        return String(text || '')
            .toLowerCase()
            .replace(/ё/g, 'е')
            .replace(/[4]/g, 'ч')
            .replace(/[3]/g, 'з')
            .replace(/[0]/g, 'о')
            .replace(/[6]/g, 'б')
            .replace(/[1!]/g, 'и');
    }

    function wordsIn(text) {
        return String(text || '').match(/[a-zа-яё0-9]{2,}/gi) || [];
    }

    function urlsIn(text) {
        var m = String(text || '').match(URL_RE);
        return m ? uniq(m) : [];
    }

    function removeUrls(text) {
        return String(text || '').replace(URL_RE, ' ');
    }

    function hasQuestion(text) {
        var t = clean(text);

        return /[??]/.test(t) ||
            /(как\s+вам|что\s+думаете|ваше\s+мнение|почему|зачем|обсудим|интересно|а\s+вы|стоит\s+ли)/i.test(t);
    }

    function uniq(arr) {
        var seen = {};
        var out = [];

        for (var i = 0; i < arr.length; i++) {
            var v = String(arr[i] || '').trim();
            var k = v.toLowerCase();

            if (!v || seen[k]) continue;

            seen[k] = true;
            out.push(v);
        }

        return out;
    }

    function oneLine(text) {
        return clean(text).replace(/\n+/g, ' ');
    }

    function clip(text, max) {
        text = String(text || '');

        return text.length <= max ?
            text :
            text.slice(0, max - 20).trim() + ' [обрезано]';
    }

    function esc(str) {
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }

    async function copyText(text) {
        try {
            await navigator.clipboard.writeText(text);
        } catch (e) {
            fallbackCopy(text);
        }

        toast('Скопировано для Claude.');
    }

    function fallbackCopy(text) {
        var ta = document.createElement('textarea');

        ta.value = text;
        ta.style.position = 'fixed';
        ta.style.left = '-9999px';

        document.body.appendChild(ta);
        ta.focus();
        ta.select();

        try {
            document.execCommand('copy');
        } catch (e) {}

        ta.remove();
    }

    function toast(text) {
        var old = document.getElementById(CFG.toast);
        if (old) old.remove();

        var el = document.createElement('div');
        el.id = CFG.toast;
        el.textContent = text;
        document.body.appendChild(el);

        setTimeout(function () {
            if (el.parentElement) el.remove();
        }, 1600);
    }

    function closePanel() {
        var p = document.getElementById(CFG.panel);
        if (p) p.remove();
    }

    function dragPanel(panel) {
        var dragging = false;
        var sx = 0;
        var sy = 0;
        var sl = 0;
        var st = 0;

        panel.addEventListener('mousedown', function (e) {
            if (!e.target.closest('.st-head') || e.target.closest('.st-icon')) return;

            dragging = true;
            sx = e.clientX;
            sy = e.clientY;

            var r = panel.getBoundingClientRect();

            sl = r.left;
            st = r.top;

            panel.style.left = sl + 'px';
            panel.style.top = st + 'px';
            panel.style.right = 'auto';
            document.body.style.userSelect = 'none';
        });

        document.addEventListener('mousemove', function (e) {
            if (!dragging) return;

            panel.style.left = Math.max(8, sl + e.clientX - sx) + 'px';
            panel.style.top = Math.max(8, st + e.clientY - sy) + 'px';
        });

        document.addEventListener('mouseup', function () {
            if (!dragging) return;

            dragging = false;
            document.body.style.userSelect = '';
        });
    }

    function exposeApi() {
        window.SafeTopic = {
            version: CFG.v,

            check: function (data) {
                data = data || {};

                return analyze({
                    title: data.title || '',
                    body: data.body || '',
                    text: (data.title || '') + '\n' + stripIgnored(data.body || ''),
                    hasMedia: !!data.hasMedia,
                    mediaName: data.mediaName || '',
                    nodeId: data.nodeId || 8,
                    section: data.section || 'Оффтоп'
                });
            },

            rerun: function () {
                runCheck(true);
                return 'ok';
            },

            forceButton: function () {
                addButton();
                return 'ok';
            },

            close: function () {
                closePanel();
                return 'ok';
            }
        };
    }
})();