<?php
/**
 * -------------------------------------------------------------------------
 *
 * Модуль парсера открытых данных с государственной платформы TrudVsem.ru.
 *
 * -------------------------------------------------------------------------
 *
 * @package    MimimiFramework
 * @subpackage Examples / Repost Vacancies
 * @license    GPL-2.0
 *             https://opensource.org/license/gpl-2-0/
 * @copyright  2022 MiMiMi Community
 *             https://mimimi.software/
 *
 * -------------------------------------------------------------------------
 */

    /**
     * ---------------------------------------------------------------------
     *
     * Подключаем из папки ядра фреймворка файл "mimimi.core/Module.php".
     * Там объявлен класс "MimimiModule", являющийся простейшей модульной
     * заготовкой. Этот класс подходит как основа для реализуемого ниже
     * модуля.
     *
     * ---------------------------------------------------------------------
     */

    mimimiInclude ( 'Module.php' );

    /**
     * ---------------------------------------------------------------------
     *
     * Создаём на основе класса той заготовки новый класс, в котором напишем
     * программный код текущего модуля. Обратите внимание как задано имя
     * нового класса - оно сложено из имени класса вышестоящего модуля, то
     * есть "MyMimimiControllersParser", и имени текущего PHP-файла без
     * расширения.
     *
     * ---------------------------------------------------------------------
     */

    class MyMimimiControllersParserTrudvsem extends MimimiModule {

        /**
         * -----------------------------------------------------------------
         *
         * Свойство: URL-ы запросов к разным видам открытых данных на
         *           платформе. Эти адреса указываются без GET-параметров,
         *           так как те будут взяты из настроек задачи парсинга в
         *           момент её выполнения.
         *
         * -----------------------------------------------------------------
         *
         * @var    string
         * @access protected
         *
         * -----------------------------------------------------------------
         */

        protected $urlVacancies      = 'https://trudvsem.ru/iblocks/_catalog/flat_filter_prr_search_vacancies/data';
        protected $urlVacancyDetails = 'https://trudvsem.ru/iblocks/job_card';

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Спарсить следующую страницу согласно задаче.
         *
         * -----------------------------------------------------------------
         *
         * С вызова этого метода начинается каждый шаг извлечения данных с
         * платформы TrudVsem.ru. Вызов происходит из вышестоящего модуля
         * "repost.vacancies/Controllers/Parser.php", смотрите там метод
         * parse().
         *
         * Метод принимает на входе запись о выполняемой задаче. Эта запись
         * содержит поле "type" с названием типа спарсиваемых данных.
         * Согласно ему, вызывается тот или иной метод парсинга.
         *
         * Сейчас метод обслуживает лишь единственный тип - "vacancies". Но
         * если в будущем вам понадобится парсить с этой платформы ещё типы
         * данных, вам придётся добавить их названия в switch конструкцию
         * ниже и написать методы парсинга к каждому добавленному типу.
         *
         * -----------------------------------------------------------------
         *
         * @public
         * @param   array  $task  (optional) Запись из таблицы БД о задаче, которую сейчас выполнием.
         * @return  array         Плоский список действий, совершённых парсером.
         *
         * -----------------------------------------------------------------
         */

        public function run ( $task = '' ) {
            $report = [ ];
            switch ( $task[ 'type' ] ) {
                case 'vacancies':
                     $report = $this->parseVacancies ( $task );
                     break;
            }
            return $report;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Спарсить следующую страницу вакансий.
         *
         * -----------------------------------------------------------------
         *
         * Работа этого метода состоит в следующем. Прежде всего вспоминаем
         * метрику задачи. То есть извлекаем из предоставленной записи о
         * задаче некие показания из её прошлого запуска. Например: какой
         * лист вакансий мы планировали качать в будущем, сколько там было
         * листов вообще, сколько есть вакансий в текущем разбираемом листе,
         * и тому подобные числа. Все они окажутся в переменной $metrics в
         * виде ассоциативного массива.
         *
         * На основе этой метрики строим URL листа вакансий, планируемого к
         * скачиванию, и качаем его. Данный лист представляет собой документ
         * в формате JSON. Обратите также внимание, что во время скачивания
         * мы передаём методу downloadVacancies() и саму метрику по ссылке,
         * чтобы метод обновил там некоторые показания о загруженном листе,
         * ведь с момента прежнего запуска число вакансий на текущем листе
         * в базе данных платформы могло измениться, значит далее нам лучше
         * пользоваться последними числами.
         *
         * Исходя из результата скачивания листа, мы сначала отрабатываем
         * возможные состояния ошибки (FALSE, ZERO, EMPTY STRING, NULL), в
         * каждом случае или сразу же закольцовывая сканирование или просто
         * переводя метрику к следующему листу/попытке.
         *
         * А при успешном скачивании листа мы строим URL вакансиии, которую
         * планировалось парсить следующей, исходя из прошлых показаний
         * метрики. Причём, обращаю внимание, здесь мы получаем по ссылке
         * ещё и $vid вакансии, чтобы несколькими строками ниже добавить его
         * как поле "vacancy_id" в сохраняемую запись, так как в JSON-е с
         * платформы отсутствует эта деталь. Затем Подобным же образом
         * отрабатываем сначала состяния ошибки (FALSE, NULL), так как URL
         * к текущему шагу мог исчезнуть из БД платформы или уже спарсен
         * нами ранее.
         *
         * Затем скачиваем вакансию, она тоже представлена документом в
         * формате JSON. Далее очищаем от лишних полей, так как с платформы
         * дополнительно поступает много ненужных нам данных. И сохраняем
         * "отмытую" вакансию в нашей базе данных, запостив её также в ТГ
         * канал. Переводим метрику к следующей вакансии.
         *
         * В конце сохраняем в базе данных изменившуюся метрику задачи,
         * чтобы при следующем шаге этой же задачи продолжить с места её
         * остановки.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $task  Запись из таблицы БД о задаче, которую сейчас выполнием.
         * @return  array         Плоский список действий, совершённых парсером.
         *
         * -----------------------------------------------------------------
         */

        protected function parseVacancies ( $task ) {
            $report  = [ ];
            $metrics = $this->recallMetrics ( $task );

            $url = $this->buildUrl ( $task, $metrics );
            if ( $url === FALSE ) {
                $this->disableTask ( $task );
                $report[ ] = '    └─> Ошибка: Неверная запись о задании!';
                $report[ ] = '        └─> Задание переводим в статус "неактивно".';
            } else {

                $list = $this->downloadVacancies ( $url, $metrics );
                $report[ ] = '    └─> Начинаем загрузку JSON-файла вакансий, лист ' . ( $metrics[ 'page_index' ] + 1 ) . ' из ' . $metrics[ 'page_count' ] . '.';

                if ( $list === FALSE ) {
                    $metrics   = $this->resetMetrics ( );
                    $report[ ] = '        └─> Ошибка: Не удалось загрузить файл или там не JSON!';
                    $report[ ] = '            └─> Метрику задания сбрасываем на первый лист.';
                } else if ( $list === 0 ) {
                    $this->nextRetry ( $metrics );
                    $report[ ] = '        └─> Ошибка: Какой-то сбой у провайдера открытых данных!';
                    $report[ ] = '            └─> Метрику задания переводим на попытку ' . ( 3 - $metrics[ 'retry_count' ] ) . ' из 3.';
                } else if ( $list === '' ) {
                    $metrics   = $this->resetMetrics ( );
                    $report[ ] = '        └─> Ошибка: JSON не соответствует ожидаемой структуре!';
                    $report[ ] = '            └─> Метрику задания сбрасываем на первый лист.';
                } else if ( $list === NULL ) {
                    $this->nextPage ( $metrics );
                    $report[ ] = '        └─> Ошибка: Запрошенный лист JSON не имеет списка вакансий!';
                    $report[ ] = '            └─> Метрику задания переводим на следующий лист.';
                } else {
                    $report[ ] = '        └─> Ок! Этот лист перечисляет ' . count ( $list ) . ' указателей на вакансии.';

                    $cid  = '';
                    $vid  = '';
                    $name = '';
                    $url  = $this->buildVacancyUrl ( $list, $task, $metrics, $cid, $vid, $name );
                    $report[ ] = '            ├─> Анализируем на листе указатель вакансии №' . ( $metrics[ 'item_index' ] + 1 ) . ' из ' . $metrics[ 'item_count' ] . '.';
                    $report[ ] = '            ├─> Её идентификатор у провайдера: ' . $vid  . '.';
                    $report[ ] = '            └─> Название: '                      . $name . '.';

                    if ( $url === FALSE ) {
                        $this->nextPage ( $metrics );
                        $report[ ] = '                └─> Упс, этот лист закончился!';
                        $report[ ] = '                    └─> Метрику задания переводим на следующий лист.';
                    } else if ( $url === 0 ) {
                        $this->nextVacancy ( $metrics );
                        $report[ ] = '                └─> Ошибка: Указатель не соответствует ожидаемой структуре!';
                        $report[ ] = '                    └─> Метрику задания переводим на следующий указатель.';
                    } else if ( $url === NULL ) {
                        $this->nextVacancy ( $metrics );
                        $report[ ] = '                └─> Ок! Пропускаем, так как эта вакансия уже спарсена ранее.';
                        $report[ ] = '                    └─> Метрику задания переводим на следующий указатель.';
                    } else {

                        $item = $this->downloadVacancy ( $url );
                        $report[ ] = '                └─> Ок! Эту вакансию видим впервые, начинаем загрузку её JSON-файла.';

                        if ( $item === FALSE ) {
                            $this->nextVacancy ( $metrics );
                            $report[ ] = '                    └─> Ошибка: Не удалось загрузить файл или там не JSON!';
                            $report[ ] = '                        └─> Метрику задания переводим на следующий указатель.';
                        } else if ( $item === 0 ) {
                            $this->nextRetry ( $metrics );
                            $report[ ] = '                    └─> Ошибка: Какой-то сбой у провайдера открытых данных!';
                            $report[ ] = '                        └─> Метрику задания переводим на попытку ' . ( 3 - $metrics[ 'retry_count' ] ) . ' из 3.';
                        } else if ( $item === NULL ) {
                            $this->nextVacancy ( $metrics );
                            $report[ ] = '                    └─> Ошибка: Запрошенный JSON не имеет данных вакансии!';
                            $report[ ] = '                        └─> Метрику задания переводим на следующий указатель.';
                        } else {

                            $item = $this->dryVacancy ( $item );
                            $item[ 'company_id' ] = $cid;
                            $item[ 'vacancy_id' ] = $vid;
                            $report[ ] = '                    ├─> Чистим вакансию от неожидаемых полей.';

                            $message_ids = [ 'telegram' => 0 ,
                                             'max'      => 0 ];
                            if ( $task[ 'tg_chat_id' ] ) {
                                $message_ids[ 'telegram' ] = $this->app->views->telegram->run ( [ 'chat_id' => $task[ 'tg_chat_id' ] ,
                                                                                                  'module'  => $task[ 'module'     ] ,
                                                                                                  'type'    => 'vacancy'             ,
                                                                                                  'row'     => $item                 ] );
                                $report[ ] = '                    ├─> Отправляем в Телеграм канал @' . $task[ 'tg_chat_id' ] . '.';
                            }
                            if ( $task[ 'max_chat_id' ] ) {
                                $message_ids[ 'max' ] = $this->app->views->max->run ( [ 'chat_id' => $task[ 'max_chat_id' ] ,
                                                                                        'module'  => $task[ 'module'      ] ,
                                                                                        'type'    => 'vacancy'              ,
                                                                                        'row'     => $item                  ] );
                                $report[ ] = '                    ├─> Отправляем в Max канал @' . $task[ 'max_chat_id' ] . '.';
                            }

                            $this->saveVacancy ( $item, $task, $message_ids );
                            $this->nextVacancy ( $metrics );
                            $report[ ] = '                    └─> Сохраняем вакансию у себя в базе данных.';
                            $report[ ] = '                        └─> Метрику задания переводим на следующий указатель.';
                        }
                    }
                }
            }
            $this->updateTask ( $task, $metrics );
            return $report;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Вспомнить метрику задачи.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается в начале каждого шага извлечения данных с
         * платформы TrudVsem.ru. Он позволяет получить некоторые числовые
         * показатели из прошлого запуска указанной на входе задачи.
         *
         * Обратите внимание, что мы применили конструкцию try-catch, чтобы
         * избежать падения приложения, если содержимое необходимой колонки
         * оказалось испорченным. Тогда считаем эту ситуацию равной началу
         * сканирования, то есть с первого листа с первой вакансии.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $task  Запись из таблицы БД о задаче, которую собираемся выполнять.
         * @return  array         Прежние метрические показатели задачи.
         *
         * -----------------------------------------------------------------
         */

        protected function recallMetrics ( $task ) {
            try {
                $params = @ json_decode ( $task[ 'metrics' ], TRUE );
            } catch ( Exception $e ) {
                $params = [ ];
            }
            $fromFirst = $this->resetMetrics ( );
            return array_merge ( $fromFirst, (array) $params );
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Получить начальную метрику задачи.
         *
         * -----------------------------------------------------------------
         *
         * Метрика имеет поля: индекс разбираемого листа вакансий,
         *                     количество листов в списке вакансий,
         *                     индекс разбираемой вакансии с текущего листа,
         *                     количество вакансий в текущем листе,
         *                     число повторов того же запроса при ошибке.
         *
         * Обратите внимание, не номер, а именно индекс, то есть номер-1.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @return  array  Метрика задачи с нулевыми показатели.
         *
         * -----------------------------------------------------------------
         */

        protected function resetMetrics ( ) {
            return [ 'page_index'  => 0 ,
                     'page_count'  => 0 ,
                     'item_index'  => 0 ,
                     'item_count'  => 0 ,
                     'retry_count' => 3 ];
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Перевести метрику задачи на следуюущую попытку.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $metrics  (by-reference) Текущие метрические показатели задачи.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function nextRetry ( & $metrics ) {
            $metrics[ 'retry_count' ]--;
            if ( $metrics[ 'retry_count' ] < 0 ) {
                $this->nextVacancy ( $metrics );
            }
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Перевести метрику задачи на следуюущую вакансию.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $metrics  (by-reference) Текущие метрические показатели задачи.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function nextVacancy ( & $metrics ) {
            $metrics[ 'retry_count' ] = 3;
            $metrics[ 'item_index'  ]++;
            if ( $metrics[ 'item_index'  ] >= $metrics[ 'item_count' ] ) {
                $this->nextPage ( $metrics );
            }
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Перевести метрику задачи на следуюущий лист вакансий.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $metrics  (by-reference) Текущие метрические показатели задачи.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function nextPage ( & $metrics ) {
            $metrics[ 'retry_count' ] = 3;
            $metrics[ 'item_index'  ] = 0;
            $metrics[ 'item_count'  ] = 0;
            $metrics[ 'page_index'  ]++;
            if ( $metrics[ 'page_index'  ] >= $metrics[ 'page_count' ] ) {
                $metrics = $this->resetMetrics ( );
            }
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Собрать URL запроса к текущему листу вакансий.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается только мз метода parseVacancies() выше.
         * Здесь мы проверяем, что в предоставленной записи о задаче указаны
         * GET-параметры для URL запроса. И если это так, тогда формируем
         * полный URL. Иначе метод parseVacancies() воспримет отсутствие как
         * сбой и переведёт задачу в статус "неактивная".
         *
         * Обратите внимание, GET-параметры могут храниться в базе данных
         * отформатированными пробелами для удобного чтения. Это зависит от
         * предпочтения администратора, который создаёт задачу парсинга. Мы
         * во время сборки URL убираем из параметров все пробельные символы.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array        $task     Запись из таблицы БД о задаче, которую собираемся выполнять.
         * @param   array        $metrics  Метрические показатели этой задачи из её прошлого запуска.
         * @return  string|bool            Одно из: STRING если URL удалось собрать.
         *                                          FALSE  если в записи о задаче отсутствует колонка параметров запроса.
         *
         * -----------------------------------------------------------------
         */

        protected function buildUrl ( $task, $metrics ) {
            $params = isset ( $task[ 'params' ] ) ? preg_replace ( '~\s~u', '', $task[ 'params' ] )
                                                  : FALSE;
            return $params ? ( $this->urlVacancies . '?' . $params . '&page=' . $metrics[ 'page_index' ] )
                           : FALSE;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Выключить задачу.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $task  (by-reference) Запись из таблицы БД о задаче, которую только что выполняли.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function disableTask ( & $task ) {
            $task[   'active' ] =  FALSE;
            $row = [ 'active'   => FALSE ];
            $id  = $task[ 'id' ];
            $this->app->models->tasks->update ( $id, $row );
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Скачать JSON лист вакансий.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается только мз метода parseVacancies() выше.
         * Здесь мы пытаемся скачать JSON документ вакансий из указанного
         * URL. Для этого задействуем метод downloadJSON() вышестоящего
         * модуля, то есть owner-а по отношению к текущему модулю.
         *
         * После загрузки листа вакансий мы проверяем, что его содержимое
         * имеет следуюущую структуру:
         *
         *     [
         *         'result' => [
         *             'code' => 'SUCCESS',
         *             'data' => [
         *                 [...ВАКАНСИЯ...],
         *                 [...ВАКАНСИЯ...],
         *                 [...ВАКАНСИЯ...]
         *             ],
         *             'paging' => [
         *                 'total'    => ЧИСЛО,
         *                 'current'  => ЧИСЛО,
         *                 'pageSize' => ЧИСЛО,
         *                 'pages'    => ЧИСЛО
         *             ]
         *         ]
         *     ]
         *
         * Несовпадение структуры будет воспринято методом parseVacancies()
         * как сбой системный, что приведёт к зацикливанию метрики, то есть
         * переводу её на повторный парсинг листов вакансий с самого начала.
         *
         * Совпадение же структуры, но имеющийся код результата, не равный
         * "SUCCESS", будет воспринят методом parseVacancies() как временная
         * ошибка платформы (бывает что сервер подвисает из-за нагрузки),
         * что приведёт к переводу метрики на повторную попытку.
         *
         * Совпадение структуры и отсутствие вакансий в поле "data" приведёт
         * к переводу метрики на следующий лист вакансий.
         *
         * Обратите также внимание, мы применили конструкцию try-catch плюс
         * intval, чтобы избежать падения приложения, поскольку парсим в
         * этот момент данные, соответствие которых не контролируем. То есть
         * это попытка устоять, если на той стороне что-то пошло не так.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   string                      $url      URL скачиваемого листа.
         * @param   array                       $metrics  (by-reference) Метрические показатели задачи из её прошлого запуска.
         * @return  array|bool|int|string|null            Одно из: ARRAY OF VACANCIES если лист удалось скачать и распарсить без ошибок.
         *                                                         FALSE              если скачать не удалось или там был не JSON документ       (то есть неформат или сбой).
         *                                                         ZERO               если это JSON документ, но платформа сообщает об ошибке    (то есть попробуйте позже).
         *                                                         EMPTY STRING       если это JSON документ, но платформа не передала пагинацию (то есть неформат).
         *                                                         NULL               если это JSON документ, но платформа не передала вакансии  (то есть лист пустой).
         *
         * -----------------------------------------------------------------
         */

        protected function downloadVacancies ( $url, & $metrics ) {
            $data = $this->owner->downloadJSON ( $url );
            if ( isset ( $data[ 'result' ][ 'code' ] )            ) {
                if (     $data[ 'result' ][ 'code' ] == 'SUCCESS' ) {
                    if ( isset ( $data[ 'result' ][ 'paging' ][ 'pages' ] ) ) {
                        try {
                            $count = $data[ 'result' ][ 'paging' ][ 'pages' ];
                            $count = is_numeric ( $count ) ? intval ( $count )
                                                           : 0;
                        } catch ( Exception $e ) {
                            $count = 0;
                        }
                        $metrics = array_merge ( $metrics, [ 'page_count' => $count ,
                                                             'item_count' => 0      ] );
                        if ( isset    ( $data[ 'result' ][ 'data' ] )
                        &&   is_array ( $data[ 'result' ][ 'data' ] ) ) {
                            $metrics[ 'item_count' ] = count ( $data[ 'result' ][ 'data' ] );
                            return                             $data[ 'result' ][ 'data' ];
                        }
                        return NULL;
                    }
                    return '';
                }
                return 0;
            }
            return FALSE;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Собрать URL запроса к текущей вакансии.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array                 $list     Список вакансий, находящихся на текущем листе.
         * @param   array                 $task     Запись из таблицы БД о задаче, которую сейчас выполняем.
         * @param   array                 $metrics  Метрические показатели этой задачи из её прошлого запуска.
         * @param   string                $cid      (by-reference) В какую переменную вернуть публичный ИД организации, то есть как этот ИД записан на платформе.
         * @param   string                $vid      (by-reference) В какую переменную вернуть публичный ИД вакансии, то есть как он записан на платформе.
         * @param   string                $name     (by-reference) В какую переменную вернуть название вакансии.
         * @return  string|bool|int|null            Одно из: STRING если URL удалось собрать.
         *                                                   FALSE  если такой вакансии нет в списке        (то есть лист окончен).
         *                                                   ZERO   если вакансия есть, но нет нужного поля (то есть неформат).
         *                                                   NULL   если вакансия уже спарсена ранее        (то есть идём дальше).
         *
         * -----------------------------------------------------------------
         */

        protected function buildVacancyUrl ( $list, $task, $metrics, & $cid, & $vid, & $name ) {
            if ( isset ( $list[ $metrics[ 'item_index' ] ] ) ) {
                $row =   $list[ $metrics[ 'item_index' ] ];
                if ( isset ( $row[ 0 ] )
                &&   isset ( $row[ 1 ] )
                &&   isset ( $row[ 2 ] ) ) {
                    $cid  = $row[ 2 ];
                    $vid  = $row[ 0 ];
                    $name = $row[ 1 ];
                    $test = $this->app->models->vacancies->findBy ( $vid, TRUE );
                    if ( ! $test ) {
                        return $this->urlVacancyDetails . '?companyId=' . $cid
                                                        . '&vacancyId=' . $vid;
                    }
                    $this->updateKilltime ( $test, $task );
                    return NULL;
                }
                return 0;
            }
            return FALSE;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Скачать JSON документ деталей вакансии.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается только мз метода parseVacancies() выше.
         * Здесь мы пытаемся скачать JSON документ вакансии из указанного
         * URL. Для этого задействуем метод downloadJSON() вышестоящего
         * модуля, то есть owner-а по отношению к текущему модулю.
         *
         * После загрузки вакансии мы проверяем, что её содержимое имеет
         * следуюущую структуру:
         *
         *     [
         *         'code' => 'SUCCESS',
         *         'data' => [
         *             'vacancy' => [
         *                 'vacancyName'  => СТРОКА,
         *                 ...ПАРАМЕТР... => ЗНАЧЕНИЕ,
         *                 ...ПАРАМЕТР... => ЗНАЧЕНИЕ
         *             ],
         *             'vacanciesCompany'      => ЧИСЛО,
         *             'educationCourses'      => МАССИВ,
         *             'availableResponse'     => ФЛАГ,
         *             'contactAvailable'      => ФЛАГ,
         *             'aboutCompanyAvailable' => ФЛАГ
         *         ],
         *         'details' => МАССИВ
         *     ]
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   string               $url  URL скачиваемой вакансии.
         * @return  array|bool|int|null        Одно из: ARRAY если вакансию удалось скачать и распарсить без ошибок.
         *                                              FALSE если скачать не удалось или там был не JSON документ      (то есть неформат или сбой).
         *                                              ZERO  если это JSON документ, но платформа сообщает об ошибке   (то есть попробуйте позже).
         *                                              NULL  если это JSON документ, но платформа не передала вакансию (то есть документ пустой).
         *
         * -----------------------------------------------------------------
         */

        protected function downloadVacancy ( $url ) {
            $data = $this->owner->downloadJSON ( $url );
            if ( isset ( $data[ 'code' ] )            ) {
                if (     $data[ 'code' ] == 'SUCCESS' ) {
                    if ( isset ( $data[ 'data' ][ 'vacancy' ][ 'vacancyName' ] ) ) {
                        return   $data[ 'data' ][ 'vacancy' ];
                    }
                    return NULL;
                }
                return 0;
            }
            return FALSE;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Очистить вакансию от лишних полей.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается из метода parseVacancies() выше, чтобы при
         * парсинге файла вакансии получить такую структуру данных, какую
         * ожидаем видеть от получателя, которого мы не контролируем. Иначе
         * говоря, это попытка устоять, если на той стороне что-то не так.
         *
         * Здесь мы просто описываем имена ожидаемых полей и их тип. Затем
         * проходимся по предоставленной записи, извлекая лишь нужные поля.
         * Если какие-то из них отсутствуют, добавляем как пустые. В итоге
         * имеем запись, отмытую от неожиданных полей, а типы ожидавшихся
         * приведены к оговоренному виду.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $row  Запись о вакансии, полученная из только что спарсенного JSON документа.
         * @return  array        "Отмытая" запись.
         *
         * -----------------------------------------------------------------
         */

        protected function dryVacancy ( $row ) {
            $item  = [ ];
            $valid = [ 'changeTime'                   => 'timestamp'           ,
                       'vacancyNumber'                => 'string'              ,
                       'vacancyName'                  => 'string'              ,
                       'benefits'                     => 'flatlist'            ,
                       'bonusType'                    => 'string'              ,
                       'busyType'                     => 'string'              ,
                       'contactPerson'                => 'string'              ,
                       'contacts'                     => 'array/Email,Телефон' ,
                       'skills'                       => 'arrayOf/text'        ,
                       'educationSpeciality'          => 'string'              ,
                       'educationType'                => 'string'              ,
                       'fullCompanyName'              => 'string'              ,
                       'languageKnowledge'            => 'indexlist'           ,
                       'medCard'                      => 'string'              ,
                       'positionResponsibilities'     => 'html'                ,
                       'positionRequirements'         => 'html'                ,
                       'additionalRequirements'       => 'html'                ,
                       'qualification'                => 'string'              ,
                       'profession'                   => 'string'              ,
                       'publishedDate'                => 'timestamp'           ,
                       'requiredDriveLicense'         => 'flatlist'            ,
                       'requiredCertificates'         => 'html'                ,
                       'requiredExperience'           => 'int'                 ,
                       'maxExperience'                => 'int'                 ,
                       'salaryMin'                    => 'int'                 ,
                       'salaryMax'                    => 'int'                 ,
                       'scheduleType'                 => 'string'              ,
                       'socialProtected'              => 'flatlist'            ,
                       'sourceType'                   => 'string'              ,
                       'stateRegion'                  => 'string'              ,
                       'stateRegionCode'              => 'string'              ,
                       'fullAddress'                  => 'string'              ,
                       'vacancyAddressAdditionalInfo' => 'html'                ,
                       'vacancyAddressLatitude'       => 'float'               ,
                       'vacancyAddressLongitude'      => 'float'               ,
                       'workPlaces'                   => 'int'                 ,
                       'startWorking'                 => 'string'              ,
                       'endWorking'                   => 'string'              ,
                       'companyDTO'                   => 'array/name'          ,
                       'videoInterviewingMandatory'   => 'bool'                ,
                       'contactsFromCZN'              => 'bool'                ,
                       'originalVacancySource'        => 'string'              ,
                       'isMilitaryReprieve'           => 'bool'                ,
                       'status'                       => 'string'              ,
                       'isTajikistanRecruitment'      => 'bool'                ,
                       'isUzbekistanRecruitment'      => 'bool'                ,
                       'isForeignCitizenAccess'       => 'bool'                ,
                       'createdTimestamp'             => 'timestamp'           ,
                       'modifiedTimestamp'            => 'timestamp'           ,
                       'typicalPositionId'            => 'string'              ,
                       'typicalPositionName'          => 'string'              ,
                       'workPlaceOrdinary'            => 'bool'                ,
                       'workPlaceQuota'               => 'bool'                ,
                       'workPlaceSpecial'             => 'bool'                ,
                       'hireDate'                     => 'timestamp'           ,
                       'disabilityGroup'              => 'array/name'          ,
                       'workingCondition'             => 'array/name'          ,
                       'medicalDocument'              => 'array/text'          ,
                       'detailedInfoAgreement'        => 'bool'                ,
                       'workScheduleType'             => 'array/name'          ,
                       'professionalSphere'           => 'array/name'          ,
                       'trainingDays'                 => 'int'                 ];
            foreach ( $row as $key => $value ) {
                if ( isset ( $valid[ $key ] ) ) {
                    switch ( $valid[ $key ] ) {
                        case 'bool':
                             $item[ $key ] = $value == TRUE;
                             break;
                        case 'timestamp':
                        case 'int':
                             $item[ $key ] = is_numeric ( $value ) ? intval ( $value )
                                                                   : 0;
                             break;
                        case 'float':
                             $item[ $key ] = is_numeric ( $value ) ? floatval ( $value )
                                                                   : 0;
                             break;
                        case 'string':
                             $item[ $key ] = (string) $value;
                             break;
                        case 'html':
                             $value = preg_replace ( '~<\?.*?\?>~us',                   '',    (string) $value );
                             $value = preg_replace ( '~<\?.*$~ui',                      '',             $value );
                             $value = preg_replace ( '~<!--.*?-->~ui',                  '',             $value );
                             $value = preg_replace ( '~<!--.*$~ui',                     '',             $value );
                             $value = preg_replace ( '~&nbsp;~ui',                      ' ',            $value );
                             $value = preg_replace ( '~(</?[a-z][a-z\d]*)\s[^>]*>?~ui', '$1>',          $value );
                             $value = preg_replace ( '~</?[^poul][^>]*>~ui',            ' ',            $value );
                             $value = preg_replace ( '~\s+~u',                          ' ',            $value );
                             $value = preg_replace ( '~(^\s+|\s+$)~u',                  '',             $value );
                             $item[ $key ] = $value;
                             break;
                        case 'flatlist':
                             $item[ $key ] = [ ];
                             $value = (array) $value;
                             foreach ( $value as $v ) {
                                 $item[ $key ][ ] = (string) $v;
                             }
                             break;
                        case 'indexlist':
                             $item[ $key ] = [ ];
                             $value = (array) $value;
                             foreach ( $value as $k => $v ) {
                                 $item[ $key ][ $k ] = (string) $v;
                             }
                             break;
                        case 'array':
                             $item[ $key ] = (array) $value;
                             break;
                        case 'array/name':
                             $value = (array) $value;
                             $item[ $key ] = isset ( $value[ 'name' ] ) ? [ 'name' => (string) $value[ 'name' ] ]
                                                                        : [ 'name' => ''                        ];
                             break;
                        case 'array/text':
                             $value = (array) $value;
                             $item[ $key ] = isset ( $value[ 'text' ] ) ? [ 'text' => (string) $value[ 'text' ] ]
                                                                        : [ 'text' => ''                        ];
                             break;
                        case 'array/Email,Телефон':
                             $value = (array) $value;
                             if ( isset ( $value[ 'Email' ] ) ) {
                                 $item[ $key ] = isset ( $value[ 'Телефон' ] ) ? [ 'Email'   => (string) $value[ 'Email'   ] ,
                                                                                   'Телефон' => (string) $value[ 'Телефон' ] ]
                                                                               : [ 'Email'   => (string) $value[ 'Email'   ] ,
                                                                                   'Телефон' => ''                           ];
                             } else {
                                 $item[ $key ] = isset ( $value[ 'Телефон' ] ) ? [ 'Email'   => ''                           ,
                                                                                   'Телефон' => (string) $value[ 'Телефон' ] ]
                                                                               : [ 'Email'   => ''                           ,
                                                                                   'Телефон' => ''                           ];
                             }
                             break;
                        case 'arrayOf/text':
                             $item[ $key ] = [ ];
                             $value = (array) $value;
                             foreach ( $value as $v ) {
                                 $v = (array) $v;
                                 if ( isset ( $v[ 'text' ] ) ) {
                                     $item[ $key ][ ] = [ 'text' => (string) $v[ 'text' ] ];
                                 }
                             }
                             break;
                    }
                }
            }
            foreach ( $valid as $key => $value ) {
                if ( ! isset ( $item[ $key ] ) ) {
                    switch ( $value ) {
                        case 'bool':                $item[ $key ] = FALSE; break;
                        case 'timestamp':
                        case 'int':                 $item[ $key ] = 0;     break;
                        case 'float':               $item[ $key ] = 0;     break;
                        case 'string':              $item[ $key ] = '';    break;
                        case 'html':                $item[ $key ] = '';    break;
                        case 'flatlist':
                        case 'indexlist':
                        case 'array':               $item[ $key ] = [ ];   break;
                        case 'array/name':          $item[ $key ] = [ 'name' => '' ]; break;
                        case 'array/text':          $item[ $key ] = [ 'text' => '' ]; break;
                        case 'array/Email,Телефон': $item[ $key ] = [ 'Email' => '', 'Телефон' => '' ]; break;
                        case 'arrayOf/text':        $item[ $key ] = [ ];   break;
                    }
                }
            }
            return $item;
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Сохранить вакансию в базе данных.
         *
         * -----------------------------------------------------------------
         *
         * Данные вакансии упаковываются в формат JSON и сохраняются в
         * в соответствующей колонке таблицы базы данных. Имя колонки равно
         * "content". Одновременно сохраняем и коды региона/города, чтобы на
         * клиентской стороне сайта можно было отобрать вакансию в список на
         * странице города.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $row   Запись о вакансии, уже "отмытая" из спарсенного JSON документа.
         * @param   array  $task  Запись о задаче, которую только что выполняли.
         * @param   array  $ids   ИДы сообщений, созданных в мессенджерах для этой вакансии.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function saveVacancy ( $row, $task, $ids ) {
            $last = time ( ) + $task[ 'lifetime' ] * 24 * 60 * 60;
            $json = json_encode ( $row );
            $row  = [ 'vacancy_id'     => $row[  'vacancy_id'      ]    ,
                      'module'         => $task[ 'module'          ]    ,
                      'region'         => $row[  'stateRegionCode' ]    ,
                      'district'       => $task[ 'filter_value'    ]    ,
                      'town'           => $task[ 'filter_name'     ]    ,
                      'content'        => $json                         ,
                      'source_url'     => 'https://trudvsem.ru/vacancy/card/' . $row[ 'company_id' ] . '/' . $row[ 'vacancy_id' ] ,
                      'via_task'       => $task[ 'id'              ]    ,
                      'tg_message_id'  => $ids[ 'telegram'         ]    ,
                      'max_message_id' => $ids[ 'max'              ]    ,
                      'killtime'       => date ( 'Y-m-d H:i:s', $last ) ,
                      'active'         => TRUE                          ];
            $this->app->models->vacancies->add ( $row );
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Обновить срок истечения вакансии.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод обновляет единственнную колонку в записи о вакансии.
         * Он вызывается из метода buildVacancyUrl() выше ровно в тот момент,
         * когда во время парсинга установлено, что такая вакансия уже была
         * спарсена ранее. Так как таблица вакансий периодически вычищается
         * от устаревших записей, подобное обновление срока истечения даёт
         * удерживать в таблице записи, чьи вакансии всё ещё существуют в
         * данных парсинга. Иначе вакансия автоматически удалится, если на
         * всём сроке её истечения она ни разу более не упоминалась в данных
         * парсинга.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $row   Запись о вакансии, которую только что удалось отыскать по значению колонки "vacancy_id".
         * @param   array  $task  Запись о задаче, которую сейчас выполняем.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function updateKilltime ( $row, $task ) {
            $id   = $row[ 'id' ];
            $last = time ( ) + $task[ 'lifetime' ] * 24 * 60 * 60;
            $row  = [ 'killtime' => date ( 'Y-m-d H:i:s', $last ) ];
            $this->app->models->vacancies->update ( $id, $row );
        }

        /**
         * -----------------------------------------------------------------
         *
         * Метод: Сохранить метрику задачи.
         *
         * -----------------------------------------------------------------
         *
         * Этот метод вызывается в конце каждого шага извлечения данных с
         * платформы TrudVsem.ru. Ведь к концу шага мы продвинулись либо по
         * номеру вакансии из текущего листа, либо по номеру листа, либо же
         * вернулись по закольцовке в начало сканирования вакансий.
         *
         * Новые показатели задачи упаковываются в формат JSON и сохраняются
         * в соответствующей колонке записи. Её имя "metrics". Обратите
         * внимание, что при сохранении мы готовим усечённую копию записи
         * только с нужной колонкой, а не полную из переменной $task. Эта
         * мера принята лишь для снижения нагрузки на БД, чтобы избежать ещё
         * и сохранения всех неизменившихся колонок.
         *
         * -----------------------------------------------------------------
         *
         * @protected
         * @param   array  $task     (by-reference) Запись из таблицы БД о задаче, которую только что выполняли.
         * @param   array  $metrics  Метрические показатели задачи на момент окончания парсинга.
         * @return  void
         *
         * -----------------------------------------------------------------
         */

        protected function updateTask ( & $task, $metrics ) {
            $json = json_encode ( $metrics );
            $task[   'metrics' ] =  $json;
            $row = [ 'metrics'   => $json ];
            $id  = $task[ 'id' ];
            $this->app->models->tasks->update ( $id, $row );
        }
    }
