Пишем телеграм бота-парсера вакансий на JS



    Тема создания ботов для Telegram становится все более популярной, привлекая программистов попробовать свои силы на этом поприще. У каждого периодически возникают идеи и задачи, которые можно решить, написав тематического бота. Для меня, как программиста на JS, пример такой актуальной задачи — мониторинг рынка вакансий по соответствующей тематике.

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

    Задача


    Основная задача: создать детализированную ленту вакансий с тегированием и приятной визуальной разметкой. Ее можно разбить на отдельные подзадачи:

    • взаимодействие с Telegram API;
    • парсинг RSS-лент сайтов с вакансиями;
    • парсинг отдельно взятой вакансии;
    • тематическое тегирование;
    • визуальное оформление информации;
    • предотвращение дублирования.

    Сначала я думал использовать универсального готового бота, например, @TheFeedReaderBot. Но после его детального изучения выяснилось, что тегирование полностью отсутствует, а возможности по настройке отображения контента сильно ограничены. К счастью, современный Javascript предоставляет множество библиотек, которые помогут решить эти проблемы. Но обо всем по порядку.

    Каркас бота


    Конечно, можно было бы напрямую взаимодействовать с REST API Telegram, но с точки зрения трудозатрат проще взять готовые решения. Поэтому я выбрал npm-пакет slimbot, на который ссылаются официальные туториалы по созданию ботов. И хотя мы будем только отправлять сообщения, этот пакет существенно упростит жизнь, позволив создать внутренний API бота как сущности:

    const Slimbot = require('slimbot');
    const config = require('./config.json');
    const bot = new Slimbot(config.TELEGRAM_API_KEY);
    
    bot.startPolling();
    
    function logMessageToAdmin(message, type='Error') {
        bot.sendMessage(config.ADMIN_USER, `<b>${type}</b>\n<code>${message}</code>`, {
            parse_mode: 'HTML'
        });
    }
    
    function postVacancy(message) {
        bot.sendMessage(config.TARGET_CHANNEL, message, {
            parse_mode: 'HTML',
            disable_web_page_preview: true,
            disable_notification: true
        });
    }
    
    module.exports = {
        postVacancy,
        logMessageToAdmin
    };
    

    В качестве планировщика будем использовать обычный setInterval, а для парсинга RSS – feed-read, а источником вакансий будут сайты «Мой круг» и hh.ru.

    const feed = require("feed-read");
    const config = require('./config.json');
    const HhAdapter = require('./adapters/hh');
    const MoikrugAdapter = require('./adapters/moikrug');
    const bot = require('./bot');
    const { FeedItemModel } = require('./lib/models');
    
    function processFeed(articles, adapter) {
      articles.forEach(article => {
        if (adapter.isValid((article))) {
          const key = adapter.getKey(article);
          new FeedItemModel({
            key,
            data: article
          }).save().then(
            model => adapter.parseItem(article).then(bot.postVacancy),
            () => {}
          );
        }
      });
    }
    
    setInterval(() => {
        feed(config.HH_FEED, function (err, articles) {
            if (err) {
                bot.logMessageToAdmin(err);
                return;
            }
            processFeed(articles, HhAdapter);
        });
    
        feed(config.MOIKRUG_FEED, function (err, articles) {
            if (err) {
                bot.logMessageToAdmin(err);
                return;
            }
    
            processFeed(articles, MoikrugAdapter);
        });
    }, config.REQUEST_PERIOD_TIME);
    

    Парсинг отдельно взятой вакансии


    Из-за различной структуры страниц с вакансиями для каждого сайта-источника реализация парсинга своя. Поэтому в ход пошли адаптеры, предоставляющие унифицированный интерфейс. Для работы с DOM на сервере подошла библиотека jsdom, с которой можно выполнять стандартные операции: нахождение элемента по CSS-селектору, получение содержимого элемента, которые мы активно используем.

    MoikrugAdapter
    const request = require('superagent');
    const jsdom = require('jsdom');
    const { JSDOM } = jsdom;
    const { getTags } = require('../lib/tagger');
    const { getJobType } = require('../lib/jobType');
    const { render } = require('../lib/render');
    
    function parseItem(item) {
    
        return new Promise((resolve, reject) => {
            request
                .get(item.link)
                .end(function(err, res) {
                    if(err) {
                        console.log(err);
                        reject(err);
                        return;
                    }
    
                    const dom = new JSDOM(res.text);
                    const element = dom.window.document.querySelector(".vacancy_description");
                    const salaryElem =  dom.window.document.querySelector(".footer_meta .salary");
                    const salary = salaryElem ? salaryElem.textContent : 'Не указана.';
                    const locationElem =  dom.window.document.querySelector(".footer_meta .location");
                    const location = locationElem && locationElem.textContent;
                    const title =  dom.window.document.querySelector(".company_name").textContent;
                    const titleFooter =  dom.window.document.querySelector(".footer_meta").textContent;
                    const pureContent = element.textContent;
    
                    resolve(render({
                        tags: getTags(pureContent),
                        salary: `ЗП: ${salary}`,
                        location,
                        title,
                        link: item.link,
                        description: element.innerHTML,
                        jobType: getJobType(titleFooter),
                        important: Array.from(element.querySelectorAll('strong')).map(e => e.textContent)
                    }))
                });
        });
    }
    
    function getKey(item) {
        return item.link;
    }
    
    function isValid() {
        return true
    }
    
    module.exports = {
        getKey,
        isValid,
        parseItem
    };
    


    HhAdapter
    const request = require('superagent');
    const jsdom = require('jsdom');
    const { JSDOM } = jsdom;
    const { getTags } = require('../lib/tagger');
    const { getJobType } = require('../lib/jobType');
    const { render } = require('../lib/render');
    
    function parseItem(item) {
        const splited = item.content.split(/\n<p>|<\/p><p>|<\/p>\n/).filter(i => i);
        const [
            title,
            date,
            region,
            salary
        ] = splited;
    
        return new Promise((resolve, reject) => {
            request
                .get(item.link)
                .end(function(err, res) {
                    if(err) {
                        console.log(err);
                        reject(err);
                        return;
                    }
    
                    const dom = new JSDOM(res.text);
                    const element = dom.window.document.querySelector('.b-vacancy-desc-wrapper');
                    const title = dom.window.document.querySelector('.companyname').textContent;
                    const pureContent = element.textContent;
                    const tags = getTags(pureContent);
    
                    resolve(render({
                        title,
                        location: region.split(': ')[1] || region,
                        salary: `ЗП: ${salary.split(': ')[1] || salary}`,
                        tags,
                        description: element.innerHTML,
                        link: item.link,
                        jobType: getJobType(pureContent),
                        important: Array.from(element.querySelectorAll('strong')).map(e => e.textContent)
                    }))
                });
        });
    }
    
    function getKey(item) {
        return item.link;
    }
    
    function isValid() {
        return true
    }
    
    module.exports = {
        getKey,
        isValid,
        parseItem
    };
    


    Форматирование


    После парсинга нужно представить информацию в удобном виде, но с API Telegram не так много возможностей для этого: в сообщениях можно проставлять только теги и символы юникода (смайлики и стикеры не в счет). На входе получается пара смысловых полей в описании и само описание в «сыром» HTML. После недолгого поиска находим решение — библиотеку html-to-text. После детального изучения API и его реализации невольно удивляешься, почему функции форматирования вызываются не из динамического конфига, а через замыкание, что нивелирует многие плюсы, предоставленные конфигурационными параметрами. И чтобы красиво выводить bullets вместо li в списках, приходится немного схитрить:

    const htmlToText = require('html-to-text');
    const whiteSpaceRegex = /^\s*$/;
    
    function render({
        title, location, salary, tags, description, link, important = [], jobType='' 
    }) {
        let formattedDescription = htmlToText
            .fromString(description, {
                wordwrap: null,
                noLinkBrackets: true,
                hideLinkHrefIfSameAsText: true,
                format: {
                    unorderedList: function formatUnorderedList(elem, fn, options) {
                        let result = '';
                        const nonWhiteSpaceChildren = (elem.children || []).filter(
                            c => c.type !== 'text' || !whiteSpaceRegex.test(c.data)
                        );
                        nonWhiteSpaceChildren.forEach(function(elem) {
                            result += ' <b>●</b> ' + fn(elem.children, options) + '\n';
                        });
                        return '\n' + result + '\n';
                    }
                }
            })
            .replace(/\n\s*\n/g, '\n');
    
        important.filter(text => text.includes(':')).forEach(text => {
            formattedDescription = formattedDescription.replace(
                new RegExp(text, 'g'),
                `<b>${text}</b>`
            )
        });
    
        const formattedTags = tags.map(t => '#' + t).join(' ');
        const locationFormatted = location ? `#${location.replace(/ |-/g, '_')} `: '';
    
        return `<b>${title}</b>\n${locationFormatted}#${jobType}\n<b>${salary}</b>\n${formattedTags}\n${formattedDescription}\n${link}`;
    }
    
    module.exports = {
        render
    };
    

    Тегирование


    Допустим, у нас есть красивые описания вакансий, но не хватает тегирования. Чтобы решить этот вопрос, я токенизировал естественный русский язык с помощью библиотеки az. Так у меня получилась фильтрация слов в потоке токенов и замена тегами при наличии соответствующих слов в словаре тегов.

    const Az = require('az');
    const namesMap = require('../resources/tagNames.json');
    
    function onlyUnique(value, index, self) {
        return self.indexOf(value) === index;
    }
    
    function getTags(pureContent) {
        const tokens = Az.Tokens(pureContent).done();
        const tags = tokens.filter(t => t.type.toString() === 'WORD')
            .map(t => t.toString().toLowerCase().replace('-', '_'))
            .map(name => namesMap[name])
            .filter(t => t)
            .filter(onlyUnique);
        return tags;
    }
    
    module.exports = {
        getTags
    };
    

    Формат словаря
    {
      "js": "JS",
      "javascript": "JS",
      "sql": "SQL",
      "ангуляр": "Angular",
      "angular": "Angular",
      "angularjs": "Angular",
      "react": "React",
      "reactjs": "React",
      "реакт": "React",
      "node": "NodeJS",
      "nodejs": "NodeJS",
      "linux": "Linux",
      "ubuntu": "Ubuntu",
      "unix": "UNIX",
      "windows": "Windows"
       ....
    }
    


    Деплой и все остальное


    Чтобы публиковать каждую вакансию только один раз, я использовал базу данных MongoDB, сведя все к уникальности ссылок самих вакансий. Для мониторинга процессов и их логов на сервере выбрал менеджер процессов pm2, где деплой осуществляется обычным bash скриптом. К слову сказать, в качестве сервера используется самый простой Droplet от Digital Ocean.

    Скрипт деплоя
    #!/usr/bin/env bash
    # rs - алиас для конфигурацци доступа к серверу
    rsync ./ rs:/var/www/js_jobs_bot --delete -r --exclude=node_modules
    ssh rs "
    . ~/.nvm/nvm.sh
    cd /var/www/js_jobs_bot/ 
    mv prod-config.json config.json
    npm i && pm2 restart processes.json
    "
    


    Выводы


    Делать простеньких ботов оказалось не сложно, нужно лишь желание, знание какого-нибудь языка программирования (желательно Python или JS) и пара дней свободного времени. Результаты работы моего бота (как и тематическую ленту вакансий) вы можете найти в соответствующем канале — @javascriptjobs.

    P.S. Полную версию исходников можно найти в моем репозитории
    Tinkoff.ru 152,40
    Самый большой онлайн-банк в мире
    Поделиться публикацией
    Похожие публикации

    Вакансии компании Tinkoff.ru

    Комментарии 8
    • +1

      Тоже имел опыт написания Telegram бота на JS. Но у меня идея была совсем другая


      Моя история. Коротко

      После покупки квартиры от старых хозяев осталась аудио-система: 4 колонки по углам комнаты (2 набора по 2). Ранее эти колонки были подключены к навороченному роутеру от Apple, который позволял транслировать аудио с телефона. Идея шикарная, но мне достался только Mini Jack, который я мог вставить в комп или телефон, а религия не позволяет использовать продукцию Apple. Токового решения трансляции аудио из Android (без root) => Ubuntu (KDE, 14.04) по Wi-Fi я не нашел, поэтому решил сделать домашнего бота, который мог бы реагировать на простые команды. В итоге получился бот, работающий через D-Bus и консольные команды, который может уменьшать и увеличивать громкость, ставить беззвучный режим и разговаривать (Строкой "скажи привет" используя festival, но очень хочется прикрутить Ivona).
      Еще в планах есть научить его включать радио, управлять плеером и запускать тесты с последующем информированием результата.


      P.S. Авторизации у бота нет, поэтому заставить его разговаривать может любой желающий. Дал своему другу ссылку на бота, после чего мой комп неожиданно начал доказывать мне ничтожность человечества перед ИИ :D

      • 0
        А трансляция по BT с Android на Ubuntu вас бы не устроила? Я сопрягаю устройство, включаю на телефоне условный VLC, и звук с телефона транслируется через колонки компьютера.
        • 0

          Устроила бы. Даже очень. Но Вам, наверное, очень повезло, что у Вас хорошо работает BT. У меня в свою очередь с этим проблемы на уровне драйверов. И никакой бубен не может мне помочь. Либо руки нужно выпрямлять. В любом случае, гугление приводит к одному единственному драйверу, который я изначально и установил.

      • 0
        Я вот наоборот не понимаю в чем преимущества телегам ботов по сравнению с просто веб страницей?
        • 0

          Простота реализации и универсальность. Чтобы сделать веб страницу, придется ко всему этому функционалу еще и frontend прикручивать, и свою систему авторизации, и Service Worker, если хочется уведомления без вкладок и т.д. и т.п. Особенно телеграмм. У него настолько простой API и огромное количество библиотек и фреймворков, что бота можно написать в 15 строк без особого труда.
          К тому же люди охотнее будут использовать бота, которого можно просто добавить и все, чем снова и снова заходить на сайт (ИМХО).

          • 0
            Верно всё новое должно быть проще.
        • 0
          Как я понял — этот бот используется только для отправки сообщений и ему не требуется обрабатывать какие-либо приходящие команды.

          Тогда вопрос, можно ли было включать Polling только на момент отправки сообщения?
          Или даже перейти на WebHook — хотя тогда он бы выглядел уже не так просто в своей реализации.
          • 0
            Да в принципе можно было бы да, но в будущем есть идея добавить подписку в личку на конкретные теги, поэтому пока оставил в таком виде.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое