457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
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<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);
|
||
|
||
// 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<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);
|
||
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<Food[][]> => {
|
||
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 <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 = 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<Food[][]> => {
|
||
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<Food[][]> => {
|
||
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;
|
||
}
|