Luncher/server/src/restaurants.ts

306 lines
12 KiB
TypeScript

import axios from "axios";
import { load } from 'cheerio';
import { Food } from "../../types";
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock } from "./mock";
// Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
/**
* Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku.
* Využito tam, kde nelze polévku identifikovat lepším způsobem (TechTower).
*
* @param text vstupní text
* @returns true, pokud text představuje polévku
*/
const isTextSoupName = (text: string): boolean => {
for (const name of SOUP_NAMES) {
if (text.toLowerCase().includes(name)) {
return true;
}
}
return false;
}
const capitalize = (word: string): string => {
return word.charAt(0).toUpperCase() + word.slice(1);
}
const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
}
/**
* Stáhne a vrátí aktuální HTML z dané URL.
*
* @param url URL pro stažení
* @returns stažené HTML
*/
const getHtml = async (url: string): Promise<any> => {
return await axios.get(url).then(res => res.data).then(content => content);
}
/**
* Získá obědovou nabídku Sladovnické pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro daný týden
*/
export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSladovnickaMock();
}
const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html);
const list = $('ul.tab-links').children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
result[dayIndex] = [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
continue;
}
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
})
result[dayIndex] = currentDayFood;
}
return result;
}
/**
* Získá obědovou nabídku restaurace U Motlíků pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuUMotlikuMock();
}
const html = await getHtml(U_MOTLIKU_URL);
const $ = load(html);
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped');
let usedTable;
let usedDate = new Date(firstDayOfWeek.getTime());
for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) {
const table = $(tableNode);
const h3 = table.parent().prev();
const s1 = h3.text().split("-")[0].split(".");
const foundFirstDayString = `${s1[0]}.${s1[1]}.`;
if (foundFirstDayString === dayOfWeekString) {
usedTable = table;
}
}
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
}
if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
}
const body = usedTable.children().first();
const rows = body.children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
if (!(dayIndex in result)) {
result[dayIndex] = [];
}
let parsing = false;
let isSoup = false;
rows.each((i, row) => {
const firstChild = $(row).children().get(0);
if (firstChild?.name == 'th') {
const childText = $(firstChild).text();
if (capitalize(DAYS_IN_WEEK[dayIndex]) === childText) {
parsing = true;
} else if (parsing) {
// Narazili jsme na další den - konec parsování
parsing = false;
return;
}
} else if (parsing) {
const children = $(row).children();
if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo"
const foodType = children.first().text();
if (foodType === 'Polévka') {
isSoup = true;
} else if (foodType === 'Hlavní jídlo') {
isSoup = false;
} else {
throw Error("Neočekáváný typ jídla: " + foodType);
}
} else {
if (children.length !== 3) {
throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
}
const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text());
const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0');
result[dayIndex].push({
amount,
name,
price,
isSoup,
})
}
}
})
}
return result;
}
/**
* Získá obědovou nabídku TechTower pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuTechTowerMock();
}
const html = await getHtml(TECHTOWER_URL);
const $ = load(html);
const fonts = $('font.wsw-41');
let font = undefined;
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) {
font = f;
}
})
if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text)) {
if (text === DAYS_IN_WEEK[currentDayIndex]) {
// Našli jsme dnešní den, odtud začínáme parsovat jídla
parsing = true;
continue
} else if (parsing) {
// Už parsujeme jídla, ale narazili jsme na následující den - posouváme index
currentDayIndex += 1;
continue;
}
} else if (parsing) {
if (text.length == 0) {
// Prázdná řádka - končíme (je za pátečním menu TechTower)
break;
}
let price = 'na váhu';
let name = text;
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
name = split[0]
}
if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
}
result[currentDayIndex].push({
amount: '-',
name,
price,
isSoup: isTextSoupName(name),
})
}
}
return result;
}