Антижалоба для оффтопа: проверка темы перед публикацией
// ==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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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';
}
};
}
})();