Привет, друзья!

Мы разрабатываем платформу Я в агро. Платформа, помимо прочего, помогает найти вакансии в сфере агро.

Есть у нас задачка - генерировать уникальные страницы сайта, да так, чтобы и seo-заголовки для индексации поисковиками тоже генерировались автоматически под разные сущности проекта.

Допустим, нам нужно генерировать страницы вакансий, которые были бы привязаны к городу или региону страны. Этот же пример подходит и для генерации страниц сайта какого-нибудь интернет магазина или маркетплейса, продающих товары или услуги.

В нашем случае, у нас есть следующие сущности:

  1. Название вакансии или товарной позиции;

  2. Регион страны;

  3. Город.

На выходе мы хотим получить автоматизировано сгенерированный HTML контент, который бы имел текст описания вакансии, метатеги и seo-заголовки в коде.

Что нам нужно по SEO:

  1. title - заголовок;

  2. h1 - заголовок первого уровня;

  3. description - описание;

  4. keywords - ключевые слова.

Итак, пример. Допустим, у нас есть вакансия "Генетик", мы генерируем страницу для региона "Брянская область", то есть на выходе мы хотим получить набор различных вариантов контента, который мы применили бы в теле страницы сайта, возможно, и в сообщениях или письмах пользователям/подписчикам:

Вакансия генетика в Брянской области
Мы нашли 5 вакансий генетика в Брянской области
Работа генетиком, 32 вакансии
Поможем генетику найти работу в Брянской области
35 вакансий генетика в Брянске
Предложения работы генетиком в Севске Брянской области, 21 уникальная вакансия

Для SEO мы бы сгенерировали:

  1. title: Работа генетиком в Брянской области, 175 уникальных вакансий;

  2. h1: Работа генетиком в Брянской области, 175 вакансий;

  3. description: Работа в АПК. Свежие вакансии генетика в Брянской области от прямых работодателей. Мы собрали 175 уникальных вакансий;

  4. keywords: работа генетиком, вакансии генетика, вакансии ассистента генетика, работа ассистентом генетика, вакансии генетик.


Как это сделали мы?

Наш проект написан на Laravel (PHP), так что все примеры будут описаны для Laravel.

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

Скачать и установить phpMorphy можно, например, из этих 2х источников:

Для упрощения работы с phpMorphy мы создали свой Helper.

Код нашего Helper'а (как есть)
<?php namespace {скрыто}\PhpMorphy\Helpers;

use phpMorphy;
use phpMorphy_Exception;
use Log;

require_once(base_path('{скрыто}/phpmorphy/common.php'));

class Morphy
{
    const MORPHY_PATH = '{скрыто}/phpmorphy';

    const MORPHY_SKEEP_WORD_END_WITH = ['кабардино', 'карачаево', 'ханты', 'ямало']; //Пропускать слова, которые заканчиваются на один из элементов массива
    const MORPHY_SKEEP_WORD_DELIMS = ['-']; //Разделители при нахождении которых преобразуем фразу по каждому слову отдельно

    /**
     * Создание класса Morphy с настройками
     *
     * @return phpMorphy|void
     */
    public static function getMorphy(){
        try {
            $dir = base_path(self::MORPHY_PATH) . '/dicts';
            $lang = 'ru_RU';
            $opts = array(
                // storage type, follow types supported
                // PHPMORPHY_STORAGE_FILE - use file operations(fread, fseek) for dictionary access, this is very slow...
                // PHPMORPHY_STORAGE_SHM - load dictionary in shared memory(using shmop php extension), this is preferred mode
                // PHPMORPHY_STORAGE_MEM - load dict to memory each time when phpMorphy intialized, this useful when shmop ext. not activated. Speed same as for PHPMORPHY_STORAGE_SHM type
                'storage' => PHPMORPHY_STORAGE_MEM,
                'predict_by_suffix' => true,
                'predict_by_db' => true,
                'graminfo_as_text' => true,
            );
            return new phpMorphy($dir, $lang, $opts);
        } catch(phpMorphy_Exception $e) {
            log::error('Morphy::getMorphy(): Error occured while creating phpMorphy instance: ' . $e->getMessage());
        }
    }

    /**
     * Получение существительного для числового обозначения
     * Пример: 3 вакансии, 5 вакансий, 1 заявление
     *
     * @param $count
     * @param string $word
     * @param null $morphy
     * @return string
     */
    public static function getMorphedCount($count, string $word, $morphy = null): string
    {
        if (empty($morphy)) $morphy = self::getMorphy();
        $word = mb_strtoupper($word);
        $one = $morphy->castFormByGramInfo($word, null, ['ЕД', 'ИМ'], true)[0] ?? '';
        $twoFour = $morphy->castFormByGramInfo($word, null, ['ЕД', 'РД'], true)[0] ?? '';
        $fiveZero = $morphy->castFormByGramInfo($word, null, ['МН', 'РД'], true)[0] ?? '';
        switch (substr($count, -1)) {
            case '1': $countStr = $one; break;
            case '2':
            case '3':
            case '4': $countStr = $twoFour; break;
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
            case '0':
            default: $countStr = $fiveZero;
        }
        return mb_strtolower($countStr);
    }

    /**
     * Преобразование слова к указанному падежу и численности
     *
     * @param $morphy
     * @param string $word
     * @param array $gramInfo
     * @return string
     */
    public static function getMorphed($morphy, string $word, array $gramInfo): string
    {
        try {
            $delims = collect(self::MORPHY_SKEEP_WORD_DELIMS);

            $word = str_replace(' - ', '-', $word);

            $delimsFounded = $delims->filter(function ($delim) use ($word){ return mb_strpos($word, $delim) !== false; });

            if ($delimsFounded->count() > 0){
                //Преобразование фразы, делаем каждое слово отдельно
                $resultWord = '';
                $delimsFounded->each(function ($delim) use ($word, &$result, $morphy, $gramInfo, &$resultWord){
                    $result = [];
                    collect(explode($delim, $word))->each(function ($word) use (&$result, $morphy, $gramInfo){
                        $result[] = self::isWordEndWithSkipArr($word) ? $word : ($morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)[0]??'');
                    });
                    $resultWord = implode($delim, $result);
                });
                return mb_strtolower($resultWord);
            } else {
                //Преобразование слова
                return mb_strtolower($morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)[0] ?? '');
            }
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::getMorphed(): EXCEPTION. $word: '.$word.' $gramInfo: '.json_encode($gramInfo).' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    /**
     * Проверка, заканчивается ли слово на одно из значений массива
     *
     * @param $word
     * @return bool
     */
    public static function isWordEndWithSkipArr($word){
        return collect(self::MORPHY_SKEEP_WORD_END_WITH)->filter(function ($skipLetter) use ($word){
                return ends_with(mb_strtolower($word), mb_strtolower($skipLetter));
            })->count() > 0;
    }

    /**
     * Привести первую буква слова к верхнему регистру в кодировке UTF-8
     *
     * @param $text
     * @return string
     */
    public static function mb_ucfirst($text): string
    {
        try {
            $text = mb_strtolower($text);
            return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1);
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::mb_ucfirst(): EXCEPTION. $text: '.$text.' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    /**
     * Привести первую букву каждого слова в строке к верхнему регистру в кодировке UTF-8
     *
     * @param $text
     * @return string
     */
    public static function mb_ucwords($text): string
    {
        try {
            $text = mb_strtolower($text);
            $result = [];
            collect(explode(' ', $text))->each(function ($word) use (&$result) {
                $result[] = self::mb_ucfirst($word);
            });
            return implode(' ', $result);
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::mb_ucwords(): EXCEPTION. $text: '.$text.' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    public static function getMorphedRegionName($morphy, string $regionName, bool $addV = true)
    {
        //В республике
        if (mb_strpos($regionName, ' ') === false) return $regionName;

        $regionName = mb_strtoupper(str_replace(' — ','-', $regionName));

        $result = $regionName;
        $regionArr = explode(' ', $regionName);

        $v = $addV ? 'в ' : '';

        if (mb_strpos($regionName, 'РЕСПУБЛИКА') !== false){
            if (starts_with($regionName, 'РЕСПУБЛИКА')){
                //Республика Крым -> в республике Крым
                $result = $v.'республике '.Morphy::mb_ucfirst(mb_strtolower(str_replace('РЕСПУБЛИКА ', '', $regionName)));
            } else {
                //Чувашская Республика -> в Чувашской республике
                $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' республике';
            }

        } elseif (mb_strpos($regionName, 'КРАЙ') !== false){
            //Алтайский край -> в Алтайском крае
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'МР', 'ПР'])).' крае';
        } elseif (mb_strpos($regionName, 'АВТОНОМНАЯ ОБЛАСТЬ') !== false){
            //в Еврейской автономной области
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' автономной области';
        } elseif (mb_strpos($regionName, 'ОБЛАСТЬ') !== false){
            //в Архангельской области
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' области';
        } elseif (mb_strpos($regionName, 'АВТОНОМНЫЙ ОКРУГ') !== false){
            //в Ханты-Мансийском автономном округе
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'МР', 'ПР'])).' автономном округе';
        }

        return $result;
    }
}

Что есть в Helper'е:

  1. getMorphy() - создает класс phpMorphy с предустановленными настройками;

  2. getMorphedCount($count, string $word, $morphy = null) - склоняет существительное для числового отображения. Пример: 5 вакансий, 101 рудокоп.

  3. getMorphed($morphy, string $word, array $gramInfo) - применяет к слову или фразе указанные правила преобразования

  4. isWordEndWithSkipArr($word) - используется для пропуска слов-исключений, нам это нужно для регионов

  5. mb_ucfirst($text) - делает первую букву первого слова в строке заглавной, работает корректно с кириллицей и разделителями в строке, в отличии от штатного средства. Имя метода специально сделано максимально приближенным к штатному.

  6. mb_ucwords($text) - то же самое, что и предыдущий метод, только делает первую букву заглавной в каждом слове строки

  7. getMorphedRegionName($morphy, string $regionName, bool $addV = true) - приводит регион к нужному склонению. Пример: "в Краснодарском крае".

Вся магия происходит в методе phpMorthy->castFormByGramInfo()

castFormByGramInfo($word, $partOfSpeech, $grammems, $returnOnlyWord = false, $callback = null, $type = self::NORMAL)

Для текущей задачи мы используем этот метод так:

$morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)

Метод castFormByGramInfo() отработает некорректно, если получит на входе слово или фразу не в формате заглавных букв (UPPER_CASE), поэтому был создан метод mb_strtoupper(). Повторюсь, штатный метод приведения ко всем заглавным буквам плохо работает с кириллицей в купе с разделителями, поэтому мы создали свой метод.

$gramInfo - это правила приведения, массив.

Мы применяем правила вида:

[{КОД_ЧИСЛЕННОСТИ}, {КОД_РОДА}, {КОД_ПАДЕЖА}]

Пример:

['ЕД', 'ЖР', 'РД']

Подробно коды описаны в документации phpMorphy: http://phpmorphy.sourceforge.net/dokuwiki/manual-graminfo

Пример: ['ЕД', 'ЖР', 'РД'] - Единственное число, женский род, родительный падеж

Применив данные правила к слову "Чувашский", мы получим "Чувашской".

Возможности phpMorphy при должном использовании поражают.

Еще пример:

return 'Работа '.Morphy::getMorphed($morphy, $name, ['ЕД', 'ТВ']);

Данный код вернет вакансию в творительном падеже, "Работа генетиком".

Если Вам сложно подобрать правила для Вашего случая, можно воспользоваться методом phpMorphy:

$morphy->getAllFormsWithGramInfo('ТЕСТ', true)

Данный метод выдаст массивом все варианты слов на основе Вашего примера и покажет какие правила привели к каждой форме слова.

Также phpMorphy умеет приводить слова по образцу:

$morphy->castFormByPattern('ДИВАН', 'СТОЛАМИ', null, true)

Так мы получим слово "ДИВАНАМИ" по образцу в виде слова "СТОЛАМИ".

Практически во всех методах Helper'а мы передаем заранее созданный экземпляр phpMorphy в переменной $morphy, чтобы не создавать его при каждом обращении к Helper'у, например при обработке коллекции данных (генерации страниц).

Пример метода генерации SEO данных
     /**
     * Генерим SOE данные для группы вакансий
     *
     * @return array
     */
    public function getSeoDataForGroup($morphy, $count, $groupName, $regionName): array
    {
        // Хотим на выходе:
        // title: Работа биоинженером в России, 175 уникальных вакансий
        // h1: Работа биоинженером в России, 175 вакансий
        // description: Работа в АПК. Свежие вакансии биоинженера в России от прямых работодателей. Мы собрали 175 уникальных вакансий.
        // keywords: работа биоинженером, вакансии биоинженера, вакансии ассистента биоинженера, работа ассистентом биоинженера, вакансии биоинженер

        $groupName = mb_strtolower($groupName);
        $regionName = mb_strtolower($regionName);

        $groupNameTV = Morphy::getMorphed($morphy, $groupName, ['ЕД', 'ТВ']); //кем - биоинженером - Творительный падеж
        $groupNameVN = Morphy::getMorphed($morphy, $groupName, ['ЕД', 'ВН']); //кого - биоинженера - Винительный падеж

        $regionNameV = Morphy::getMorphedRegionName($morphy, $regionName);

        $countStr = Morphy::getMorphedCount($count, 'вакансия', $morphy);

        $titlePart = "Работа $groupNameTV $regionNameV, $count"; //Работа биоинженером в России, 175
        $descripton = "Работа в АПК. Свежие вакансии $groupNameVN $regionNameV от прямых работодателей. Мы собрали $count уникальных $countStr.";
        $keywords = "работа $groupNameTV, вакансии $groupNameVN, вакансии ассистента $groupNameVN, работа ассистентом $groupNameVN, вакансии $groupName";


        return [
            'seo_title'         => "$titlePart уникальных $countStr",
            'seo_h1'            => "$titlePart $countStr",
            'seo_description'   => $descripton,
            'seo_keywords'      => $keywords
        ];
    }

Далее, после генерации контента страниц и SEO-заголовков, для каждой страницы мы сохраняем запись в специальную табличку базы данных, с генерацией уникального пути к странице сайта (url).

Мы настроили наш веб сервер таким образом, что при запросе sitemap поисковиком, он генерируется на основе данных вышеописанной таблице БД. Таким образом, все сгенерированные данные индексируются поисковиками.


Заключение

Благодаря phpMorphy Вы можете генерировать практически любой контент и корректные SEO-заголовки. Данный подход также применим и для генерации метаданных.

В будущем мы планируем реализовать грамотное склонение фраз, результаты опубликуемом (шутка) в новой публикации.

Очень надеемся, что кому-нибудь помогли данной статьей.

С уважением и спасибо за внимание!

Александр Корабельников, Россельхозбанк.

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


  1. igrishaev
    15.09.2022 09:44

    Делал похожее на 1С. Все сводится к словарям падежей, родов и чисел. Самое сложное -- закрепить человека, который бы оперативно правил эти словари.


  1. a_si_lex
    15.09.2022 21:58
    +1

    Несколько лет, в качестве локального сервиса/микросервиса, используем https://morpher.ru/


    1. MansikM Автор
      15.09.2022 22:11

      Классное решение. Спасибо! Маленький минус в платности, но не критичный минус, крупная компания может купить. И ещё, скрытая реализация, например, для php нужно привязать *.so, а что там внутри - загадка. Я не говорю, что решение от morpher.ru хуже или лучше, это коммерческое решение, которое авторы защищают от просмотра кода, и нет возможности взглянуть под капот. Не каждая служба безопасности такое пропустит. Но, если у вас нет с этим проблем, то конечно лучше использовать более современное и коммерческое решение. phpMorphy в данном кейсе абсолютно прозрачен. Это критично для определённого сектора.

      Возможно, я ошибаюсь, буду рад Вашему ответу.


      1. a_si_lex
        15.09.2022 23:04

        Мы используем его как веб-сервис, взаимодействуя через API


        1. MansikM Автор
          16.09.2022 06:43

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


          1. MansikM Автор
            16.09.2022 08:36

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