diff --git a/README.md b/README.md index 10a3734d04..bcbb4ad37c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Simple static API for the canteens of the [Studentenwerk München](http://www.st - Mensa Arcisstraße (mensa-arcisstrasse) - StuBistro Großhadern (stubistro-grosshadern) - FMI Bistro Garching (fmi-bistro) +- IPP Bistro Garching (ipp-bistro) ## Usage @@ -27,10 +28,10 @@ The JSON files are produced by the tool shown in this repository. Hence, it is e ``` $ python src/main.py -h usage: main.py [-h] [-d DATE] [-j PATH] - {mensa-garching,mensa-arcisstrasse,stubistro-grosshadern,fmi-bistro} + {mensa-garching,mensa-arcisstrasse,stubistro-grosshadern,fmi-bistro,ipp-bistro} positional arguments: - {mensa-garching,mensa-arcisstrasse,stubistro-grosshadern} + {mensa-garching,mensa-arcisstrasse,stubistro-grosshadern,fmi-bistro,ipp-bistro} the location you want to eat at optional arguments: diff --git a/scripts/parse.sh b/scripts/parse.sh index f40fb4560f..0c2c58f308 100755 --- a/scripts/parse.sh +++ b/scripts/parse.sh @@ -4,4 +4,5 @@ python src/main.py mensa-garching -j ./dist/mensa-garching python src/main.py mensa-arcisstrasse -j ./dist/mensa-arcisstrasse python src/main.py stubistro-grosshadern -j ./dist/stubistro-grosshadern python src/main.py fmi-bistro -j ./dist/fmi-bistro +python src/main.py ipp-bistro -j ./dist/ipp-bistro tree dist/ \ No newline at end of file diff --git a/src/cli.py b/src/cli.py index 6596bd024c..a99a67c90c 100644 --- a/src/cli.py +++ b/src/cli.py @@ -6,7 +6,7 @@ def parse_cli_args(): parser = argparse.ArgumentParser() parser.add_argument('location', choices=['mensa-garching', 'mensa-arcisstrasse', 'stubistro-grosshadern', - 'fmi-bistro'], + 'fmi-bistro', 'ipp-bistro'], help='the location you want to eat at') parser.add_argument('-d', '--date', help='date (DD.MM.YYYY) of the day of which you want to get the menu') parser.add_argument('-j', '--jsonify', diff --git a/src/main.py b/src/main.py index a1629ee29a..1024d218c6 100644 --- a/src/main.py +++ b/src/main.py @@ -17,6 +17,8 @@ def get_menu_parsing_strategy(location): parser = menu_parser.StudentenwerkMenuParser() elif location == "fmi-bistro": parser = menu_parser.FMIBistroMenuParser() + elif location == "ipp-bistro": + parser = menu_parser.IPPBistroMenuParser() return parser diff --git a/src/menu_parser.py b/src/menu_parser.py index 057a2dfaf0..78ac3d14e0 100644 --- a/src/menu_parser.py +++ b/src/menu_parser.py @@ -197,3 +197,100 @@ def get_menus(self, text, year, week_number): menus[date] = menu return menus + + +class IPPBistroMenuParser(MenuParser): + url = "http://konradhof-catering.de/ipp/" + weekday_positions = {"mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5} + price_regex = r"\d+,\d+\s\€[^\)]" + dish_regex = r".+?\d+,\d+\s\€[^\)]" + + def parse(self, location): + page = requests.get(self.url) + # get html tree + tree = html.fromstring(page.content) + # get url of current pdf menu + xpath_query = tree.xpath("//a[contains(text(), 'KW-')]/@href") + pdf_url = xpath_query[0] if len(xpath_query) >= 1 else None + + if pdf_url is None: + return None + + # Example PDF-name: KW-48_27.11-01.12.10.2017-3.pdf + pdf_name = pdf_url.split("/")[-1] + year = int(pdf_name.replace(".pdf","").split(".")[-1].split("-")[0]) + week_number = int(pdf_name.split("_")[0].replace("KW-","").lstrip("0")) + + with tempfile.NamedTemporaryFile() as temp_pdf: + # download pdf + response = requests.get(pdf_url) + temp_pdf.write(response.content) + with tempfile.NamedTemporaryFile() as temp_txt: + # convert pdf to text by calling pdftotext; only convert first page to txt (-l 1) + call(["pdftotext", "-l", "1", "-layout", temp_pdf.name, temp_txt.name]) + with open(temp_txt.name, 'r') as myfile: + # read generated text file + data = myfile.read() + menus = self.get_menus(data, year, week_number) + return menus + + def get_menus(self, text, year, week_number): + menus = {} + lines = text.splitlines() + count = 0 + # remove headline etc. + for line in lines: + if line.replace(" ", "").replace("\n", "").lower() == "montagdienstagmittwochdonnerstagfreitag": + break + + count += 1 + + lines = lines[count:] + weekdays = lines[0] + lines = lines[3:] + + positions = [(a.start(), a.end()) for a in list(re.finditer('Tagessuppe siehe Aushang', lines[0]))] + if len(positions) != 5: + # TODO handle special cases (e.g. that bistro is closed) + return None + + pos_mon = positions[0][0] + pos_tue = positions[1][0] + pos_wed = positions[2][0] + pos_thu = positions[3][0] + pos_fri = positions[4][0] + + lines_weekdays = {"mon": "", "tue": "", "wed": "", "thu": "", "fri": ""} + for line in lines[2:]: + lines_weekdays["mon"] += " " + line[pos_mon:pos_tue].replace("\n", " ") + lines_weekdays["tue"] += " " + line[pos_tue:pos_wed].replace("\n", " ") + lines_weekdays["wed"] += " " + line[pos_wed:pos_thu].replace("\n", " ") + lines_weekdays["thu"] += " " + line[pos_thu:pos_fri].replace("\n", " ") + lines_weekdays["fri"] += " " + line[pos_fri:].replace("\n", " ") + + for key in lines_weekdays: + # get rid of two-character umlauts (e.g. SMALL_LETTER_A+COMBINING_DIACRITICAL_MARK_UMLAUT) + lines_weekdays[key] = unicodedata.normalize("NFKC", lines_weekdays[key]) + # remove multi-whitespaces + lines_weekdays[key] = ' '.join(lines_weekdays[key].split()) + # get all dish including name and price + dish_names = re.findall(self.dish_regex, lines_weekdays[key] + " ") + # get dish prices + prices = re.findall(self.price_regex, ' '.join(dish_names)) + # convert prices to float + prices = [float(price.replace("€", "").replace(",", ".").strip()) for price in prices] + # remove price and commas from dish names + dish_names = [re.sub(self.price_regex, "", dish).strip() for dish in dish_names] + # create list of Dish objects + dishes = [Dish(dish_name, price) for (dish_name, price) in list(zip(dish_names, prices))] + # get date from year, week number and current weekday + # https://stackoverflow.com/questions/17087314/get-date-from-week-number + date_str = "%d-W%d-%d" % (year, week_number, self.weekday_positions[key]) + date = datetime.strptime(date_str, "%Y-W%W-%w").date() + # create new Menu object and add it to dict + menu = Menu(date, dishes) + # remove duplicates + menu.remove_duplicates() + menus[date] = menu + + return menus diff --git a/src/test/assets/ipp/KW-47_20.11-24.11.2017-1.txt b/src/test/assets/ipp/KW-47_20.11-24.11.2017-1.txt new file mode 100644 index 0000000000..c8e492bcd0 --- /dev/null +++ b/src/test/assets/ipp/KW-47_20.11-24.11.2017-1.txt @@ -0,0 +1,60 @@ + KONRADHOF CATERING - Betriebskantine IPP + + Speiseplan KW 47 - 20.November bis 24. November 2017 + + Montag Dienstag Mittwoch Donnerstag Freitag + + + Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang + Suppentopf Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € + + + + Gebratene Mediterrane Frittata + Weißkohl-Kartoffelpfanne mit Zucchini, Kartoffeln, Nudelpfanne + Gefüllter Germknödel Erbseneintopf + Veggie mit gerösteten Paprika, mit Gemüsesauce + mit Vanillesauce (mit Wienerle 4,20 €) + Sonnenblumenkernen kleiner Salatbeilage und (auf Wunsch mit Reibekäse) + Joghurt-Limetten Dip + 3,50 € 3,50 € 3,50 € 3,50 € 3,50 € + + + + + Ofengulasch vom Rind Frischer Bayrischer + Jägerschnitzel Matjes + mit Kürbis und Pflaumen, Hackbraten mit Schweinenackenbraten vom +Traditionelle Küche mit Spätzle "Hausfrauen Art" + dazu Rigatoni Zigeunersauce und Reis Brett geschnitten dazu Kartoffel- + oder Reis mit Salzkartoffeln + Gurkensalat + 5,50 € 4,80 € 4,80 € 4,50 € 5,20 € + + + + "Palek Tofu" "Enchilada Verdura", + Vegetarisch gefüllte "Farfalle Rustico" + Gebratener Tofu mit Spinat, überbackene Weizentortilla, Currygeschnetzeltes + Tortelli mit mit Champignons, Schinken +Internationale Küche Ingwer, gefüllt von der Pute mit Früchten + leichter Zitronen-Buttersauce Tomaten und Peperoni + Curry-Sahnesauce und mit Hähnchenfleisch, und Reis + und gehobeltem Parmesan (auf Wunsch mit Reibekäse) + Basmatireis Sauerrahm, Kidneybohnen, + 5,20 € 4,80 € 4,60 € Mais, dazu 5,90 € 4,90 € + + + + "Bami Goreng" + Gebratene Hähnchenbrust + indonesische Bratnudeln mit + auf Fenchelgemüse, dazu Rumpsteak mit "Lamm Palak" mit Honig-Kassler + Gemüse, Huhn, + Specials Kräuterreis und Balsamico Pilzen Spinat und Curry mit Apfel-Spitzkohl und + Schweinefleisch + Orangensauce und Wedges (mittelscharf), dazu Reis Kartoffelspalten + und Pilzen, dazu Honig-Chili- + Dip + 6,90 € 6,90 € 7,90 € 6,90 € 6,20 € + \ No newline at end of file diff --git a/src/test/assets/ipp/KW-48_27.11-01.12.10.2017-3.txt b/src/test/assets/ipp/KW-48_27.11-01.12.10.2017-3.txt new file mode 100644 index 0000000000..4398a1f416 --- /dev/null +++ b/src/test/assets/ipp/KW-48_27.11-01.12.10.2017-3.txt @@ -0,0 +1,58 @@ + KONRADHOF CATERING - Betriebskantine IPP + + Speiseplan KW 48 - 27. November bis 01. Dezember 2017 + + Montag Dienstag Mittwoch Donnerstag Freitag + + + Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang Tagessuppe siehe Aushang + Suppentopf Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € Preis ab 0,70 € + + + + + Weißwurst Gröst´l + Herbstliche Rote Beete Eintopf Exotische + Wirsing-Kartoffelauflauf mit Knödel, Lauchzwiebeln, + Veggie Gemüse-Reis Pfanne mit Kartoffeln, Nudeln Linsen-Spätzle + mit Bechamel und Käse Karotten und Kräuter + mit pikantem Mango Dip und Dill Pfanne + auf Wunsch mit Bratenjus + 3,50 € 3,50 € 3,50 € 3,50 € 3,50 € + + + + + Paprikarahm Sauerbraten "Nepal" + Krautwickerl Estragonrahmschnitzel Seelachsfilet gebacken + Geschnetzeltes mit mit weißen Bohnen, +Traditionelle Küche mit Speck-Zwieblsauce mit Pommes frites mit Sardellenmayonnaise + Paprikamix und getrockneten + und Püree oder Reis und Pommes frites + Nudeln Tomaten und Pasta + 4,80 € 4,50 € 4,60 € 5,80 € 4,60 € + + + + + "Dal Curry" "Kaku Chicken" Gemüse-Linguini mit + Rigatoni mit + mit Kartoffeln, Kokosmilch, Gemüse mit geröstetem Curry, Pesto-Rahmsauce und +Internationale Küche Rosenkohl + Ingwer, Koriander, Reis Lasagne Kokosraspel, Tomaten und Parmesankäse + und Schnittlauch + und scharfem Chutney Reis + 4,90 € 4,60 € 4,90 € 6,90 € 4,40 € + + + + "Tandoori Chicken" Schweinefilet Medaillons + Deftiger Hüttenschmaus, Leberkäs Burger + Spanferkelrücken mit Auberginen, Tomaten, in grüner Pfefferrahmsauce + Rinderrostbraten mit Zwiebeln, special + Specials mit Knödel und Zucchini, Zitronenschale mit Kroketten und + Semmelknödel mit Pommes frites und + Bayerisch Kraut Minze und Reis karamellisierten Möhren + und gebratenem Gemüse Cole slaw + 7,90 € 6,80 € 6,90 € 4,80 € 7,20 € + \ No newline at end of file diff --git a/src/test/test_menu_parser.py b/src/test/test_menu_parser.py index c655923571..387d5904f2 100644 --- a/src/test/test_menu_parser.py +++ b/src/test/test_menu_parser.py @@ -7,7 +7,7 @@ from datetime import date import main -from menu_parser import StudentenwerkMenuParser, FMIBistroMenuParser +from menu_parser import StudentenwerkMenuParser, FMIBistroMenuParser, IPPBistroMenuParser from entities import Dish, Menu, Week import json @@ -204,3 +204,104 @@ def test_Should_Return_Menu(self): self.assertEqual(self.menu_wed2, menus_actual2[self.date_wed2]) self.assertEqual(self.menu_thu2, menus_actual2[self.date_thu2]) self.assertEqual(self.menu_fri2, menus_actual2[self.date_fri2]) + + +class IPPBistroParserTest(unittest.TestCase): + ipp_parser = IPPBistroMenuParser() + test_menu1 = open('src/test/assets/ipp/KW-47_20.11-24.11.2017-1.txt', 'r').read() + year1 = 2017 + week_number1 = 47 + test_menu2 = open('src/test/assets/ipp/KW-48_27.11-01.12.10.2017-3.txt', 'r').read() + year2 = 2017 + week_number2 = 48 + + date_mon1 = date(2017, 11, 20) + date_tue1 = date(2017, 11, 21) + date_wed1 = date(2017, 11, 22) + date_thu1 = date(2017, 11, 23) + date_fri1 = date(2017, 11, 24) + dish1_mon1 = Dish("Gefüllter Germknödel mit Vanillesauce", 3.5) + dish2_mon1 = Dish("Ofengulasch vom Rind mit Kürbis und Pflaumen, dazu Rigatoni", 5.5) + dish3_mon1 = Dish("\"Palek Tofu\" Gebratener Tofu mit Spinat, Ingwer, Curry-Sahnesauce und Basmatireis", 5.2) + dish4_mon1 = Dish("Gebratene Hähnchenbrust auf Fenchelgemüse, dazu Kräuterreis und Orangensauce", 6.9) + dish1_tue1 = Dish("Gebratene Weißkohl-Kartoffelpfanne mit gerösteten Sonnenblumenkernen", 3.5) + dish2_tue1 = Dish("Jägerschnitzel mit Spätzle oder Reis", 4.8) + dish3_tue1 = Dish("Vegetarisch gefüllte Tortelli mit leichter Zitronen-Buttersauce und gehobeltem Parmesan", 4.8) + dish4_tue1 = Dish("\"Bami Goreng\" indonesische Bratnudeln mit Gemüse, Huhn, Schweinefleisch und Pilzen, " \ + "dazu Honig-Chili- Dip", 6.9) + dish1_wed1 = Dish("Erbseneintopf (mit Wienerle 4,20 €)", 3.5) + # TODO fix "B" + dish2_wed1 = Dish("Hackbraten mit Zigeunersauce und Reis B", 4.8) + dish3_wed1 = Dish("\"Farfalle Rustico\" mit Champignons, Schinken Tomaten und Peperoni (auf Wunsch mit " + "Reibekäse)", 4.6) + dish4_wed1 = Dish("Rumpsteak mit Balsamico Pilzen und Wedges", 7.9) + dish1_thu1 = Dish("Mediterrane Frittata mit Zucchini, Kartoffeln, Paprika, kleiner Salatbeilage und " + "Joghurt-Limetten Dip", 3.5) + # TODO fix bug that B of Brett is missing -> rett + dish2_thu1 = Dish("Frischer Bayrischer Schweinenackenbraten vom rett geschnitten dazu Kartoffel- Gurkensalat", 4.5) + dish3_thu1 = Dish("\"Enchilada Verdura\", überbackene Weizentortilla, gefüllt mit Hähnchenfleisch, Sauerrahm, " + "Kidneybohnen, Mais, dazu", 5.9) + dish4_thu1 = Dish("\"Lamm Palak\" mit Spinat und Curry (mittelscharf), dazu Reis", 6.9) + dish1_fri1 = Dish("Nudelpfanne mit Gemüsesauce (auf Wunsch mit Reibekäse)", 3.5) + dish2_fri1 = Dish("Matjes \"Hausfrauen Art\" mit Salzkartoffeln", 5.2) + dish3_fri1 = Dish("Currygeschnetzeltes von der Pute mit Früchten und Reis", 4.9) + dish4_fri1 = Dish("Honig-Kassler mit Apfel-Spitzkohl und Kartoffelspalten", 6.2) + menu_mon1 = Menu(date_mon1, [dish1_mon1, dish2_mon1, dish3_mon1, dish4_mon1]) + menu_tue1 = Menu(date_tue1, [dish1_tue1, dish2_tue1, dish3_tue1, dish4_tue1]) + menu_wed1 = Menu(date_wed1, [dish1_wed1, dish2_wed1, dish3_wed1, dish4_wed1]) + menu_thu1 = Menu(date_thu1, [dish1_thu1, dish2_thu1, dish3_thu1, dish4_thu1]) + menu_fri1 = Menu(date_fri1, [dish1_fri1, dish2_fri1, dish3_fri1, dish4_fri1]) + + date_mon2 = date(2017, 11, 27) + date_tue2 = date(2017, 11, 28) + date_wed2 = date(2017, 11, 29) + date_thu2 = date(2017, 11, 30) + date_fri2 = date(2017, 12, 1) + dish1_mon2 = Dish("Wirsing-Kartoffelauflauf mit Bechamel und Käse", 3.5) + dish2_mon2 = Dish("Paprikarahm Geschnetzeltes mit Paprikamix und Nudeln", 4.8) + dish3_mon2 = Dish("\"Dal Curry\" mit Kartoffeln, Kokosmilch, Ingwer, Koriander, Reis und scharfem Chutney", 4.9) + # TODO fix missing "R" of "Rinderbraten" + dish4_mon2 = Dish("Deftiger Hüttenschmaus, inderrostbraten mit Zwiebeln, Semmelknödel und gebratenem Gemüse", + 7.9) + dish1_tue2 = Dish("Herbstliche Gemüse-Reis Pfanne mit pikantem Mango Dip", 3.5) + dish2_tue2 = Dish("Krautwickerl mit Speck-Zwieblsauce und Püree", 4.5) + dish3_tue2 = Dish("Rigatoni mit Rosenkohl und Schnittlauch", 4.6) + dish4_tue2 = Dish("Spanferkelrücken mit Knödel und Bayerisch Kraut", 6.8) + dish1_wed2 = Dish("Weißwurst Gröst ́l mit Knödel, Lauchzwiebeln, Karotten und Kräuter auf Wunsch mit " + "Bratenjus", 3.5) + dish2_wed2 = Dish("Estragonrahmschnitzel mit Pommes frites oder Reis", 4.6) + dish3_wed2 = Dish("Gemüse Lasagne", 4.9) + dish4_wed2 = Dish("\"Tandoori Chicken\" mit Auberginen, Tomaten, Zucchini, Zitronenschale Minze und Reis", 6.9) + dish1_thu2 = Dish("Rote Beete Eintopf mit Kartoffeln, Nudeln und Dill", 3.5) + dish2_thu2 = Dish("Sauerbraten \"Nepal\" mit weißen Bohnen, getrockneten Tomaten und Pasta", 5.8) + dish3_thu2 = Dish("\"Kaku Chicken\" mit geröstetem Curry, Kokosraspel, Tomaten und Reis", 6.9) + dish4_thu2 = Dish("Leberkäs Burger special mit Pommes frites und Cole slaw", 4.8) + dish1_fri2 = Dish("Exotische Linsen-Spätzle Pfanne", 3.5) + dish2_fri2 = Dish("Seelachsfilet gebacken mit Sardellenmayonnaise und Pommes frites", 4.6) + dish3_fri2 = Dish("Gemüse-Linguini mit Pesto-Rahmsauce und Parmesankäse", 4.4) + dish4_fri2 = Dish("Schweinefilet Medaillons in grüner Pfefferrahmsauce mit Kroketten und karamellisierten " + "Möhren", 7.2) + + menu_mon2 = Menu(date_mon2, [dish1_mon2, dish2_mon2, dish3_mon2, dish4_mon2]) + menu_tue2 = Menu(date_tue2, [dish1_tue2, dish2_tue2, dish3_tue2, dish4_tue2]) + menu_wed2 = Menu(date_wed2, [dish1_wed2, dish2_wed2, dish3_wed2, dish4_wed2]) + menu_thu2 = Menu(date_thu2, [dish1_thu2, dish2_thu2, dish3_thu2, dish4_thu2]) + menu_fri2 = Menu(date_fri2, [dish1_fri2, dish2_fri2, dish3_fri2, dish4_fri2]) + + def test_Should_Return_Menu1(self): + menus_actual1 = self.ipp_parser.get_menus(self.test_menu1, self.year1, self.week_number1) + self.assertEqual(5, len(menus_actual1)) + self.assertEqual(self.menu_mon1, menus_actual1[self.date_mon1]) + self.assertEqual(self.menu_tue1, menus_actual1[self.date_tue1]) + self.assertEqual(self.menu_wed1, menus_actual1[self.date_wed1]) + self.assertEqual(self.menu_thu1, menus_actual1[self.date_thu1]) + self.assertEqual(self.menu_fri1, menus_actual1[self.date_fri1]) + + def test_Should_Return_Menu2(self): + menus_actual2 = self.ipp_parser.get_menus(self.test_menu2, self.year2, self.week_number2) + self.assertEqual(5, len(menus_actual2)) + self.assertEqual(self.menu_mon2, menus_actual2[self.date_mon2]) + self.assertEqual(self.menu_tue2, menus_actual2[self.date_tue2]) + self.assertEqual(self.menu_wed2, menus_actual2[self.date_wed2]) + self.assertEqual(self.menu_thu2, menus_actual2[self.date_thu2]) + self.assertEqual(self.menu_fri2, menus_actual2[self.date_fri2])