mod: валидатор форм, парсера и моделей (12) валидатор для случаев "схожести текущей записи и синонимов с синонимами других записей"

This commit is contained in:
2026-06-26 17:54:52 +03:00
parent 8ed61bae13
commit d53330554b
2 changed files with 290 additions and 14 deletions
+277 -12
View File
@@ -15,7 +15,7 @@ from django.utils.html import mark_safe
from lpon_site.settings import ( from lpon_site.settings import (
SLUG_MAX_LENGTH, KEY_SYNONYM, SLUG_MAX_LENGTH, KEY_SYNONYM,
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE, VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
ValidateMatchType ValidateMatchType, MIN_SYNONYM_WORD_LENGTH
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +24,7 @@ logger = logging.getLogger(__name__)
def normalize_string(s: str) -> str: def normalize_string(s: str) -> str:
""" """
Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы. Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы.
Также приводит к нижнему регистру для case-insensitive сравнения при поиске дублей.
Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.). Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.).
@@ -31,19 +32,25 @@ def normalize_string(s: str) -> str:
s: Строка для нормализации s: Строка для нормализации
Returns: Returns:
str: Нормализованная строка (или пустая если была пуста) str: Нормализованная строка в нижнем регистре (или пустая если была пуста)
Пример: Пример:
>> normalize_string(" Sony Music ") >> normalize_string(" Sony Music ")
'Sony Music' 'sony music'
>> normalize_string("SONY MUSIC")
'sony music'
>> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами >> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами
'Sony Music' 'sony music'
""" """
if not s: if not s:
return "" return ""
result = str(s) result = str(s)
# Преобразуем в нижний регистр для case-insensitive сравнения
# (регистр не должен быть значимым при поиске дублей артистов/лейблов/стилей)
result = result.lower()
# Удаляем невидимые символы (не заменять, а полностью удалять) # Удаляем невидимые символы (не заменять, а полностью удалять)
result = result.translate({ result = result.translate({
ord("\xad"): None, # символ мягкого переноса ord("\xad"): None, # символ мягкого переноса
@@ -70,6 +77,51 @@ def normalize_string(s: str) -> str:
return result return result
def super_normalize_string(s: str) -> str:
"""
Супер-нормализация строки для проверки частичного совпадения слов (PARTIAL_MATCH).
Выполняет глубокую нормализацию для сравнения словарных совпадений:
- Очищает HTML через safe_html_special_symbols() (которая вызывает normalize_string())
- Удаляет все символы кроме букв, цифр и пробелов
- Сохраняет буквы и цифры ВСЕХ языков (Unicode-safe)
- Приводит к нижнему регистру (через safe_html_special_symbols → normalize_string)
- Нормализует пробелы (множественные → одиночные)
Результат используется для поиска словарных совпадений:
"The Beatles" и "Beatles, The" оба станут: "the beatles"
Затем разбиваются на слова: ["the", "beatles"]
Args:
s: Строка для нормализации
Returns:
str: Строка со словами, разделенными пробелами (в нижнем регистре)
Пример:
>> super_normalize_string("The Beatles (Rock)")
'the beatles rock'
>> super_normalize_string("Beatles, The - Rock Band!")
'beatles the rock band'
"""
if not s:
return ""
# Сначала очищаем HTML и спецсимволы через существующую функцию
# (safe_html_special_symbols вызывает normalize_string(), который уже приводит к нижнему регистру)
cleaned = safe_html_special_symbols(s)
# Удаляем все символы кроме букв, цифр и пробелов (Unicode-safe)
# \w в Python regex с флагом UNICODE включает: буквы всех языков, цифры и подчеркивание
# Используем [^\w\s] для удаления всего кроме слов и пробелов, затем исключаем подчеркивание
cleaned = re.sub(r'[^\w\s]|_', '', cleaned, flags=re.UNICODE)
# Нормализуем пробелы (множественные → одиночные)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned
def safe_html_special_symbols(s: str) -> str: def safe_html_special_symbols(s: str) -> str:
"""Преобразует HTML-фрагмент в чистый текст. """Преобразует HTML-фрагмент в чистый текст.
@@ -245,7 +297,13 @@ def validate_for_duplicates(
duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__} duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__}
# ПОДГОТОВКА: Получаем базовый queryset (все записи кроме текущей, если редактируем) # ПОДГОТОВКА 1: Инициализируем переменную для отслеживания минимальной длины синонимов
# Используется для адаптивной фильтрации при PARTIAL_MATCH на очень коротких названиях
# (например, "B'Z", "XL", которые после супер-нормализации становятся еще короче).
# Начинаем с MIN_SYNONYM_WORD_LENGTH, будет переопределяться по необходимости.
effective_min_word_len = MIN_SYNONYM_WORD_LENGTH
# ПОДГОТОВКА 2: Получаем базовый queryset (все записи кроме текущей, если редактируем)
# Это ленивый запрос - запрос выполнится, только когда мы применим фильтры # Это ленивый запрос - запрос выполнится, только когда мы применим фильтры
records_to_check = model_class.objects.all() records_to_check = model_class.objects.all()
if instance_pk is not None: if instance_pk is not None:
@@ -254,8 +312,14 @@ def validate_for_duplicates(
# ПРОВЕРКА 1: EXACT MATCH (точное совпадение основного поля) # ПРОВЕРКА 1: EXACT MATCH (точное совпадение основного поля)
# Ищем: есть ли другая запись с точно таким же main_field_value? # Ищем: есть ли другая запись с точно таким же main_field_value?
filter_kwargs = {f"{main_field_name}__exact": normalized_main_value} # Используем RawSQL с LOWER() для case-insensitive сравнения нормализованных значений
exact_matches = records_to_check.filter(**filter_kwargs) exact_matches = records_to_check.annotate(
has_exact_match=RawSQL(
f"LOWER({main_field_name}) = %s",
(normalized_main_value,)
)
).filter(has_exact_match=True)
if exact_matches.exists(): if exact_matches.exists():
duplicates_found.update({ duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE, VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
@@ -274,17 +338,18 @@ def validate_for_duplicates(
# Строим RawSQL запрос для поиска в JSON массиве синонимов # Строим RawSQL запрос для поиска в JSON массиве синонимов
# json_each распарсивает массив и ищет совпадение со значением # json_each распарсивает массив и ищет совпадение со значением
# LOWER() используется т.к. в БД могут быть строки в любом регистре, но нормализованное значение в нижнем
synonym_matches = records_to_check.annotate( synonym_matches = records_to_check.annotate(
has_synonym=RawSQL( has_synonym=RawSQL(
f""" f"""
EXISTS ( EXISTS (
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}')
WHERE json_each.value = %s WHERE LOWER(json_each.value) = %s
) )
""", """,
(normalized_main_value,) (normalized_main_value,)
) )
).filter(has_synonym=True) ).filter(has_synonym=True)
# Если найдены совпадения в синонимах - возвращаем все найденные записи # Если найдены совпадения в синонимах - возвращаем все найденные записи
if synonym_matches.exists(): if synonym_matches.exists():
@@ -294,28 +359,47 @@ def validate_for_duplicates(
}) })
return duplicates_found return duplicates_found
# Подготавливаем единый список всех значений для анализа:
# основное поле + все синонимы из метаданных
# Этот список переиспользуется на этапах 3 и 4, избегая дублирования логики
raw_values_to_analyze = [main_field_value]
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
raw_values_to_analyze.extend(metadata_dict[KEY_SYNONYM])
# ПРОВЕРКА 3: EXACT_SYNONYM_MATCH (точное совпадение синонимов текущей записи с синонимами других) # ПРОВЕРКА 3: EXACT_SYNONYM_MATCH (точное совпадение синонимов текущей записи с синонимами других)
# Ищем: есть ли синонимы из текущей записи в синонимах других записей? # Ищем: есть ли синонимы из текущей записи в синонимах других записей?
# Пользователь мог отредактировать список синонимов в форме, и мы не хотим дубликатов среди синонимов. # Пользователь мог отредактировать список синонимов в форме, и мы не хотим дубликатов среди синонимов.
# Например, если текущая запись имеет синонимы=['Polydor Records', 'Vertigo France'], # Например, если текущая запись имеет синонимы=['Polydor Records', 'Vertigo France'],
# а запись A имеет синонимы=['Vertigo France', 'Swirl'], это совпадение! # а запись A имеет синонимы=['Vertigo France', 'Swirl'], это совпадение!
# Пользователь подтверждает - удалим конфликтующие синонимы из других записей. # Пользователь подтверждает - удалим конфликтующие синонимы из других записей.
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list): if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
# Собираем все синонимы текущей записи с нормализацией # Собираем все синонимы текущей записи с нормализацией
current_synonyms = [ current_synonyms = [
normalize_string(syn) for syn in metadata_dict[KEY_SYNONYM] normalize_string(syn) for syn in metadata_dict[KEY_SYNONYM]
] ]
# Удаляем дубликаты синонимов, сохраняя порядок
# (может быть пользователь вручную добавил одно и то же несколько раз)
current_synonyms = list(dict.fromkeys(current_synonyms))
# Определяем минимальную длину синонимов из текущей записи
# (для адаптивной фильтрации при PARTIAL_MATCH на очень коротких названиях)
# Учитываем как синонимы, так и основное значение поля (normalized_main_value)
# которое может быть коротким и уже совпадало на этапе 2
all_values_to_check = current_synonyms + [normalized_main_value]
min_syn_len = min(len(val) for val in all_values_to_check if val)
effective_min_word_len = min(effective_min_word_len, min_syn_len)
# Если есть синонимы для проверки # Если есть синонимы для проверки
if current_synonyms: if current_synonyms:
# Строим RawSQL условие для поиска любого из наших синонимов в JSON массиве других записей # Строим RawSQL условие для поиска любого из наших синонимов в JSON массиве других записей
# Используем один аннотированный queryset со всеми условиями OR для поиска всех конфликтов сразу # Используем один аннотированный queryset со всеми условиями OR для поиска всех конфликтов сразу
# LOWER() используется т.к. в БД могут быть строки в любом регистре, но нормализованные значения в нижнем
synonym_query_parts = [] synonym_query_parts = []
query_params = [] query_params = []
for normalized_synonym in current_synonyms: for normalized_synonym in current_synonyms:
synonym_query_parts.append( synonym_query_parts.append(
f"EXISTS (SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE json_each.value = %s)" f"EXISTS (SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE LOWER(json_each.value) = %s)"
) )
query_params.append(normalized_synonym) query_params.append(normalized_synonym)
@@ -335,6 +419,82 @@ def validate_for_duplicates(
}) })
return duplicates_found return duplicates_found
# ПРОВЕРКА 4: PARTIAL_MATCH (частичное совпадение слов текущей записи с другими)
# Ищем: есть ли значимые слова (> MIN_SYNONYM_WORD_LENGTH) из текущей записи и синонимов
# в основных полях и синонимах других записей?
# Пример: "The Beatles" содержит слово "beatles" (5 букв > 3),
# "Beatles, The" тоже содержит "beatles", это схожесть (предупреждение, не блокировка)
#
# Собираем все слова из main_field_value и синонимов текущей записи
all_current_words = set()
# Переиспользуем raw_values_to_analyze (основное поле + синонимы из метаданных),
# которое уже подготовили на этапе 3, чтобы избежать дублирования логики.
# Обрабатываем каждое значение: супер-нормализуем и собираем слова
for value in raw_values_to_analyze:
# Используем супер-нормализатор для удаления пунктуации и спецсимволов
normalized_value = super_normalize_string(value)
value_words = re.split(r'\s+', normalized_value)
for word in value_words:
# Собираем ВСЕ слова (даже очень короткие) для отслеживания
if word: # Только непустые
all_current_words.add(word)
# Отслеживаем минимальную длину встреченных слов (независимо от MIN_SYNONYM_WORD_LENGTH)
effective_min_word_len = min(effective_min_word_len, len(word))
# Если собрали значимые слова - ищем их в других записях
if all_current_words:
# Строим RawSQL условие для поиска любого из наших слов в main_field_name других записей
# или в синонимах других записей (как в main_field_name, так и в JSON)
word_search_conditions = []
word_search_params = []
# Условия поиска в основном поле (case-insensitive)
for word in all_current_words:
# Фильтруем слова по эффективной минимальной длине (может быть меньше MIN_SYNONYM_WORD_LENGTH
# если встречались короткие синонимы, например, "B'Z" → "B Z")
if len(word) >= effective_min_word_len:
# Ищем слово как подстроку в основном поле (окружено пунктуацией/пробелами или в начале/конце)
# Используем LIKE с wildcard
word_search_conditions.append(
f"LOWER({main_field_name}) LIKE %s"
)
word_search_params.append(f"%{word}%")
# Условия поиска в синонимах (в JSON массиве)
for word in all_current_words:
# Фильтруем слова по эффективной минимальной длине
if len(word) >= effective_min_word_len:
word_search_conditions.append(
f"""EXISTS (
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}')
WHERE LOWER(json_each.value) LIKE %s
)"""
)
word_search_params.append(f"%{word}%")
# Объединяем условия через OR - ищем хотя бы одно совпадение по словам
partial_matches = records_to_check.annotate(
has_partial_match=RawSQL(
" OR ".join(word_search_conditions),
word_search_params
)
).filter(has_partial_match=True).distinct()
# Если найдены записи с частичным совпадением - возвращаем их
if partial_matches.exists():
# Определяем тип совпадения: если были найдены очень короткие слова (< MIN_SYNONYM_WORD_LENGTH),
# помечаем это как PARTIAL_MATCH__RISK_SHORT_WORDS для специального обращения в админке
match_type = ValidateMatchType.PARTIAL_MATCH
if effective_min_word_len < MIN_SYNONYM_WORD_LENGTH:
match_type = ValidateMatchType.PARTIAL_MATCH__RISK_SHORT_WORDS
duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: match_type,
VALIDATE_KEY__VALUE: partial_matches,
})
return duplicates_found
# Когда все проверки прошли -- возвращаем пустой словарь # Когда все проверки прошли -- возвращаем пустой словарь
return duplicates_found return duplicates_found
@@ -618,6 +778,87 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
) )
) )
case ValidateMatchType.PARTIAL_MATCH:
# ОБРАБОТКА ЧАСТИЧНЫХ СОВПАДЕНИЙ (слова совпадают)
# Это просто схожесть, не критично. Показываем предупреждение, но не блокируем сохранение.
# При подтверждении просто сохраняем без каких-либо изменений в других записях.
if request and request.GET.get('ignore_validate') == '1':
# РЕЖИМ: ОБХОД ВАЛИДАЦИИ (пользователь подтвердил что ознакомлен)
# Тихо сохраняем запись, ничего не меняя в других записях
return
else:
# РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА
# Показываем пользователю информативное предупреждение о схожести
for dup in duplicates_queryset:
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/"
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
dup_list = ", ".join(dup_links)
# Кнопка для игнорирования предупреждения (без критичности)
confirmation_button = '''
<div class="confirmation-button-container">
<button type="button" onclick="markSubmitButtonsToIgnoreValidation();">
<big>OK, Я ПРОВЕРИЛ. ПРОБЛЕМ НЕ БУДЕТ</big><br/>
Сохранить запись<br/>
<i>Я уверен в своих действиях.</i>
</button>
<em>Теперь нажмите стандартные кнопки сохранения снизу, чтобы сохранить.</em>
</div>
'''
raise ValidationError(
mark_safe(
f"ИНФОРМАЦИЯ: Найдены похожие записи со схожими словами! "
f"Проверьте: {dup_list} "
f"Это просто информация, не ошибка. Но неточности на сайте могут рассмешить"
f" (или огорчить) пользователей. {confirmation_button}"
)
)
case ValidateMatchType.PARTIAL_MATCH__RISK_SHORT_WORDS:
# ОБРАБОТКА ЧАСТИЧНЫХ СОВПАДЕНИЙ С ВЫСОКИМ РИСКОМ (очень короткие слова/синонимы)
# Найдены совпадения, но среди слов есть очень короткие (< MIN_SYNONYM_WORD_LENGTH)
# Это может быть как вероятное совпадение (B'Z, XL) так и ложное срабатывание (А, И)
# Показываем ещё более серьезное предупреждение о необходимости проверки
if request and request.GET.get('ignore_validate') == '1':
# РЕЖИМ: ОБХОД ВАЛИДАЦИИ (пользователь подтвердил что ознакомлен с рисками)
# Тихо сохраняем запись, ничего не меняя в других записях
return
else:
# РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА
# Показываем пользователю СЕРЬЕЗНОЕ предупреждение о высоком риске ошибки
for dup in duplicates_queryset:
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/"
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
dup_list = ", ".join(dup_links)
# Кнопка для подтверждения с указанием на риск
confirmation_button = '''
<div class="confirmation-button-container">
<button type="button" onclick="markSubmitButtonsToIgnoreValidation();">
<big>ВНИМАНИЕ: ВЫСОКИЙ РИСК!</big><br/>
Я уверен в своих действиях<br/>
<i>Найдены очень короткие слова (< 4 символов), высокий риск ошибки!</i>
</button>
<em>Теперь нажмите стандартные кнопки сохранения снизу, чтобы сохранить.</em>
</div>
'''
raise ValidationError(
mark_safe(
f"ВНИМАНИЕ: ВЫСОКИЙ РИСК ОШИБКИ! Найдены похожие записи с ОЧЕНЬ КОРОТКИМИ словами! "
f"Проверьте ОЧЕНЬ ВНИМАТЕЛЬНО: {dup_list} "
f"Совпадения могут быть с однобуквенными или двухбуквенными словами (например: 'B'Z''B Z'). "
f"Велик риск ложных срабатываний! {confirmation_button}"
)
)
case _: case _:
# Неизвестный или не обработанный тип совпадения # Неизвестный или не обработанный тип совпадения
# В будущем сюда можно добавить логирование неожиданных типов # В будущем сюда можно добавить логирование неожиданных типов
@@ -715,7 +956,7 @@ def validate_and_raise_for_duplicates(
) )
case ValidateMatchType.EXACT_SYNONYM_MATCH: case ValidateMatchType.EXACT_SYNONYM_MATCH:
# Совпадение синонимов найдено - синонимы в текущей записи совпадают с синонимами других записей # Совпадение синонимов найдено - синонимы текущей записи совпадают с синонимами других записей
# Теритически, это может произойти вне админки (парсер, API, батник, bulk операции и т.д.) # Теритически, это может произойти вне админки (парсер, API, батник, bulk операции и т.д.)
# но синонимы определяются и редактируются только в админке (через интерфейс), парсеры их генерировать # но синонимы определяются и редактируются только в админке (через интерфейс), парсеры их генерировать
# не должны (и не будут). Блокируем (может быть в будущем сохранить состояние в брокере, но вряд ли). # не должны (и не будут). Блокируем (может быть в будущем сохранить состояние в брокере, но вряд ли).
@@ -728,6 +969,30 @@ def validate_and_raise_for_duplicates(
f"PK конфликтующих записей: {dup_pks}" f"PK конфликтующих записей: {dup_pks}"
) )
case ValidateMatchType.PARTIAL_MATCH:
# Частичное совпадение по словам найдено - это просто предупреждение (warning)
# Не блокируем, просто логируем для информации
# Парсеры/батники могут работать с такими данными без ограничений
model_name = model_class.__name__
dup_pks = [dup.pk for dup in duplicates_result[VALIDATE_KEY__VALUE]]
logger.info(
f"{model_name}.save(): Найдено частичное совпадение по словам. "
f"PK записей с похожими названиями: {dup_pks}"
)
case ValidateMatchType.PARTIAL_MATCH__RISK_SHORT_WORDS:
# Частичное совпадение, но с очень короткими словами (< MIN_SYNONYM_WORD_LENGTH)
# Повышенный риск ложных срабатываний, но все равно это предупреждение (warning)
# Не блокируем, просто логируем с более высоким приоритетом
# Администраторы могут обратить внимание на такие случаи
model_name = model_class.__name__
dup_pks = [dup.pk for dup in duplicates_result[VALIDATE_KEY__VALUE]]
logger.warning(
f"{model_name}.save(): Найдено частичное совпадение по словам с ВЫСОКИМ РИСКОМ ошибки! "
f"Обнаружены очень короткие слова/синонимы (< {MIN_SYNONYM_WORD_LENGTH} символов). "
f"Проверьте вручную! PK записей с похожими названиями: {dup_pks}"
)
case _: case _:
# Неизвестный тип совпадения или дубликатов нет # Неизвестный тип совпадения или дубликатов нет
# Это нормальная ситуация - логируем только если что-то странное # Это нормальная ситуация - логируем только если что-то странное
+13 -2
View File
@@ -330,6 +330,17 @@ class ValidateMatchType(IntEnum):
IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.) IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.)
FIND_IN_SYNONYM = 2 # Точное совпадение в синонимах FIND_IN_SYNONYM = 2 # Точное совпадение в синонимах
EXACT_SYNONYM_MATCH = 3 # Точное совпадение синонимов проверяемой записи с синонимами остальных записей EXACT_SYNONYM_MATCH = 3 # Точное совпадение синонимов проверяемой записи с синонимами остальных записей
PARTIAL_MATCH = 4 # Частичное совпадение (например, часть названия артиста или лейбла в других записях) PARTIAL_MATCH = 4 # Схожесть: частичное совпадение (например, часть названия артиста или лейбла в других записях).
PARTIAL_SYNONYM_MATCH = 5 # Частичное совпадение между синонимами проверяемой записи с синонимами остальных записей # Основного поля и синонимами проверяемой записи и всех остальных записей.
PARTIAL_MATCH__RISK_SHORT_WORDS = 5 # Схожесть: Частичное совпадение, но с короткими словами (например,
# Японская группа "B'Z" или лейбл "XL"), что может вызвать ложные срабатывания
# схожести. Нужны быть аккуратнее, проверять вручную (особенно если короткие
# слова среди синонимов внутри метаданных других записей). Довольно частое
# явление для распростарннеых "суффиксов" MC, DJ, артиклей, инициалов
# исполнителей и т.п.
# Минимальная длина слова для поиска совпадений схожести в синонимах (например, в "The Beatles": "The" не учитывается,
# а "Beatles" учитывается)
MIN_SYNONYM_WORD_LENGTH = 4