Files
LGBot/src/bad_words.py
Евгений Храмов 6a7df9bc55 Улучшение системы фильтрации мата и управления кармой
Фильтрация бранных слов:
- Добавлена полная транслитерация латиница→кириллица (все 26 букв)
- Добавлен маппинг цифр→буквы (0→о, 3→з, 4→ч, 6→б, 8→в и др.)
- Добавлено определение ASCII-art паттернов (><→х, }|{→ж и др.)
- Реализована 3-уровневая система категоризации паттернов:
  * whole_word - только целые слова с границами \b
  * word_start - только начало слова (новая категория)
  * contains - любое вхождение (минимум 4 буквы)
- Добавлен расширенный список исключений для предотвращения ложных срабатываний
  (требует, употреблять, скребу, гребу, республика, документ и др.)

Система кармы:
- Добавлен штраф за эмодзи клоуна 🤡 (-20 кармы тому, кто добавил)
- Карма не возвращается при снятии клоуна
- Реализована возможность отключения кармы в конкретных топиках:
  * Новая таблица disabled_karma_topics в БД
  * Методы: is_karma_disabled_in_topic(), disable/enable_karma_in_topic()
- Перенесены команды управления кармой из /badwords в /karma:
  * /karma disable - отключить карму в текущем топике
  * /karma enable - включить карму в текущем топике
  * /karma status - показать статус кармы во всех топиках
- Убраны упоминания пользователей в команде /top (используется <code>)

Аудит и логирование:
- Добавлена отправка в канал аудита для операций со словами:
  * Добавление/удаление бранных слов
  * Добавление/удаление исключений
- Расширен action_reporter для поддержки операций без привязки к пользователю
2025-11-18 18:16:59 +03:00

448 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Система управления бранными словами
# Список слов хранится в JSON файле для возможности управления через команды
import json
import os
import logging
import re
logger = logging.getLogger(__name__)
# Путь к файлу с бранными словами
BAD_WORDS_FILE = os.path.join(os.path.dirname(__file__), 'data', 'bad_words.json')
# Кэш для загруженных слов
_bad_words_cache = None
_exceptions_cache = None
_whole_word_patterns_cache = None
_word_start_patterns_cache = None
_contains_patterns_cache = None
def load_bad_words():
"""
Загружает список бранных слов из JSON файла.
Поддерживает как новый формат (patterns), так и старый (bad_words).
Returns:
tuple: (список бранных слов, список исключений)
"""
global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache
try:
with open(BAD_WORDS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
# Проверяем, какой формат используется
if 'patterns' in data:
# Новый формат с паттернами
patterns = data['patterns']
_whole_word_patterns_cache = patterns.get('whole_word', [])
_word_start_patterns_cache = patterns.get('word_start', [])
_contains_patterns_cache = patterns.get('contains', [])
_exceptions_cache = data.get('exceptions', [])
# Для обратной совместимости объединяем в один список
_bad_words_cache = _whole_word_patterns_cache + _word_start_patterns_cache + _contains_patterns_cache
logger.info(
f"Загружено паттернов: {len(_whole_word_patterns_cache)} whole_word, "
f"{len(_word_start_patterns_cache)} word_start, "
f"{len(_contains_patterns_cache)} contains, {len(_exceptions_cache)} исключений"
)
else:
# Старый формат для обратной совместимости
_bad_words_cache = data.get('bad_words', [])
_exceptions_cache = data.get('exceptions', [])
_whole_word_patterns_cache = []
_word_start_patterns_cache = []
_contains_patterns_cache = _bad_words_cache.copy()
logger.info(f"Загружено {len(_bad_words_cache)} бранных слов (старый формат) и {len(_exceptions_cache)} исключений")
return _bad_words_cache, _exceptions_cache
except FileNotFoundError:
logger.error(f"Файл {BAD_WORDS_FILE} не найден")
return [], []
except json.JSONDecodeError as e:
logger.error(f"Ошибка чтения JSON: {e}")
return [], []
def save_bad_words(bad_words: list, exceptions: list):
"""
Сохраняет список бранных слов в JSON файл.
Args:
bad_words: Список бранных слов
exceptions: Список исключений
Returns:
bool: True если успешно, иначе False
"""
global _bad_words_cache, _exceptions_cache
try:
# Создаем директорию, если её нет
os.makedirs(os.path.dirname(BAD_WORDS_FILE), exist_ok=True)
data = {
'bad_words': sorted(list(set(bad_words))), # Убираем дубликаты и сортируем
'exceptions': sorted(list(set(exceptions)))
}
with open(BAD_WORDS_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# Обновляем кэш
_bad_words_cache = data['bad_words']
_exceptions_cache = data['exceptions']
logger.info(f"Сохранено {len(_bad_words_cache)} бранных слов и {len(_exceptions_cache)} исключений")
return True
except Exception as e:
logger.error(f"Ошибка сохранения: {e}")
return False
def get_bad_words():
"""Возвращает список бранных слов (с кэшированием)"""
global _bad_words_cache
if _bad_words_cache is None:
load_bad_words()
return _bad_words_cache or []
def get_exceptions():
"""Возвращает список исключений (с кэшированием)"""
global _exceptions_cache
if _exceptions_cache is None:
load_bad_words()
return _exceptions_cache or []
def reload_words():
"""Перезагружает списки из файла (сбрасывает кэш)"""
global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache
_bad_words_cache = None
_exceptions_cache = None
_whole_word_patterns_cache = None
_word_start_patterns_cache = None
_contains_patterns_cache = None
return load_bad_words()
def normalize_text(text: str) -> str:
"""
Нормализует текст для обхода обфускации.
Убирает:
- Транслитерацию латиницы в кириллицу (xyu → хуй, p0rn → porn)
- Цифры похожие на буквы (0→о, 3→з, 6→б)
- Языковые варианты (ё→е)
- ASCII-имитации (><→х, |\|→и, /\→л)
- Звездочки, точки, подчеркивания между буквами (х*й, х.у.й, х_у_й → хуй)
- Повторяющиеся символы (хууууууй → хуй)
- ОДИНОЧНЫЕ пробелы между ОДИНОЧНЫМИ буквами (х у й → хуй, но "не бу" остаётся "не бу")
Args:
text: Исходный текст
Returns:
Нормализованный текст
"""
if not text:
return text
# Приводим к нижнему регистру
normalized = text.lower()
# 1. Транслитерация латиницы → кириллица (защита от обхода через латиницу)
# Основано на визуальном сходстве букв
transliteration_map = {
'a': 'а', # a → а
'b': 'в', # b → в
'c': 'с', # c → с (латинская c похожа на кириллическую с)
'd': 'д', # d → д
'e': 'е', # e → е
'f': 'ф', # f → ф
'g': 'г', # g → г
'h': 'н', # h → н
'i': 'и', # i → и
'j': 'ж', # j → ж
'k': 'к', # k → к
'l': 'л', # l → л
'm': 'м', # m → м
'n': 'н', # n → н
'o': 'о', # o → о
'p': 'р', # p → р
'q': 'к', # q → к
'r': 'р', # r → р
's': 'с', # s → с
't': 'т', # t → т
'u': 'и', # u → и
'v': 'в', # v → в
'w': 'в', # w → в
'x': 'х', # x → х
'y': 'у', # y → у
'z': 'з', # z → з
}
# Применяем транслитерацию
for lat, cyr in transliteration_map.items():
normalized = normalized.replace(lat, cyr)
# 2. Замена цифр на похожие буквы
digit_map = {
'0': 'о', # 0 → о
'1': 'и', # 1 → и (или l)
'3': 'з', # 3 → з
'4': 'ч', # 4 → ч
'6': 'б', # 6 → б
'8': 'в', # 8 → в
}
for digit, letter in digit_map.items():
normalized = normalized.replace(digit, letter)
# 3. Языковые паттерны (унификация написания)
normalized = normalized.replace('ё', 'е') # ё → е
# 4. ASCII-имитации букв (сложные комбинации символов)
# Важно: делать это ДО удаления других символов
ascii_patterns = [
(r'\|\|', 'и'), # |\| → и (вертикальные черты)
(r'/\\', 'л'), # /\ → л (треугольник)
(r'><', 'х'), # >< → х
(r'\}\{', 'х'), # }{ → х
(r'\)\(', 'х'), # )( → х
(r'>\|<', 'ж'), # >|< → ж
(r'\}\|\{', 'ж'), # }|{ → ж
]
for pattern, replacement in ascii_patterns:
normalized = re.sub(pattern, replacement, normalized)
# Циклически убираем обфускацию, пока что-то меняется
max_iterations = 10
for _ in range(max_iterations):
before = normalized
# Убираем звёздочки, точки, дефисы, подчёркивания между буквами
# х*й, х.у.й, х_у_й → хуй
normalized = re.sub(r'([а-яё])[\*\.\-_]+([а-яё])', r'\1\2', normalized)
# Убираем ОДИНОЧНЫЕ пробелы между ОДИНОЧНЫМИ буквами (обфускация)
# "х у й" → "хуй", но "не бу" → "не бу" (не склеиваем обычные слова)
# Паттерн: одиночная буква + пробелы + одиночная буква
normalized = re.sub(r'\b([а-яё])\s+([а-яё])\b', r'\1\2', normalized)
if before == normalized:
break
# Убираем повторяющиеся буквы (более 2 подряд)
# хууууууй → хуй, пииииздец → пиздец
normalized = re.sub(r'([а-яё])\1{2,}', r'\1', normalized)
return normalized
# Предзагружаем слова при импорте модуля в кэш
load_bad_words()
def contains_bad_word(text: str) -> bool:
"""
Проверяет, содержит ли текст бранные слова.
Использует комбинированный подход для минимизации ложных срабатываний:
- Нормализацию текста для обхода обфускации
- Проверку границ слов для whole_word паттернов
- Проверку начала слова для word_start паттернов (НОВОЕ!)
- Проверку подстрок для contains паттернов с минимальной длиной
- Расширенный список исключений
Args:
text: Текст для проверки
Returns:
True, если найдено бранное слово, иначе False
"""
if not text:
return False
# Нормализуем текст (обход обфускации)
normalized_text = normalize_text(text)
# Получаем паттерны и исключения
global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache
# Если кэш не загружен, загружаем
if _whole_word_patterns_cache is None:
load_bad_words()
whole_word_patterns = _whole_word_patterns_cache or []
word_start_patterns = _word_start_patterns_cache or []
contains_patterns = _contains_patterns_cache or []
exceptions = _exceptions_cache or []
# Вспомогательная функция для проверки исключений
def is_in_exception(pattern_pos, pattern_len, text, exceptions_list):
"""Проверяет, входит ли найденный паттерн в слово-исключение"""
for exception in exceptions_list:
# Нормализуем исключение
norm_exception = normalize_text(exception)
if pattern_len == 0 or len(norm_exception) == 0:
continue
# Ищем исключение в тексте около найденного паттерна
# Проверяем область от (pos - len(exception)) до (pos + len(pattern))
search_start = max(0, pattern_pos - len(norm_exception))
search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception))
search_area = text[search_start:search_end]
if norm_exception in search_area:
exc_pos = search_area.find(norm_exception)
abs_exc_pos = search_start + exc_pos
abs_exc_end = abs_exc_pos + len(norm_exception)
# Проверяем, что паттерн полностью внутри исключения
if abs_exc_pos <= pattern_pos < abs_exc_end:
return True
return False
# 1. Проверяем whole_word паттерны (только целые слова с границами \b)
for pattern in whole_word_patterns:
# Используем границы слов \b для поиска только целых слов
regex = r'\b' + re.escape(pattern) + r'\b'
match = re.search(regex, normalized_text, re.IGNORECASE)
if match:
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions):
return True
# 2. Проверяем word_start паттерны (НОВОЕ! только начало слова)
# Паттерн должен быть либо в начале строки, либо после не-буквы
for pattern in word_start_patterns:
# Regex: начало строки ИЛИ не-буква, затем паттерн
# (?:^|(?<=[^а-яёa-z])) - положительный lookbehind для начала или не-буквы
regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern)
match = re.search(regex, normalized_text, re.IGNORECASE)
if match:
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions):
return True
# 3. Проверяем contains паттерны (любое вхождение)
# Применяем минимальную длину для снижения false positives
MIN_CONTAINS_LENGTH = 4
for pattern in contains_patterns:
# Пропускаем слишком короткие паттерны (для безопасности)
if len(pattern) < MIN_CONTAINS_LENGTH:
logger.warning(f"Паттерн '{pattern}' слишком короткий для contains, пропускаем")
continue
if pattern in normalized_text:
# Проверяем все вхождения паттерна
start = 0
while True:
pos = normalized_text.find(pattern, start)
if pos == -1:
break
if not is_in_exception(pos, len(pattern), normalized_text, exceptions):
return True
start = pos + 1
return False
def get_bad_words_from_text(text: str) -> list:
"""
Возвращает список найденных бранных слов в тексте.
Использует комбинированный подход для минимизации ложных срабатываний:
- Нормализацию текста для обхода обфускации
- Проверку границ слов для whole_word паттернов
- Проверку начала слова для word_start паттернов (НОВОЕ!)
- Проверку подстрок для contains паттернов с минимальной длиной
- Расширенный список исключений
Args:
text: Текст для проверки
Returns:
Список найденных бранных слов
"""
if not text:
return []
# Нормализуем текст (обход обфускации)
normalized_text = normalize_text(text)
found_words = []
# Получаем паттерны и исключения
global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache
# Если кэш не загружен, загружаем
if _whole_word_patterns_cache is None:
load_bad_words()
whole_word_patterns = _whole_word_patterns_cache or []
word_start_patterns = _word_start_patterns_cache or []
contains_patterns = _contains_patterns_cache or []
exceptions = _exceptions_cache or []
# Вспомогательная функция для проверки исключений
def is_in_exception(pattern_pos, pattern_len, text, exceptions_list):
"""Проверяет, входит ли найденный паттерн в слово-исключение"""
for exception in exceptions_list:
norm_exception = normalize_text(exception)
if pattern_len == 0 or len(norm_exception) == 0:
continue
search_start = max(0, pattern_pos - len(norm_exception))
search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception))
search_area = text[search_start:search_end]
if norm_exception in search_area:
exc_pos = search_area.find(norm_exception)
abs_exc_pos = search_start + exc_pos
abs_exc_end = abs_exc_pos + len(norm_exception)
if abs_exc_pos <= pattern_pos < abs_exc_end:
return True
return False
# 1. Проверяем whole_word паттерны (только целые слова)
for pattern in whole_word_patterns:
regex = r'\b' + re.escape(pattern) + r'\b'
match = re.search(regex, normalized_text, re.IGNORECASE)
if match:
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words:
found_words.append(pattern)
# 2. Проверяем word_start паттерны (НОВОЕ! только начало слова)
for pattern in word_start_patterns:
regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern)
match = re.search(regex, normalized_text, re.IGNORECASE)
if match:
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words:
found_words.append(pattern)
# 3. Проверяем contains паттерны (любое вхождение)
MIN_CONTAINS_LENGTH = 4
for pattern in contains_patterns:
if len(pattern) < MIN_CONTAINS_LENGTH:
continue
if pattern in normalized_text:
start = 0
word_is_valid = False
while True:
pos = normalized_text.find(pattern, start)
if pos == -1:
break
if not is_in_exception(pos, len(pattern), normalized_text, exceptions):
word_is_valid = True
break
start = pos + 1
if word_is_valid and pattern not in found_words:
found_words.append(pattern)
return found_words