fix: распознавать одиночные имена и расширить правила дат и CVV/PIN
This commit is contained in:
@@ -132,6 +132,34 @@ public final class NameDictionary {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется
|
||||
* для строчных имён после ролевого слова («клиент иван иванов»), где регистр
|
||||
* не подсказывает, что перед нами имя.
|
||||
*/
|
||||
public static boolean containsNamePart(String value) {
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
if (GIVEN_NAMES.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
if (isPatronymic(lower) || isSurname(lower)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Отчество: Иванович, Петровна, Сидоровна. */
|
||||
private static boolean isPatronymic(String lower) {
|
||||
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
|
||||
}
|
||||
|
||||
/** Фамилия по словообразованию: Иванов, Петрова, Троицкий, Шевченко. */
|
||||
private static boolean isSurname(String lower) {
|
||||
return lower.matches(".*(?:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)|енко|ко|ук|юк|ян|швили|дзе)$");
|
||||
}
|
||||
|
||||
/**
|
||||
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
||||
* сверху.
|
||||
|
||||
@@ -61,26 +61,52 @@ public class RuleRegistry {
|
||||
/** Серия и номер: «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}";
|
||||
|
||||
/**
|
||||
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер
|
||||
* «ммм» (в логах встречается и латинская «M»). Сокращения нужны, потому что
|
||||
* в датах вида «15 ЯНВ 10» месяц записан тремя буквами.
|
||||
*/
|
||||
private static final String MONTH =
|
||||
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
|
||||
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
|
||||
+ "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*";
|
||||
|
||||
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
||||
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
||||
|
||||
/** «12 мая 1985 г.» */
|
||||
/** «15 03 1990», «15 03 10» — числовая дата с пробелами вместо разделителей. */
|
||||
private static final String DATE_DIGITS_SPACE = "\\b\\d{1,2}\\s+\\d{1,2}\\s+\\d{2,4}\\b";
|
||||
|
||||
/** «15 03», «15/03» — день и месяц без года. */
|
||||
private static final String DATE_DAY_MONTH = "\\b\\d{1,2}\\s*[-/.]?\\s*\\d{1,2}\\b";
|
||||
|
||||
/** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */
|
||||
private static final String DATE_MONTH_WORD =
|
||||
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
|
||||
"\\b\\d{1,2}\\s*[-/.]?\\s*" + MONTH + "\\s*[-/.]?\\s*(?:\\d{2,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|г\\.)";
|
||||
+ "(?iu:ьего|ого|его|ое)\\s+" + MONTH
|
||||
+ "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)";
|
||||
|
||||
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
|
||||
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
|
||||
/** Любая из записей даты; внутри только незахватывающие группы. */
|
||||
private static final String DATE_ANY =
|
||||
"(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + "|"
|
||||
+ DATE_DIGITS_SPACE + "|" + DATE_DAY_MONTH + ")";
|
||||
|
||||
/**
|
||||
* Промежуток между якорем даты («дата рождения») и самой датой: слова,
|
||||
* скобочные группы («(день и месяц)») и знаки препинания. Без скобочной
|
||||
* ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы:
|
||||
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
|
||||
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
|
||||
* которая тоже матчит пробелы, — это приводило к катастрофическому
|
||||
* возврату на длинных текстах.
|
||||
*/
|
||||
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}";
|
||||
|
||||
/**
|
||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||
@@ -120,11 +146,21 @@ public class RuleRegistry {
|
||||
|
||||
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
|
||||
|
||||
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
|
||||
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
|
||||
// описательные якоря («код на обороте карты»). Между якорем и числом
|
||||
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные
|
||||
// разделители («код на обороте карты 789»).
|
||||
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
|
||||
+ "|код\\s+проверки|защитный\\s+код)\\b)"
|
||||
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
|
||||
.anchoredBy("cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
|
||||
|
||||
Rule.of(PdTypes.PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
|
||||
// PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом
|
||||
// допускаются слова («ПИН-код карты 2468») и длинные разделители
|
||||
// («Пин Код: 1234»).
|
||||
Rule.of(PdTypes.PIN, "(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)"
|
||||
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("пин", "pin"),
|
||||
|
||||
@@ -222,12 +258,14 @@ public class RuleRegistry {
|
||||
|
||||
// --- Даты с явным якорем ---
|
||||
|
||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)"
|
||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("рожден"),
|
||||
|
||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))"
|
||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("родил"),
|
||||
@@ -238,7 +276,8 @@ public class RuleRegistry {
|
||||
.anchoredBy("г.р", "г/р", "года рождения"),
|
||||
|
||||
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
||||
Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)"
|
||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("выдач"),
|
||||
@@ -285,6 +324,24 @@ public class RuleRegistry {
|
||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
||||
"контактное лицо", "исполнител", "держател"),
|
||||
|
||||
// «клиент иван иванов», «поручитель петрович» — строчные имена после
|
||||
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь
|
||||
// имён отсекает «клиент пришёл в офис».
|
||||
Rule.of(PdTypes.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держател"
|
||||
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
|
||||
+ "|\\bпоручител)\\p{L}*"
|
||||
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77)
|
||||
.groups(1)
|
||||
.validatedBy(NameDictionary::containsNamePart)
|
||||
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
||||
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
||||
"контактное лицо", "исполнител", "держател", "отправител", "бенефициар",
|
||||
"доверенное лицо", "наследник", "созаемщик"),
|
||||
|
||||
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
||||
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
||||
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
||||
@@ -300,6 +357,14 @@ public class RuleRegistry {
|
||||
.validatedBy(NameDictionary::containsGivenName)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
|
||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
|
||||
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
|
||||
// Словарь отсекает «сочи», «казань» и прочие не-имена.
|
||||
Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70)
|
||||
.groups(1)
|
||||
.validatedBy(NameDictionary::containsNamePart),
|
||||
|
||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||
|
||||
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||
|
||||
Reference in New Issue
Block a user