У меня возникла идея сделать список упоминаний названий городов в статьях Хабра за 2023 год и карту по которой можно найти статьи. Публикации, где упоминается конкретный город. С первого взгляда задачка простая, но это как всегда дьявол кроется в деталях!
Для этого нужны данные статей Хабра, названия городов с координатами и поиск этих названий в текстах статей. Задача осложняется великим и могучим языком со склонениями и многозначностью слов. Создание списка статей с Хабра за 2023 год по городам мне чем-то напомнило работу первых поисковых движков в рунете. Теперь я понимаю как кусали себя за локти программисты тех дней!
Для статьи написанной за несколько дней полноту данных, качество кода и 100% правильность результата я не гарантирую. Ведь анализ текста непростая задача, если только вы не специалист по обработке естественного языка (NLP) и не гуру использования Больших Языковых Моделей (LLM). В любом случае результат статьи будет интересным, а процесс разработки программы местами был смешным для меня, когда смотрел в данные которые выдавал мой код!
Статьи с Хабра
Про то как скачать статьи с Хабра здесь так же было несколько публикаций и есть даже репозитарии с кодом. Я решил не создавать нагрузку/мусорить в accesslog на глубокоуважаемом ресурсе методом перебора идентификаторов, а список ссылок для закачки взял из архива интернета.
Результат сохранил как json файлы в директории articles, где имя файла - идентификатор статьи. Буду искать города только в статьях на русском языке ('lang'='ru')
привычным для меня инструментом jackson databind.
Список городов
Тут тоже не все так просто как кажется, когда начинаешь пробовать. Можно скачать все названия мира osmnames.org и извлечь только интересующие, но эта задача с разбором данных и формата для меня кажется дольше, чем самому извлечь из геоданных OpenStreetMap. Выбрал все place=city или place=town по миру, а как координаты взял центроиды:
Извлек русские названия городов из OSM дампа планеты своей утилитой openstreetmap_h3 с помощью запроса:
select name,x,y from (select tags->'name:ru' name, row_number() over (partition by tags->'name:ru' order by type desc) row_num, type, st_x(centre) x,st_y(centre) y from geometry_global_view where tags@>'place=>city' or tags@>'place=>town' and tags?'name:ru') city where row_num=1 and name between 'А' and 'Яя' and length(name)>2;
Разбивка статьи на токены
Сначала загрузим JSON из каждого файла в директории и извлечем идентификатор статьи, заголовок и текст.
Map<String, Object> article = objectMapper.readValue(habrFile, new TypeReference<HashMap<String, Object>>() {});
if (!"ru".equals(article.get("lang"))) {
return null;
}
long articleId = Long.parseLong(article.get("id").toString());
String titleHtml = article.get("titleHtml").toString();
String textHtml = article.get("textHtml").toString();
String text = Jsoup.parse(textHtml).text();
Причем текст статьи содержит Html разметку и она для анализа мне не нужна. Превращу это в обычный текст без разметки при помощи парсера Jsoup (org.jsoup:jsoup:1.17.2
).
Set<String> words = Arrays.stream(text.replaceAll("[\\p{Punct}&&[^-]]", " ").
replaceAll("\\n", " ").split(" ")).
map(String::toLowerCase).
filter(Predicate.not(String::isBlank)).
filter(s -> s.length() > 2).
filter(s -> !Pattern.matches("^[a-zA-Z\\d]+$", s)).
collect(Collectors.toSet());
После этого заменяю пунктуационные символы, кроме "-" на пробелы, привожу строки в нижний регистр и отбрасываю в процессе пустые строки, строки короче 3 символов, а также английские слова и идентификаторы из латинских букв и цифр. Результат сохраняю в объекте Set - что сразу убирает дубликаты из результатов. В высокопроизводительных системах и при обработке больших документов никто не пишет такой #овнокод, но для демонстрации идеи "и так сойдет"(с).
Поиск города в токенах
Название города может склоняться в тексте статьи на Хабре, поэтому нужна морфология для названий городов. В стародавние времена был открытый проект AOT.ru вот его по старой памяти и использую чтобы для каждого города из списка сгенерировать все возможные склонения. Для этого воспользуюсь JVM портом com.github.demidko:aot:2022.11.28
доисторической морфологии без нейронок и GPGPU.
List<City> cities = null;
try (BufferedReader reader = new BufferedReader(new FileReader("city.tsv"))) {
cities = reader.lines().map(line -> {
String[] split = line.split("\t");
String name = split[0].toLowerCase();
Set<String> forms = WordformMeaning.lookupForMeanings(name).
stream().filter(wordformMeaning -> wordformMeaning.getPartOfSpeech()== PartOfSpeech.Noun).
filter(morphologyTags -> morphologyTags.getMorphology().contains(MorphologyTag.Singular)).
filter(morphologyTags -> morphologyTags.getMorphology().contains(MorphologyTag.Noun)).
filter(morphologyTags -> morphologyTags.getMorphology().contains(MorphologyTag.Nominative)).
map(WordformMeaning::getTransformations).
flatMap(Collection::stream).map(Objects::toString).
collect(Collectors.toSet());
return new City(name, forms, Double.parseDouble(split[1]),Double.parseDouble(split[2]));
}).collect(Collectors.toList());
}
Библиотека при вызове lookupForMeanings генерирует словоформы на все случаи жизни, поэтому приходится оставлять только астионимимы в единственном числе именительного падежа. А уж потом из них вызовом getTransformations получать склонения. Например для "Пекин" результат будет:
"пекину"
"пекинами"
"пекином"
"пекине"
"пекин"
"пекинам"
"пекины"
"пекинах"
"пекинов"
"пекина"
Формат файла со списком городов для сохранения выбрал следующий: _название_ \t x \t y
. А пример данных в скриншоте выше.
Поскольку все города буду держать в памяти программы и статей за год не так много, то поиск буду выполнять во вложенных циклах, не используя более "продвинутые" структуры данных, Apache Lucene или Elasticsearch и тем самым сэкономлю время на разработку и тестирование.
List<CityReference> cityRefs = Arrays.stream(new File("/home/iam/dev/projects/habr/articles").listFiles((dir, name) -> name.endsWith("json"))).parallel().map(habrFile -> {
try {
Map<String, Object> article = objectMapper.readValue(habrFile, new TypeReference<HashMap<String, Object>>() {
});
if (!"ru".equals(article.get("lang"))) {
return null;
}
long articleId = Long.parseLong(article.get("id").toString());
String titleHtml = article.get("titleHtml").toString();
String textHtml = article.get("textHtml").toString();
String text = Jsoup.parse(textHtml).text();
Set<String> words = Arrays.stream(text.replaceAll("[\\p{Punct}&&[^-]]", " ").replaceAll("\\n", " ").split(" ")).map(String::toLowerCase).filter(Predicate.not(String::isBlank)).filter(s -> s.length() > 2).filter(s -> !Pattern.matches("^[a-zA-Z\\d]+$", s)).collect(Collectors.toSet());
return cities.parallelStream().filter(city -> {
HashSet<String> cityNameForms = new HashSet<>(city.getAllNames());
cityNameForms.retainAll(words);
return !cityNameForms.isEmpty();
}).map(city -> new CityReference(articleId, titleHtml, city)).collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException(e);
}
}).filter(Objects::nonNull).filter(cityReferences -> !cityReferences.isEmpty()).flatMap(Collection::stream).collect(Collectors.toList());
И о, ужас! В каждом nested loop создаю копию Set, чтобы найти пересечение множеств words
и cities
с помощью метода retainAll
. Никогда так не делайте в продакшен коде: море лишних аллокаций объектов, java стримы, регэкспы и "код с душком". Я бы переписал сразу на VHDL минуя ассемблер для x86-64 и оптимизировал бы алгоритмы чтобы запускать с наносекундными задержками на FPGA, позже на ASIC и реагировать на новые публикации Хабра моментально. Но не в этот раз и не на прототипе!
Первый запуск и истеричный смех
И тут неожиданно Хабр оказался географическим альманахом с 1839 различными городами за 2023 и ссылается на такие популярные в нашей стране города как Бани, Банк и Мама.
С этим надо что-то срочно делать!
Правильно было бы учитывать контекст и получать фреймы данных ( не Spark датафрейм, а сущность из инженерии знаний) где в тексте статьи анализируется, где Флинт - это населенный пункт, где Флинт Вествуд, а кое-где и вовсе профессор Флинт. То есть простой подход с токинезацией текста в общем случае тупиковая идея. Можно заморочиться "раскурить мануалы" анализа текстов, но это не спасет от ручной разметки данных, тысяч проверок и очередных анекдотов на выходе программы. Для демонстрационной идеи я "вручную" отсмотрел список городов в выводе программы и добавил те варианты, что исключают неоднозначность в сопоставлении - создал "белый список" городов и стал фильтровать исходный city.tsv в коде программы по нему.
Все это напоминает поисковые системы конца 90х с морем законфигурированных в движке правил. Да наверное и до последних времен поисковики используют прописанные людьми эвристики, а вебмастера и SEO пытаются угадать что же это за правила там, чтобы поэксплуатировать их в свою пользу.
Хабрагорода на карте
Для того чтобы сделать интерактивный глобус понадобиться браузер и GeoJSON файл с данными.
Данные из коллекции cityRefs которую создали в разделе "Поиск города в токенах" этой статьи перегрупирую по городам:
Map<City, List<Long>> cityFromArticle = cityRefs.stream().map(cityReference -> new AbstractMap.SimpleImmutableEntry<>(
cityReference.city, cityReference.postId))
.collect(Collectors.groupingBy(AbstractMap.SimpleImmutableEntry::getKey,
Collectors.mapping(AbstractMap.SimpleImmutableEntry::getValue, Collectors.toList())));
Теперь для создания GeoJSON есть все что нужно: названия города, координаты и список айдишников статей где упоминается город:
Чтобы превратить это в GeoJSON нужно просто структуры программы переписать в этот формат при сохранении. Можно было бы делать оптимально, а можно по крудошлепски - перекладывая данные в новые объекты специально созданные под выходной формат и скинуть всю рутину по форматированию файла в jackson-databind:
GeoJson geoJson = new GeoJson(cityFromArticle.entrySet().stream().map(cityEntry ->
new GeoFeature(new GeoPoint(Arrays.asList(cityEntry.getKey().getX(), cityEntry.getKey().getY())),
new HabrLinks(cityEntry.getKey().getName(), cityEntry.getValue()))).
collect(Collectors.toList()));
Пример GeoJSON для этой карты и исходных классов в программе из которых получился файл
{
"type" : "FeatureCollection",
"features" : [ {
"type" : "Feature",
"geometry" : {
"type" : "Point",
"coordinates" : [ 74.6070079, 42.8765615 ]
},
"properties" : {
"city" : "Бишкек",
"articleIds" : [ 743366, 726250, 714274, 704178, 754262, 762108, 741860 ]
}
}, {
"type" : "Feature",
"geometry" : {
"type" : "Point",
"coordinates" : [ 114.16281310000001, 22.2793278 ]
},
"properties" : {
"city" : "Гонконг",
"articleIds" : [ 711854, 705906, 737872, 704798, 707072, 772694, 707566, 710992, 772768, 724544, 717924, 760068, 705052, 707748, 779102, 742430, 719440, 705882, 731592, 706266, 710722, 735358, 711842, 756554, 750926, 756154, 742456, 750708, 719300, 758552, 750174, 752524, 781798, 721354, 741560, 722694, 767438, 741208, 769400, 725924, 783642 ]
}
} ]
}
package com.github.isuhorukov;
import java.util.List;
import java.util.stream.Collectors;
public class GeoJson {
String type="FeatureCollection";
List<GeoFeature> features;
public GeoJson(List<GeoFeature> features) {
this.features = features;
}
public String getType() {
return type;
}
public List<GeoFeature> getFeatures() {
return features;
}
}
public class GeoFeature {
String type="Feature";
GeoPoint geometry;
HabrLinks properties;
public GeoFeature(GeoPoint geometry, HabrLinks properties) {
this.geometry = geometry;
this.properties = properties;
}
public String getType() {
return type;
}
public GeoPoint getGeometry() {
return geometry;
}
public HabrLinks getProperties() {
return properties;
}
}
public class GeoPoint {
String type="Point";
List<Double> coordinates;
public GeoPoint(List<Double> coordinates) {
this.coordinates = coordinates;
}
public String getType() {
return type;
}
public List<Double> getCoordinates() {
return coordinates;
}
}
public class HabrLinks {
String city;
List<Long> articleIds;
public HabrLinks(String city, List<Long> articleIds) {
this.city = city;
this.articleIds = articleIds;
}
public String getCity() {
return city;
}
public List<Long> getArticleIds() {
return articleIds;
}
public List<String> getArticleLinks() {
return articleIds.stream().map(id -> "https://habr.com/ru/article/"+id).collect(Collectors.toList());
}
}
Для просмотра GeoJSON файла можно воспользоваться geojson.io или любым другим удобным просмотрщиком.
Файл habr_cities_2023.json ссылками на статьи на Хабре для городов мира можете скачать из моего GitHub репозитария.
Итог
Скачать и распарсить Хабр оказалось легко и быстро, а вот извлечь из статей названия городов - в общем случае непростая задача. Поиск географических ссылок можно решить сложными методами анализа текстов с привлечением ChatGPT, Llama2 и подобных моделей, а можно внедрить в разметку статей возможность автору явно указать географические координаты для фрагмента текста.
Нужна ли вообще такая фича на Хабре и насколько она полезна я не знаю. Но обработка данных Хабра и промежуточные результаты меня развеселили. Обработка текста в свободной форме и в общем виде - это самая настоящая "кроличья нора", куда точно надолго провалишься впервые столкнувшись.
Надеюсь что вам было так же интересно как и мне!