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

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

Скриншот с заказанными обедами

Скриншот со страницей формирования заказа обеда
Позиции неудобно разбиты по категориям. Информация о названии и составе идет сплошным текстом и сложно ориентироваться.
Хочется понять, что лучше не заказывать, а что можно попробовать, потому что другим нравится. То есть хочется рейтинга. А еще хочется получить свой заказ в 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});
После этого Бот присылает сообщение с заказом.

Далее, по пути /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-бот.

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

Бот предоставляет возможность выбрать время напоминания: 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}); }
И вот что получилось.

Согласитесь, гораздо приятнее и удобнее?
Клиентская часть. Голосование
Далее перейдем к добавлению возможности голосовать за заказанные блюда, а так же высылать сообщение с заказом в telegram.

Страница с заказами без скрипта
На странице заказанных блюд добавляем рейтинг:
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(); }
И вот что получили мы на выходе:

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