From 0b9371e4f223f5bf7ae15ccba9b1dd61377d0554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 23 Sep 2026 17:23:05 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20RuleRegistry=20=D0=BD=D0=B0=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D1=8B=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=B8=D1=8F=D0=BC=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuleRegistry.java вырос до 621 строки — вынесены общие regex-фрагменты в RulePatterns и группы правил в DocumentRules, FinanceRules, DateRules, FioRules, ContactRules, AddressRules. RuleRegistry теперь только собирает списки и хранит публичный API (detect, isAddressType, hasAddressContext). Co-Authored-By: Claude Sonnet 5 --- .../java/ru/pdguard/detect/AddressRules.java | 132 +++++ .../java/ru/pdguard/detect/ContactRules.java | 38 ++ .../java/ru/pdguard/detect/DateRules.java | 45 ++ .../java/ru/pdguard/detect/DocumentRules.java | 85 +++ .../java/ru/pdguard/detect/FinanceRules.java | 72 +++ src/main/java/ru/pdguard/detect/FioRules.java | 100 ++++ .../java/ru/pdguard/detect/RulePatterns.java | 133 +++++ .../java/ru/pdguard/detect/RuleRegistry.java | 519 +----------------- 8 files changed, 618 insertions(+), 506 deletions(-) create mode 100644 src/main/java/ru/pdguard/detect/AddressRules.java create mode 100644 src/main/java/ru/pdguard/detect/ContactRules.java create mode 100644 src/main/java/ru/pdguard/detect/DateRules.java create mode 100644 src/main/java/ru/pdguard/detect/DocumentRules.java create mode 100644 src/main/java/ru/pdguard/detect/FinanceRules.java create mode 100644 src/main/java/ru/pdguard/detect/FioRules.java create mode 100644 src/main/java/ru/pdguard/detect/RulePatterns.java diff --git a/src/main/java/ru/pdguard/detect/AddressRules.java b/src/main/java/ru/pdguard/detect/AddressRules.java new file mode 100644 index 0000000..378644e --- /dev/null +++ b/src/main/java/ru/pdguard/detect/AddressRules.java @@ -0,0 +1,132 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP; +import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE; +import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY; +import static ru.pdguard.detect.RulePatterns.ROLE_GAP; +import static ru.pdguard.detect.RulePatterns.STREET_NAME; +import static ru.pdguard.detect.RuleRegistry.ADDRESS_NEARBY; + +/** Правила распознавания органа выдачи паспорта, места рождения, гражданства и адреса. */ +final class AddressRules { + + private AddressRules() { + } + + static final List RULES = List.of( + + // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, + // её забирает отдельное правило. Приоритет выше городского, иначе от органа + // осталась бы замаскированной только его часть. + // Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся» + // (обычное слово, не про выдачу документа). + Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)" + + "\\W{0,3}([^,;\\n]{3,90}?)" + + "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78) + .groups(1) + .anchoredBy("выдан", "выдал", "выдач"), + + // «совпадает с указанным в анкете: X» — второе упоминание органа выдачи + // под собственным якорем, без бэкреференса на первое. + Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)" + + "(?=[,;.\\n]|\\s*$)", 78) + .groups(1) + .anchoredBy("указанн"), + + // «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря, + // до слова «совпадает» или конца фразы. + Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)" + + "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)", 78) + .groups(1) + .anchoredBy("орган выдачи"), + + Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76) + .groups(1) + .anchoredBy("рождения"), + + Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" + + "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) + .groups(1) + .anchoredBy("родил"), + + // ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» — + // между якорем и значением бывает несколько слов, не только пунктуация. + // Список через запятую/слэш — вторая опциональная группа тем же шаблоном. + Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP + + "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80) + .groups(1, 2) + .validatedBy(CountryDictionary::isKnownCountry) + .anchoredBy("гражданств"), + + // ин/ка/ина/ки — именительный/родительный; ином/кой — творительный + // («гражданином», «гражданкой»). + Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+" + + "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 75) + .groups(1, 2) + .validatedBy(CountryDictionary::isKnownCountry) + .anchoredBy("граждан"), + + // --- Адрес: каждая составляющая настраивается отдельно --- + + Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("индекс"), + + Rule.of(PdTypes.ADDRESS_POSTCODE, + "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY), + + // Не только «г.»: перепись, на которой проверяется словарь, покрывает + // сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево» + // из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём. + Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b" + + "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b" + + "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) + .groups(1) + .validatedBy(ToponymDictionary::isKnownSettlement) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", + "дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"), + + Rule.of(PdTypes.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(PdTypes.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(PdTypes.ADDRESS_HOUSE, + "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("д.", "дом"), + + Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("кв"), + + Rule.of(PdTypes.ADDRESS_COUNTRY, + "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" + + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("стран") + ); +} diff --git a/src/main/java/ru/pdguard/detect/ContactRules.java b/src/main/java/ru/pdguard/detect/ContactRules.java new file mode 100644 index 0000000..f163d06 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/ContactRules.java @@ -0,0 +1,38 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.ROLE_GAP; + +/** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */ +final class ContactRules { + + private ContactRules() { + } + + static final List RULES = List.of( + + Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84) + .groups(1) + .anchoredBy("инн"), + + // «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш. + Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84) + .groups(1) + .anchoredBy("инн/кпп"), + + Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84) + .groups(1) + .validatedBy(Validators::snils), + + // \b7, не только +7: номер без плюса («79031119955») тоже встречается. + Rule.of(PdTypes.PHONE, "(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), + + Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80) + .anchoredBy("@"), + + // ИНН физлица без якорного слова — только с верной контрольной суммой. + Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62) + .validatedBy(Validators::inn) + ); +} diff --git a/src/main/java/ru/pdguard/detect/DateRules.java b/src/main/java/ru/pdguard/detect/DateRules.java new file mode 100644 index 0000000..a2c2348 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/DateRules.java @@ -0,0 +1,45 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.DATE_ANY; +import static ru.pdguard.detect.RulePatterns.DATE_GAP; + +/** Правила распознавания дат: рождения, выдачи документа и дат без якорного слова. */ +final class DateRules { + + private DateRules() { + } + + static final List RULES = List.of( + + 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:родил(?:ся|ась))" + + DATE_GAP + "(" + DATE_ANY + ")", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("родил"), + + Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("г.р", "г/р", "года рождения"), + + // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» + Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)" + + DATE_GAP + "(" + DATE_ANY + ")", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("выдач"), + + // Дата без якорного слова персональными данными сама по себе не является: + // маскируется, только если в тексте есть ПД другого типа. + Rule.of(PdTypes.DATE, DATE_ANY, 58) + .validatedBy(Validators::date) + ); +} diff --git a/src/main/java/ru/pdguard/detect/DocumentRules.java b/src/main/java/ru/pdguard/detect/DocumentRules.java new file mode 100644 index 0000000..c70219a --- /dev/null +++ b/src/main/java/ru/pdguard/detect/DocumentRules.java @@ -0,0 +1,85 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.ROLE_GAP; +import static ru.pdguard.detect.RulePatterns.SERIES_AND_NUMBER; + +/** Правила распознавания документов, удостоверяющих личность, и кодов подразделений. */ +final class DocumentRules { + + private DocumentRules() { + } + + static final List RULES = List.of( + + // 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", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"), + + // 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"), + + // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» + Rule.of(PdTypes.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(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) + .groups(1, 2) + .anchoredBy("сери"), + + // Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890». + Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" + + "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89) + .groups(1) + .anchoredBy("водительск", "в/у", "вод.", "ву "), + + Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" + + "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) + .groups(1) + .anchoredBy("загранпаспорт", "заграничн"), + + Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) + .groups(1) + .anchoredBy("военн"), + + Rule.of(PdTypes.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(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) + .groups(1) + .anchoredBy("полис"), + + // ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и + // значением есть слово («стоит»/«объекта»), не только пунктуация. + Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP + + "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) + .groups(1) + .anchoredBy("подразделени", "к/п"), + + // «770-001 — таков код подразделения» — значение перед якорем. + Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}" + + "(?iu:код\\w*\\s+подразделения)", 88) + .groups(1) + .anchoredBy("подразделени") + ); +} diff --git a/src/main/java/ru/pdguard/detect/FinanceRules.java b/src/main/java/ru/pdguard/detect/FinanceRules.java new file mode 100644 index 0000000..8bbae30 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/FinanceRules.java @@ -0,0 +1,72 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.HOLDER_STEM; + +/** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */ +final class FinanceRules { + + private FinanceRules() { + } + + static final List RULES = List.of( + + // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. + Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|" + + "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83) + .groups(1) + .anchoredBy("р/с", "расчетн", "расчётн", "лицев"), + + Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83) + .groups(1) + .anchoredBy("бик"), + + // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. + Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" + + "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) + .groups(1) + .anchoredBy("срок действия", "действительн", "exp"), + + // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы + // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные + // числа рядом со словом — раньше якоря было достаточно самого по себе. + Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) + .groups(1) + .validatedBy(Validators::ogrnip) + .anchoredBy("огрнип"), + + Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) + .groups(1) + .validatedBy(Validators::ogrn) + .anchoredBy("огрн"), + + Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) + .groups(1) + .anchoredBy("кпп"), + + // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может + // стоять слово («доход клиента», «доход за год») — без этого якорь ловил + // бы только «доход 85000», вплотную. + Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?" + + "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76) + .groups(1) + .anchoredBy("доход", "заработн", "зарплат"), + + // Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного + // значения для захвата нет: маскируется якорная фраза целиком. + Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)" + + "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) + .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), + + Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) + .groups(1) + .anchoredBy(HOLDER_STEM, "cardholder", "на имя"), + + // --- Уровень 1: подтверждается контрольной суммой --- + + Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) + .validatedBy(Validators::luhn) + ); +} diff --git a/src/main/java/ru/pdguard/detect/FioRules.java b/src/main/java/ru/pdguard/detect/FioRules.java new file mode 100644 index 0000000..e5190c3 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/FioRules.java @@ -0,0 +1,100 @@ +package ru.pdguard.detect; + +import java.util.List; + +import static ru.pdguard.detect.RulePatterns.CAPITALISED; +import static ru.pdguard.detect.RulePatterns.HOLDER_STEM; +import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY; +import static ru.pdguard.detect.RulePatterns.PATRONYMIC; +import static ru.pdguard.detect.RulePatterns.SURNAME; + +/** Правила распознавания ФИО — от полной тройки с ролевым словом до одиночного имени по словарю. */ +final class FioRules { + + private FioRules() { + } + + static final List RULES = List.of( + + // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. + // Свободная тройка «любое слово с заглавной + имя + отчество» здесь + // сознательно не используется: она захватывает глагол в начале + // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. + // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. + Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), + + // Имя Отчество Фамилия — второй распространённый порядок слов. + Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), + + // Иванов И.И. и И.И. Иванов + Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), + Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79), + + // Имя Отчество без фамилии + Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), + + // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру + Rule.of(PdTypes.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(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держател)\\p{L}*" + + "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77) + .groups(1) + .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", + "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", + "заемщик", "заявител", "доверител", "вкладчик", "ответственн", + "контактное лицо", "исполнител", HOLDER_STEM), + + // «клиент иван иванов», «поручитель петрович» — строчные имена после + // ролевого слова. Регистр снимает требование к заглавной букве, а словарь + // имён отсекает «клиент пришёл в офис». + 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созаемщик" + // \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял + // движку «отдать» уже съеденное падежное окончание ролевого слова и + // захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов». + + "|\\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("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", + "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", + "заемщик", "заявител", "доверител", "вкладчик", "ответственн", + "контактное лицо", "исполнител", HOLDER_STEM, "отправител", "бенефициар", + "доверенное лицо", "наследник", "созаемщик"), + + // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы + // «Тверская улица» и тому подобное. Имя проверяется по множеству уже + // после совпадения — чередование из ста веток в шаблоне обходится дорого. + // Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного + // якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие + // Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс + // случайное слово, «Богдана Хмельницкого» — улица в честь исторической + // фигуры. Найдено на реальных адресах отделений из реестра ЦБ. + Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74) + .validatedBy(NameDictionary::containsGivenName) + .vetoedBy(ORGANISATION_NEARBY), + Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74) + .validatedBy(NameDictionary::containsGivenName) + .vetoedBy(ORGANISATION_NEARBY), + + // Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович». + // Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары + // слов, — поэтому приоритет ниже и проверка по словарю обязательна. + // Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста + // не рассматривается: заглавная буква там от начала предложения, а не от + // имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел». + Rule.of(PdTypes.FIO, "(?Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен * именно на них. На захватываемое значение регистронезависимость не распространяется: * там, где значение опознаётся по заглавной букве, это существенно. + * + *

Сами правила сгруппированы по категориям в отдельных классах пакета — + * {@link DocumentRules}, {@link FinanceRules}, {@link DateRules}, {@link FioRules}, + * {@link ContactRules}, {@link AddressRules} — чтобы каждая категория читалась + * отдельно от остальных. Здесь их списки только объединяются и используются. */ @Component public class RuleRegistry { - /** - * Слово с заглавной буквы; остальные буквы любого регистра, чтобы - * «ИВАНОВ» распознавался наравне с «Иванов». - */ - private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; - - /** Якорное слово-основа: держатель карты, держателем и т.п. */ - private static final String HOLDER_STEM = "держател"; - - /** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */ - private static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}"; - - /** - * То же самое, но только строчные слова-филлеры: ролевые слова перед значением - * гражданства всегда строчные («бенефициара», «поручителя»), а само значение — - * с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно - * поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя - * CITIZENSHIP_VALUE только хвост («Республики Беларусь» → «Беларусь»). - */ - private static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}"; - - /** - * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — - * «Тверская», «Малая Никитская», «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», - * а также с произвольным числом пробелов и словом «номер» между частями — - * «12 34 номер 567890» (реальный кейс из бланка). - */ - private static final String SERIES_AND_NUMBER = - "\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}"; - - /** - * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер - * «ммм» (в логах встречается и латинская «M»). Сокращения нужны, потому что - * в датах вида «15 ЯНВ 10» месяц записан тремя буквами. - */ - private static final String MONTH = - "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр" - + "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*"; - - /** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ - private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b"; - - /** «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*[-/.]?\\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:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)"; - - /** Любая из записей даты; внутри только незахватывающие группы. */ - private static final String DATE_ANY = - "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + "|" - + DATE_DIGITS_SPACE + "|" + DATE_DAY_MONTH + ")"; - - /** - * Промежуток между якорем даты («дата рождения») и самой датой: слова, - * скобочные группы («(день и месяц)») и знаки препинания. Без скобочной - * ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы: - * «день и месяц» — это слова, а не дата. Ветка со словами требует пробела - * перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, - * которая тоже матчит пробелы, — это приводило к катастрофическому - * возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных - * слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится - * веткой {@code \W}, но следующие за ним буквы без пробела перед ними не - * покрывала ни одна ветка. - */ - private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}"; - - /** - * Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ - * «республики X» — частые формы отдельным списком; последняя ветка — страна из - * 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост - * идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт. - */ - private static final String CITIZENSHIP_VALUE = - "\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}"; - /** * Слова, при которых адрес принадлежит организации, а не человеку: * адрес отделения банка персональными данными не является. Части адреса рядом: @@ -154,7 +48,7 @@ public class RuleRegistry { Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); /** Адресные типы, которые вне адресного окружения персональными данными не являются. */ - private static final java.util.Set ADDRESS_TYPES = java.util.Set.of( + private static final Set ADDRESS_TYPES = Set.of( PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY, PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE); @@ -171,398 +65,11 @@ public class RuleRegistry { return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find(); } - private static final String ORGANISATION_NEARBY = - "(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; - - private static final List RULES = List.of( - - // --- Уровень 3: значение опознаётся только рядом с якорным словом --- - - // 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", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"), - - // 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"), - - // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» - Rule.of(PdTypes.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(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) - .groups(1, 2) - .anchoredBy("сери"), - - // Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890». - Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" - + "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89) - .groups(1) - .anchoredBy("водительск", "в/у", "вод.", "ву "), - - // --- Прочие документы, удостоверяющие личность --- - - Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" - + "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) - .groups(1) - .anchoredBy("загранпаспорт", "заграничн"), - - Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" - + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) - .groups(1) - .anchoredBy("военн"), - - Rule.of(PdTypes.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(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) - .groups(1) - .anchoredBy("полис"), - - // ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и - // значением есть слово («стоит»/«объекта»), не только пунктуация. - Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP - + "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) - .groups(1) - .anchoredBy("подразделени", "к/п"), - - // «770-001 — таков код подразделения» — значение перед якорем. - Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}" - + "(?iu:код\\w*\\s+подразделения)", 88) - .groups(1) - .anchoredBy("подразделени"), - - // --- Банковские реквизиты сверх карты --- - - // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. - Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|" - + "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83) - .groups(1) - .anchoredBy("р/с", "расчетн", "расчётн", "лицев"), - - Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83) - .groups(1) - .anchoredBy("бик"), - - // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. - Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" - + "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) - .groups(1) - .anchoredBy("срок действия", "действительн", "exp"), - - // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы - // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные - // числа рядом со словом — раньше якоря было достаточно самого по себе. - Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) - .groups(1) - .validatedBy(Validators::ogrnip) - .anchoredBy("огрнип"), - - Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) - .groups(1) - .validatedBy(Validators::ogrn) - .anchoredBy("огрн"), - - Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) - .groups(1) - .anchoredBy("кпп"), - - // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может - // стоять слово («доход клиента», «доход за год») — без этого якорь ловил - // бы только «доход 85000», вплотную. - Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?" - + "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76) - .groups(1) - .anchoredBy("доход", "заработн", "зарплат"), - - // Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного - // значения для захвата нет: маскируется якорная фраза целиком. - Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)" - + "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) - .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), - - // --- Даты с явным якорем --- - - 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:родил(?:ся|ась))" - + DATE_GAP + "(" + DATE_ANY + ")", 87) - .groups(1) - .validatedBy(Validators::date) - .anchoredBy("родил"), - - Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) - .groups(1) - .validatedBy(Validators::date) - .anchoredBy("г.р", "г/р", "года рождения"), - - // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» - Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)" - + DATE_GAP + "(" + DATE_ANY + ")", 87) - .groups(1) - .validatedBy(Validators::date) - .anchoredBy("выдач"), - - Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" - + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) - .groups(1) - .anchoredBy(HOLDER_STEM, "cardholder", "на имя"), - - // --- ФИО --- - - // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. - // Свободная тройка «любое слово с заглавной + имя + отчество» здесь - // сознательно не используется: она захватывает глагол в начале - // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. - // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. - Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), - - // Имя Отчество Фамилия — второй распространённый порядок слов. - Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), - - // Иванов И.И. и И.И. Иванов - Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), - Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79), - - // Имя Отчество без фамилии - Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), - - // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру - Rule.of(PdTypes.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(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держател)\\p{L}*" - + "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77) - .groups(1) - .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", - "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", - "заемщик", "заявител", "доверител", "вкладчик", "ответственн", - "контактное лицо", "исполнител", HOLDER_STEM), - - // «клиент иван иванов», «поручитель петрович» — строчные имена после - // ролевого слова. Регистр снимает требование к заглавной букве, а словарь - // имён отсекает «клиент пришёл в офис». - 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созаемщик" - // \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял - // движку «отдать» уже съеденное падежное окончание ролевого слова и - // захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов». - + "|\\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("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", - "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", - "заемщик", "заявител", "доверител", "вкладчик", "ответственн", - "контактное лицо", "исполнител", HOLDER_STEM, "отправител", "бенефициар", - "доверенное лицо", "наследник", "созаемщик"), - - // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы - // «Тверская улица» и тому подобное. Имя проверяется по множеству уже - // после совпадения — чередование из ста веток в шаблоне обходится дорого. - // Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного - // якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие - // Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс - // случайное слово, «Богдана Хмельницкого» — улица в честь исторической - // фигуры. Найдено на реальных адресах отделений из реестра ЦБ. - Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74) - .validatedBy(NameDictionary::containsGivenName) - .vetoedBy(ORGANISATION_NEARBY), - Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74) - .validatedBy(NameDictionary::containsGivenName) - .vetoedBy(ORGANISATION_NEARBY), - - // Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович». - // Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары - // слов, — поэтому приоритет ниже и проверка по словарю обязательна. - // Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста - // не рассматривается: заглавная буква там от начала предложения, а не от - // имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел». - Rule.of(PdTypes.FIO, "(? RULES = Stream.of( + DocumentRules.RULES, FinanceRules.RULES, DateRules.RULES, + FioRules.RULES, ContactRules.RULES, AddressRules.RULES) + .flatMap(List::stream) + .toList(); /** Все типы ПД, которые умеет распознавать сервис. */ public List knownTypes() {