Как мы заказ обеда в офис улучшали (без доступа к серверу)

в 14:25, , рубрики: javascript, node.js, пользовательские скрипты

Всем привет.
Я работаю в офисе. Разработчиком ПО. И иногда я ем. Да что уж, каждый день. Работодатель снабжает нас обедами — работники заказывают обед на завтра, а в это завтра поставщик обедов привозит то, что работники заказали. То, что заказали и то, что привезли, не всегда совпадает, но к делу это не относится. Обед заказывается на странице заказа обедов. Но…

Но сначала о том, как формируется страница заказа обедов: поставщик присылает XLS файл с прайсом на неделю.
image
Пример прайса, который присылает поставщик

Ответственный за обеды парсит через разработанную кем-то в недрах нашей компании утилиту, переводя ее в вид, который сможет отобразить наш корпоративный портал. И он отображает это…
image
Скриншот с заказанными обедами

image
Скриншот со страницей формирования заказа обеда

Позиции неудобно разбиты по категориям. Информация о названии и составе идет сплошным текстом и сложно ориентироваться.
Хочется понять, что лучше не заказывать, а что можно попробовать, потому что другим нравится. То есть хочется рейтинга. А еще хочется получить свой заказ в Telegram, чтобы в столовой не вспоминать, что заказал.

Итак, цели ясны. Сразу скажу: путь, которым мы с коллегой пошли, далеко не самый правильный и рациональный. Даже так: это полная дичь с точки зрения архитектуры/безопасности/поддержки/отказоустойчивости. Но что выросло, то выросло.
Доступа к серверу у нас нет, поэтому изменить внешний вид страницы можно только пользовательскими скриптами. Но как быть с рейтингом? К БД доступа тоже нет. Что ж, нам нужен сервер для обработки заказов, рейтинга и взаимодействия с Telegram. На эту роль взяли NodeJS-сервер.

Серверная часть

Я займусь сервером, а коллега — пользовательским скриптом, добавляющим функциональность на страницу. Берем nodejs-сервер, подключаем express, добавляем MySQL. Сверху кладем Sequelize. А взаимодействовать с Telegram будем через node-telegram-bot-api:

// Создаем новое приложение
const app = express();
// ...
// Добавляем обработчики
// Получаем пользовательский скрипт
app.get("/dinners/user_menu", dinner.getUserMenu);
// Получаем рейтинг по всем позициям
app.get("/dinners/r/:id", dinner.getPersonalRatings);
// Сохраняем рейтинг
app.post("/dinners/r/:id", dinner.setRating);
// Пересылаем сообщение о заказе в Telegram
app.post("/dinners/resend/:id", dinner.resendMessage);
// Сохраняем данные о сделанном заказе
app.post("/dinners/order", dinner.order);
// Устанавливаем дни, на которые нужно заказывать обед
app.post("/dinners/days", dinner.setDinnerDays);

Если коротко о функциональности:
Путь /dinners/user_menu возвращает пользовательский скрипт:

res.sendFile(__dirname + '/public_html/user_script.js');

Это сделано для того, чтобы не отвлекать коллег, которые им пользуются, установкой новой версии скрипта. Поправил — закинул на сервер — у всех обновилось.
Да, знаю, что с точки зрения безопасности это плохо, но сама функциональность не критична и будем считать сервер, на котором хранится скрипт, довольно защищенным.
Далее, по пути /dinners/r/:id можно получить рейтинг по всем позициям и сохранить рейтинг, то есть проголосовать за блюда.
Путь /dinners/resend/:id служит для передачи сообщения в Telegram. Текст сообщения формируется на клиенте, на сервере происходит лишь взаимодействие с Telegram:

const parseMode: TelegramBot.SendMessageOptions = {parse_mode: "HTML"};
await this.bot.sendMessage(telegramId, htmlMessage, {...options, ...parseMode});

После этого Бот присылает сообщение с заказом.
image

Далее, по пути /dinners/order происходит сохранение заказа. Так как оригинальный запрос заказа сложно определить (после нажатия кнопки “Сохранить” появляется alert с кнопкой подтверждения заказа), то запрос на сервер с заказами отправляется при загрузке страницы заказов (а вся система заказов на сайте делится на 2 страницы — страница заказов и страница меню — выбора блюд на конкретный день — то есть формирование заказа). Это дико не рационально, посылать запросы каждый раз при входе на страницу заказов, но варианта лучше навскидку не нашлось.

Наконец, путь /dinners/days устанавливает дни, на которые нужно заказывать обед. Эта часть функциональности появилась для корректной работы напоминаний о не сделанном заказе — нужно знать, какой следующий день заказа (ведь есть выходные и праздники посреди недели). Вместо того, чтобы взять реализацию производственного календаря, я просто разбираю даты на странице заказов, где уже помечены рабочие и нерабочие дни (нельзя сделать заказ на нерабочий день). Нерабочие дни помечаются на портале классом isHoliday:

// Вообще это клиентская часть
const trToday = $(".dinner_today")[0];
const tbodyAllDays = $(trToday).parent();
const dinnerDays = [];
$(tbodyAllDays).children().each(async function() {
    if ($(this).hasClass("isHoliday")) {
        return;
    }
    const itemMenuDate = $(this).find("> td:first-child").text().substring(0, 10);
    dinnerDays.push(itemMenuDate);
    // ...
});
await sendRequest("POST", `https://****/dinners/days/`, {days: dinnerDays});

О да, используем jquery для ковыряния. Очень удобно копаться в дереве страницы.

Telegram-бот

Еще одна часть всей надстройки — telegram-бот.
image
С вот такой функциональностью

Получить ID — это такая система идентификации. Чтобы связать пользовательский скрипт на конкретном браузере с userId в telegram.
Посмотреть заказ на сегодня, посмотреть список заказов (последние 5), установить напоминание.
Обед в автоматическом режиме отправляется поставщику в одно и то же время каждый день, поэтому важно делать заказ до определенного времени, скажем, 13:00.
После этого возможность сделать заказ блокируется.
Напоминания:
image
Бот предоставляет возможность выбрать время напоминания: 9, 10 или 11 часов.

Причем, если после напоминания ты не сделал заказ, то каждые следующие 10 минут бот будет напоминать о заказе, пока не закажешь, либо пока не заблокируется возможность заказа.
Это сделано cron-задачей (используем node-schedule):

schedule.scheduleJob('*/10 9-13 * * 1-5', async function() {
    // ...
});

Клиентская часть. Меню

Повторюсь, что текущий интерфейс в связке с текстом позиций меню, который присылает поставщик просто ужасен (см скрин 2). И в один прекрасный момент ты перестаешь что-либо видеть в тоннах монотонного сплошного и мало полезного текста.

Поискав по просторам интернета что может нам помочь, наткнулись на вполне неплохой плагин для пользовательских скриптов Greasemonkey, им и решили воспользоваться.

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

// @include  http://****.int/*
// @include  http://****/*
// @grant GM.xmlHttpRequest

Так же для модификации самой страницы обедов мы воспользовались jQuery, подключив его посредством // @require

Теперь начнем перелопачивать страницу обедов. Посмотрев html код страницы, находим идентификатор таблицы обедов, получаем таблицу и модифицируем.

const table = $(".dinner__innerData");
const categoryList = [];
// Проходимся по всем названиям категорий
$(table).find(“tbody tr td:nth-child(2})”).each(function () {
    const text = $(this).text();
    // Перед первой строкой новой категории добавляем строку с названием категории
    if (!categoryList.find(name => name === text)) {
        $(this).parent().before("<tr><th colspan='6'>" + text + "</th><th style='display:none'></th><th style='display:none'></th><th style='display:none'>0</th><th style='display:none'><span class='dish__amount'>0</span></th></tr>");
        categoryList.push(text);
    }
});
// Удаляем колонку с категорией блюда
$(table).find(“thead th:nth-child(2)”).remove();
$(table).find("tbody tr td:nth-child(2)”).remove();
// Добавляем колонку с рейтингом
$(table).find(“tbody tr td:nth-child(2)”).after("<td></td>");
$(table).find(“thead th:nth-child(2)”).after("<th class='ui-state-default'>Рейтинг</th>");

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

Теперь перейдем к чистке текста и добавлению информации по рейтингу блюда. Для начала несколько вспомогательных функций. Блюдо в рейтинге идентифицируется по названию без всякого мусора в виде граммов, всяких символов препинания и пробелов. То есть блюдо с названием “Бульон куриный с яйцом (бульон куриный, морковь, лук, яйцо, зелень) В100гр: белки-3,43; жиры-2,86; углеводы-1,0; эн.ценность-43,39ккал (200гр)” идентифицируется как “бульонкуриныйсяйцом”. Это связано с тем что у поставщика могут закрадываться лишние пробелы, знаки и ещё что-нибудь. Как показала практика, этого было достаточно, чтобы точно идентифицировать в 90% случаев блюдо, и мы решили не заморачиваться и не вводить полнотекстовый поиск.

/**
 * Поиск элемента в рейтинге по имени в таблице
 * @param items  элементы рейтинга
 * @param tdText текст в таблице
 * @return элемент рейтинга
 */
function findByName(items, tdText) {
    tdText = clearTrash(tdText, true, true, true);
    return items.find(({clear_name}) => {
        return clear_name.trim().toLowerCase() === tdText;
    });
}

/**
 * Очистить мусор из названий
 * @param text       название
 * @param clearDescr признак очистки того что в скобках
 * @param clearGrams признак удаления граммы
 * @return название без мусора
 */
function clearTrash(text, clearDescr, clearGrams, clearSymbols) {
    // Обычный парсинг строки, на котором заострять внимание не будем
}

А это формирование рейтинга:

const table = $(".dinner__innerData");
const nameTd = $(table).find(“tr td:nth-child(2)”);
for (let index = 0; index <= nameTd.length; index++) {
    const tdText = $(nameTd[index]).text();
    // Ищем позицию в рейтинге
    const item = findByName(items, tdText);

    if (item) {
        let ratingTd = $(nameTd[index]).parent().find(“td:nth-child(2)”)[0];
        // Добавляем информацию об общем рейтинге и личном с количествами заказов
        let ratingText = "<i>о</i> " + parseFloat(item.avgrating).toFixed(1) + " (заказов: " + item.orders + ", чел: " + item.ratingsCount + ")";
        ratingText = item.persrating ? `<b><i>л</i> ${parseFloat(item.persrating).toFixed(1)} (заказов: ${item.perscount})</b><br>` + ratingText : ratingText;
       // Устанавливаем рейтинг
       $(ratingTd).css({
	// getColorRating возвращает цвет в зависимости от рейтинга
            background: getColorRating(item.avgrating)
      }).html(ratingText);
    }
    // Из названия блюда получаем мало полезную информацию в виде граммов
    // Мы её оставим, но в более подходящем отображении
    const grams = getGrams(tdText);
    // Чистим наименования от граммовки
    $(nameTd[index]).html(clearTrash(tdText, false, true, false));
    // Добавляем граммы в ту же ячейку, но строкой ниже и меньшим размером
    $(nameTd[index]).append("<br/><span></span>")
                                .find("span")
                                .append(grams)
                                .css({"font-size": 10});
}

И вот что получилось.
image
Согласитесь, гораздо приятнее и удобнее?

Клиентская часть. Голосование

Далее перейдем к добавлению возможности голосовать за заказанные блюда, а так же высылать сообщение с заказом в telegram.
image
Страница с заказами без скрипта

На странице заказанных блюд добавляем рейтинг:

async function addRatingForm() {
    const table = $(".dinner__innerData");
    const nameTd = $(table).find("tr td:nth-child(1)");
    // Чистим текст
    for (let index = 0; index <= nameTd.length; index++) {
        const tdText = $(nameTd[index]).text();
        $(nameTd[index]).html(clearTrash(tdText, false, true, false));
    }
    // Добавляем кнопку Проголосовать и Отправить в Telegram
    $(table).append("<tfoot><tr><th colspan='6' class='rating-buttons btn-group margT0' style='display: table-cell;'></tr></tfoot>");
    $(".rating-buttons").prepend(`<input type="submit" value="Проголосовать" class="btn_primary rating-button">`);
    $(".rating-buttons").prepend(`<input type="submit" value="В Telegram" class="btn_primary send-button">`);
    // Отключаем голосование если уже голосовали
    await diableButtonByDate();
    // Добавляем форму рейтинга для блюда
    for (let index = 0; index <= table.length; index++) {
        $(table[index]).find("tbody tr td:nth-child(4)").after("<td class='ratingInputTd'><input id='horizontal-spinner' class='ui-spinner-input' style='width:20px;'></td>");
        $(table[index]).find("thead th:nth-child(4)").after("<th class='ui-state-default'></th>");
    }
    $(".ui-spinner-input").spinner({
         max: 10,
         min: 1
    });
    // Устанавливаем обработчики
    $(".rating-button").click(sendRating);
    $(".send-button").click(sendTelegram);
}

/**
 * Дизейблим кнопку голосования для определенной даты, если уже голосовали
 */
async function diableButtonByDate() {
    // Просто идем по всем кнопкам и проверяем голосовали ли мы в этот день.
    // Благо у нас есть дата заказа в таблице и даты заказов в кеше
    const buttons  = $(".rating-button");
    for (let index = 0; index <= buttons.length; index++) {
        const button = $(buttons[index]);
        const date = button.parent().parent().parent().parent().parent().parent().find("> td:nth-child(1)").text().substring(0, 10);
        if (await GM.getValue(date)) {
            button.attr({disabled: "disabled"});
        }
    }
}

/**
 * Проголосовать
 */
async function sendRating(event) {
    event.preventDefault();
    const items = [];
    // Собираем все рейтинги из формы и формируем запрос
    $(this).parent().parent().parent().parent().find("tr").each(function () {
        const tdList = $(this).find("td");
        const ratingInput = $(tdList[4]).find("input");
        if (!ratingInput.length) {
            return;
        }
        items.push({
             count: $(tdList[2]).text(),
             price: $(tdList[1]).text(),
             name: $(tdList[0]).text(),
             rating: ratingInput.val(),
        });
     });
    await sendRequest("POST", `https://****/dinners/r/${telegramId}`, items);
    const menuDate = $(this).parent().parent().parent().parent().parent().parent().find("> td:nth-child(1)").text().substring(0, 10);
    await GM.setValue(menuDate, true);
    location.reload();
}

И вот что получили мы на выходе:
image

Да — код ужасен. Да — не оптимизирован. И да — местами нелогичен. Но потрачено времени при этом было по минимуму, а функциональность и удобство значительно возросли.
Цель была сделать заказ обеда приятнее для себя и товарищей и эта цель, на мой взгляд, была достигнута.

Автор: TRTHHRTS

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js