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("Заявление Смирнова"));
+ }
+}