Zbavení se Food API, zahrnutí do serveru
This commit is contained in:
@@ -6,8 +6,9 @@ import cors from 'cors';
|
||||
import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getData, lockPizzaDay, removePizzaOrder, unlockPizzaDay, updateChoice, updateNote } from "./service";
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fetchMenus } from "./restaurants";
|
||||
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants";
|
||||
import { getQr } from "./qr";
|
||||
import { Restaurants } from "./types";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV || 'production'
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -34,10 +35,15 @@ app.get("/api/data", (req, res) => {
|
||||
});
|
||||
|
||||
/** Vrátí obědové menu pro dostupné podniky. */
|
||||
app.get("/api/food", (req, res) => {
|
||||
fetchMenus().then(food => {
|
||||
res.status(200).json(food);
|
||||
})
|
||||
app.get("/api/food", async (req, res) => {
|
||||
const mock = !!req.query?.mock;
|
||||
const date = new Date();
|
||||
const data = {
|
||||
[Restaurants.SLADOVNICKA]: await getMenuSladovnicka(date, mock),
|
||||
[Restaurants.UMOTLIKU]: await getMenuUMotliku(date, mock),
|
||||
[Restaurants.TECHTOWER]: await getMenuTechTower(date, mock),
|
||||
}
|
||||
res.status(200).json(data);
|
||||
});
|
||||
|
||||
/** Vrátí seznam dostupných pizz. */
|
||||
|
||||
@@ -1,13 +1,359 @@
|
||||
import axios from "axios";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { load } from 'cheerio';
|
||||
import { formatDate } from "./utils";
|
||||
import { Food } from "./types";
|
||||
|
||||
// URL na Food API - získání jídelních lístků restaurací
|
||||
const foodUrl = process.env.FOOD_API_URL || 'http://127.0.0.1:3002';
|
||||
// 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'];
|
||||
|
||||
export const fetchMenus = async () => {
|
||||
try {
|
||||
return await axios.get(foodUrl, { params: { mock: !!process.env.MOCK_DATA } }).then(res => res.data);
|
||||
} catch (error) {
|
||||
console.error("Chyba při volání Food API", error);
|
||||
return {};
|
||||
// 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 (v případě potřeby) a vrátí HTML z dané URL pro předané datum.
|
||||
* Pokud je pro dané datum již staženo, vrátí jeho obsah ze souboru.
|
||||
*
|
||||
* @param url URL pro stažení
|
||||
* @param prefix prefix pro uložení do souboru
|
||||
* @param date datum ke kterému stáhnout HTML
|
||||
* @returns stažené HTML, nebo HTML ze souborové cache
|
||||
*/
|
||||
const getHtml = async (url: string, prefix: string, date: Date): Promise<Buffer> => {
|
||||
const fileName = path.join(os.tmpdir(), `${prefix}_${formatDate(date)}.html`);
|
||||
if (!fs.existsSync(fileName)) {
|
||||
await axios.get(url).then(res => res.data).then(content => {
|
||||
fs.writeFileSync(fileName, content);
|
||||
});
|
||||
}
|
||||
return fs.readFileSync(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Food[]> => {
|
||||
if (mock) {
|
||||
return [
|
||||
{
|
||||
amount: "0,25l",
|
||||
name: "Zelná polévka s klobásou",
|
||||
price: "35 Kč",
|
||||
isSoup: true,
|
||||
},
|
||||
{
|
||||
amount: "150g",
|
||||
name: "Hovězí na česneku s bramborovým knedlíkem",
|
||||
price: "135 Kč",
|
||||
isSoup: false,
|
||||
},
|
||||
{
|
||||
amount: "250g",
|
||||
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
|
||||
price: "135 Kč",
|
||||
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 Kč",
|
||||
isSoup: false,
|
||||
}
|
||||
]
|
||||
}
|
||||
const todayDayIndex = getDayOfWeekIndex(date);
|
||||
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
|
||||
return [];
|
||||
}
|
||||
const html = await getHtml(SLADOVNICKA_URL, 'sladovnicka', date);
|
||||
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) {
|
||||
throw Error("V HTML nebyl nalezen index pro datum " + searchedDayText);
|
||||
}
|
||||
|
||||
// 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()),
|
||||
isSoup: true,
|
||||
});
|
||||
// Hlavní jídla - div -> table -> tbody -> 3x tr
|
||||
const mainCourseRows = $(tables.get(1)).children().first().children();
|
||||
// TODO tohle nemusí být vždy pravda, jídel může být jiný počet
|
||||
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()),
|
||||
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<Food[]> => {
|
||||
if (mock) {
|
||||
return [
|
||||
{
|
||||
amount: "0,33l",
|
||||
name: "Hovězí vývar s nudlemi",
|
||||
price: "35 Kč",
|
||||
isSoup: true,
|
||||
},
|
||||
{
|
||||
amount: "150g",
|
||||
name: "Opečený párek, čočka, sázené vejce, okurka",
|
||||
price: "135 Kč",
|
||||
isSoup: false,
|
||||
},
|
||||
{
|
||||
amount: "150g",
|
||||
name: "Hovězí líčka na červeném víně, bramborová kaše",
|
||||
price: "145 Kč",
|
||||
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 Kč",
|
||||
isSoup: false,
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
const todayDayIndex = getDayOfWeekIndex(date);
|
||||
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
|
||||
return [];
|
||||
}
|
||||
const html = await getHtml(U_MOTLIKU_URL, 'umotliku', date);
|
||||
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(',-', '');
|
||||
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 Kč",
|
||||
isSoup: true,
|
||||
},
|
||||
{
|
||||
amount: "-",
|
||||
name: "Vepřové výpečky, kedlubnové zelí, bramborový knedlík",
|
||||
price: "120 Kč",
|
||||
isSoup: false,
|
||||
},
|
||||
{
|
||||
amount: "-",
|
||||
name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky",
|
||||
price: "220 Kč",
|
||||
isSoup: false,
|
||||
}
|
||||
]
|
||||
}
|
||||
const todayDayIndex = getDayOfWeekIndex(date);
|
||||
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
|
||||
return [];
|
||||
}
|
||||
const html = await getHtml(TECHTOWER_URL, 'techtower', date);
|
||||
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.');
|
||||
}
|
||||
// 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;
|
||||
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.endsWith('Kč')) {
|
||||
const tmp = text.split(' ');
|
||||
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
|
||||
price = split.slice(1).join(" ")
|
||||
name = split[0]
|
||||
}
|
||||
results.push({
|
||||
amount: '-',
|
||||
name,
|
||||
price,
|
||||
isSoup: isTextSoupName(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -61,6 +61,21 @@ export interface ClientData {
|
||||
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
|
||||
}
|
||||
|
||||
/** Jídlo z obědového menu restaurace. */
|
||||
export interface Food {
|
||||
amount?: string, // množství standardní porce, např. 0,33l nebo 150g
|
||||
name: string, // název/popis jídla
|
||||
price: string, // cena ve formátu '135 Kč'
|
||||
isSoup: boolean, // příznak, zda se jedná o polévku
|
||||
}
|
||||
|
||||
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
|
||||
export enum Restaurants {
|
||||
SLADOVNICKA = 'sladovnicka',
|
||||
UMOTLIKU = 'uMotliku',
|
||||
TECHTOWER = 'techTower',
|
||||
}
|
||||
|
||||
export enum Locations {
|
||||
SLADOVNICKA = 'Sladovnická',
|
||||
UMOTLIKU = 'U Motlíků',
|
||||
|
||||
Reference in New Issue
Block a user