Прошлогодние Хабрагорода

У меня возникла идея сделать список упоминаний названий городов в статьях Хабра за 2023 год и карту по которой можно найти статьи. С первого взгляда задачка простая, но это как всегда дьявол кроется в деталях!

Для этого нужны данные статей Хабра, названия городов с координатами и поиск этих названий в текстах статей. Задача осложняется великим и могучим языком со склонениями и многозначностью слов. Создание списка статей с Хабра за 2023 год по городам мне чем-то напомнило работу первых поисковых движков в рунете. Теперь я понимаю как кусали себя за локти программисты тех дней!

Для статьи написанной за несколько дней полноту данных, качество кода и 100% правильность результата я не гарантирую. Ведь анализ текста непростая задача, если только вы не специалист по обработке естественного языка (NLP) и не гуру использования Больших Языковых Моделей (LLM). В любом случае результат статьи будет интересным, а процесс разработки программы местами был смешным для меня, когда смотрел в данные которые выдавал мой код!

Статьи с Хабра

Про то как скачать статьи с Хабра здесь так же было несколько публикаций и есть даже репозитарии с кодом. Я решил не создавать нагрузку/мусорить в accesslog на глубокоуважаемом ресурсе методом перебора идентификаторов, а список ссылок для закачки взял из архива интернета.

8928745d2ecd00c6c5e07709775e13c2.png

Результат сохранил как json файлы в директории articles, где имя файла — идентификатор статьи. Буду искать города только в статьях на русском языке ('lang'='ru') привычным для меня инструментом jackson databind.

Список городов

Тут тоже не все так просто как кажется, когда начинаешь пробовать. Можно скачать все названия мира osmnames.org и извлечь только интересующие, но эта задача с разбором данных и формата для меня кажется дольше, чем самому извлечь из исходных геоданных. Выбрал все place=city или place=>town по миру, а как координаты взял центроиды:

cd39e97781ebf7aebbe0da015c459993.png

Разбивка статьи на токены

Сначала загрузим JSON из каждого файла в директории и извлечем идентификатор статьи, заголовок и текст.

Map article = objectMapper.readValue(habrFile, new TypeReference>() {});
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 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 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 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. А пример данных в скриншоте выше.

Поскольку все города буду держать в памяти программы и статей за год не так много, то поиск буду выполнять во вложенных циклах, не используя более «продвинутые» структуры данных и тем самым сэкономлю время на разработку и тестирование.

List cityRefs = Arrays.stream(new File("/home/iam/dev/projects/habr/articles").listFiles((dir, name) -> name.endsWith("json"))).parallel().map(habrFile -> {
    try {
        Map article = objectMapper.readValue(habrFile, new TypeReference>() {
        });
        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 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 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 и ссылается на такие популярные в нашей стране города как Бани, Банк и Мама.

76334115b0996887ddd77a68f237df7e.png

С этим надо что-то срочно делать!

Правильно было бы учитывать контекст и получать фреймы данных (не Spark датафрейм, а сущность из инженерии знаний) где в тексте статьи анализируется, где Флинт — это населенный пункт, где Флинт Вествуд, а кое-где и вовсе профессор Флинт. То есть простой подход с токинезацией текста в общем случае тупиковая идея. Можно заморочиться «раскурить мануалы» анализа текстов, но это не спасет от ручной разметки данных, тысяч проверок и очередных анекдотов на выходе программы. Для демонстрационной идеи я «вручную» отсмотрел список городов в выводе программы и добавил те варианты, что исключают неоднозначность в сопоставлении — создал «белый список» городов и стал фильтровать исходный city.tsv в коде программы по нему.

743914a6aa847e1466def870c2d64026.png

Все это напоминает поисковые системы конца 90х с морем законфигурированных в движке правил. Да наверное и до последних времен поисковики используют прописанные людьми эвристики, а вебмастера и SEO пытаются угадать что же это за правила там, чтобы поэксплуатировать их в свою пользу.

Хабрагорода на карте

Для того чтобы сделать интерактивный глобус понадобиться браузер и GeoJSON файл с данными.

Данные из коллекции cityRefs которую создали в разделе «Поиск города в токенах» этой статьи перегрупирую по городам:

Map> 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 есть все что нужно: названия города, координаты и список айдишников статей где упоминается город:

dc4432fbbaf443458402473cb3b3b205.png

Чтобы превратить это в 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 features;

    public GeoJson(List features) {
        this.features = features;
    }

    public String getType() {
        return type;
    }

    public List 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 coordinates;

    public GeoPoint(List coordinates) {
        this.coordinates = coordinates;
    }

    public String getType() {
        return type;
    }

    public List getCoordinates() {
        return coordinates;
    }
}

public class HabrLinks {
    String city;
    List articleIds;

    public HabrLinks(String city, List articleIds) {
        this.city = city;
        this.articleIds = articleIds;
    }

    public String getCity() {
        return city;
    }

    public List getArticleIds() {
        return articleIds;
    }

    public List 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?

Что же там с другой стороны планеты на Гаваях?

Что же там с другой стороны планеты на Гаваях?

Файл с данными можете скачать в github репозитарии.

Итог

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

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

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

© Habrahabr.ru