javascript

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

  • среда, 4 октября 2017 г. в 03:14:10
https://habrahabr.ru/company/tinkoff/blog/337940/
  • Open source
  • Node.JS
  • JavaScript
  • Блог компании Tinkoff.ru




Тема создания ботов для 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. Полную версию исходников можно найти в моем репозитории