Init
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import io.quarkus.runtime.Startup;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import opennlp.tools.namefind.NameFinderME;
|
||||
import opennlp.tools.namefind.TokenNameFinderModel;
|
||||
import opennlp.tools.tokenize.SimpleTokenizer;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
import ru.pdguard.core.Span;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Вторая ступень распознавания имён.
|
||||
*
|
||||
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
|
||||
* микросекунд. Модель нужна там, где они бессильны: имена без русского
|
||||
* словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо».
|
||||
*
|
||||
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
|
||||
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в
|
||||
* обычном запросе единицы, и на задержку это почти не влияет.
|
||||
*
|
||||
* <p>Модели нет — ступень выключена и поведение сервиса не меняется. Путь к файлу
|
||||
* задаётся свойством {@code pdguard.ner.model}.
|
||||
*
|
||||
* <p>Сбой второй ступени не должен отражаться на первой: ошибка перехватывается
|
||||
* здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
|
||||
* исключение обнуляло бы маскирование целиком.
|
||||
*/
|
||||
@Startup
|
||||
@ApplicationScoped
|
||||
public class NameCascade {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(NameCascade.class);
|
||||
|
||||
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться именем. */
|
||||
private static final Pattern CANDIDATE = Pattern.compile(
|
||||
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||
|
||||
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
|
||||
private static final int PRIORITY = 73;
|
||||
|
||||
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
|
||||
private static final int CONTEXT_CHARS = 60;
|
||||
|
||||
/** Сколько ждать свободный распознаватель, прежде чем обойтись правилами. */
|
||||
private static final long BORROW_TIMEOUT_MILLIS = 50;
|
||||
|
||||
/** Текст для прогрева: важно не что в нём, а что модель отработала хотя бы раз. */
|
||||
private static final String[] WARMUP_WORDS =
|
||||
{"Клиент", "Иванов", "Иван", "Иванович", "обратился", "в", "отделение"};
|
||||
|
||||
private final BlockingQueue<NameFinderME> pool;
|
||||
private final int maxCandidates;
|
||||
private final boolean enabled;
|
||||
private volatile boolean broken;
|
||||
|
||||
public NameCascade(
|
||||
@ConfigProperty(name = "pdguard.ner.model") Optional<String> modelPath,
|
||||
@ConfigProperty(name = "pdguard.ner.max-candidates", defaultValue = "16") int maxCandidates,
|
||||
@ConfigProperty(name = "pdguard.ner.pool-size", defaultValue = "16") int poolSize) {
|
||||
this.maxCandidates = maxCandidates;
|
||||
TokenNameFinderModel model = load(modelPath);
|
||||
this.enabled = model != null;
|
||||
this.pool = enabled ? warmedPool(model, Math.max(1, poolSize)) : null;
|
||||
}
|
||||
|
||||
/** Выключенная ступень — для тестов и для сборок без модели. */
|
||||
public static NameCascade disabled() {
|
||||
return new NameCascade(Optional.empty(), 0, 1);
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет имена, которые не нашла первая ступень. Уже принятые фрагменты
|
||||
* не трогаются: модель разбирает только непокрытые участки.
|
||||
*/
|
||||
public List<Span> addMissedNames(String text, List<Span> accepted) {
|
||||
if (!enabled || broken) {
|
||||
return accepted;
|
||||
}
|
||||
NameFinderME finder = borrow();
|
||||
if (finder == null) {
|
||||
// Все распознаватели заняты: отвечаем по правилам, а не копим очередь.
|
||||
return accepted;
|
||||
}
|
||||
try {
|
||||
List<Span> found = new ArrayList<>(accepted);
|
||||
int examined = 0;
|
||||
Matcher m = CANDIDATE.matcher(text);
|
||||
while (m.find() && examined < maxCandidates) {
|
||||
if (coveredBy(accepted, m.start(), m.end())) {
|
||||
continue;
|
||||
}
|
||||
examined++;
|
||||
recognise(finder, text, m.start(), m.end(), found);
|
||||
}
|
||||
return found;
|
||||
} catch (RuntimeException e) {
|
||||
broken = true;
|
||||
LOG.errorf(e, "Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам");
|
||||
return accepted;
|
||||
} finally {
|
||||
finder.clearAdaptiveData();
|
||||
pool.offer(finder);
|
||||
}
|
||||
}
|
||||
|
||||
private NameFinderME borrow() {
|
||||
try {
|
||||
return pool.poll(BORROW_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void recognise(NameFinderME finder, String text,
|
||||
int candidateStart, int candidateEnd, List<Span> sink) {
|
||||
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
|
||||
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
|
||||
String region = text.substring(from, to);
|
||||
|
||||
opennlp.tools.util.Span[] tokens = SimpleTokenizer.INSTANCE.tokenizePos(region);
|
||||
String[] words = new String[tokens.length];
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
words[i] = region.substring(tokens[i].getStart(), tokens[i].getEnd());
|
||||
}
|
||||
|
||||
for (opennlp.tools.util.Span name : finder.find(words)) {
|
||||
int start = from + tokens[name.getStart()].getStart();
|
||||
int end = from + tokens[name.getEnd() - 1].getEnd();
|
||||
// Берём только то, что пересекается с кандидатом: контекст добавлен
|
||||
// ради качества разбора, а не для расширения находки.
|
||||
if (start < candidateEnd && candidateStart < end) {
|
||||
sink.add(new Span(start, end, RuleRegistry.FIO, PRIORITY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean coveredBy(List<Span> accepted, int start, int end) {
|
||||
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовые к работе распознаватели создаются на старте и сразу прогоняют текст.
|
||||
*
|
||||
* <p>{@link NameFinderME} хранит состояние между вызовами, поэтому одним
|
||||
* экземпляром на несколько потоков пользоваться нельзя. Создание экземпляра
|
||||
* вместе с первым разбором стоит сотни миллисекунд, и при создании по
|
||||
* требованию эта цена доставалась первому запросу каждого рабочего потока.
|
||||
* Пул снимает и то, и другое: к первому обращению всё создано и прогрето.
|
||||
*/
|
||||
private static BlockingQueue<NameFinderME> warmedPool(TokenNameFinderModel model, int size) {
|
||||
long started = System.nanoTime();
|
||||
BlockingQueue<NameFinderME> ready = new ArrayBlockingQueue<>(size);
|
||||
for (int i = 0; i < size; i++) {
|
||||
NameFinderME finder = new NameFinderME(model);
|
||||
finder.find(WARMUP_WORDS);
|
||||
finder.clearAdaptiveData();
|
||||
ready.add(finder);
|
||||
}
|
||||
LOG.infof("Прогрев второй ступени: %d распознавателей за %d мс",
|
||||
size, (System.nanoTime() - started) / 1_000_000);
|
||||
return ready;
|
||||
}
|
||||
|
||||
private TokenNameFinderModel load(Optional<String> modelPath) {
|
||||
if (modelPath.isEmpty() || modelPath.get().isBlank()) {
|
||||
LOG.info("Вторая ступень распознавания имён выключена: модель не задана");
|
||||
return null;
|
||||
}
|
||||
Path file = Path.of(modelPath.get());
|
||||
if (!Files.isReadable(file)) {
|
||||
LOG.warnf("Модель %s недоступна, вторая ступень выключена", file.toAbsolutePath());
|
||||
return null;
|
||||
}
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
TokenNameFinderModel model = new TokenNameFinderModel(in);
|
||||
LOG.infof("Вторая ступень распознавания имён включена, модель %s", file.toAbsolutePath());
|
||||
return model;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// Испорченная модель не должна мешать сервису подняться: работают правила.
|
||||
LOG.errorf(e, "Не удалось загрузить модель %s, вторая ступень выключена", file.toAbsolutePath());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
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.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Словари для распознавания ФИО.
|
||||
*
|
||||
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
|
||||
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
||||
*
|
||||
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
||||
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
||||
* Пушкин в тексте без других ПД замаскирован не будет.
|
||||
*/
|
||||
public final class NameDictionary {
|
||||
|
||||
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");
|
||||
|
||||
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||
private static final int MAX_INFLECTION = 3;
|
||||
|
||||
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
||||
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
||||
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
||||
|
||||
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
|
||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private NameDictionary() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбрасывает у основы конечную гласную, которая меняется по падежам:
|
||||
* Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю).
|
||||
*/
|
||||
private static String withoutInflectedEnding(String stem) {
|
||||
if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) {
|
||||
return stem.substring(0, stem.length() - 1);
|
||||
}
|
||||
return stem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||
*
|
||||
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
||||
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
||||
* приходится не больше четырёх обращений к хеш-таблице.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Содержит ли текст упоминание известного человека. */
|
||||
public static boolean isWellKnown(String value) {
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
for (String stem : WELL_KNOWN_STEMS) {
|
||||
if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Основы сортируются от длинных к коротким: в чередовании регулярного
|
||||
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
|
||||
* перехватывать совпадение у длинной.
|
||||
*/
|
||||
private static List<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
/**
|
||||
* Классы, которые OpenNLP создаёт по имени, разбирая описание признаков внутри модели.
|
||||
*
|
||||
* <p>В обычной сборке это работает само, в native-образе — нет: класс, не упомянутый
|
||||
* в коде, туда просто не попадает. Без регистрации загрузка модели проходит, а
|
||||
* создание распознавателя падает с {@code ClassNotFoundException} на первом запросе.
|
||||
*
|
||||
* <p>Перечислены фабрики целиком, а не только те, что встречаются в текущей модели:
|
||||
* набор признаков задаётся при обучении и может измениться без правки кода.
|
||||
*/
|
||||
@RegisterForReflection(classNames = {
|
||||
"opennlp.tools.namefind.TokenNameFinderFactory",
|
||||
"opennlp.tools.namefind.BioCodec",
|
||||
"opennlp.tools.util.featuregen.AggregatedFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.BigramNameFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.BrownClusterBigramFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.BrownClusterTokenClassFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.BrownClusterTokenFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.CachedFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.CharacterNgramFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.DefinitionFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.DictionaryFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.DocumentBeginFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.POSTaggerNameFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.PosTaggerFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.PrefixFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.PreviousMapFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.SentenceFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.SuffixFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.TokenClassFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.TokenFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.TokenPatternFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.TrigramNameFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.WindowFeatureGeneratorFactory",
|
||||
"opennlp.tools.util.featuregen.WordClusterFeatureGeneratorFactory"
|
||||
})
|
||||
final class OpenNlpReflection {
|
||||
|
||||
private OpenNlpReflection() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Одно правило детекции персональных данных.
|
||||
*
|
||||
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
|
||||
* {@link RuleRegistry}; менять остальной код не требуется.
|
||||
*
|
||||
* @param type тип ПД, который распознаёт правило
|
||||
* @param pattern регулярное выражение
|
||||
* @param priority приоритет при разрешении перекрытий
|
||||
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
|
||||
* Несколько групп нужны, когда значение разорвано словами:
|
||||
* «серия 4509 номер 123456»
|
||||
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
|
||||
* {@code null} — проверка не нужна
|
||||
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
|
||||
* адрес отделения банка не является ПД, хотя выглядит как адрес
|
||||
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
|
||||
* где форма совпадения сама по себе слишком общая: «Невский проспект»
|
||||
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
|
||||
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
|
||||
* Проверка через {@code indexOf} на порядок дешевле запуска
|
||||
* регулярного выражения и отсекает большинство правил на коротком
|
||||
* запросе. Пустой список — правило запускается всегда
|
||||
*/
|
||||
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
|
||||
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
|
||||
|
||||
/**
|
||||
* Флаги компиляции для всех правил.
|
||||
*
|
||||
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
|
||||
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
|
||||
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
|
||||
* {@code (?i)} корректным для кириллицы.
|
||||
*/
|
||||
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
|
||||
|
||||
/** Сколько символов слева и справа от совпадения просматривает вето-шаблон. */
|
||||
public static final int VETO_LOOKBEHIND = 80;
|
||||
public static final int VETO_LOOKAHEAD = 40;
|
||||
|
||||
/** Правило без проверок, маскируется всё совпадение. */
|
||||
public static Rule of(String type, String regex, int priority) {
|
||||
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
|
||||
}
|
||||
|
||||
/** Маскировать только перечисленные группы, а не всё совпадение. */
|
||||
public Rule groups(Integer... indexes) {
|
||||
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
|
||||
}
|
||||
|
||||
/** Принять совпадение, только если значение прошло проверку. */
|
||||
public Rule validatedBy(Predicate<String> check) {
|
||||
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
|
||||
}
|
||||
|
||||
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
|
||||
public Rule anchoredBy(String... required) {
|
||||
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
|
||||
}
|
||||
|
||||
/** Есть ли в тексте хоть один из якорей правила. */
|
||||
public boolean mayMatch(String lowercasedText) {
|
||||
if (anchors.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String anchor : anchors) {
|
||||
if (lowercasedText.contains(anchor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Принять совпадение, только если рядом встретилось указанное слово. */
|
||||
public Rule requiringNear(String regex) {
|
||||
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
|
||||
}
|
||||
|
||||
/** Отбросить совпадение, если рядом встретилось указанное слово. */
|
||||
public Rule vetoedBy(String regex) {
|
||||
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.Span;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/**
|
||||
* Реестр правил детекции и сам поиск ПД в тексте.
|
||||
*
|
||||
* <p>Правила разбиты на три уровня доверия:
|
||||
* <ol>
|
||||
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
|
||||
* <li>однозначные по формату — email, телефон;</li>
|
||||
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
|
||||
* где сама по себе последовательность знаков ни о чём не говорит.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
|
||||
* именно на них. На захватываемое значение регистронезависимость не распространяется:
|
||||
* там, где значение опознаётся по заглавной букве, это существенно.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class RuleRegistry {
|
||||
|
||||
public static final String EMAIL = "EMAIL";
|
||||
public static final String PHONE = "PHONE";
|
||||
public static final String CARD = "CARD";
|
||||
public static final String INN = "INN";
|
||||
public static final String SNILS = "SNILS";
|
||||
public static final String PASSPORT = "PASSPORT";
|
||||
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
|
||||
public static final String PASSPORT_DATE = "PASSPORT_DATE";
|
||||
public static final String DEPT_CODE = "DEPT_CODE";
|
||||
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
|
||||
public static final String CITIZENSHIP = "CITIZENSHIP";
|
||||
public static final String BIRTH_PLACE = "BIRTH_PLACE";
|
||||
public static final String BIRTH_DATE = "BIRTH_DATE";
|
||||
public static final String DATE = "DATE";
|
||||
public static final String CVV = "CVV";
|
||||
public static final String PIN = "PIN";
|
||||
public static final String CARDHOLDER = "CARDHOLDER";
|
||||
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
|
||||
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
|
||||
public static final String ADDRESS_CITY = "ADDRESS_CITY";
|
||||
public static final String ADDRESS_STREET = "ADDRESS_STREET";
|
||||
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
|
||||
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
|
||||
public static final String FIO = "FIO";
|
||||
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
|
||||
public static final String MILITARY_ID = "MILITARY_ID";
|
||||
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
|
||||
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
|
||||
|
||||
/**
|
||||
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
|
||||
* «ИВАНОВ» распознавался наравне с «Иванов».
|
||||
*/
|
||||
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
|
||||
|
||||
/**
|
||||
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
|
||||
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
|
||||
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
|
||||
* до вечера» оказывался под маской целиком.
|
||||
*/
|
||||
private static final String STREET_NAME =
|
||||
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
|
||||
|
||||
/**
|
||||
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
|
||||
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
|
||||
*/
|
||||
private static final String SURNAME =
|
||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
|
||||
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
|
||||
|
||||
/**
|
||||
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
|
||||
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
|
||||
*/
|
||||
private static final String PATRONYMIC =
|
||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
|
||||
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
|
||||
|
||||
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
|
||||
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
|
||||
|
||||
private static final String MONTH =
|
||||
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
|
||||
|
||||
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
||||
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
||||
|
||||
/** «12 мая 1985 г.» */
|
||||
private static final String DATE_MONTH_WORD =
|
||||
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
|
||||
|
||||
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
|
||||
private static final String DATE_WORDS =
|
||||
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
|
||||
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
|
||||
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
|
||||
+ "(?iu:ьего|ого|его)\\s+" + MONTH
|
||||
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
|
||||
|
||||
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
|
||||
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
|
||||
|
||||
/**
|
||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||
* адрес отделения банка персональными данными не является.
|
||||
*/
|
||||
/**
|
||||
* Части адреса рядом. Улица, упомянутая в рассказе о городе, адресом клиента не
|
||||
* является — ровно как адрес отделения банка из технического задания. Требование
|
||||
* стояло только у постфиксной формы правила, префиксная его не имела.
|
||||
*/
|
||||
private static final String ADDRESS_NEARBY =
|
||||
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
|
||||
|
||||
private static final String ORGANISATION_NEARBY =
|
||||
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
|
||||
|
||||
private static final List<Rule> RULES = List.of(
|
||||
|
||||
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
|
||||
|
||||
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
|
||||
|
||||
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("пин", "pin"),
|
||||
|
||||
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
|
||||
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
|
||||
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
|
||||
.groups(1)
|
||||
.anchoredBy("паспорт"),
|
||||
|
||||
// «серия 4509 номер 123456», «серии 45 09 № 123456»
|
||||
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
|
||||
// «серии 4509 за номером 123456», «серия 4509 № 123456».
|
||||
Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
|
||||
.groups(1, 2)
|
||||
.anchoredBy("сери"),
|
||||
|
||||
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||
|
||||
// --- Прочие документы, удостоверяющие личность ---
|
||||
|
||||
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
|
||||
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("загранпаспорт", "заграничн"),
|
||||
|
||||
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
|
||||
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("военн"),
|
||||
|
||||
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
|
||||
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("свидетельств"),
|
||||
|
||||
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("полис"),
|
||||
|
||||
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||
.groups(1)
|
||||
.anchoredBy("подразделени", "к/п"),
|
||||
|
||||
// --- Даты с явным якорем ---
|
||||
|
||||
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("рожден"),
|
||||
|
||||
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("г.р", "г/р", "года рождения"),
|
||||
|
||||
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
||||
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("выдач"),
|
||||
|
||||
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
|
||||
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
|
||||
.groups(1)
|
||||
.anchoredBy("держател", "cardholder", "на имя"),
|
||||
|
||||
// --- ФИО ---
|
||||
|
||||
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
|
||||
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
|
||||
// сознательно не используется: она захватывает глагол в начале
|
||||
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
|
||||
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
|
||||
|
||||
// Имя Отчество Фамилия — второй распространённый порядок слов.
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
|
||||
|
||||
// Иванов И.И. и И.И. Иванов
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
|
||||
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
|
||||
|
||||
// Имя Отчество без фамилии
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
|
||||
|
||||
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
|
||||
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
|
||||
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
|
||||
.groups(1)
|
||||
.anchoredBy("фио", "ф.и.о", "на имя"),
|
||||
|
||||
// «клиент Иванов Иван», «плательщик Петрова»
|
||||
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
||||
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
|
||||
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
|
||||
.groups(1)
|
||||
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
||||
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
||||
"контактное лицо", "исполнител", "держател"),
|
||||
|
||||
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
||||
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
||||
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
|
||||
.validatedBy(NameDictionary::containsGivenName),
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
|
||||
.validatedBy(NameDictionary::containsGivenName),
|
||||
|
||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||
|
||||
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||
.validatedBy(Validators::luhn),
|
||||
|
||||
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
|
||||
.groups(1)
|
||||
.anchoredBy("инн"),
|
||||
|
||||
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::snils),
|
||||
|
||||
// --- Уровень 2: формат однозначен сам по себе ---
|
||||
|
||||
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||
|
||||
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
||||
.anchoredBy("@"),
|
||||
|
||||
// --- Уровень 3: свободный текст после якорного слова ---
|
||||
|
||||
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||
// осталась бы замаскированной только его часть.
|
||||
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
|
||||
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
|
||||
.groups(1)
|
||||
.anchoredBy("выдан"),
|
||||
|
||||
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||
.groups(1)
|
||||
.anchoredBy("рождения"),
|
||||
|
||||
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
|
||||
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||
.groups(1)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
.anchoredBy("гражданств"),
|
||||
|
||||
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
.anchoredBy("граждан"),
|
||||
|
||||
// --- Адрес: каждая составляющая настраивается отдельно ---
|
||||
|
||||
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("индекс"),
|
||||
|
||||
Rule.of(ADDRESS_POSTCODE,
|
||||
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("г.", "гор", "город"),
|
||||
|
||||
Rule.of(ADDRESS_STREET,
|
||||
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
|
||||
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.requiringNear(ADDRESS_NEARBY)
|
||||
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
|
||||
|
||||
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
|
||||
// принимается только рядом с другими частями адреса: иначе под маску попал бы
|
||||
// любой рассказ про Невский проспект.
|
||||
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
|
||||
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.requiringNear(ADDRESS_NEARBY)
|
||||
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
|
||||
|
||||
Rule.of(ADDRESS_HOUSE,
|
||||
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("д.", "дом"),
|
||||
|
||||
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("кв"),
|
||||
|
||||
Rule.of(ADDRESS_COUNTRY,
|
||||
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
|
||||
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("стран"),
|
||||
|
||||
// --- Значения без якоря: принимаются только вместе с другими ПД ---
|
||||
|
||||
// ИНН физлица без якорного слова — только с верной контрольной суммой.
|
||||
Rule.of(INN, "\\b\\d{12}\\b", 62)
|
||||
.validatedBy(Validators::inn),
|
||||
|
||||
// Дата без якорного слова персональными данными сама по себе не является:
|
||||
// маскируется, только если в тексте есть ПД другого типа.
|
||||
Rule.of(DATE, DATE_ANY, 58)
|
||||
.validatedBy(Validators::date)
|
||||
);
|
||||
|
||||
/** Все типы ПД, которые умеет распознавать сервис. */
|
||||
public List<String> knownTypes() {
|
||||
return RULES.stream().map(Rule::type).distinct().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит все фрагменты ПД, разрешённые политикой системы.
|
||||
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
|
||||
*/
|
||||
public List<Span> detect(String text, SystemPolicy policy) {
|
||||
List<Span> found = new ArrayList<>();
|
||||
String lowercased = text.toLowerCase(Locale.ROOT);
|
||||
for (Rule rule : RULES) {
|
||||
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
|
||||
continue;
|
||||
}
|
||||
collect(rule, text, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static void collect(Rule rule, String text, List<Span> sink) {
|
||||
Matcher m = rule.pattern().matcher(text);
|
||||
while (m.find()) {
|
||||
for (int group : rule.groups()) {
|
||||
int start = m.start(group);
|
||||
int end = m.end(group);
|
||||
if (start < 0 || end <= start) {
|
||||
continue;
|
||||
}
|
||||
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
|
||||
continue;
|
||||
}
|
||||
if (rule.veto() != null && rule.veto().matcher(surroundings(text, start, end)).find()) {
|
||||
continue;
|
||||
}
|
||||
if (rule.context() != null && !rule.context().matcher(surroundings(text, start, end)).find()) {
|
||||
continue;
|
||||
}
|
||||
sink.add(new Span(start, end, rule.type(), rule.priority()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String surroundings(String text, int start, int end) {
|
||||
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
|
||||
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
|
||||
return text.substring(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
/**
|
||||
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
|
||||
* которые по форме похожи на ПД, но ими не являются.
|
||||
*/
|
||||
public final class Validators {
|
||||
|
||||
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
|
||||
private Validators() {
|
||||
}
|
||||
|
||||
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
|
||||
public static boolean luhn(String value) {
|
||||
int sum = 0;
|
||||
int digits = 0;
|
||||
boolean doubled = false;
|
||||
for (int i = value.length() - 1; i >= 0; i--) {
|
||||
char c = value.charAt(i);
|
||||
if (!Character.isDigit(c)) {
|
||||
continue;
|
||||
}
|
||||
int d = c - '0';
|
||||
digits++;
|
||||
if (doubled) {
|
||||
d *= 2;
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
sum += d;
|
||||
doubled = !doubled;
|
||||
}
|
||||
return digits >= 13 && digits <= 19 && sum % 10 == 0;
|
||||
}
|
||||
|
||||
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
|
||||
public static boolean inn(String value) {
|
||||
int[] d = digits(value);
|
||||
if (d.length == 10) {
|
||||
return d[9] == checksum(d, INN_10);
|
||||
}
|
||||
if (d.length == 12) {
|
||||
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
|
||||
public static boolean snils(String value) {
|
||||
int[] d = digits(value);
|
||||
if (d.length != 11) {
|
||||
return false;
|
||||
}
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
sum += d[i] * (9 - i);
|
||||
}
|
||||
int control = sum < 100 ? sum : (sum == 100 || sum == 101 ? 0 : sum % 101 % 100);
|
||||
return control == d[9] * 10 + d[10];
|
||||
}
|
||||
|
||||
/**
|
||||
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
|
||||
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
|
||||
* последовательности вроде {@code 192.168.1}.
|
||||
*/
|
||||
public static boolean date(String value) {
|
||||
// Запись с названием месяца словом в дополнительной проверке не нуждается:
|
||||
// «мая» само по себе однозначно указывает на дату.
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isLetter(value.charAt(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] parts = value.split("[.\\-/]");
|
||||
if (parts.length != 3) {
|
||||
return false;
|
||||
}
|
||||
int[] n = new int[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (parts[i].isEmpty() || parts[i].length() > 4) {
|
||||
return false;
|
||||
}
|
||||
n[i] = Integer.parseInt(parts[i]);
|
||||
}
|
||||
for (int y = 0; y < 3; y++) {
|
||||
if (parts[y].length() == 4) {
|
||||
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
|
||||
}
|
||||
}
|
||||
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
|
||||
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
|
||||
}
|
||||
|
||||
/** Пара чисел похожа на «день и месяц» в любом порядке. */
|
||||
private static boolean dayAndMonth(int a, int b) {
|
||||
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
|
||||
}
|
||||
|
||||
private static int checksum(int[] d, int[] weights) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < weights.length; i++) {
|
||||
sum += d[i] * weights[i];
|
||||
}
|
||||
return sum % 11 % 10;
|
||||
}
|
||||
|
||||
private static int[] digits(String value) {
|
||||
int[] out = new int[value.length()];
|
||||
int n = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
out[n++] = c - '0';
|
||||
}
|
||||
}
|
||||
int[] trimmed = new int[n];
|
||||
System.arraycopy(out, 0, trimmed, 0, n);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user