package ru.pdguard.detect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Path; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * Словари для распознавания ФИО. * *

Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: * «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени. * *

Список известных людей решает обратную задачу — упоминание Пушкина * персональными данными не является. Ограничение осознанное: клиент по фамилии * Пушкин в тексте без других ПД замаскирован не будет. * *

Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх * него можно дописать своих публичных лиц без пересборки — файл по пути * {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt}) * перечитывается сам при изменении, тем же приёмом, что {@code systems.json} * в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время * изменения, содержимое читается заново только когда оно другое. */ public final class NameDictionary { private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class); private static final List GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream() .map(Declension::withoutInflectedEnding) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) .toList(); // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // что и для личных имён. private static final Set BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream() .map(Declension::withoutInflectedEnding) .collect(Collectors.toUnmodifiableSet()); private static final ResourceLoader.FileWatchState> WELL_KNOWN_STATE = new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS); private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt"); /** Разделитель слов: любая последовательность не-буквенных символов. */ private static final String WORD_SPLIT = "\\P{L}+"; /** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */ private static final String REGNAL_ORDINALS = "перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят"; /** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */ private static final String REGNAL_EPITHETS = "велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн"; /** * Имя правителя: личное имя плюс порядковое числительное или прозвище — * «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый». * Задано правилом, а не перечнем: правителей много, а форма записи одна. */ private static final Pattern REGNAL_NAME = Pattern.compile( "^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$", Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ); /** Не более скольких падежных букв дописывается к основе имени. */ private static final int MAX_INFLECTION = 3; /** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */ private static final Set SURNAME_SUFFIXES = Set.of( "ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна", "евна", "овы", "евы", "ины"); private static final Set GIVEN_NAMES = GIVEN_NAME_STEMS.stream() .map(stem -> stem.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); /** * Слова-маркеры персональных данных и реквизитов, которые по словообразованию * совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно * распознаются как ФИО. Это аббревиатуры, а не имена. */ private static final Set PD_MARKERS = Set.of( "инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", "телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву"); private NameDictionary() { } /** Экземпляр для Spring-бина; словарь работает через статические методы. */ public static NameDictionary create() { return new NameDictionary(); } /** * Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения * из конфигурации Spring-бина; статические методы словаря работают без * экземпляра, поэтому путь хранится в статическом поле. */ public static void configure(String wellKnownFile) { WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; WELL_KNOWN_STATE.mtime = -1; WELL_KNOWN_STATE.lastCheck = 0; // Путь фиксирован в статическом поле; для тестов используется useExternalFile. if (!"config/well-known.txt".equals(wellKnownFile)) { useExternalFile(Path.of(wellKnownFile)); } } /** * Есть ли среди слов личное имя из словаря в любом падеже. * *

Проверка множеством, а не чередованием в регулярном выражении: сто с лишним * веток пришлось бы перебирать в каждой позиции текста, здесь же на слово * приходится не больше четырёх обращений к хеш-таблице. */ public static boolean containsGivenName(String value) { for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», // но это имя, а не фамилия. if (GIVEN_NAMES.contains(lower)) { return true; } // По началу слова имя ищется с оглядкой на остаток: «Марина» это основа // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное // «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за // человека, а «Марина Шевченко» переставала бы им быть. for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) { if (GIVEN_NAMES.contains(lower.substring(0, length)) && !SURNAME_SUFFIXES.contains(lower.substring(length))) { return true; } } } return false; } /** * Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется * для строчных имён после ролевого слова («клиент иван иванов»), где регистр * не подсказывает, что перед нами имя. */ public static boolean containsNamePart(String value) { for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); if (GIVEN_NAMES.contains(lower)) { return true; } if (isPatronymic(lower) || isSurname(lower)) { return true; } } return false; } /** * Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова * или соседнего личного имени рядом, самое слабое основание для ФИО. Точное * совпадение с личным именем принимается в любом регистре («иван» тоже имя), * а вот словообразовательная эвристика (фамилия/отчество по окончанию) — * только с заглавной буквы: без этого «законов», «домов», «холодов» — * обычные родительные падежи, а не фамилии — ложно матчились бы. */ public static boolean isStandaloneNameCandidate(String word) { String lower = word.toLowerCase(Locale.ROOT); if (PD_MARKERS.contains(lower)) { return false; } if (GIVEN_NAMES.contains(lower)) { return true; } if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) { return false; } return isPatronymic(lower) || isSurname(lower); } /** Отчество: Иванович, Петровна, Сидоровна. */ private static boolean isPatronymic(String lower) { return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$"); } /** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */ private static final Set SURNAME_ENDINGS = Set.of( "ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", "цкий", "цкая", "енко", "ко", "ук", "юк", "ян", "швили", "дзе"); /** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */ private static boolean isSurname(String lower) { for (String ending : SURNAME_ENDINGS) { if (lower.endsWith(ending)) { return true; } } return false; } /** * Содержит ли текст упоминание известного человека — из сборки или дописанных * сверху. * *

Проверяются префиксы слова по множеству, а не каждая основа по слову: * при тысяче с лишним записей (столько городов в {@link ToponymDictionary}, * тот же приём) перебор списка на каждое слово текста был бы заметен, а * префиксов у слова — не больше, чем в нём букв. */ public static boolean isWellKnown(String value) { if (REGNAL_NAME.matcher(value.strip()).matches()) { return true; } Set stems = currentWellKnownStems(); for (String word : value.split(WORD_SPLIT)) { String lower = word.toLowerCase(Locale.ROOT); for (int length = lower.length(); length > 0; length--) { if (stems.contains(lower.substring(0, length))) { return true; } } } return false; } /** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */ static void useExternalFile(Path path) { WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; WELL_KNOWN_STATE.mtime = -1; WELL_KNOWN_STATE.lastCheck = 0; // Перечитываем немедленно, минуя секундный троттлинг. reloadExternal(path); } /** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */ static synchronized void reloadExternal(Path path) { WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis(); if (!java.nio.file.Files.isReadable(path)) { if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) { LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список", path.toAbsolutePath()); } WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; WELL_KNOWN_STATE.mtime = 0; return; } try { WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis(); Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) { String trimmed = Declension.withoutInflectedEnding(line.trim()); if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { merged.add(trimmed); } } WELL_KNOWN_STATE.current = Set.copyOf(merged); LOG.info("Денилист дополнен из {}: {} имён сверх встроенных", path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); } catch (java.io.IOException e) { // Битый файл не должен ронять маскирование: остаётся прежний список. LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e); } } private static Set currentWellKnownStems() { return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE, lines -> { Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); for (String line : lines) { String trimmed = Declension.withoutInflectedEnding(line); if (!trimmed.isEmpty()) { merged.add(trimmed); } } return Set.copyOf(merged); }); } }