Всем привет.
Я работаю в офисе. Разработчиком ПО. И иногда я ем. Да что уж, каждый день. Работодатель снабжает нас обедами — работники заказывают обед на завтра, а в это завтра поставщик обедов привозит то, что работники заказали. То, что заказали и то, что привезли, не всегда совпадает, но к делу это не относится. Обед заказывается на странице заказа обедов. Но…
Но сначала о том, как формируется страница заказа обедов: поставщик присылает 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();
}
И вот что получили мы на выходе:
Да — код ужасен. Да — не оптимизирован. И да — местами нелогичен. Но потрачено времени при этом было по минимуму, а функциональность и удобство значительно возросли.
Цель была сделать заказ обеда приятнее для себя и товарищей и эта цель, на мой взгляд, была достигнута.
Автор: TRTHHRTS