diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index 4341008..906f684 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -1,11 +1,17 @@ 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.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -21,15 +27,39 @@ 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(NameDictionary::withoutInflectedEnding) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) .toList(); - private static final List WELL_KNOWN_STEMS = load("/names/well-known.txt"); + // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ + // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не + // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, + // что и для личных имён. + private static final List BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() + .map(NameDictionary::withoutInflectedEnding) + .distinct() + .toList(); + + private static volatile Path externalFile = Path.of(ConfigProvider.getConfig() + .getOptionalValue("pdguard.well-known-file", String.class) + .orElse("config/well-known.txt")); + private static volatile List wellKnownStems = BUNDLED_WELL_KNOWN_STEMS; + private static volatile long externalTimestamp; + private static volatile long lastCheck; /** Не более скольких падежных букв дописывается к основе имени. */ private static final int MAX_INFLECTION = 3; @@ -86,11 +116,13 @@ public final class NameDictionary { return false; } - /** Содержит ли текст упоминание известного человека. */ + /** Содержит ли текст упоминание известного человека — из сборки или дописанных сверху. */ public static boolean isWellKnown(String value) { + refreshIfChanged(); + List stems = wellKnownStems; for (String word : value.split("\\P{L}+")) { String lower = word.toLowerCase(Locale.ROOT); - for (String stem : WELL_KNOWN_STEMS) { + for (String stem : stems) { if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) { return true; } @@ -99,6 +131,64 @@ public final class NameDictionary { 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(); + List merged = new ArrayList<>(BUNDLED_WELL_KNOWN_STEMS); + for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) { + String trimmed = withoutInflectedEnding(line.trim()); + if (!trimmed.isEmpty() && !trimmed.startsWith("#") && !merged.contains(trimmed)) { + merged.add(trimmed); + } + } + wellKnownStems = List.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); + } + } + /** * Основы сортируются от длинных к коротким: в чередовании регулярного * выражения побеждает первая подошедшая ветка, и короткая основа не должна diff --git a/src/main/resources/names/well-known.txt b/src/main/resources/names/well-known.txt index f606867..a6b95d3 100644 --- a/src/main/resources/names/well-known.txt +++ b/src/main/resources/names/well-known.txt @@ -43,3 +43,19 @@ Мцыри Хлестаков Митрофанушка + +# Действующие публичные фигуры — упоминание в новостном/служебном контексте +# («Президент подписал закон», «ЦБ во главе с Набиуллиной повысил ставку») +# персональными данными клиента не является. +Путин +Мишустин +Набиуллина +Греф +Костин +Тиньков +Дуров +Мордашов +Потанин +Дерипаска +Абрамович +Усманов diff --git a/src/test/java/ru/pdguard/detect/NameDictionaryTest.java b/src/test/java/ru/pdguard/detect/NameDictionaryTest.java new file mode 100644 index 0000000..61a6497 --- /dev/null +++ b/src/test/java/ru/pdguard/detect/NameDictionaryTest.java @@ -0,0 +1,65 @@ +package ru.pdguard.detect; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */ +class NameDictionaryTest { + + /** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */ + @AfterEach + void resetToBundledList() { + NameDictionary.useExternalFile(Path.of("config/well-known.txt")); + } + + @Test + void bundledListCoversClassicFigures() { + // Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» → + // «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где + // «-ой» меняется на «-ого» целиком, а не дописывается) — известное + // ограничение самого приёма, не завязанное на список имён. + assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); + assertTrue(NameDictionary.isWellKnown("Портрет Толстой")); + } + + @Test + void bundledListCoversCurrentPublicFigures() { + assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной")); + assertTrue(NameDictionary.isWellKnown("Интервью Путина")); + } + + @Test + void unknownSurnameIsNotWellKnown() { + assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); + } + + @Test + void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException { + Path file = dir.resolve("well-known.txt"); + Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8); + + NameDictionary.useExternalFile(file); + + assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"), + "дописанное сверху имя должно распознаваться наравне со встроенными"); + assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"), + "встроенный список не должен теряться при дозагрузке"); + } + + @Test + void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) { + NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt")); + + assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); + assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); + } +}