import axios from "axios"; import { load } from 'cheerio'; import { Food } from "../../types"; // 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', '').trim(); } /** * Vrátí index dne v týdnu, kde pondělí=0, neděle=6 * * @param date datum * @returns index dne v týdnu */ const getDayOfWeekIndex = (date: Date) => { // https://stackoverflow.com/a/4467559 return (((date.getDay() - 1) % 7) + 7) % 7; } /** * 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 => { return await axios.get(url).then(res => res.data).then(content => content); } /** * Získá obědovou nabídku Sladovnické pro předané datum. * * @param date datum, pro které získat menu * @param mock zda vrátit mock data * @returns seznam jídel pro dané datum */ export const getMenuSladovnicka = async (date: Date = new Date(), mock: boolean = false): Promise => { if (mock) { return [ { amount: "0,25l", name: "Zelná polévka s klobásou", price: "35\xA0Kč", isSoup: true, }, { amount: "150g", name: "Hovězí na česneku s bramborovým knedlíkem", price: "135\xA0Kč", isSoup: false, }, { amount: "250g", name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát", price: "135\xA0Kč", isSoup: false, }, { amount: "350g", name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka", price: "135\xA0Kč", isSoup: false, } ] } const todayDayIndex = getDayOfWeekIndex(date); if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend return []; } const html = await getHtml(SLADOVNICKA_URL); const $ = load(html); // 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) const list = $('ul.tab-links').children(); const searchedDayText = `${date.getDate()}.${date.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[todayDayIndex])}`; 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 return [{ amount: undefined, name: "Pro daný den nebyla nalezena denní nabídka", price: "", isSoup: false, }]; } // 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 results: 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"); } results.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(); // Záměrně zakomentováno - občas je ve Sladovnické jídel méně // if (mainCourseRows.length !== 3) { // throw Error("Neočekávaný počet řádek jídel: " + mainCourseRows.length + ", ale očekávány byly 3"); // } 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"); } results.push({ amount: sanitizeText($(foodCells.get(0)).text()), name: sanitizeText($(foodCells.get(1)).text()), price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')), isSoup: false, }); }) return results; } /** * Získá obědovou nabídku restaurace U Motlíků pro předané datum. * * @param date datum, pro které získat menu * @param mock zda vrátit mock data * @returns seznam jídel pro dané datum */ export const getMenuUMotliku = async (date: Date = new Date(), mock: boolean = false): Promise => { if (mock) { return [ { amount: "0,33l", name: "Hovězí vývar s nudlemi", price: "35\xA0Kč", isSoup: true, }, { amount: "150g", name: "Opečený párek, čočka, sázené vejce, okurka", price: "135\xA0Kč", isSoup: false, }, { amount: "150g", name: "Hovězí líčka na červeném víně, bramborová kaše", price: "145\xA0Kč", isSoup: false, }, { amount: "150g", name: "Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát", price: "135\xA0Kč", isSoup: false, }, ] } const todayDayIndex = getDayOfWeekIndex(date); if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend return []; } const html = await getHtml(U_MOTLIKU_URL); const $ = load(html); const table = $('table.table.table-hover.Xtable-striped').first(); const body = table.children().first(); const rows = body.children(); const results: Food[] = []; 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[todayDayIndex]) === childText) { // Našli jsme dnešek parsing = true; } else if (parsing) { // Narazili jsme na další den - konec parsování parsing = false; return; } } else if (parsing) { // Jsme aktuálně na dnešním dni 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'); results.push({ amount, name, price, isSoup, }) } } }) return results; } /** * Získá obědovou nabídku TechTower pro předané datum. * * @param date datum, pro které získat menu * @param mock zda vrátit mock data * @returns seznam jídel pro dané datum */ export const getMenuTechTower = async (date: Date = new Date(), mock: boolean = false) => { if (mock) { return [ { amount: "-", name: "Bavorská gulášová polévka s kroupami", price: "40\xA0Kč", isSoup: true, }, { amount: "-", name: "Vepřové výpečky, kedlubnové zelí, bramborový knedlík", price: "120\xA0Kč", isSoup: false, }, { amount: "-", name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky", price: "220\xA0Kč", isSoup: false, } ] } const todayDayIndex = getDayOfWeekIndex(date); if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend return []; } 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 pro obědy v HTML Techtower.'); } // TODO validovat, že v textu nalezeného je rozsah, do kterého spadá vstupní datum const siblings = $(font).parent().parent().siblings(); let parsing = false; const results: Food[] = []; 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[todayDayIndex]) { // 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 - končíme break; } } else if (parsing) { if (text.length == 0) { // Prázdná řádka - končíme (je za pátečním menu TechTower) break; } let price = '? Kč'; 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] } results.push({ amount: '-', name, price, isSoup: isTextSoupName(name), }) } } return results; }