feat: улучшение regexp + деплой одного инстанса
This commit is contained in:
@@ -50,6 +50,9 @@ public class ProxyResource {
|
||||
@JsonProperty("replaced") Map<String, String> replaced,
|
||||
@JsonProperty("llm") String llm,
|
||||
@JsonProperty("error") String error) {
|
||||
public ProxyResponse {
|
||||
replaced = replaced == null ? null : Map.copyOf(replaced);
|
||||
}
|
||||
}
|
||||
|
||||
private final Pipeline pipeline;
|
||||
|
||||
@@ -39,6 +39,10 @@ public class SystemsConfig {
|
||||
/** Описание одной системы в файле настроек. */
|
||||
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
|
||||
List<String> types, List<String> requireCompanion, String key) {
|
||||
public SystemEntry {
|
||||
types = types == null ? null : List.copyOf(types);
|
||||
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path file;
|
||||
|
||||
@@ -7,6 +7,7 @@ import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
@@ -62,7 +63,7 @@ public class PayloadCipher {
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +81,7 @@ public class PayloadCipher {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,11 +224,30 @@ public class Pipeline {
|
||||
return spans;
|
||||
}
|
||||
return spans.stream()
|
||||
.filter(span -> !PdTypes.FIO.equals(span.type())
|
||||
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
|
||||
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим
|
||||
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от
|
||||
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком.
|
||||
*/
|
||||
private static boolean isWellKnownHere(String text, Span span) {
|
||||
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
|
||||
return true;
|
||||
}
|
||||
int wordStart = span.end();
|
||||
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
|
||||
wordStart++;
|
||||
}
|
||||
int wordEnd = wordStart;
|
||||
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
|
||||
wordEnd++;
|
||||
}
|
||||
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает типы, которые опасны только в сочетании с другими ПД.
|
||||
* Пин-код в отрыве от номера карты не является персональными данными,
|
||||
@@ -251,7 +270,7 @@ public class Pipeline {
|
||||
}
|
||||
|
||||
/** Замаскированный текст вместе с таблицей обратной замены. */
|
||||
public record Masked(String text, java.util.Map<String, String> restorations) {
|
||||
public record Masked(String text, Map<String, String> restorations) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +286,7 @@ public class Pipeline {
|
||||
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
|
||||
List<Span> spans = findPersonalData(text, reversible);
|
||||
if (spans.isEmpty()) {
|
||||
return new Masked(text, java.util.Map.of());
|
||||
return new Masked(text, Map.of());
|
||||
}
|
||||
MaskContext context = new MaskContext();
|
||||
String masked = apply(text, spans, reversible, context);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Словарь названий стран — проверка того, что значение, пойманное правилом
|
||||
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное
|
||||
* слово с заглавной буквы после якоря «гражданство».
|
||||
*
|
||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||
* («в России», «из Казахстана») и формы прилагательных («российская»,
|
||||
* «российское») тем самым покрываются без отдельного разбора морфологии.
|
||||
* Основа «российск» покрывает и «Российская», и «российская», и «российское».
|
||||
*/
|
||||
public final class CountryDictionary {
|
||||
|
||||
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
|
||||
|
||||
private CountryDictionary() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
|
||||
*
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая основа по
|
||||
* значению: префиксов у слова не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isKnownCountry(String value) {
|
||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,25 @@ public final class NameDictionary {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
|
||||
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
|
||||
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
|
||||
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
|
||||
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
|
||||
* обычные родительные падежи, а не фамилии — ложно матчились бы.
|
||||
*/
|
||||
public static boolean isStandaloneNameCandidate(String word) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
if (GIVEN_NAMES.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
|
||||
return false;
|
||||
}
|
||||
return isPatronymic(lower) || isSurname(lower);
|
||||
}
|
||||
|
||||
/** Отчество: Иванович, Петровна, Сидоровна. */
|
||||
private static boolean isPatronymic(String lower) {
|
||||
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
|
||||
|
||||
@@ -90,6 +90,8 @@ final class ResourceLoader {
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Битый файл не должен ронять работу: остаётся прежнее значение.
|
||||
java.util.logging.Logger.getLogger(ResourceLoader.class.getName())
|
||||
.fine("Не удалось перечитать файл " + path + ": " + e.getMessage());
|
||||
}
|
||||
return state.current;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ public class RuleRegistry {
|
||||
/** Якорное слово-основа: держатель карты, держателем и т.п. */
|
||||
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 Марта». Ограничение по форме обязательно:
|
||||
@@ -61,8 +73,13 @@ public class RuleRegistry {
|
||||
"\\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}";
|
||||
/**
|
||||
* Серия и номер: «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}";
|
||||
|
||||
/**
|
||||
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер
|
||||
@@ -107,9 +124,21 @@ public class RuleRegistry {
|
||||
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
|
||||
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
|
||||
* которая тоже матчит пробелы, — это приводило к катастрофическому
|
||||
* возврату на длинных текстах.
|
||||
* возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных
|
||||
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится
|
||||
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
|
||||
* покрывала ни одна ветка.
|
||||
*/
|
||||
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}";
|
||||
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}";
|
||||
|
||||
/**
|
||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||
@@ -180,8 +209,9 @@ public class RuleRegistry {
|
||||
.groups(1, 2)
|
||||
.anchoredBy("сери"),
|
||||
|
||||
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
|
||||
Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||
|
||||
@@ -206,10 +236,19 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.anchoredBy("полис"),
|
||||
|
||||
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||
// 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 цифр после якоря, группировка пробелами не важна.
|
||||
@@ -335,7 +374,10 @@ public class RuleRegistry {
|
||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
|
||||
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
|
||||
+ "|\\bпоручител)\\p{L}*"
|
||||
// \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)
|
||||
@@ -363,27 +405,35 @@ public class RuleRegistry {
|
||||
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
|
||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
|
||||
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
|
||||
// Словарь отсекает «сочи», «казань» и прочие не-имена.
|
||||
Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70)
|
||||
// Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
|
||||
// не рассматривается: заглавная буква там от начала предложения, а не от
|
||||
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
|
||||
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
|
||||
.groups(1)
|
||||
.validatedBy(NameDictionary::containsNamePart),
|
||||
.validatedBy(NameDictionary::isStandaloneNameCandidate),
|
||||
|
||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||
|
||||
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||
.validatedBy(Validators::luhn),
|
||||
|
||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
|
||||
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),
|
||||
|
||||
// --- Уровень 2: формат однозначен сам по себе ---
|
||||
|
||||
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||
// \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("@"),
|
||||
@@ -393,10 +443,27 @@ public class RuleRegistry {
|
||||
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||
// осталась бы замаскированной только его часть.
|
||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
|
||||
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
|
||||
// (обычное слово, не про выдачу документа).
|
||||
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("выдан"),
|
||||
.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)
|
||||
@@ -407,19 +474,26 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
// 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+"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
|
||||
// («гражданином», «гражданкой»).
|
||||
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{0,5}(\\d{6})\\b", 74)
|
||||
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("индекс"),
|
||||
|
||||
Reference in New Issue
Block a user