feat: validate ADDRESS_CITY against a real city dictionary
The rule matched any capitalized word after "г."/"город" — no check
that it's an actual place. ToponymDictionary checks the match against
1111 Russian cities (pensnarik/russian-cities) plus CIS capitals,
matching by stem so declined forms work ("Москве" against "Москва").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a758c5ef05
commit
f7eee7577c
@@ -317,6 +317,7 @@ public class RuleRegistry {
|
|||||||
|
|
||||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||||
.groups(1)
|
.groups(1)
|
||||||
|
.validatedBy(ToponymDictionary::isKnownCity)
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
.anchoredBy("г.", "гор", "город"),
|
.anchoredBy("г.", "гор", "город"),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Словарь городов России — проверка того, что значение, пойманное правилом
|
||||||
|
* {@code ADDRESS_CITY}, действительно похоже на существующий город, а не на
|
||||||
|
* произвольное слово с заглавной буквы после «г.».
|
||||||
|
*
|
||||||
|
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||||
|
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
||||||
|
* морфологии, как и у известных людей в {@link NameDictionary}.
|
||||||
|
*/
|
||||||
|
public final class ToponymDictionary {
|
||||||
|
|
||||||
|
private static final List<String> CITY_STEMS = load("/names/cities.txt").stream()
|
||||||
|
.map(ToponymDictionary::withoutInflectedEnding)
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
private ToponymDictionary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Похоже ли значение на название города из словаря в любом падеже.
|
||||||
|
*
|
||||||
|
* <p>Города на согласную склоняются добавлением окончания («Тамбов» →
|
||||||
|
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
||||||
|
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||||
|
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||||
|
* в {@link NameDictionary}.
|
||||||
|
*/
|
||||||
|
public static boolean isKnownCity(String value) {
|
||||||
|
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String stem : CITY_STEMS) {
|
||||||
|
if (lower.startsWith(stem)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String withoutInflectedEnding(String name) {
|
||||||
|
String lower = name.toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||||
|
return lower.substring(0, lower.length() - 1);
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> load(String resource) {
|
||||||
|
try (InputStream in = ToponymDictionary.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Словарь городов — проверка вместо любого слова с заглавной буквы после «г.». */
|
||||||
|
class ToponymDictionaryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesNominativeCase() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Москва"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Казань"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Санкт-Петербург"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesInflectedForms() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Москве"), "дательный падеж города на гласную");
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Тамбове"), "предложный падеж города на согласную");
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Казани"), "родительный падеж");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesCisCapitals() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Минск"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("Алматы"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMadeUpWord() {
|
||||||
|
assertFalse(ToponymDictionary.isKnownCity("Ерунда"));
|
||||||
|
assertFalse(ToponymDictionary.isKnownCity("Бла-бла"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isCaseInsensitive() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("МОСКВА"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownCity("москва"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user