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

от автора

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

Но сначала о том, как формируется страница заказа обедов: поставщик присылает 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

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


ссылка на оригинал статьи https://habr.com/ru/post/475892/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *