Zbavení se Food API, zahrnutí do serveru
This commit is contained in:
parent
0d6020d1a0
commit
9090b156ce
19
README.md
19
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
|
||||
- [ ] 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)')
|
@ -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<boolean>(false);
|
||||
const [data, setData] = useState<ClientData>();
|
||||
const [food, setFood] = useState<any>();
|
||||
const [food, setFood] = useState<{ [key in Restaurants]: Food[] }>();
|
||||
const [pizzy, setPizzy] = useState<Pizza[]>();
|
||||
const [myOrder, setMyOrder] = useState<Order>();
|
||||
const socket = useContext(SocketContext);
|
||||
@ -208,13 +208,14 @@ function App() {
|
||||
<li>Nová žárovka zatím funguje</li>
|
||||
<li>Funkční generování a zobrazení QR kódů pro Pizza day</li>
|
||||
<li>Možnost zadat k Pizza day objednávce poznámku</li>
|
||||
<li>Zbavení se Food API, přepsání a zahrnutí parseru do serveru</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
<h1 className='title'>Dnes je {data.date}</h1>
|
||||
<Row className='food-tables'>
|
||||
{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])}
|
||||
</Row>
|
||||
<div className='content-wrapper'>
|
||||
<div className='content'>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"]
|
@ -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
|
@ -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)
|
||||
}
|
@ -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 <td> 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 <td> 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 <font> 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())
|
@ -1,3 +0,0 @@
|
||||
beautifulsoup4==4.12.2
|
||||
fastapi==0.95.2
|
||||
uvicorn==0.22.0
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -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.
|
||||
|
@ -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ů',
|
||||
|
Loading…
x
Reference in New Issue
Block a user