feat: expandable denylist + hot-reload, fix declension for -a surnames
Adds current public figures (Nabiullina, Gref, Putin, ...) so news-
style mentions near CB/banking topics aren't treated as client PII.
More importantly, isWellKnown() only worked for consonant-ending
surnames: "Пушкина" starts with "Пушкин" so it matched, but "Толстого"
doesn't start with "Толстой" (adjectival declension replaces the
ending, doesn't extend it), and neither does any oblique case of a
feminine -a surname ("Набиуллиной" vs "Набиуллина"). Strips the
trailing vowel at load time, same trick already used for given names.
Also lets the list grow without a rebuild: an optional external file
(pdguard.well-known-file, default config/well-known.txt) is merged on
top of the bundled list and re-read on change, mirroring how
SystemsConfig watches systems.json.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f7eee7577c
commit
7dd66d45bd
@@ -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;
|
||||
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
||||
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
||||
* Пушкин в тексте без других ПД замаскирован не будет.
|
||||
*
|
||||
* <p>Базовый список собран в сборку из {@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<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
||||
.map(NameDictionary::withoutInflectedEnding)
|
||||
.distinct()
|
||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||
.toList();
|
||||
private static final List<String> WELL_KNOWN_STEMS = load("/names/well-known.txt");
|
||||
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||
// что и для личных имён.
|
||||
private static final List<String> 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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Основы сортируются от длинных к коротким: в чередовании регулярного
|
||||
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
|
||||
|
||||
Reference in New Issue
Block a user