From 9090b156cea5f511753d49542a55b39a86e9fc73 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Sun, 18 Jun 2023 18:10:38 +0200 Subject: [PATCH] =?UTF-8?q?Zbaven=C3=AD=20se=20Food=20API,=20zahrnut=C3=AD?= =?UTF-8?q?=20do=20serveru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++- client/src/App.tsx | 11 +- client/src/Types.tsx | 15 ++ docker-compose.yml | 11 -- food_api/Dockerfile | 10 -- food_api/README.md | 43 ----- food_api/food_api.py | 20 --- food_api/food_service.py | 284 ------------------------------ food_api/requirements.txt | 3 - food_api/run_dev.sh | 8 - nginx/default.conf | 10 -- run_dev.sh | 1 - server/.env.template | 8 +- server/src/index.ts | 16 +- server/src/restaurants.ts | 362 +++++++++++++++++++++++++++++++++++++- server/src/types.ts | 15 ++ 16 files changed, 415 insertions(+), 423 deletions(-) delete mode 100644 food_api/Dockerfile delete mode 100644 food_api/README.md delete mode 100644 food_api/food_api.py delete mode 100755 food_api/food_service.py delete mode 100644 food_api/requirements.txt delete mode 100755 food_api/run_dev.sh diff --git a/README.md b/README.md index 165d739..b3121cd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # Luncher Aplikace pro profesionální management obědů. -Aplikace sestává ze tří (čtyř) modulů. -- food_api - - Python scraper/parser pro zpracování obědových menu restaurací +Aplikace sestává ze dvou (tří) modulů. - server - backend psaný v [node.js](https://nodejs.dev) - client @@ -13,9 +11,6 @@ Aplikace sestává ze tří (čtyř) modulů. ## Spuštění pro vývoj ### Závislosti -#### Food API -- [Python 3](https://www.python.org) -- [pip](https://pypi.org/project/pip) #### Klient/server - [Node.js 18.x](https://nodejs.dev) - [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com) @@ -56,7 +51,7 @@ Aplikace sestává ze tří (čtyř) modulů. - [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář - [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku) - [ ] Vylepšit dokumentaci projektu - - [ ] Popsat Food API, nginx + - [ ] Popsat nginx - [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj - [x] Popsat dostupné env - [ ] Přesunout autentizaci na server (JWT?) @@ -68,6 +63,14 @@ Aplikace sestává ze tří (čtyř) modulů. - [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) - [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek) - [ ] Možnost zadat si oběd dopředu na následující dny v týdnu -- [ ] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru +- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru - [ ] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx) -- [ ] Mazat z databáze předchozí dny, aktuálně je to k ničemu \ No newline at end of file +- [ ] Mazat z databáze předchozí dny, aktuálně je to k ničemu +- [ ] Vylepšit parsery restaurací + - [ ] Sladovnická + - [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru + - [ ] U Motlíků + - [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') + - [ ] Jídelní lístek se stahuje jednou každý den, stačilo by to jednou týdně ("ID" by mohlo být číslo týdne v roce, místo celého datumu) + - [ ] TechTower + - [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 728fc68..dafa7e6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,7 +4,7 @@ import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, getQrUrl, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice, updateNote } from './Api'; import { useAuth } from './context/auth'; import Login from './Login'; -import { Locations, ClientData, Pizza, PizzaOrder, State, Order } from './Types'; +import { Locations, ClientData, Pizza, PizzaOrder, State, Order, Food, Restaurants } from './Types'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import Header from './components/Header'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -24,7 +24,7 @@ function App() { const bank = useBank(); const [isConnected, setIsConnected] = useState(false); const [data, setData] = useState(); - const [food, setFood] = useState(); + const [food, setFood] = useState<{ [key in Restaurants]: Food[] }>(); const [pizzy, setPizzy] = useState(); const [myOrder, setMyOrder] = useState(); const socket = useContext(SocketContext); @@ -208,13 +208,14 @@ function App() {
  • Nová žárovka zatím funguje
  • Funkční generování a zobrazení QR kódů pro Pizza day
  • Možnost zadat k Pizza day objednávce poznámku
  • +
  • Zbavení se Food API, přepsání a zahrnutí parseru do serveru
  • Dnes je {data.date}

    - {renderFoodTable('Sladovnická', food.sladovnicka)} - {renderFoodTable('U Motlíků', food.uMotliku)} - {renderFoodTable('TechTower', food.techTower)} + {renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} + {renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} + {renderFoodTable('TechTower', food[Restaurants.TECHTOWER])}
    diff --git a/client/src/Types.tsx b/client/src/Types.tsx index d3c81b5..13c6bd4 100644 --- a/client/src/Types.tsx +++ b/client/src/Types.tsx @@ -60,6 +60,21 @@ export enum Locations { NEOBEDVAM = 'Mám vlastní/neobědvám', } +/** 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 State { NOT_CREATED, // Pizza day nebyl založen CREATED, // Pizza day je založen diff --git a/docker-compose.yml b/docker-compose.yml index e10d350..d3ca80f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,12 @@ version: '3.8' services: - food_api: - build: - context: ./food_api - # ports: - # - "3002:80" server: - depends_on: - - food_api build: context: ./server - # ports: - # - "3001:3001" client: build: context: ./client - # ports: - # - "3000:3000" nginx: depends_on: - server diff --git a/food_api/Dockerfile b/food_api/Dockerfile deleted file mode 100644 index 9cfeb90..0000000 --- a/food_api/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.9 -WORKDIR /app - -COPY ./requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt - -COPY ./food_service.py /app -COPY ./food_api.py /app - -CMD ["uvicorn", "food_api:app", "--host", "0.0.0.0", "--port", "3002"] \ No newline at end of file diff --git a/food_api/README.md b/food_api/README.md deleted file mode 100644 index 44fc34a..0000000 --- a/food_api/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# TODO -Následující informace jsou neaktuální. Už nemáme Flask, místo WSGI jedeme přes ASGI apod. Místo tohoto dokumentu využijte nadřazený README.md. - -# POMPSZČPS -POMPSZČPS, neboli Parser Obědových Menu Plzeňských Stravovacích Zařízení v Části Plzeň-Slovany, je Python aplikace poskytující na jednom místě aktuální obědové menu pro několik stravovacích zařízení v městské části Plzeň 2-Slovany. Aktuálně podporuje následující podniky: -- [Pivnice Sladovnická](https://sladovnicka.unasplzenchutna.cz) -- [Restaurace U Motlíků](https://www.umotliku.cz) -- [Restaurace TechTower](https://www.equifarm.cz/restaurace-techtower) - -Pro tyto podniky umožňuje získání aktuálního obědového menu, a to buďto barevným výpisem do konzole (přímým spuštěním `food_service.py`) nebo v podobě [WSGI](https://cs.wikipedia.org/wiki/Web_Server_Gateway_Interface) endpointu (`wsgi.py`), který vrací zmíněná menu jako strukturovaný JSON objekt pro další použití v jiných aplikacích. - -## Závislosti -- [Python 3.x](https://www.python.org) - -Pro použití jako konzolová aplikace -- [beautifulsoup4](https://pypi.org/project/beautifulsoup4) - -Pro použití jako API endpoint -- [beautifulsoup4](https://pypi.org/project/beautifulsoup4) -- [Flask](https://pypi.org/project/Flask) -- [gunicorn](https://pypi.org/project/gunicorn) - -## Použití -```bash -python -m venv venv -(Unix): source venv/bin/activate -(Windows): venv\Scripts\activate.bat -pip install -r requirements.txt -``` -- Jako konzolová aplikace: `python food_service.py` - - Vypíše přehledně pod sebe menu všech aktuálně integrovaných podniků -- Jako JSON API endpoint - - TODO - -## TODO -- Umožnit zadat a zobrazit menu pro jiné dny - - umožnit zadání datumem nebo názvem dne v týdnu - - validace - žádná sobota, neděle - - validace - datum musí být tento týden - - minimálně pro Motlíky to znamená úpravu URL a parseru -- Otestovat rozchození - vytvoření venv, instalace requirements, spuštění jako konzole -- Umožnit konfiguračně určit pro které podniky se bude menu získávat a zobrazovat (vyberu si jen ty, které mě zajímají) -- Umožnit konfiguračně nastavit výrazy pro detekci polévky \ No newline at end of file diff --git a/food_api/food_api.py b/food_api/food_api.py deleted file mode 100644 index 52344ea..0000000 --- a/food_api/food_api.py +++ /dev/null @@ -1,20 +0,0 @@ -from food_service import getMenuSladovnicka, getMenuTechTower, getMenuUMotliku -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -@app.get("/") -def read_root(mock: bool = False): - return { - 'sladovnicka': getMenuSladovnicka(mock), - 'uMotliku': getMenuUMotliku(mock), - 'techTower': getMenuTechTower(mock) - } diff --git a/food_api/food_service.py b/food_api/food_service.py deleted file mode 100755 index 1c4aa11..0000000 --- a/food_api/food_service.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 - -import datetime -from typing import List -from bs4 import BeautifulSoup -import tempfile -import sys -import os -import urllib.request -import platform -from datetime import date, timedelta - -URL_SLADOVNICKA = "https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka" -URL_MOTLICI = "https://www.umotliku.cz" -URL_TECHTOWER = "https://www.equifarm.cz/restaurace-techtower" - -DAY_NAMES = ['pondělí', 'úterý', 'středa', - 'čtvrtek', 'pátek', 'sobota', 'neděle'] - -# Fráze v názvech jídel, které naznačují že se jedná o polévku -SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar'] - - -class bcolors: - HEADER = '\033[95m' - OKBLUE = '\033[94m' - OKCYAN = '\033[96m' - OKGREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - - -class Food: - name = None - amount = None - price = None - is_soup = False - - def __init__(self, name, amount, price, is_soup=False) -> None: - self.name = name - self.amount = amount - self.price = price - self.is_soup = is_soup - - -def getOrDownloadHtml(prefix: str, url: str): - '''Vrátí HTML pro daný prefix pro aktuální den. - Pokud v tempu neexistuje, provede jeho stažení z předané URL a uložení.''' - filename = prefix + "_" + date.today().strftime("%Y_%m_%d") + ".html" - filepath = os.path.join(tempfile.gettempdir(), filename) - if not os.path.isfile(filepath): - urllib.request.urlretrieve(url, filepath) - file = open(filepath, "r", encoding='utf-8') - contents = file.read() - file.close() - return contents - - -def isNameOfDay(text: str): - '''Vrátí True, pokud předaný text představuje název dne v týdnu (např. "pondělí")''' - return text.strip().lower() in DAY_NAMES - - -def getDayNameOfDate(date: datetime.datetime): - '''Vrátí název dne v týdnu - např. pondělí, úterý, ...''' - return DAY_NAMES[date.weekday()] - - -def getStartOfWeekDate(): - '''Vrátí datetime představující pondělí v aktuálním týdnu.''' - today = datetime.datetime.now() - return today - timedelta(days=today.weekday()) - - -def isTextSoupName(text: str): - '''Vrátí True, pokud se předaný text jeví jako název polévky. - Používá se tam, kde nemáme lepší způsob detekce (TechTower).''' - for name in SOUP_NAMES: - if name in text.lower(): - return True - return False - - -def printMenu(name: str, foodList: List[Food]): - '''Vytiskne jídelní lístek na obrazovku.''' - print(f"{bcolors.OKGREEN}{name}{bcolors.ENDC}\n---------------------------------------------------------------------------------") - maxLength = 0 - for jidlo in foodList: - if len(jidlo.name) > maxLength: - maxLength = len(jidlo.name) - for jidlo in foodList: - barva = bcolors.HEADER if jidlo.is_soup else bcolors.WARNING - print(f"{barva}{jidlo.amount}\t{jidlo.name.ljust(maxLength)}\t{bcolors.ENDC}{bcolors.OKCYAN}{jidlo.price}{bcolors.ENDC}") - print('\n') - - -def getMenuSladovnicka(mock: bool = False) -> List[Food]: - if mock: - foodList: List[Food] = [] - foodList.append(Food("Zelná polévka s klobásou", "0,25l", "35 Kč", True)) - foodList.append(Food("Hovězí na česneku s bramborovým knedlíkem", "150g", "135 Kč")) - foodList.append(Food("Přírodní holandský řízek s bramborovou kaší, rajčatový salát", "250g", "135 Kč")) - foodList.append(Food("Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka", "350g", "135 Kč")) - return foodList - if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle': - return [] - html = getOrDownloadHtml('sladovnicka', URL_SLADOVNICKA) - soup = BeautifulSoup(html, "html.parser") - div = soup.select_one("div.tab-pane.fade.in.active") - datumDen = div.find("h2").text - split = datumDen.split(".") - denMesic = split[0] + "." + split[1] + "." - # nazevDen = split[2] - # Windows má pro padding '#', POSIX systémy '-' - if platform.system() == 'Windows': - format = "%#d.%#m." - else: - format = "%-d.%-m." - dnesniDatum = date.today().strftime(format) - if denMesic != dnesniDatum: - print('Chyba: neočekávané datum na stránce Sladovnické (' + - denMesic + '), očekáváno ' + dnesniDatum, file=sys.stderr) - sys.exit(1) - tables = div.find_all("table", {"class": "simple"}) - if len(tables) != 2: - print('Chyba: neočekávaný počet tabulek na stránce Sladovnické (' + - str(len(tables)) + '), očekávány 2', file=sys.stderr) - sys.exit(1) - - foodList: List[Food] = [] - - polevkaValues = tables[0].find_all("td") - amount = polevkaValues[0].text.strip() - name = polevkaValues[1].text.strip() - price = polevkaValues[2].text.strip() - foodList.append(Food(name, amount, price, True)) - - foodTables = tables[1].find_all("tr") - for food in foodTables: - rows = food.find_all("td") - if (len(rows) != 3): - print("Neočekávaný počet řádek hlavního jídla Sladovnické (" + - str(len(rows)) + ", očekávány 3, přeskakuji...") - continue - amount = rows[0].text.strip() - name = rows[1].text.strip() - price = rows[2].text.strip() - foodList.append(Food(name, amount, price)) - return foodList - - -def getMenuUMotliku(mock: bool = False) -> List[Food]: - if mock: - foodList: List[Food] = [] - foodList.append(Food("Hovězí vývar s nudlemi", "0,33l", "35 Kč", True)) - foodList.append(Food("Opečený párek, čočka, sázené vejce, okurka", "150g", "135 Kč")) - foodList.append(Food("Hovězí líčka na červeném víně, bramborová kaše", "150g", "145 Kč")) - foodList.append(Food("Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát", "150g", "135 Kč")) - return foodList - if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle': - return [] - html = getOrDownloadHtml('u_motliku', URL_MOTLICI) - soup = BeautifulSoup(html, "html.parser") - table = soup.find("table", {"class": "Xtable-striped"}) - rows = table.find_all("tr") - if len(rows) < 4: - print('Chyba: neočekávaný celkový počet řádek tabulky (' + - str(len(rows)) + '), očekáváno 4 a více', file=sys.stderr) - sys.exit(1) - - foodList: List[Food] = [] - - if rows[0].td.text.strip() == 'Polévka': - tds = rows[1].find_all("td") - if len(tds) != 3: - print('Chyba: neočekávaný počet elementů v řádce polévky (' + - str(len(tds)) + '), očekáváno 3', file=sys.stderr) - sys.exit(1) - amount = tds[0].text.strip() - name = tds[1].text.strip() - price = tds[2].text.strip().replace(',-', '') - foodList.append(Food(name, amount, price, True)) - rows = rows[2:] - - if rows[0].td.text.strip() == 'Hlavní jídlo': - for i in range(1, len(rows)): - tds = rows[i].find_all("td") - if len(tds) != 3: - print("Neočekávaný počet elementů (" + str(len(tds) - ) + ") pro hlavní jídlo " + str(i) + ", přeskakuji") - continue - amount = tds[0].text.strip() - name = tds[1].text.strip() - price = tds[2].text.strip().replace(',-', '') - foodList.append(Food(name, amount, price)) - return foodList - - -def getMenuTechTower(mock: bool = False) -> List[Food]: - if mock: - foodList: List[Food] = [] - foodList.append(Food("Bavorská gulášová polévka s kroupami", "-", "40 Kč", True)) - foodList.append(Food("Vepřové výpečky, kedlubnové zelí, bramborový knedlík", "-", "120 Kč")) - foodList.append(Food("Hambuger Black Angus s čedarem a slaninou, cibulové kroužky", "-", "220 Kč")) - return foodList - if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle': - return [] - html = getOrDownloadHtml('techtower', URL_TECHTOWER) - soup = BeautifulSoup(html, "html.parser") - fonts = soup.find_all("font", {"class": ["wsw-41"]}) - font = None - for f in fonts: - if (f.text.strip().startswith("Obědy")): - font = f - if font is None: - print('Chyba: nenalezen pro obědy v HTML Techtower.', file=sys.stderr) - sys.exit(1) - siblings = font.parent.parent.find_next_siblings("p") - # dayNumber = date.today().strftime("%w") - currentDayName = getDayNameOfDate(datetime.datetime.now()) - foodList: List[Food] = [] - doParse = False - for i in range(0, len(siblings)): - text = siblings[i].text.strip().replace('\t', '').replace('\n', ' ') - if isNameOfDay(text): - if text == currentDayName: - # Našli jsme dnešní den, odtud začínáme parsovat jídla - doParse = True - elif doParse == True: - # Už parsujeme jídla, ale narazili jsme na následující den - končíme - break - elif doParse: - if len(text.strip()) == 0: - # Prázdná řádka - končíme (je za pátečním menu TechTower) - break - price = '? Kč' - if text.endswith('Kč'): - split = text.rsplit(' ', 2) - price = " ".join(split[1:]) - text = split[0] - foodList.append(Food(text, '-', price, isTextSoupName(text))) - return foodList - - -if __name__ == "__main__": - if len(sys.argv) > 1: - input = sys.argv[1].lower() - selectedDate = None - if input[0].isalpha(): - matches = [] - for day in DAY_NAMES: - if day.startswith(input): - matches.append(day) - if len(matches) == 1: - print("Match - den v týdnu - " + matches[0]) - selectedDate = getStartOfWeekDate( - ) + timedelta(DAY_NAMES.index(matches[0])) - elif len(matches) == 0: - # TODO zkusit v, z (včera, zítra) - if 'zítra'.startswith(input): - print("Match - zítra") - selectedDate = datetime.datetime.now() + timedelta(days=1) - elif 'včera'.startswith(input): - print("Match - včera") - selectedDate = datetime.datetime.now() + timedelta(days=-1) - elif 'dneska'.startswith(input): - print("Match - dnes") - selectedDate = datetime.datetime.now() - else: - print('Nejasný parametr "' + input + - '" - může znamenat jednu z možností: ' + ', '.join(matches), file=sys.stderr) - sys.exit(1) - else: - # TODO implementovat zadání datem - print('Zadání datem není aktuálně implementováno', file=sys.stderr) - sys.exit(1) - print("Datum: " + selectedDate.strftime('%d.%m.%Y')) - print("Den: " + getDayNameOfDate(selectedDate)) - # printMenu('Sladovnická', getMenuSladovnicka()) - # printMenu('U Motlíků', getMenuUMotliku()) - # printMenu('TechTower', getMenuTechTower()) diff --git a/food_api/requirements.txt b/food_api/requirements.txt deleted file mode 100644 index a503330..0000000 --- a/food_api/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -beautifulsoup4==4.12.2 -fastapi==0.95.2 -uvicorn==0.22.0 \ No newline at end of file diff --git a/food_api/run_dev.sh b/food_api/run_dev.sh deleted file mode 100755 index 8ff42a5..0000000 --- a/food_api/run_dev.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -dir="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -cd $dir - -python3 -m venv venv -source venv/bin/activate -pip3 install -r requirements.txt -uvicorn food_api:app --port 3002 --reload \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf index a87e5c9..6e41b7e 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -6,10 +6,6 @@ upstream server { server server:3001; } -upstream food_api { - server food_api:3002; -} - server { listen 80; @@ -35,13 +31,7 @@ server { proxy_set_header Connection "Upgrade"; } - location /api/food { - rewrite /api/food(.*) /$1 break; - proxy_pass http://food_api; - } - location /api { - # rewrite /api/(.*) /$1 break; proxy_pass http://server; } } \ No newline at end of file diff --git a/run_dev.sh b/run_dev.sh index 49583c3..2929a20 100755 --- a/run_dev.sh +++ b/run_dev.sh @@ -1,5 +1,4 @@ export NODE_ENV=development -./food_api/run_dev.sh & cd server && yarn install && yarn start & cd client && yarn install && yarn start & wait \ No newline at end of file diff --git a/server/.env.template b/server/.env.template index 1bceafe..099e9ce 100644 --- a/server/.env.template +++ b/server/.env.template @@ -1,10 +1,6 @@ -# URL na kterém je dostupný Food API parser. -# Pro vývoj není potřeba, bude použita výchozí hodnota http://127.0.0.1:3002 -# FOOD_API_URL=http://nginx/api/food - -# Zapne režim mockování jídelních lístků. +# Zapne režim mockování obědových menu. # Vhodné pro vývoj o víkendech, svátcích a dalších dnech, pro které podniky nenabízejí obědové menu. -# V tomto režimu vrací server vždy falešné datum (pracovní den) a Food API pevně nadefinovanou, smyšlenou nabídku jídel. +# V tomto režimu vrací server vždy falešné datum (pracovní den) a pevně nadefinovanou, smyšlenou nabídku jídel. # MOCK_DATA=true # Určuje servery Gotify a příslušné klíče API. diff --git a/server/src/index.ts b/server/src/index.ts index dde5add..3993e3d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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. */ diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index 8ea040c..7d02ad6 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -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 => { + 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 => { + 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 => { + 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 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.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; } \ No newline at end of file diff --git a/server/src/types.ts b/server/src/types.ts index cab1bc8..0a52a9b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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ů',