diff --git a/.gitignore b/.gitignore index db200c1..ffb6401 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /.coverage /htmlcov *.pyc - +.devcontainer +.vscode diff --git a/Makefile b/Makefile index 5936797..8a83168 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ run: $(RUN) debug: - FLASK_ENV=development $(RUN) + FLASK_ENV=development $(RUN) --debug test: pipenv run python -m pytest -vv --cov-branch --cov stw_potsdam --cov-report term --cov-report html diff --git a/stw_potsdam/swp_webspeiseplan_api.py b/stw_potsdam/swp_webspeiseplan_api.py new file mode 100644 index 0000000..8204b7f --- /dev/null +++ b/stw_potsdam/swp_webspeiseplan_api.py @@ -0,0 +1,82 @@ +import logging +import urllib.request +import re +import time +import json + + +class SWP_Webspeiseplan_API: + def __init__(self): + logging.basicConfig() + self.logger = logging.getLogger(__name__) + self.url_base = "https://swp.webspeiseplan.de" + self.__parse_token() + params = { + "token": self.proxy_token, + "model": "outlet", + "location": "", + "languagetype": "", + "_": int(time.time() * 1000), + } + + self.outlets = {outlet["name"]: outlet for outlet in self.__parse_model(params)} + self.menus = {} + self.meal_categories = {} + for outlet in self.outlets.values(): + params["model"] = "menu" + params["location"] = outlet["standortID"] + params["languagetype"] = 2 + params["_"] = int(time.time() * 1000) + menu = self.__parse_model(params) + self.menus[outlet["name"]] = menu + + params["model"] = "mealCategory" + params["_"] = int(time.time() * 1000) + categories = self.__parse_model(params) + id2cat = {item["gerichtkategorieID"]: item for item in categories} + self.meal_categories[outlet["name"]] = id2cat + + def __parse_token(self): + req = urllib.request.Request(self.url_base) + with urllib.request.urlopen(req) as resp: + txt = resp.read().decode("utf-8") + match = re.findall(r"/main.[0-9a-f]+.js", txt)[0] + self.logger.debug(f"__parse_token: downloading script {match}") + req = urllib.request.Request(f"{self.url_base}{match}") + with urllib.request.urlopen(req) as resp: + txt = resp.read().decode("utf-8") + self.proxy_token = re.findall(r"PROXY_TOKEN:\"([0-9a-f]+)\"", txt)[0] + self.logger.debug(f"__parse_token: PROXY_TOKEN {self.proxy_token}") + + def __spoof_req_headers(req: urllib.request.Request): + req.add_header("Accept", "application/json, text/javascript, */*; q=0.01") + req.add_header("Accept-Language", "en-US,en;q=0.9") + req.add_header("Connection", "keep-alive") + req.add_header("Host", "swp.webspeiseplan.de") + req.add_header("Referer", "https://swp.webspeiseplan.de/InitialConfig") + req.add_header( + "Sec-Ch-Ua", + '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"', + ) + req.add_header("Sec-Ch-Ua-Mobile", "?0") + req.add_header("Sec-Ch-Ua-Platform", "Linux") + req.add_header("Sec-Fetch-Dest", "empty") + req.add_header("Sec-Fetch-Mode", "cors") + req.add_header("Sec-Fetch-Site", "same-origin") + req.add_header( + "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", + ) + req.add_header("X-Requested-With", "XMLHttpRequest") + + def __parse_model(self, params: dict): + url = f"{self.url_base}/index.php?" + "&".join( + [f"{k}={v}" for k, v in params.items()] + ) + self.logger.debug(f"__parse_model: {url}") + req = urllib.request.Request(url) + SWP_Webspeiseplan_API.__spoof_req_headers(req) + with urllib.request.urlopen(req) as resp: + data = resp.read() + return json.loads(data)["content"] + diff --git a/stw_potsdam/swp_webspeiseplan_parser.py b/stw_potsdam/swp_webspeiseplan_parser.py new file mode 100644 index 0000000..009257b --- /dev/null +++ b/stw_potsdam/swp_webspeiseplan_parser.py @@ -0,0 +1,70 @@ +import logging +from pyopenmensa.feed import LazyBuilder +from datetime import datetime + + +class SWP_Webspeiseplan_Parser: + def __init__( + self, menu_data: list[dict], meal_categories: list[dict], outlet_data: dict + ): + logging.basicConfig() + self.logger = logging.getLogger(__name__) + self.menu_data = menu_data + self.meal_categories = meal_categories + self.outlet_data = outlet_data + self.canteen = None + self.__parse_canteen(outlet_data) + self.__parse_meals() + + def __parse_canteen(self, outlet: dict): + builder = LazyBuilder() + builder.name = outlet["name"] + builder.address = outlet["addressInfo"]["street"] + builder.city = ( + f'{outlet["addressInfo"]["postalCode"]} {outlet["addressInfo"]["city"]}' + ) + builder.phone = outlet["contactInfo"][0]["phone"] + builder.email = outlet["contactInfo"][0]["email"] + if outlet["positionInfo"]: + builder.location( + str(outlet["positionInfo"]["longitude"]), + str(outlet["positionInfo"]["latitude"]), + ) + + builder.availability = f"Montag: {outlet['moZeit1']}, {outlet['moZeit2']}\n" + builder.availability += f"Dienstag: {outlet['diZeit1']}, {outlet['diZeit2']}\n" + builder.availability += f"Mittwoch: {outlet['miZeit1']}, {outlet['miZeit2']}\n" + builder.availability += ( + f"Donnerstag: {outlet['doZeit1']}, {outlet['doZeit2']}\n" + ) + builder.availability += f"Freitag: {outlet['frZeit1']}, {outlet['frZeit2']}\n" + builder.availability += f"Samstag: {outlet['saZeit1']}, {outlet['saZeit2']}\n" + builder.availability += f"Sonntag: {outlet['soZeit1']}, {outlet['soZeit2']}" + builder.availability = ( + builder.availability.replace("None, None", "") + .replace("None,", "") + .replace(", None", "") + ) + + self.canteen = builder + + def __parse_meals(self): + for menu in self.menu_data: + for meal in menu["speiseplanGerichtData"]: + info = meal["speiseplanAdvancedGericht"] + date = datetime.fromisoformat(info["datum"]).date() + + additional_info = meal["zusatzinformationen"] + self.canteen.addMeal( + date=date, + category=self.meal_categories[info["gerichtkategorieID"]]["name"], + name=info["gerichtname"], + prices={ + "employee": f'{additional_info["mitarbeiterpreisDecimal2"]:.2f}', + "other": f'{additional_info["gaestepreisDecimal2"]:.2f}', + }, + ) + + @property + def xml_feed(self): + return self.canteen.toXMLFeed() diff --git a/stw_potsdam/views.py b/stw_potsdam/views.py index 40ed7c1..9c51e48 100644 --- a/stw_potsdam/views.py +++ b/stw_potsdam/views.py @@ -11,6 +11,8 @@ from flask.logging import create_logger from stw_potsdam import feed from stw_potsdam.config import read_canteen_config from stw_potsdam.canteen_api import MenuParams, download_menu +from stw_potsdam.swp_webspeiseplan_api import SWP_Webspeiseplan_API +from stw_potsdam.swp_webspeiseplan_parser import SWP_Webspeiseplan_Parser CACHE_TIMEOUT = 45 * 60 @@ -32,6 +34,7 @@ if 'BASE_URL' in os.environ: # pragma: no cover cache = ct.TTLCache(maxsize=30, ttl=CACHE_TIMEOUT) +swp_api = SWP_Webspeiseplan_API() def canteen_not_found(config, canteen_name): log.warning('Canteen %s not found', canteen_name) @@ -90,8 +93,12 @@ def canteen_menu_feed(canteen_name): return canteen_not_found(config, canteen_name) canteen = config[canteen_name] - menu = get_menu(canteen) - return canteen_menu_feed_xml(menu) + swp_parser = SWP_Webspeiseplan_Parser( + swp_api.menus[canteen.name], + swp_api.meal_categories[canteen.name], + swp_api.outlets[canteen.name], + ) + return _canteen_feed_xml(swp_parser.xml_feed) @app.route('/') diff --git a/tests/resources/swp_categories.json b/tests/resources/swp_categories.json new file mode 100644 index 0000000..a0e7d56 --- /dev/null +++ b/tests/resources/swp_categories.json @@ -0,0 +1,395 @@ +{ + "success": true, + "content": [ + { + "id": 5, + "name": "ALT 1", + "logoImage": null, + "reihenfolgeInApp": 50, + "titelNaehrwerte": null, + "gerichtkategorieID": 5, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": null, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:00" + }, + { + "id": 18, + "name": "ALT 2", + "logoImage": null, + "reihenfolgeInApp": 50, + "titelNaehrwerte": null, + "gerichtkategorieID": 18, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": null, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:08" + }, + { + "id": 19, + "name": "ALT 3", + "logoImage": null, + "reihenfolgeInApp": 50, + "titelNaehrwerte": null, + "gerichtkategorieID": 19, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": null, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:17" + }, + { + "id": 20, + "name": "ALT 4", + "logoImage": null, + "reihenfolgeInApp": 50, + "titelNaehrwerte": null, + "gerichtkategorieID": 20, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": null, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:27" + }, + { + "id": 171, + "name": "Offer 1", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 149, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:47" + }, + { + "id": 172, + "name": "Offer 2", + "logoImage": null, + "reihenfolgeInApp": 2, + "titelNaehrwerte": null, + "gerichtkategorieID": 150, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:56:59" + }, + { + "id": 173, + "name": "Offer 3", + "logoImage": null, + "reihenfolgeInApp": 3, + "titelNaehrwerte": null, + "gerichtkategorieID": 151, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:57:11" + }, + { + "id": 174, + "name": "Offer 4", + "logoImage": null, + "reihenfolgeInApp": 4, + "titelNaehrwerte": null, + "gerichtkategorieID": 152, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:57:21" + }, + { + "id": 159, + "name": "Hei\u00dfe Theke klein", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 159, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:54:08" + }, + { + "id": 160, + "name": "Hei\u00dfe Theke togo", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 160, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:54:13" + }, + { + "id": 178, + "name": "Hot Counter", + "logoImage": null, + "reihenfolgeInApp": 8, + "titelNaehrwerte": null, + "gerichtkategorieID": 294, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-09T09:41:53" + }, + { + "id": 232, + "name": "Abend Angebot 1", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 232, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:48:49" + }, + { + "id": 233, + "name": "Abend Angebot 2", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 233, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:48:56" + }, + { + "id": 234, + "name": "Abend Angebot 3", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 234, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:49:03" + }, + { + "id": 235, + "name": "Abend Angebot 4", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 235, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:49:12" + }, + { + "id": 236, + "name": "Abend Aktions-Essen", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 236, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:49:24" + }, + { + "id": 237, + "name": "Angebot des Tages - Nur solange der Vorrat reicht", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 237, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:49:33" + }, + { + "id": 238, + "name": "Hei\u00dfe Theke Abend", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 238, + "languageTypeID": 1, + "bildschirmeID": 40, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-08T07:49:42" + }, + { + "id": 239, + "name": "Hei\u00dfe Theke klein Abend", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 239, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 300, + "timestampLog": "2023-11-08T07:45:00" + }, + { + "id": 177, + "name": "Salad bar", + "logoImage": null, + "reihenfolgeInApp": 7, + "titelNaehrwerte": null, + "gerichtkategorieID": 112, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:58:19" + }, + { + "id": 240, + "name": "Hei\u00dfe Theke togo Abend", + "logoImage": null, + "reihenfolgeInApp": 1, + "titelNaehrwerte": null, + "gerichtkategorieID": 240, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 300, + "timestampLog": "2023-11-08T07:45:00" + }, + { + "id": 113, + "name": "Essen 1", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 113, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-09-29T11:56:17" + }, + { + "id": 114, + "name": "Essen 2", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 114, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-09-29T11:56:21" + }, + { + "id": 115, + "name": "Essen 3", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 115, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-09-29T11:56:27" + }, + { + "id": 116, + "name": "Essen 4", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 116, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-09-29T11:56:32" + }, + { + "id": 175, + "name": "Special Offer", + "logoImage": null, + "reihenfolgeInApp": 5, + "titelNaehrwerte": null, + "gerichtkategorieID": 117, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:57:48" + }, + { + "id": 176, + "name": "Daily Special", + "logoImage": null, + "reihenfolgeInApp": 6, + "titelNaehrwerte": null, + "gerichtkategorieID": 118, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:58:04" + }, + { + "id": 179, + "name": "Dessert 1", + "logoImage": null, + "reihenfolgeInApp": 9, + "titelNaehrwerte": null, + "gerichtkategorieID": 119, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:58:57" + }, + { + "id": 180, + "name": "Dessert 2", + "logoImage": null, + "reihenfolgeInApp": 10, + "titelNaehrwerte": null, + "gerichtkategorieID": 120, + "languageTypeID": 2, + "bildschirmeID": 23, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-10-13T10:59:09" + }, + { + "id": 121, + "name": "Hei\u00dfe Theke gro\u00df", + "logoImage": null, + "reihenfolgeInApp": 12, + "titelNaehrwerte": null, + "gerichtkategorieID": 121, + "languageTypeID": 1, + "bildschirmeID": null, + "outletID": 12, + "benutzerID": 35, + "timestampLog": "2023-11-09T09:46:44" + } + ] +} \ No newline at end of file diff --git a/tests/resources/swp_location.json b/tests/resources/swp_location.json new file mode 100644 index 0000000..1b944f6 --- /dev/null +++ b/tests/resources/swp_location.json @@ -0,0 +1,159 @@ +{ + "success": true, + "content": [ + { + "id": 9600, + "name": "Am Neuen Palais", + "logoImage": "https:\/\/swp.konkaapps.de\/kms-resources\/standort\/Studentenwerk_Potsdam_Logo_4c_klein.jpg", + "standortImage": null, + "mapImage": null, + "dashboardImage": null, + "reihenfolge": 1, + "active": true, + "legalInfo": { + "id": 1, + "impressumLink": null, + "impressumRichtext": "\n\n