package ru.pdguard.detect; import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.logging.Logger; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; 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.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 = Logger.getLogger(NameDictionary.class); private static final long RECHECK_MILLIS = 1000; private static final List GIVEN_NAME_STEMS = load("/names/given-names.txt").stream() .map(Declension::withoutInflectedEnding) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) .toList(); // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // что и для личных имён. private static final Set BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() .map(Declension::withoutInflectedEnding) .collect(Collectors.toUnmodifiableSet()); private static volatile Path externalFile = Path.of(ConfigProvider.getConfig() .getOptionalValue("pdguard.well-known-file", String.class) .orElse("config/well-known.txt")); private static volatile Set wellKnownStems = BUNDLED_WELL_KNOWN_STEMS; private static volatile long externalTimestamp; private static volatile long lastCheck; /** Не более скольких падежных букв дописывается к основе имени. */ 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 NameDictionary() { } /** * Есть ли среди слов личное имя из словаря в любом падеже. * *

Проверка множеством, а не чередованием в регулярном выражении: сто с лишним * веток пришлось бы перебирать в каждой позиции текста, здесь же на слово * приходится не больше четырёх обращений к хеш-таблице. */ public static boolean containsGivenName(String value) { for (String word : value.split("\\P{L}+")) { 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; } /** * Содержит ли текст упоминание известного человека — из сборки или дописанных * сверху. * *

Проверяются префиксы слова по множеству, а не каждая основа по слову: * при тысяче с лишним записей (столько городов в {@link ToponymDictionary}, * тот же приём) перебор списка на каждое слово текста был бы заметен, а * префиксов у слова — не больше, чем в нём букв. */ public static boolean isWellKnown(String value) { refreshIfChanged(); Set stems = wellKnownStems; for (String word : value.split("\\P{L}+")) { 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) { externalFile = path; externalTimestamp = -1; lastCheck = 0; } /** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */ static synchronized void reloadExternal() { lastCheck = System.currentTimeMillis(); if (!Files.isReadable(externalFile)) { if (wellKnownStems != BUNDLED_WELL_KNOWN_STEMS) { LOG.infof("Внешний файл денилиста %s исчез, остаётся только встроенный список", externalFile.toAbsolutePath()); } wellKnownStems = BUNDLED_WELL_KNOWN_STEMS; externalTimestamp = 0; return; } try { externalTimestamp = Files.getLastModifiedTime(externalFile).toMillis(); Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) { String trimmed = Declension.withoutInflectedEnding(line.trim()); if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { merged.add(trimmed); } } wellKnownStems = Set.copyOf(merged); LOG.infof("Денилист дополнен из %s: %d имён сверх встроенных", externalFile.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); } catch (IOException e) { // Битый файл не должен ронять маскирование: остаётся прежний список. LOG.errorf(e, "Не удалось прочитать %s, денилист не изменён", externalFile.toAbsolutePath()); } } private static void refreshIfChanged() { long now = System.currentTimeMillis(); if (now - lastCheck < RECHECK_MILLIS) { return; } lastCheck = now; try { if (!Files.isReadable(externalFile)) { if (externalTimestamp != 0) { reloadExternal(); } return; } if (Files.getLastModifiedTime(externalFile).toMillis() != externalTimestamp) { reloadExternal(); } } catch (IOException e) { LOG.debugf(e, "Не удалось проверить время изменения %s", externalFile); } } /** * Основы сортируются от длинных к коротким: в чередовании регулярного * выражения побеждает первая подошедшая ветка, и короткая основа не должна * перехватывать совпадение у длинной. */ private static List load(String resource) { try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) { if (in == null) { throw new IllegalStateException("Словарь не найден в сборке: " + resource); } try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { return reader.lines() .map(String::trim) .filter(line -> !line.isEmpty() && !line.startsWith("#")) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) .toList(); } } catch (IOException e) { throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e); } } }