import axios from "axios"; import { load } from 'cheerio'; import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock"; import { formatDate } from "./utils"; import { Food } from "../../types/gen/types.gen"; // 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', 'fazolová', 'cuketový krém', 'boršč', 'slepičí s ', 'zeleninová s ', 'hovězí s ', 'kachní kaldoun', 'dršťková' ]; 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'; const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz'; const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.html'; /** * 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(); } /** * Parsuje čísla alergenů z názvu jídla a vrací vyčištěný název spolu s polem alergenů. * Alergeny jsou očekávány na konci názvu ve formátu číslic oddělených čárkami. * * @param name původní název jídla * @returns objekt obsahující vyčištěný název a pole alergenů */ const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => { // Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/; const match = regex.exec(name); if (match) { const allergenString = match[1]; const allergens = allergenString.split(',').map(num => Number.parseInt(num.trim(), 10)).filter(num => !Number.isNaN(num)); const cleanName = name.replace(regex, '').trim(); return { cleanName, allergens }; } return { cleanName: name, allergens: [] }; } /** * 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 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 => { if (mock) { return getMenuSladovnickaMock(); } const html = await getHtml(SLADOVNICKA_URL); const $ = load(html); // Zjistíme, které dny jsou k dispozici z tab elementů const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]'); const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex tabElements.each((contentIndex, tabElement) => { const dayText = $(tabElement).find('.daily-menu-tab__day').text().toLowerCase(); const dayIndex = DAYS_IN_WEEK.indexOf(dayText); if (dayIndex !== -1 && dayIndex < 5) { // pouze pracovní dny (0-4) availableDays[dayIndex] = contentIndex; } }); const menuContentElements = $('#daily-menu-content-list').children('.daily-menu-content__content').not('.daily-menu-content__content--static'); const result: Food[][] = []; // Inicializujeme všechny pracovní dny (0-4) prázdnými poli for (let dayIndex = 0; dayIndex < 5; dayIndex++) { result[dayIndex] = []; } // Projdeme pouze dostupné dny for (const [dayIndex, contentIndex] of Object.entries(availableDays)) { const dayIndexNum = Number.parseInt(dayIndex); const contentIndexNum = contentIndex; if (contentIndexNum >= menuContentElements.length) { continue; // Přeskočíme, pokud content element neexistuje } const contentElement = $(menuContentElements[contentIndexNum]); const itemElement = contentElement.find('.daily-menu-content__item'); const table = itemElement.find('table.daily-menu-content__table tbody'); const rows = table.children('tr'); const currentDayFood: Food[] = []; // Projdeme všechny řádky - první je polévka, zbytek jsou hlavní jídla rows.each((i, row) => { const cells = $(row).children('td'); if (cells.length !== 3) { return; // Přeskočíme řádky s nesprávnou strukturou } const amount = sanitizeText($(cells.get(0)).text()); const nameRaw = sanitizeText($(cells.get(1)).text()); const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0')); const parsed = parseAllergens(nameRaw); // Přeskočíme prázdné řádky if (parsed.cleanName.trim().length > 0) { currentDayFood.push({ amount, name: parsed.cleanName, price, isSoup: i === 0, // První řádek je polévka allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined, }); } }); result[dayIndexNum] = 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 => { 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); 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 new 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 new Error("Neočekáváný typ jídla: " + foodType); } } else { if (children.length !== 3) { throw new 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 => { if (mock) { return getMenuTechTowerMock(); } const html = await getHtml(TECHTOWER_URL); const $ = load(html); let secondTry = false; // První pokus - varianta "Obědy" let fonts = $('font.wsw-41'); let font = undefined; fonts.each((i, f) => { if ($(f).text().trim().startsWith('Obědy')) { font = f; } }) // Druhý pokus - varianta "Jídelní lístek" if (!font) { fonts = $('font.wnd-font-size-90'); fonts.each((i, f) => { if ($(f).text().trim().startsWith('Jídelní lístek')) { font = f; secondTry = true; } }) } if (!font) { throw new Error('Chyba: nenalezen pro obědy v HTML Techtower.'); } const result: Food[][] = []; // TODO validovat, že v textu nalezeného je rozsah, do kterého spadá vstupní datum const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(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.toLocaleLowerCase())) { // Zjistíme aktuální index currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase()); if (!parsing) { // Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme parsing = true; } } else if (parsing) { if (text.length == 0) { // Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯ continue; } let price = 'na\xA0váhu'; let nameRaw = text.replace('•', ''); 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č` nameRaw = split[0].replace('•', ''); } else if (text.toLowerCase().endsWith(',-')) { const tmp = text.replace('\xA0', ' ').split(' '); const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1)); price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč` nameRaw = split[0].replace('•', ''); } if (nameRaw.endsWith('–')|| nameRaw.endsWith('—')) { nameRaw = nameRaw.slice(0, -1).trim(); } const parsed = parseAllergens(nameRaw); result[currentDayIndex] ??= []; result[currentDayIndex].push({ amount: '-', name: parsed.cleanName, price, isSoup: isTextSoupName(parsed.cleanName), allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined, }) } } return result; } /** * Získá obědovou nabídku ZastavkaUmichala 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 getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolean = false): Promise => { if (mock) { return getMenuZastavkaUmichalaMock(); } const today = new Date(); today.setHours(0,0,0,0); const headers = { "Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", }; const result: Food[][] = []; for (let dayIndex = 0; dayIndex < 5; dayIndex++) { const currentDate = new Date(firstDayOfWeek); currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); currentDate.setHours(0,0,0,0); if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) { result[dayIndex] = [{ amount: undefined, name: "Pro tento den není uveřejněna nabídka jídel", price: "", isSoup: false, }]; } else { const url = (currentDate.getTime() === today.getTime()) ? ZASTAVKAUMICHALA_URL : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY'); const html = await axios.get(url, { headers, }).then(res => res.data).then(content => content); const $ = load(html); const currentDayFood: Food[] = []; $('.foodsList li').each((index, element) => { currentDayFood.push({ amount: '-', name: sanitizeText($(element).contents().not('span').text()), price: sanitizeText($(element).find('span').text()), isSoup: (index === 0), }); }); result[dayIndex] = currentDayFood; } } return result; } /** * Získá obědovou nabídku SenkSerikova 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 getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise => { if (mock) { return getMenuSenkSerikovaMock(); } const decoder = new TextDecoder('windows-1250'); const html = await axios.get(SENKSERIKOVA_URL, { responseType: 'arraybuffer', responseEncoding: 'binary' }).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content); const $ = load(html); const today = new Date(); today.setHours(0,0,0,0); const currentDate = new Date(firstDayOfWeek); const result: Food[][] = []; let dayIndex = 0; currentDate.setHours(0,0,0,0); while (currentDate < today) { result[dayIndex] = [{ amount: undefined, name: "Pro tento den není uveřejněna nabídka jídel", price: "", isSoup: false, }]; dayIndex = dayIndex + 1; currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); } $('.menicka').each((i, element) => { const currentDayFood: Food[] = []; $(element).find('.popup-gallery li').each((j, element) => { const rawName = $(element).children('div.polozka').text(); const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, ''); currentDayFood.push({ amount: '-', name: nameWithoutNumber, price: $(element).children('div.cena').text().replaceAll(' ', '\xA0'), isSoup: $(element).hasClass('polevka'), }); }); result[dayIndex++] = currentDayFood; }); return result; }