# Система управления бранными словами # Список слов хранится в 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