У меня возникла идея сделать список упоминаний названий городов в статьях Хабра за 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 и реагировать на новые публикации Хабра моментально. Но не в этот раз и не на прототипе!

Время работы программы с вложенными циклами, морфологией, чтением и записью файлов - 16 секунд для статей за 2023 год
Время работы программы с вложенными циклами, морфологией, чтением и записью файлов - 16 секунд для статей за 2023 год

Первый запуск и истеричный смех

И тут неожиданно Хабр оказался географическим альманахом с 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
Проверю что правильно сохранил GeoJSON в онлайн просмотре geojson.io

Для просмотра GeoJSON файла можно воспользоваться geojson.io или любым другим удобным просмотрщиком.

Какие статьи есть про Байконур на Хабре в 2023?
Какие статьи есть про Байконур на Хабре в 2023?
Что же там с другой стороны планеты на Гаваях?
Что же там с другой стороны планеты на Гаваях?

Файл habr_cities_2023.json ссылками на статьи на Хабре для городов мира можете скачать из моего GitHub репозитария.

Итог

Скачать и распарсить Хабр оказалось легко и быстро, а вот извлечь из статей названия городов - в общем случае непростая задача. Поиск географических ссылок можно решить сложными методами анализа текстов с привлечением ChatGPT, Llama2 и подобных моделей, а можно внедрить в разметку статей возможность автору явно указать географические координаты для фрагмента текста.

Нужна ли вообще такая фича на Хабре и насколько она полезна я не знаю. Но обработка данных Хабра и промежуточные результаты меня развеселили. Обработка текста в свободной форме и в общем виде - это самая настоящая "кроличья нора", куда точно надолго провалишься впервые столкнувшись.

Надеюсь что вам было так же интересно как и мне!

Комментарии (9)


  1. Jury_78
    17.01.2024 10:32

    Интересно увидеть какую нибудь статистику... Частота появления, например.


    1. igor_suhorukov Автор
      17.01.2024 10:32

      Частота упоминания для городов, встречающихся минимум в 30 статьях Хабра 2023

      Казань	32
      Токио	34
      Рим	34
      Чикаго	34
      Минск	38
      Кембридж	38
      Орёл	38
      Гонконг	41
      Лос-Анджелес	41
      Дубай	43
      Берлин	47
      Сингапур	52
      Париж	60
      Сан-Франциско	65
      Екатеринбург	66
      Вашингтон	69
      Новосибирск	72
      Нью-Йорк	131
      Санкт-Петербург	191
      Москва	693


  1. positron48
    17.01.2024 10:32
    +2

    Еще бы в статье была ссылка и инструкция, чтобы самому потыкать..

    Например, такая:
    1. Копируем json
    2. Вставляем в https://geojson.io/

    А за статью спасибо!


  1. velon
    17.01.2024 10:32
    +2

    Спасибо. Но всё ещё много шума, думаю что проблема со склонениями. Например в Строение атома и материи есть упоминание посёлка Боровский. Но на самом деле:

    вдоль боровской орбиты укладывается целое число длин волн электрона и получается как бы стоячая волна

    Мне кажется зря Вы привели всё в нижний регистр и отказались от того факта что названия начинаются с заглавной буквы. Хотя в начале предложения это значения иметь не будет, - но какой-то процент шума устранит


    1. Dolios
      17.01.2024 10:32

      Это как раньше банили за просьбу никого не оскорблять )
      И хорошо, что в статье про строение атома не упоминается река Воронеж..


      1. Squoworode
        17.01.2024 10:32
        +1

        никого не оскорблять

        Олеговна, застрахуй команду корабля со скипидаром!


    1. igor_suhorukov Автор
      17.01.2024 10:32

      Спасибо, это я фильтр забыл включить во второй ревизии. Данные обновил!


  1. Squoworode
    17.01.2024 10:32

    Хабр оказался географическим альманахом с 1839 различными городами за 2023 и ссылается на такие популярные в нашей стране города как Бани, Банк и Мама.

    Хабр всё ещё ссылается на такие популярные в нашей стране города как Хост (555 раз), Нигде (366 раз) и Маску (157 раз).

    А также

    Или - 11789 раз
    Они - 8841 раз
    Раз - 7791 раз
    Новое - 7714 раз
    Эти - 7069 раз
    Тут - 5161 раз
    Котор - 4332 раз
    Мир - 4066 раз
    Люба - 4001 раз
    Новый - 3895 раз
    Оно - 3748 раз
    Ним - 3265 раз
    Сами - 2961 раз
    Игра - 2227 раз
    Среднее - 1977 раз
    Секунда - 1882 раз
    Нея - 1630 раз
    Куча - 1600 раз
    Вида - 1285 раз
    Масса - 1081 раз
    Роли - 1040 раз


    1. Paranoich
      17.01.2024 10:32

      Новый 

      Ну таких населённых пунктов только в РФ штук сто. Один из них рядом с г. Егорьевск. Бывал там и впечатления хорошие.

      Игра

      Друг у меня там родился. Иначе вряд-ли бы знал о таком населённом пункте. Кажется Нижний Новгород недалеко, если не путаю.

      Секунда это ЮАР, но может быть и оттеда люди есть. Также и из Нигде могут присутствовать. Турция оно рядом)

      Ну и станция/город Зима никого ведь не удивляет названием?