Время от времени к нам обращаются за помощью по интеграции Битрикс24 с внешними системами. Чаще всего разработка таких интеграций не вызывает особых трудностей, если система известная, имеет известный API и, на всякий случай, техподдержку.
Становится интереснее, когда внешняя система — какой-нибудь самописный ERP-софт, разработчиков которого в последний раз видели 10 лет назад. Как правило, в таких случаях про API и документацию речи не идёт. Отказаться от интеграции тоже нельзя, потому что часть критически важных для бизнеса процессов оказываются завязаны на этой внешней системе.
Ситуация
Например так было у одного из наших клиентов, про которого мы рассказывали в статье про разработку чат-ботов. Нужно было передавать информацию о внесении платежа из Битрикс24 во внешнюю учетную систему, где они хранились, но при этом было несколько проблем:
- Единственный способ работать с системой — через интерфейс в браузере. Никакого API в ней не предусмотрено.
- Разработчики системы в компании клиента уже давно не работают и обратиться с вопросом по доработке API не к кому.
- Мы могли бы доработать систему самостоятельно, но она написана на ASP.NET, с которым мы не работаем.
- При заполнении формы создания платежа система, по какой-то неведомой логике, дозаполняет часть полей перед отправкой. Чтобы разобраться, как это происходит, нужно реверсить минифицированный JS, потому что где лежат исходники тоже никто не знает.
- С учетом предыдущего пункта подделать все нужные http-запросы curl’ом тоже не представляется возможным: мы не знаем, как рассчитать некоторые требуемые поля.
Интеграция
В таком случае одним из вариантов решения проблемы будет полная имитация действий пользователя в браузере на сервере с Битрикс24. В качестве инструмента будем использовать Puppeteer, который является обёрткой над всем известным Google Chrome.
Чтобы поставить библиотеку пишем так:
$ npm install –save puppeteer
Для работы браузера нужен X-сервер. На линуксовых серверах без графического интерфейса X-сервера чаще всего нет. Нужно его поставить, список пакетов берём отсюда. Для веб-окружения Битрикс24 зависимости выглядят так:
$ yum -y install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc
Сразу после установки можно попробовать запустить тестовый скрипт из документации библиотеки:
const puppeteer = require(‘puppeteer’);
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(‘https://example.com’);
await page.screenshot({path: ‘example.png’});
await browser.close();
})();
Сохраняем скрипт в директории на сервере и запускаем на выполнение:
$ node example.js
Если всё получилось, в директории со скриптом должен появится файл example.png со скрином открытой в браузере страницы. Да, дебажить в дальнейшем придётся скриншотами 🙂
paybot.php
Нам нужно будет вызывать эту команду из Битрикс24, который написан на PHP. Сделаем это так:
protected static function doCreatePayment($clientId, $amount, $comment = ”, $recipientId = ”)
{
// Передавать данные будем через переменные окружения
$env = [
// Поля платежа
‘CLIENT_ID’ => $clientId,
‘AMOUNT’ => $amount,
‘COMMENT’ => Loc::getMessage(‘MAGNIFICO_PAYBOT_DEFAULT_COMMENT’).($comment ? ‘ (‘.$comment.’)’ : ”),
‘RECIPIENT_ID’ => $recipientId,
// Пути к бинарникам хрома и NodeJS берём из настроек модуля
‘CHROME_BIN’ => \Bitrix\Main\Config\Option::get(‘magnifico.paybot’, ‘chrome_bin’),
‘NODEJS_BIN’ => \Bitrix\Main\Config\Option::get(‘magnifico.paybot’, ‘nodejs_bin’),
// Логин и пароль для авторизации в внешней системе
‘AUTH_USERNAME’ => \Bitrix\Main\Config\Option::get(‘magnifico.paybot’, ‘auth_username’),
‘AUTH_PASSWORD’ => \Bitrix\Main\Config\Option::get(‘magnifico.paybot’, ‘auth_password’),
];
// Проверяем существование хрома
if (!is_executable($env[‘CHROME_BIN’])) {
throw new \Exception(Loc::getMessage(‘MAGNIFICO_PAYBOT_CHROME_ERROR’));
}
// То же самое для NodeJS
if (!is_executable($env[‘NODEJS_BIN’])) {
throw new \Exception(Loc::getMessage(‘MAGNIFICO_PAYBOT_NODEJS_ERROR’));
}
// Объявляем дескрипторы для чтения результатов вызова с stdout/stderr
$descriptors = [
1 => [‘pipe’, ‘w’],
2 => [‘pipe’, ‘w’],
];
// Запускаем подготовленный скрипт
$process = proc_open($env[‘NODEJS_BIN’] . ‘ ‘ . ‘paybot.js’, $descriptors, $pipes, Loader::getLocal(‘/modules/’.static::MODULE_ID.’/browser’), $env);
// Выходим, если не получилось запустит
if (false === $process) {
throw new \Exception(Loc::getMessage(‘MAGNIFICO_PAYBOT_PROCESS_ERROR’));
}
// Читаем содержимое объявленных ранее дескрипторов
list($stdout, $stderr) = [stream_get_contents($pipes[1]), stream_get_contents($pipes[2])];
AddMessage2Log([‘stdout’ => $stdout, ‘stderr’ => $stderr], static::MODULE_ID);
// Если код возврата ненулевой – была ошибка, и в stderr будет её текст
if (0 !== proc_close($process)) {
throw new \Exception(‘:!: ‘.$stderr);
}
// Тотальный успех
return true;
}
Хотелось бы, чтобы в один момент времени на сервере было запущено не более, чем один инстанс хрома. В противном случае, если одновременно придёт много запросов на создание платежа, может закончиться оперативная память и сервер ляжет. Чтобы этого не случилось – реализуем простейший мутекс через механизм блокировок в MySQL.
public static function createPayment($clientId, $amount, $comment = ”, $recipientId = ”)
{
$connection = \Bitrix\Main\Application::getInstance()->getConnection();
$connection->queryExecute(‘SELECT GET_LOCK(“‘.__CLASS__.'”, 60)’);
$success = static::doCreatePayment($clientId, $amount, $comment, $recipientId);
$connection->queryExecute(‘SELECT RELEASE_LOCK(“‘.__CLASS__.'”)’);
return $success;
}
Теперь вызовом createPayment можно создавать платежи. Со стороны PHP — остается только добавить в админке страницу настроек модуля:
paybot.js
Весь скрипт умещается в одном файле. Нам достаточно одного try .. catch, чтобы любая возникшая ошибка просто уходила бы в stderr и дальше обрабатывалась в PHP:
const puppeteer = require(‘puppeteer’);
(async () => {
try {
// here be dragons
} catch (err) {
console.error(‘[ERROR]’, err);
process.exit(1);
}
process.exit(0);
})();
Перед началом работы нужно запустить браузер:
console.log(‘[DEBUG] Launching browser…’);
const browser = await puppeteer.launch({executablePath: process.env[‘CHROME_BIN’], args: [‘–no-sandbox’, ‘–disable-setuid-sandbox’]});
После чего открыть вкладку в созданном браузере:
console.log(‘[DEBUG] Creating new page…’);
const page = await browser.newPage();
Когда в системе возникает какая-либо ошибка, она по старинке выдаст её алертом и скрипт наглухо зависнет, поэтому молча соглашаемся со всеми алертами:
console.log(‘[DEBUG] Disabling javascript alerts…’);
page.on(‘dialog’, async (dialog) => {
await dialog.accept();
});
Авторизуемся в системе, вводя полученный из PHP логин и пароль. Наличие ошибки авторизации определяем по специфической строке в теле страницы.
console.log(‘[DEBUG] Opening authorization page…’);
await page.goto(‘http://example.com/office/login.asp’);
console.log(‘[DEBUG] Enter credentials…’);
await page.evaluate((env) => {
document.querySelector(‘input[name=”CODE”]’).value = env[‘AUTH_USERNAME’];
document.querySelector(‘input[name=”PASSWORD”]’).value = env[‘AUTH_PASSWORD’];
document.querySelector(‘input[type=”submit”]’).click();
}, process.env);
console.log(‘[DEBUG] Waiting for authorization…’);
await page.waitForNavigation({waitUntil: ‘load’});
if ((await page.content()).search(‘Неправильные пароль или код’) > -1) {
throw new Error(‘Ошибка авторизации’);
}
Переходим на форму создания платежа и также заполняем её данными из PHP:
console.log(‘[DEBUG] Opening payment form…’);
await page.goto(‘http://example.com/office/operation.asp?CID=’ + process.env[‘CLIENT_ID’]);
console.log(‘[DEBUG] Checking if client exists…’);
if ((await page.content()).search(‘НЕТ ДОСТУПА ИЛИ НЕ ОПОЗНАН’) > -1) {
throw new Error(‘Клиент не существует’);
}
console.log(‘[DEBUG] Enter payment fields…’);
await page.evaluate((env) => {
if (env[‘AMOUNT’]) {
const realsum = document.querySelector(‘input[name=”realsum”]’);
realsum.value = env[‘AMOUNT’];
(‘function’ === typeof realsum.onchange) && realsum.onchange();
}
if (env[‘COMMENT’]) {
const comment = document.querySelector(‘input[name=”comment”]’);
comment.value = env[‘COMMENT’];
(‘function’ === typeof comment.onchange) && comment.onchange();
}
if (env[‘RECIPIENT_ID’]) {
const credit = document.querySelector(‘select[name=”Credit”]’);
credit.value = env[‘RECIPIENT_ID’];
(‘function’ === typeof credit.onchange) && credit.onchange();
}
document.querySelector(‘input[name=”OK”]’).click();
}, process.env);
console.log(‘[DEBUG] Waiting for payment creation…’);
await page.waitForNavigation({waitUntil: ‘load’});
И не забываем закрыть браузер:
console.log(‘[DEBUG] Closing browser…’);
await browser.close();