initial commit
This commit is contained in:
commit
d9ae6527a1
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
8
.idea/beebot-docker.iml
Normal file
8
.idea/beebot-docker.iml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
189
.idea/icon.svg
Normal file
189
.idea/icon.svg
Normal file
@ -0,0 +1,189 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 16.933333 16.933333"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:export-filename="logo.png"
|
||||
inkscape:export-xdpi="768"
|
||||
inkscape:export-ydpi="768"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
showgrid="true"
|
||||
inkscape:zoom="12.015825"
|
||||
inkscape:cx="26.132205"
|
||||
inkscape:cy="33.122986"
|
||||
inkscape:window-width="1858"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="1982"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1">
|
||||
<inkscape:grid
|
||||
id="grid1"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="0.26458333"
|
||||
spacingy="0.26458333"
|
||||
empcolor="#0099e5"
|
||||
empopacity="0.30196078"
|
||||
color="#0099e5"
|
||||
opacity="0.14901961"
|
||||
empspacing="8"
|
||||
dotted="false"
|
||||
gridanglex="30"
|
||||
gridanglez="30"
|
||||
visible="true" />
|
||||
</sodipodi:namedview>
|
||||
<defs
|
||||
id="defs1">
|
||||
<inkscape:path-effect
|
||||
effect="powermask"
|
||||
id="path-effect7"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
uri="#mask-powermask-path-effect7"
|
||||
invert="false"
|
||||
hide_mask="false"
|
||||
background="true"
|
||||
background_color="#ffffffff" />
|
||||
<filter
|
||||
id="mask-powermask-path-effect5_inverse"
|
||||
inkscape:label="filtermask-powermask-path-effect5"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
height="100"
|
||||
width="100"
|
||||
x="-50"
|
||||
y="-50">
|
||||
<feColorMatrix
|
||||
id="mask-powermask-path-effect5_primitive1"
|
||||
values="1"
|
||||
type="saturate"
|
||||
result="fbSourceGraphic" />
|
||||
<feColorMatrix
|
||||
id="mask-powermask-path-effect5_primitive2"
|
||||
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0 "
|
||||
in="fbSourceGraphic" />
|
||||
</filter>
|
||||
<mask
|
||||
maskUnits="userSpaceOnUse"
|
||||
id="mask-powermask-path-effect7">
|
||||
<path
|
||||
id="mask-powermask-path-effect7_box"
|
||||
style="fill:#ffffff;fill-opacity:1"
|
||||
d="m 5.01963,2.175 h 6.894073 V 15.816666 H 5.01963 Z" />
|
||||
<g
|
||||
id="g7"
|
||||
style="">
|
||||
<rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.33906;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
id="rect5"
|
||||
width="6.0584898"
|
||||
height="0.73989791"
|
||||
x="5.5562496"
|
||||
y="9.948801"
|
||||
d="m 5.5562496,9.948801 h 6.0584894 v 0.739898 H 5.5562496 Z" />
|
||||
<rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.33906;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
id="rect6"
|
||||
width="6.0584898"
|
||||
height="0.73989791"
|
||||
x="5.5562496"
|
||||
y="11.536301"
|
||||
d="m 5.5562496,11.536301 h 6.0584894 v 0.739898 H 5.5562496 Z" />
|
||||
<rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.33906;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
id="rect7"
|
||||
width="6.0584898"
|
||||
height="0.73989791"
|
||||
x="5.5562496"
|
||||
y="13.1238"
|
||||
d="m 5.5562496,13.1238 h 6.0584894 v 0.739898 H 5.5562496 Z" />
|
||||
</g>
|
||||
</mask>
|
||||
<filter
|
||||
id="mask-powermask-path-effect7_inverse"
|
||||
inkscape:label="filtermask-powermask-path-effect7"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
height="100"
|
||||
width="100"
|
||||
x="-50"
|
||||
y="-50">
|
||||
<feColorMatrix
|
||||
id="mask-powermask-path-effect7_primitive1"
|
||||
values="1"
|
||||
type="saturate"
|
||||
result="fbSourceGraphic" />
|
||||
<feColorMatrix
|
||||
id="mask-powermask-path-effect7_primitive2"
|
||||
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0 "
|
||||
in="fbSourceGraphic" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<path
|
||||
sodipodi:type="star"
|
||||
style="fill:#e8d6ac;fill-opacity:1;stroke:none;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
id="path8"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.4666662"
|
||||
sodipodi:cy="8.4666662"
|
||||
sodipodi:r1="8.2020829"
|
||||
sodipodi:r2="7.3787446"
|
||||
sodipodi:arg1="-2.1657387e-16"
|
||||
sodipodi:arg2="0.5235988"
|
||||
inkscape:rounded="0"
|
||||
inkscape:randomized="0"
|
||||
d="m 16.668749,8.4666662 -4.101041,7.1032118 -8.2020831,0 -4.10104131,-7.1032118 4.10104131,-7.1032119 8.2020831,0 z" />
|
||||
<path
|
||||
style="fill:#181716;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-dashoffset:0.799748;fill-opacity:1"
|
||||
d="m 8.4666663,14.816666 c 2.9104167,0 3.1749997,-7.4083331 1.0583333,-8.4666663 C 10.81418,5.4168093 10.583333,3.1749998 8.4666663,3.175 6.3499998,3.1750002 6.0854163,5.5562497 7.408333,6.3499997 5.2916664,7.4083329 5.5562499,14.816666 8.4666663,14.816666 Z"
|
||||
id="path1"
|
||||
sodipodi:nodetypes="zczcz"
|
||||
mask="url(#mask-powermask-path-effect7)"
|
||||
inkscape:path-effect="#path-effect7"
|
||||
inkscape:original-d="m 8.4666663,14.816666 c 2.9104167,0 3.1749997,-7.4083331 1.0583333,-8.4666663 C 10.81418,5.4168093 10.583333,3.1749998 8.4666663,3.175 6.3499998,3.1750002 6.0854163,5.5562497 7.408333,6.3499997 5.2916664,7.4083329 5.5562499,14.816666 8.4666663,14.816666 Z" />
|
||||
<path
|
||||
style="fill:none;stroke:#181716;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
d="m 9.7895828,6.3499997 c 0,-1.5875 4.7625002,0.2645833 4.4979162,1.8520832 C 14.022916,9.7895828 9.7895828,7.9374996 9.7895828,6.3499997 Z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="zzz" />
|
||||
<path
|
||||
style="fill:none;stroke:#181716;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
d="m 7.1437496,6.3499997 c 0,-1.5875 -4.7625002,0.2645833 -4.4979162,1.8520832 0.264583,1.5875 4.4979162,-0.2645833 4.4979162,-1.8520832 z"
|
||||
id="path2-6"
|
||||
sodipodi:nodetypes="zzz" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#181716;stroke-width:0.26458333;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
d="M 9.2604162,3.4395832 C 9.7603245,2.3048106 10.945507,2.0921784 11.641666,2.1166666"
|
||||
id="path7"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#181716;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0.799748;stroke-opacity:1"
|
||||
d="M 7.6729163,3.4395832 C 7.173008,2.3048106 5.9878255,2.0921784 5.2916665,2.1166666"
|
||||
id="path7-2"
|
||||
sodipodi:nodetypes="cc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 7.5 KiB |
25
.idea/inspectionProfiles/Project_Default.xml
Normal file
25
.idea/inspectionProfiles/Project_Default.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ourVersions">
|
||||
<value>
|
||||
<list size="3">
|
||||
<item index="0" class="java.lang.String" itemvalue="3.13" />
|
||||
<item index="1" class="java.lang.String" itemvalue="3.10" />
|
||||
<item index="2" class="java.lang.String" itemvalue="3.8" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="PyYAML" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
7
.idea/misc.xml
Normal file
7
.idea/misc.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.10" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
|
||||
</project>
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/beebot-docker.iml" filepath="$PROJECT_DIR$/.idea/beebot-docker.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
7
.idea/sqldialects.xml
Normal file
7
.idea/sqldialects.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/src/beebot.py" dialect="GenericSQL" />
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM python:3.10
|
||||
LABEL authors="Lord Baryhobal"
|
||||
LABEL maintainer="Lord Baryhobal <lordbaryhobal@gmail.com>"
|
||||
|
||||
RUN echo "Installing Typst" \
|
||||
&& wget -q -O /tmp/typst.tar.xz https://github.com/typst/typst/releases/download/v0.11.1/typst-x86_64-unknown-linux-musl.tar.xz \
|
||||
&& tar -x /tmp/typst.tar.xz -C /tmp/ \
|
||||
&& mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/bin/typst \
|
||||
&& chmod +x /usr/bin/typst \
|
||||
&& rm -r /tmp/typst-x86_64-unknown-linux-musl \
|
||||
&& rm /tmp/typst.tar.xz
|
||||
|
||||
WORKDIR /app
|
||||
COPY src /app
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
ENTRYPOINT ["python", "beebot.py"]
|
263
src/beebot.py
Normal file
263
src/beebot.py
Normal file
@ -0,0 +1,263 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from sqlite3 import Connection
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import telegram.constants
|
||||
from bs4 import BeautifulSoup
|
||||
from telegram import Update
|
||||
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
|
||||
|
||||
logging.basicConfig(
|
||||
format="[%(asctime)s] %(name)s/%(levelname)s - %(message)s",
|
||||
level=logging.INFO
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
class BeeBot:
|
||||
MENU_URL = "https://www.epfl.ch/campus/restaurants-shops-hotels/fr/industrie21-epfl-valais-wallis/?date={date}"
|
||||
RESTO_ID = 545
|
||||
BASE_DIR = os.path.dirname(__file__)
|
||||
CACHE_PATH = os.path.join(BASE_DIR, "cache.json")
|
||||
DB_PATH = os.path.join(BASE_DIR, "database.db")
|
||||
TEMPLATE_PATH = os.path.join(BASE_DIR, "menu.typ")
|
||||
IMG_DIR = "/tmp/menu_images"
|
||||
|
||||
def __init__(self, token: str):
|
||||
self.tg_token: str = token
|
||||
self.tg_app = None
|
||||
self.cache: dict[str, str] = {}
|
||||
self.db_con: Optional[Connection] = None
|
||||
self.locks: dict[str, bool] = {}
|
||||
self.fetch_lock = asyncio.Lock()
|
||||
self.gen_lock = asyncio.Lock()
|
||||
|
||||
def mainloop(self):
|
||||
logger.info("Initialization")
|
||||
|
||||
self.db_con = sqlite3.connect(self.DB_PATH)
|
||||
self.check_database()
|
||||
self.load_cache()
|
||||
|
||||
app = ApplicationBuilder().token(self.tg_token).build()
|
||||
app.add_handler(CommandHandler("week", self.cmd_week))
|
||||
app.add_handler(CommandHandler("today", self.cmd_today))
|
||||
|
||||
logger.info("Starting bot")
|
||||
app.run_polling()
|
||||
|
||||
async def cmd_week(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
logger.debug("Received /week")
|
||||
await self.request_menu(update, context, False)
|
||||
|
||||
async def cmd_today(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
logger.debug("Received /today")
|
||||
await self.request_menu(update, context, True)
|
||||
|
||||
async def request_menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE, today_only: bool) -> None:
|
||||
chat_id = update.effective_chat.id
|
||||
await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING)
|
||||
prefs = self.get_user_pref(update)
|
||||
logger.debug(f"User prefs: {prefs}")
|
||||
categories = prefs["categories"]
|
||||
|
||||
img_id = self.get_img_id(today_only, categories)
|
||||
filename = img_id + ".png"
|
||||
img_path = os.path.join(self.IMG_DIR, filename)
|
||||
|
||||
menu_id = "today_menu" if today_only else "week_menu"
|
||||
menu_path = os.path.join(self.BASE_DIR, "menus_today.json" if today_only else "menus_week.json")
|
||||
|
||||
# If menu needs to be fetched
|
||||
if not os.path.exists(menu_path) or self.is_outdated(menu_id, today_only):
|
||||
# Notify user
|
||||
msg = await update.message.reply_text("The menu is being updated, please wait...")
|
||||
async with self.fetch_lock:
|
||||
if today_only:
|
||||
await self.fetch_today_menu()
|
||||
else:
|
||||
await self.fetch_week_menu()
|
||||
await msg.delete()
|
||||
|
||||
# If image needs to be (re)generated
|
||||
if not os.path.exists(img_path) or self.is_outdated(img_id, today_only):
|
||||
await self.gen_image(today_only, categories, img_path, img_id)
|
||||
|
||||
await context.bot.send_photo(chat_id=chat_id, photo=img_path)
|
||||
|
||||
def check_database(self):
|
||||
cur = self.db_con.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" INTEGER,
|
||||
"telegram_id" INTEGER NOT NULL UNIQUE,
|
||||
"categories" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
""")
|
||||
self.db_con.commit()
|
||||
cur.close()
|
||||
|
||||
def get_user_pref(self, update: Update) -> dict:
|
||||
user_id = update.effective_user.id
|
||||
cur = self.db_con.cursor()
|
||||
res = cur.execute("SELECT categories FROM user WHERE telegram_id=?", (user_id,))
|
||||
user = res.fetchone()
|
||||
cur.close()
|
||||
|
||||
if user is None:
|
||||
self.create_user(user_id)
|
||||
return {
|
||||
"categories": {"E", "D", "C", "V"}
|
||||
}
|
||||
|
||||
categories = set(user[0].split(","))
|
||||
return {
|
||||
"categories": categories
|
||||
}
|
||||
|
||||
def create_user(self, telegram_id: int) -> None:
|
||||
logger.debug(f"New user with id {telegram_id}")
|
||||
cur = self.db_con.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO user (telegram_id, categories) VALUES (?, ?)",
|
||||
(telegram_id, "E,D,C,V")
|
||||
)
|
||||
self.db_con.commit()
|
||||
cur.close()
|
||||
|
||||
def load_cache(self):
|
||||
if os.path.exists(self.CACHE_PATH):
|
||||
with open(self.CACHE_PATH, "r") as f:
|
||||
self.cache = json.load(f)
|
||||
|
||||
def save_cache(self):
|
||||
with open(self.CACHE_PATH, "w") as f:
|
||||
json.dump(self.cache, f)
|
||||
|
||||
@staticmethod
|
||||
def get_img_id(today_only: bool, categories: set[str]) -> str:
|
||||
categs = "".join(sorted(categories))
|
||||
layout = "today" if today_only else "week"
|
||||
return f"{layout}-{categs}"
|
||||
|
||||
def is_outdated(self, obj_id: str, today_only: bool) -> bool:
|
||||
if obj_id not in self.cache:
|
||||
return True
|
||||
gen_date = datetime.datetime.strptime(self.cache[obj_id], "%Y-%m-%d")
|
||||
today = datetime.datetime.today()
|
||||
if today_only:
|
||||
if (today - gen_date).days > 0:
|
||||
return True
|
||||
else:
|
||||
delta1 = datetime.timedelta(days=gen_date.weekday())
|
||||
delta2 = datetime.timedelta(days=today.weekday())
|
||||
gen_monday = gen_date - delta1
|
||||
cur_monday = today - delta2
|
||||
if (cur_monday - gen_monday).days > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def gen_image(self, today_only: bool, categories: set[str], outpath: str, img_id: str) -> None:
|
||||
if not os.path.exists(self.IMG_DIR):
|
||||
os.mkdir(self.IMG_DIR)
|
||||
|
||||
logger.info(f"Generating {'today' if today_only else 'week'} menu image for categories {categories}")
|
||||
menu_path = "menus_today.json" if today_only else "menus_week.json"
|
||||
subprocess.call([
|
||||
"typst",
|
||||
"compile",
|
||||
"--root", self.BASE_DIR,
|
||||
"--font-path", os.path.join(self.BASE_DIR, "fonts"),
|
||||
"--input", f"path={menu_path}",
|
||||
"--input", f"categories={','.join(categories)}",
|
||||
self.TEMPLATE_PATH,
|
||||
outpath
|
||||
])
|
||||
|
||||
self.cache[img_id] = datetime.datetime.today().strftime("%Y-%m-%d")
|
||||
self.save_cache()
|
||||
|
||||
def save_menu(self, days: list, menu_id: str, filename: str) -> None:
|
||||
with open(os.path.join(self.BASE_DIR, filename), "w") as f:
|
||||
json.dump(days, f)
|
||||
|
||||
self.cache[menu_id] = datetime.datetime.today().strftime("%Y-%m-%d")
|
||||
self.save_cache()
|
||||
|
||||
async def fetch_week_menu(self) -> None:
|
||||
logger.info("Fetching week menu")
|
||||
today = datetime.datetime.today()
|
||||
delta = datetime.timedelta(days=today.weekday())
|
||||
monday = today - delta
|
||||
days = []
|
||||
for i in range(5):
|
||||
dt = datetime.timedelta(days=i)
|
||||
date = monday + dt
|
||||
menus = await self.fetch_menu(date)
|
||||
days.append({
|
||||
"date": date.strftime("%Y-%m-%d"),
|
||||
"menus": menus
|
||||
})
|
||||
|
||||
self.save_menu(days, "week_menu", "menus_week.json")
|
||||
|
||||
async def fetch_today_menu(self) -> None:
|
||||
logger.info("Fetching today menu")
|
||||
today = datetime.datetime.today()
|
||||
menus = await self.fetch_menu(today)
|
||||
days = [{
|
||||
"date": today.strftime("%Y-%m-%d"),
|
||||
"menus": menus
|
||||
}]
|
||||
|
||||
self.save_menu(days, "today_menu", "menus_today.json")
|
||||
|
||||
async def fetch_menu(self, date: datetime.date) -> list:
|
||||
url = self.MENU_URL.format(date=date.strftime("%Y-%m-%d"))
|
||||
headers = {
|
||||
"User-Agent": "BeeBot 1.0"
|
||||
}
|
||||
res = requests.get(url, headers=headers)
|
||||
|
||||
if res.status_code != 200:
|
||||
return []
|
||||
|
||||
bs = BeautifulSoup(res.content, features="lxml")
|
||||
table = bs.find("table", {"id": "menuTable"})
|
||||
lines = table.find("tbody").findAll("tr", {"data-restoid": str(self.RESTO_ID)})
|
||||
|
||||
menus = []
|
||||
|
||||
for line in lines:
|
||||
menu = line.find("td", class_="menu").find("div", class_="descr").contents[0].text
|
||||
menu_lines = menu.split("\n")
|
||||
|
||||
price_cells = line.find("td", class_="prices").findAll("span", class_="price")
|
||||
prices = {}
|
||||
for cell in price_cells:
|
||||
category = cell.find("abbr").text
|
||||
price = cell.contents[-1].replace("CHF", "").strip()
|
||||
prices[category] = float(price)
|
||||
|
||||
menus.append({
|
||||
"name": menu_lines[0],
|
||||
"extra": menu_lines[1:],
|
||||
"prices": prices
|
||||
})
|
||||
|
||||
return menus
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("Welcome to BeeBot !")
|
||||
bot = BeeBot(os.getenv("TELEGRAM_TOKEN"))
|
||||
bot.mainloop()
|
154
src/menu.typ
Normal file
154
src/menu.typ
Normal file
@ -0,0 +1,154 @@
|
||||
#let path = sys.inputs.at("path", default: "menus.json")
|
||||
#let categories = sys.inputs.at("categories", default: "E,D,C,V").split(",")
|
||||
#let days = json(path)
|
||||
#let nbsp = sym.space.nobreak
|
||||
|
||||
#let width = if days.len() > 1 {20cm} else {10cm}
|
||||
|
||||
#set page(margin: 0cm, height: auto, width: width)
|
||||
|
||||
#let parse-date(date-txt) = {
|
||||
let (year, month, day) = date-txt.split("-")
|
||||
return datetime(year: int(year), month: int(month), day: int(day))
|
||||
}
|
||||
|
||||
#let fmt-number(
|
||||
number,
|
||||
decimals: 2,
|
||||
decimal-sep: ".",
|
||||
thousands-sep: "'"
|
||||
) = {
|
||||
let integer = calc.trunc(number)
|
||||
let decimal = calc.fract(number)
|
||||
let res = str(integer).clusters()
|
||||
.rev()
|
||||
.chunks(3)
|
||||
.map(c => c.join(""))
|
||||
.join(thousands-sep)
|
||||
.rev()
|
||||
|
||||
if decimals != 0 {
|
||||
res += decimal-sep
|
||||
decimal = decimal * calc.pow(10, decimals)
|
||||
decimal = calc.round(decimal)
|
||||
decimal = str(decimal) + ("0" * decimals)
|
||||
res += decimal.slice(0, decimals)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
#let day-names = (
|
||||
"Lundi",
|
||||
"Mardi",
|
||||
"Mercredi",
|
||||
"Jeudi",
|
||||
"Vendredi",
|
||||
"Samedi",
|
||||
"Dimanche"
|
||||
)
|
||||
|
||||
#let display-menus(
|
||||
days,
|
||||
categories,
|
||||
day-sep-stroke: rgb("#82BC00") + 1pt,
|
||||
title-underline-stroke: rgb("#e34243") + 2pt,
|
||||
menu-sep-stroke: rgb("#ababab") + 1pt,
|
||||
price-categ-color: rgb("#577F25")
|
||||
) = {
|
||||
set text(font: "Liberation Sans", hyphenate: true)
|
||||
let cols = calc.min(2, days.len())
|
||||
|
||||
let cells = ()
|
||||
|
||||
for day in days {
|
||||
let weekday = parse-date(day.date).weekday() - 1
|
||||
let day-name = day-names.at(weekday)
|
||||
let menus = ()
|
||||
for menu in day.menus {
|
||||
let price-cell = grid.cell(rowspan: 2)[]
|
||||
let prices = menu.at(
|
||||
"prices",
|
||||
default: (:)
|
||||
)
|
||||
if menu.keys().contains("price") {
|
||||
prices = ("": menu.price)
|
||||
}
|
||||
|
||||
let pairs = prices.pairs()
|
||||
.filter(p => p.first() in categories)
|
||||
.sorted(key: p => p.last())
|
||||
|
||||
let prices = pairs.map(p => {
|
||||
let categ = emph(
|
||||
text(
|
||||
fill: price-categ-color,
|
||||
p.first()
|
||||
)
|
||||
)
|
||||
let price = fmt-number(p.last())
|
||||
categ + ":" + nbsp + "CHF" + nbsp + price
|
||||
})
|
||||
price-cell = grid.cell(
|
||||
rowspan: menu.extra.len() + 1,
|
||||
stack(dir: ttb, spacing: .4em, ..prices)
|
||||
)
|
||||
//let price = "CHF" + sym.space.nobreak + str(menu.price)
|
||||
menus.push(
|
||||
grid(
|
||||
columns: (1fr, auto),
|
||||
column-gutter: .4em,
|
||||
row-gutter: .4em,
|
||||
[*#menu.name*],
|
||||
price-cell,
|
||||
..menu.extra.map(l => text(10pt, l))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let title = align(
|
||||
center,
|
||||
text(size: 14pt, weight: "bold", day-name)
|
||||
)
|
||||
let title-box = box(
|
||||
width: 50%,
|
||||
inset: .6em,
|
||||
stroke: (bottom: title-underline-stroke),
|
||||
title
|
||||
)
|
||||
let title-cell = grid.cell(align: center, title-box)
|
||||
|
||||
cells.push(box(width: 100%, grid(
|
||||
columns: (100%),
|
||||
inset: (x, y) => (
|
||||
top: if y == 0 {0pt} else {.8em},
|
||||
bottom: if y == 0 {0pt} else {2em}
|
||||
),
|
||||
stroke: (_, y) => (top: if y < 2 {none} else {menu-sep-stroke}),
|
||||
title-cell,
|
||||
..menus
|
||||
)))
|
||||
}
|
||||
|
||||
if calc.rem(cells.len(), 2) == 1 {
|
||||
cells.last() = grid.cell(colspan: calc.min(2, cols), cells.last())
|
||||
}
|
||||
|
||||
grid(
|
||||
columns: (100% / cols,) * cols,
|
||||
inset: (x: 1.5em, y: .8em),
|
||||
stroke: (x, y) => {
|
||||
let borders = (:)
|
||||
if x != 0 {
|
||||
borders.insert("left", day-sep-stroke)
|
||||
}
|
||||
if y != 0 {
|
||||
borders.insert("top", day-sep-stroke)
|
||||
}
|
||||
return borders
|
||||
},
|
||||
..cells
|
||||
)
|
||||
}
|
||||
|
||||
#display-menus(days, categories)
|
4
src/requirements.txt
Normal file
4
src/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
requests==2.25.1
|
||||
beautifulsoup4==4.10.0
|
||||
lxml==4.8.0
|
||||
python-telegram-bot==21.5
|
Loading…
Reference in New Issue
Block a user