├── .gitignore ├── Program_Architecture.pdf ├── README.md ├── bodhi_alarcon.txt ├── david_wallach.txt ├── junhao_li.txt ├── main.py └── templates └── formtest.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pages 2 | *.json 3 | *.pyc 4 | -------------------------------------------------------------------------------- /Program_Architecture.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwallach1/RecipeTransformer/42fc6243aa4006b0edc3aaf7a1c83da9912c2669/Program_Architecture.pdf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RecipeTransformer 2 | 3 | This python module takes in a URL pointing to a recipe from AllRecipes.com. It then promps the user to identify any and all supported 4 | transformations they would like to make to the recipe. These transformations can be any of the following 5 | 6 | * To and from vegetarian 7 | * To and from vegan 8 | * To and from healthy 9 | * To and from pescatarian 10 | * To Style of cuisine (i.e. to Thai, Mexican, Italian, etc.) 11 | * Cooking method (i.e. from bake to stirfry) 12 | * DIY to easy 13 | 14 | 15 | The program then outputs a JSON representation of the new ingredients and changes made to the original recipe to accomplish these transformations. 16 | 17 | For grading purposes, here is what we completed: 18 | 19 | 20 | - [x] Ingredient name 21 | - [x] Quantity 22 | - [x] Measurement (cup, teaspoon, pinch, etc.) 23 | - [x] Descriptor (e.g. fresh, extra-virgin) (OPTIONAL) 24 | - [x] Preparation (e.g. finely chopped) (OPTIONAL) 25 | - [x] Tools – pans, graters, whisks, etc. 26 | - [x] Primary cooking method (e.g. sauté, broil, boil, poach, etc.) 27 | - [x] Other cooking methods used (e.g. chop, grate, stir, shake, mince, crush, squeeze, etc.) (OPTIONAL) 28 | - [x] Steps – parse the directions into a series of steps that each consist of ingredients, tools, methods, and times (OPTIONAL) 29 | - [x] To and from vegetarian (REQUIRED) 30 | - [x] To and from healthy (REQUIRED) 31 | - [x] Style of cuisine (AT LEAST ONE REQUIRED) 32 | - [x] Any input cuisine (OPTIONAL) 33 | - [x] Another Style of cuisine (OPTIONAL) 34 | - [x] to and from Pescatatian (OPTIONAL) 35 | - [x] DIY to easy (OPTIONAL) 36 | - [x] GUI to run the program (OPTIONAL) 37 | 38 | 39 | 40 | 41 | # Usage 42 | 43 | ```python 44 | # set URL variable to point to a AllRecipes.com url 45 | URL = 'http://allrecipes.com/recipe/234667/chef-johns-creamy-mushroom-pasta/?internalSource=rotd&referringId=95&referringContentType=recipe%20hub' 46 | 47 | # parse the URL to get relevant information 48 | recipe_attrs = parse_url(URL) # parse_url returns a dict with data to populate a Recipe object 49 | recipe = Recipe(**recipe_attrs) # instantiate the Recipe object by unpacking dictionary 50 | 51 | # apply a transformation 52 | recipe.to_style('Mexican') # convert the Creamy Mushroom Pasta to be Mexican style 53 | print(recipe.to_JSON()) # print the new recipe 54 | print(recipe.original_recipe.to_JSON) # if you want to access the original recipe 55 | 56 | ``` 57 | 58 | If you prefer a GUI interface, we have implemented a locally hosted webpage using web.py. To run it, simply add the --gui flag in the command line: 59 |
60 | `>> python main.py --gui` 61 |
62 | this will then print out a url to input into your webbrowser and from there you will be able to access all the functionality of this program and see the output in a much more friendly enviornment. 63 | 64 | # Classes 65 | 66 | * **Recipe Class** is the main class in which all the transformation methods are. It also holds a list of Ingredient objects and Instruction objects parsed from the input recipe's URL (from allrecipes.com). It also finds the cooking tools and cooking methods used in the recipe by parsing the Instruction objects once they are instatiated and built. The Recipe class gets built by a dictionary object returned from `parse_url(URL)` function which scrapes the URL from allrecipes.com and returns a dictionary with all the necessary information to build the Recipe object. 67 | 68 |       The Recipe class gets instatiated with a dictionary with the following schema: 69 |
70 |       { 71 |
72 |             name: string 73 |
74 |              preptime: int 75 |
76 |              cooktime: int 77 |
78 |              totaltime: int 79 |
80 |              ingredients: list of strings 81 |
82 |              instructions: list of strings 83 |
84 |              calories: int 85 |
86 |              carbs: int 87 |
88 |              fat: int 89 |
90 |              protien: int 91 |
92 |              cholesterol: int 93 |
94 |              sodium: int 95 |
96 |       } 97 |
98 | 99 |       and thus you can access information like `sodium` by calling recipe.sodium etc. The ingredients and instruction 100 |       lists are instatiated and parsed in the Recipe's `__init__` method. 101 | 102 | 103 | * **Ingredient Class** is used to parse and store the Ingredients in a clean and easily accessible manner. An Instruction object takes in a string (the text of a bullet point from the recipe's url in the ingredients section) and parses out the name, quantity, measurement, descriptor, preperation, and type. It does this using NLTK's part of speech tagger as well as a word bank. The type is a single letter correlated to one of the following: 104 |
105 |        * H --> Herbs / Spices 106 |
107 |        * V --> Vegetable 108 |
109 |        * M --> Meat 110 |
111 |        * D --> Dairy 112 |
113 |        * F --> Fruit 114 |
115 |        * S --> Sauce 116 |
117 |        * P --> Pescatarian (Seafood) 118 |
119 |        * ? --> Misc. 120 |
121 |
122 | 123 |       This is done by building out lists parsed from websites like wikipedia.com and naturalhealthtechniques.com that 124 |       have long records for each category. By tagging each ingredient with a type, we are able to infer a lot more about 125 |       the ingredient and it is integral to the Recipe's to_style method. 126 | 127 | 128 | * **Instruction Class** is used to parse and store the Instructions in a clean and easily accessible manner. The `__init__` method calls other methods within the class to find the cooking methods and tools used in that instruction as well as the amount of time the instruction takes. It is important to store this in a different object than the Recipe class so that when we change the recipes, we can update the specific instrucitons and details accordingly. 129 | 130 | 131 | 132 | # Methods 133 | 134 | The following are the methods of the Recipe class 135 | 136 | * **to_vegan()** - replaces non-vegan ingredients (meat, dairy, ect.) with vegan substitutes 137 | * **from_vegan()** - replaces vegan ingredients with non-vegan ingredients like meat, dairy, ect. 138 | * **to_vegetarian()** - replaces non-vegetarian ingredients (meat) with vegetarian substitutes such as tofu and such. Updates the instructions and times accordingly 139 | * **from_vegetarian()** - adds a random meat to the recipe and updates the instructions and times 140 | * **to_pescatarian()** - replaces meats with seafood and/or adds new seafood ingredients to the recipe 141 | * **from_pescatarian()** - replaces seafood with meat and/or adds new meat ingredients to the recipe 142 | * **to_style(style, threshold=1.0)** - takes in a parameter of type string `style` (i.e. 'Mexican', 'Thai') and converts the recipe to be more of the input style. The parameter `threshold` allows the user to control how much they want their recipe changed to the desired style. Threshold is a float from 0.0 to 1.0 with 0.0 being no changes and 1.0 being as many changes as possible. 143 | * **to_method(method)** - transforms the cooking method to be like that method. For example, if passed `'fry'` as the method paramter's value, then it will add flour and oil to the recipe if not already there and fry the meats and vegetables. 144 | * **to_easy()** - transforms the recipe from DIY to easy by making the ingredients less intenaive to get and prepare 145 | * **print_pretty()** - used to print the attributes of the recipe in an easy to read format 146 | * **to_JSON()** - used to export the recipe class to a JSON format 147 | * **compare_to_original()** - shows the additions and/or changes reflected in the current recipe from the recipe that the object was instatiated with 148 | 149 | 150 | # Dependencies 151 | 152 | * Used Python2.7 but works with Python3 as well 153 | * BeautifulSoup 154 | * NLTK 155 | * Requests 156 | * web.py (for GUI) 157 | 158 | # Program Architecture 159 | 160 | ![Program Architecture](./Program_Architecture.pdf) 161 | 162 | -------------------------------------------------------------------------------- /bodhi_alarcon.txt: -------------------------------------------------------------------------------- 1 | Bodhi Alarcon (bta566) 2 | Contributions: 3 | * GUI for the recipe transformer 4 | * additional testing -------------------------------------------------------------------------------- /david_wallach.txt: -------------------------------------------------------------------------------- 1 | David Wallach (daw647) 2 | What I contributed: 3 | * parsing input URL 4 | * parsing wiki pages to build out lists 5 | * class structure / program architecture 6 | * to/from vegan 7 | * to/from vegetarian 8 | * to/from pescatarian 9 | * to style 10 | * to method 11 | 12 | -------------------------------------------------------------------------------- /junhao_li.txt: -------------------------------------------------------------------------------- 1 | Junhao Li (jlo708) 2 | What I contributed: 3 | * initial to vegetarian (always replaced meats w/tofu and meat stocks w/vegetable broth) 4 | * initial from vegetarian (always added chicken) 5 | * to easy -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recipe Transformer Project 3 | 4 | Given a URL to a recipe from AllRecipes.com, this program uses Natural Language Processing to transform any recipe, based on 5 | user's input, into any of the following categories: 6 | * To and from vegetarian 7 | * To and from vegan 8 | * To and from healthy 9 | * To and from pescatarian 10 | * To Style of cuisine (i.e. to Thai) 11 | * Cooking method (from bake to stir fry, for example) 12 | 13 | 14 | Authors: 15 | * David Wallach 16 | * Junhao Li 17 | * Bodhi Alarcon 18 | 19 | 20 | github repository: https://github.com/dwallach1/RecipeTransformer 21 | 22 | """ 23 | import time 24 | import random 25 | import re 26 | import json 27 | import sys 28 | import argparse 29 | from urlparse import urlparse 30 | from collections import defaultdict, OrderedDict 31 | from operator import itemgetter 32 | import textwrap 33 | import copy 34 | import requests 35 | from bs4 import BeautifulSoup 36 | from nltk import word_tokenize, pos_tag 37 | import web 38 | from web import form 39 | 40 | DEBUG = False 41 | 42 | 43 | # simple regex used to find specific attributes in strings 44 | measure_regex = '(cup|spoon|fluid|ounce|pinch|gill|pint|quart|gallon|pound|drops|recipe|slices|pods|package|can|head|halves)' 45 | tool_indicator_regex = '(pan|skillet|pot|sheet|grate|whisk|griddle|bowl|oven|dish)' 46 | method_indicator_regex = '(boil|bake|baking|simmer|stir|roast|fry)' 47 | time_indicator_regex = '(min|hour)' 48 | 49 | # these are used as last resort measures to sort out names, descriptors, and preperation words that our system is having trouble parsing 50 | descriptor_regex = '(color|mini|container|skin|bone|halves|fine|parts|leftover|style|frying|breast)' 51 | preperation_regex = '(room|temperature|divided|sliced|dice|mince|chopped|quartered|cored|shredded|seperated|pieces)' 52 | names_regex = '(garlic|poppy|baking|sour|cream|broth|chicken|olive|mushroom|green|vegetable|bell)' 53 | 54 | 55 | 56 | # start of word banks 57 | 58 | healthy_substitutes = { 59 | # The way this dict is structures is so that the values are used to easily instatiate new Ingredient objects 60 | # the quantities are just for parsing, they are updated to be the amount used of the unhealthy version in the recipe 61 | # inside the Recipe to_healthy method 62 | 'oil': '2 tablespoons of Prune Puree', 63 | 'cheese': '3 tablespoons of Nutritional Yeast', 64 | 'pasta': '8 ounces of shredded zucchini', 65 | 'flour': '1 cup of whole-wheat flour', 66 | 'butter': '3 tablespoons of unsweetened applesauce', 67 | 'cream': '3 cups of greek yogurt', 68 | 'eggs': '3 egg whites', 69 | 'milk': '4 ounces of skim milk', 70 | 'potatoes': '4 handfuls of arugula', 71 | 'french fries': '4 handfuls of arugula', 72 | 'yogurt': '2 cups of low-fat cottage cheese' 73 | 74 | } 75 | 76 | dairy_substitutes = { 77 | # The way this dict is structures is so that the values are used to easily instatiate new Ingredient objects 78 | # the quantities are just for parsing, they are updated to be the amount used of the unhealthy version in the recipe 79 | # inside the Recipe to_healthy method 80 | 'butter': '2 tablespoons of olive oil', 81 | 'cheese': '3 tablespoons of yeast flakes', 82 | 'milk': '12 ounces of soy milk', 83 | 'sour cream': '2 avacados', 84 | 'cream': '2 cups of almond milk yogurt', 85 | 'ice cream': '2 cups of sorbet' 86 | 87 | } 88 | 89 | # do not need a dict because we switch based on 'type' attribute instead of 'name' attribute 90 | meat_substitutes = [ 91 | '12 ounces of Tofu', 92 | '1 cup ICantBelieveItsNotMeat', 93 | '12 ounces of Tempeh', 94 | '12 ounces of grilled Seitan', 95 | '8 ounces of textured vegetable protien', 96 | '12 ounces of gluten-free vegan meat', 97 | '4 cups of Jackfruit', 98 | '3 large portobello mushrooms', 99 | '4 cups of lentils', 100 | '4 cups of legumes' 101 | ] 102 | 103 | # use Tuples to tag the substitutes 104 | unhealthy_substitutes = [ 105 | ('1 pound of fried chicken', 'M'), 106 | ('4 pieces of milanesa', 'M'), 107 | ('3 fried eggplants', 'V'), 108 | ('10 fried pickles', 'V') 109 | 110 | 111 | ] 112 | 113 | 114 | # build list dynamically from wikipedia using the build_dynamic_lists() function -- used to tag the domain of an ingredient 115 | sauce_list = [] 116 | vegetable_list = [] 117 | herbs_spice_list = [] 118 | dairy_list = [] 119 | meat_list = [] 120 | grain_list = [] 121 | fruit_list = [] 122 | seafood_list = [] 123 | 124 | 125 | class Ingredient(object): 126 | """ 127 | Represents an Ingredient in the recipe. Ingredients have assoiciated quantities, names, measurements, 128 | preperation methods (i.e. finley chopped), and descriptors (i.e. fresh, extra-virgin). Uses NLTK to tag each word's part 129 | of speach using the pos_tag fuction. From there the module handles extracting out the relavent information and storing it in the appropiate 130 | attributes of the object. 131 | 132 | For pos_tag: 133 | * ADJ adjective new, good, high, special, big, local 134 | * ADP adposition on, of, at, with, by, into, under 135 | * ADV adverb really, already, still, early, now 136 | * CONJ conjunction and, or, but, if, while, although 137 | * DET determiner, article the, a, some, most, every, no, which 138 | * NOUN noun year, home, costs, time, Africa 139 | * NUM numeral twenty-four, fourth, 1991, 14:24 140 | * PRT particle at, on, out, over per, that, up, with 141 | * PRON pronoun he, their, her, its, my, I, us 142 | * VERB verb 143 | * . punctuation marks . , ; ! 144 | * X other ersatz, esprit, dunno, gr8, univeristy 145 | """ 146 | def __init__(self, description): 147 | description_tagged = pos_tag(word_tokenize(description)) 148 | 149 | # if DEBUG: 150 | # print ('tags: {}'.format(description_tagged)) 151 | 152 | self.name = self.find_name(description_tagged) 153 | self.quantity = self.find_quantity(description) # do not use tagged description -- custom parsing for quantities 154 | self.measurement = self.find_measurement(description_tagged, description) 155 | self.descriptor = self.find_descriptor(description_tagged) 156 | self.preperation = self.find_preperation(description_tagged) 157 | self.type = self.find_type() 158 | 159 | if DEBUG: 160 | print ('parsing ingredient: {}'.format(description)) 161 | print ('name: {}'.format(self.name)) 162 | print ('quantity: {}'.format(self.quantity)) 163 | print ('measurement: {}'.format(self.measurement)) 164 | print ('descriptor: {}'.format(self.descriptor)) 165 | print ('preperation: {}').format(self.preperation) 166 | 167 | 168 | def __str__(self): 169 | """ 170 | String representation of an Ingredient instance 171 | """ 172 | return self.name 173 | 174 | 175 | def __repr__(self): 176 | """ 177 | How a Ingredient object is represented 178 | """ 179 | return self.name 180 | 181 | 182 | def __eq__(self, other): 183 | """ 184 | Defines what makes two Ingredient instances equal 185 | """ 186 | if isinstance(self, other.__class__): 187 | return self.__dict__ == other.__dict__ 188 | return False 189 | 190 | 191 | def find_name(self, description_tagged): 192 | """ 193 | looks for name of the ingredient from the desciption. Finds the nouns that are not measurements 194 | """ 195 | name = [d[0] for d in description_tagged if ((d[1] == 'NN' or d[1] == 'NNS' or d[1] == 'NNP') 196 | and not re.search(measure_regex, d[0], flags=re.I) 197 | and not re.search(descriptor_regex, d[0], flags=re.I) 198 | and not re.search(preperation_regex, d[0], flags=re.I)) 199 | or re.search(names_regex, d[0], re.I)] 200 | if len(name) == 0: 201 | return description_tagged[-1][0] 202 | return ' '.join(name) 203 | 204 | 205 | def find_quantity(self, description): 206 | """ 207 | looks for amount descriptors in the ingredient description. 208 | if none are apparent, it returns zero. Else it converts fractions to floats and 209 | aggregates measurement (i.e. 1 3/4 --> 1.75) 210 | """ 211 | wholes = re.match(r'([0-9])\s', description) 212 | fractions = re.search(r'([0-9]\/[0-9])', description) 213 | 214 | if fractions: 215 | fractions = fractions.groups(0)[0] 216 | num = float(fractions[0]) 217 | denom = float(fractions[-1]) 218 | fractions = num / denom 219 | 220 | if wholes: wholes = int(wholes.groups(0)[0]) 221 | 222 | total = float(wholes or 0.0) + float(fractions or 0.0) 223 | 224 | return total 225 | 226 | 227 | def find_measurement(self, description_tagged, description): 228 | """ 229 | looks for measurements such as cups, teaspoons, etc. 230 | Uses measure_regex which is a compilation of possible measurements. 231 | """ 232 | measurement = [d[0] for d in description_tagged if re.search(measure_regex, d[0], flags=re.I)] 233 | m = ' '.join(measurement) 234 | if re.search('package', m, flags=re.I): 235 | extra = description[description.find("(")+1:description.find(")")] 236 | return extra + ' package(s)' 237 | if re.search('can', m, flags=re.I): 238 | extra = description[description.find("(")+1:description.find(")")] 239 | return extra + ' can(s)' 240 | 241 | if description.find("(") > -1 and any(char.isdigit() for char in description): 242 | return description[description.find("(")+1:description.find(")")] 243 | 244 | return m 245 | 246 | 247 | def find_descriptor(self, description_tagged): 248 | """ 249 | looks for descriptions such as fresh, extra-virgin by finding describing words such as 250 | adjectives 251 | """ 252 | descriptors = [d[0] for d in description_tagged if ( 253 | (d[1] == 'JJ' or d[1] == 'RB') 254 | and not re.search(measure_regex, d[0], flags=re.I) 255 | and not re.search(names_regex, d[0], flags=re.I) 256 | ) 257 | or re.search(descriptor_regex, d[0], flags=re.I)] 258 | return descriptors 259 | 260 | 261 | def find_preperation(self, description_tagged): 262 | """ 263 | find all preperations (finely, chopped) by finding action words such as verbs 264 | """ 265 | preperations = [d[0] for d in description_tagged if ( 266 | d[1] == 'VB' or d[1] == 'VBD' 267 | or re.search(preperation_regex, d[0], flags=re.I) 268 | ) 269 | and not re.search(names_regex, d[0], flags=re.I)] 270 | for i, p in enumerate(preperations): 271 | if p == 'taste': 272 | preperations[i] = 'to taste' 273 | return preperations 274 | 275 | 276 | def find_type(self): 277 | """ 278 | attempts to categorize ingredient for Recipe methods to work more smoothly and correctly 279 | 280 | * H --> Herbs / Spices 281 | * V --> Vegetable 282 | * M --> Meat 283 | * D --> Dairy 284 | * F --> Fruit 285 | * S --> Sauce 286 | * P --> Pescatarian (Seafood) 287 | * ? --> Misc. 288 | 289 | ordered by precedence of how telling the classification is --> bound to one classification 290 | """ 291 | 292 | # special cases: 293 | if self.name.lower().find('sauce') >= 0: return 'S' 294 | 295 | # normal execution: 296 | types = '' 297 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in meat_list) and len(types) == 0: types ='M' 298 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in vegetable_list) and len(types) == 0: types = 'V' 299 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in dairy_list) and len(types) == 0: types = 'D' 300 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in grain_list) and len(types) == 0: types = 'G' 301 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in sauce_list) and len(types) == 0: types = 'S' 302 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in seafood_list) and len(types) == 0: types = 'P' 303 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in herbs_spice_list) and len(types) == 0: types = 'H' 304 | if any(set(self.name.lower().split(' ')).intersection(set(example.lower().split(' '))) for example in fruit_list) and len(types) == 0: types = 'F' 305 | if len(types) == 0: types = '?' 306 | return types 307 | 308 | 309 | class Instruction(object): 310 | """ 311 | Represents an instruction to produce the Recipe. Each instruction has a set of tools used for cooking and set of 312 | methods also used for cooking. There is a time field to denote the amount of time the instruction takes to complete. 313 | """ 314 | def __init__(self, instruction): 315 | self.instruction = instruction 316 | self.instruction_words = word_tokenize(self.instruction) 317 | self.cooking_tools = self.find_tools(self.instruction_words) 318 | self.cooking_methods = self.find_methods(self.instruction_words) 319 | self.time = self.find_time(self.instruction_words) 320 | self.ingredients = None 321 | 322 | 323 | def find_tools(self, instruction_words): 324 | """ 325 | looks for any and all cooking tools apparent in the instruction text by using the tool_indicator_regex 326 | variable 327 | """ 328 | cooking_tools = [] 329 | for word in instruction_words: 330 | if re.search(tool_indicator_regex, word, flags=re.I): 331 | cooking_tools.append(word) 332 | wordset = set(cooking_tools) 333 | return [item for item in wordset if item.istitle() or item.title() not in wordset] 334 | 335 | 336 | def find_methods(self, instruction_words): 337 | """ 338 | looks for any and all cooking methods apparent in the instruction text by using the method_indicator_regex 339 | variable 340 | """ 341 | cooking_methods = [] 342 | for word in instruction_words: 343 | if re.search(method_indicator_regex, word, flags=re.I): 344 | cooking_methods.append(word) 345 | if re.search('preheat', word, re.I): 346 | cooking_methods.append('bake') 347 | 348 | wordset = set(cooking_methods) 349 | return [item for item in wordset if item.istitle() or item.title() not in wordset] 350 | 351 | 352 | def find_time(self, instruction): 353 | """ 354 | looks for all time notations apparent in the instruction text by using the time_indicator_regex 355 | variable and aggregating the total times using typecasting 356 | """ 357 | time = 0 358 | for i, word in enumerate(instruction): 359 | # if we are talking about degrees, then extracting numbers are not associated with time 360 | if word == 'degrees': return 0 361 | 362 | if re.search(time_indicator_regex, word, flags=re.I): 363 | try: 364 | if re.search('(hour)', word, flags=re.I): 365 | time += int(instruction[i-1]) * 60 366 | else: 367 | time += int(instruction[i-1]) 368 | except: 369 | pass 370 | return time 371 | 372 | 373 | def update_instruction(self): 374 | """ 375 | Uses the instances word list to update the objects instruction attribute 376 | """ 377 | self.instruction = ' '.join(self.instruction_words) 378 | 379 | 380 | class Recipe(object): 381 | """ 382 | Used to represent a recipe. Data for each recipe can be found 383 | on AllRecipes.com. 384 | """ 385 | def __init__(self, **kwargs): 386 | for key, value in kwargs.items(): 387 | setattr(self, key, value) 388 | 389 | self.text_instructions = self.instructions; # store the original instructions, 390 | # idea for right now is to modify the original instructions for transformations 391 | self.ingredients = [Ingredient(ing) for ing in self.ingredients] # store ingredients in Ingredient objects 392 | self.instructions = [Instruction(inst) for inst in self.instructions] # store instructions in Instruction objects 393 | self.cooking_tools, self.cooking_methods = self.parse_instructions() # get aggregate tools and methods apparent in all instructions 394 | self.update_instructions() # as part of the steps requirement, add the associated ingredients to each instruction step 395 | 396 | 397 | self.instructions = [i for i in self.instructions if len(i.instruction)] 398 | # save original copy to compare with the transformations 399 | self.original_recipe = copy.deepcopy(self) 400 | 401 | 402 | def parse_instructions(self): 403 | """ 404 | Gathers aggregate data from all instructions to provide overall cooking tools and methods instead of 405 | per instruction basis 406 | """ 407 | cooking_tools = [] 408 | cooking_methods = [] 409 | for inst in self.instructions: 410 | cooking_tools.extend(inst.cooking_tools) 411 | cooking_methods.extend(inst.cooking_methods) 412 | return list(set(cooking_tools)), list(set(cooking_methods)) 413 | 414 | 415 | def update_instructions(self): 416 | """ 417 | To convert instructions into steps, we need to store the associated ingredients as part an attribute for 418 | each instruction. This method does that update 419 | """ 420 | for i, instruction in enumerate(self.instructions): 421 | ingredients = [ingredient.name for ingredient in self.ingredients if instruction.instruction.find(ingredient.name) >= 0] 422 | self.instructions[i].ingredients = list(set(ingredients)) 423 | 424 | 425 | def to_JSON(self, original=False): 426 | """ 427 | convert representation to easily parseable JSON format 428 | """ 429 | data = OrderedDict() 430 | if original: self = self.original_recipe 431 | data['name'] = self.name 432 | data['url'] = self.url 433 | data['cooking tools'] = self.cooking_tools 434 | data['cooking method'] = self.cooking_methods 435 | ing_list = [] 436 | for ingredient in self.ingredients: 437 | ing_attrs = {} 438 | for attr, value in ingredient.__dict__.iteritems(): 439 | ing_attrs[attr] = value 440 | ing_list.append(ing_attrs) 441 | 442 | data['ingredients'] = ing_list 443 | 444 | inst_list = [] 445 | for instruction in self.instructions: 446 | inst_attrs = {} 447 | include = True 448 | for attr, value in instruction.__dict__.iteritems(): 449 | if attr == 'instruction_words': continue 450 | if attr == 'instruction' and len(value) == 0: include = False 451 | inst_attrs[attr] = value 452 | if include: inst_list.append(inst_attrs) 453 | 454 | data['steps'] = inst_list 455 | parsed = json.dumps(data, indent=4) 456 | return parsed 457 | 458 | 459 | def print_pretty(self): 460 | """ 461 | print a human friendly version of the recipe 462 | """ 463 | s = "" 464 | s += '\nIngredients List:' 465 | for ing in self.ingredients: 466 | # only add quantity, measurement, descriptor, and preperation if we have them 467 | quant = '' 468 | if ing.quantity != 0: 469 | quant = "{} ".format(round(ing.quantity, 2) if ing.quantity % 1 else int(ing.quantity)) 470 | 471 | measure = '' 472 | if ing.measurement != "": 473 | measure = ing.measurement + ' ' 474 | 475 | descr = '' 476 | if len(ing.descriptor) > 0: 477 | descr = ' '.join(ing.descriptor) + ' ' 478 | 479 | prep = '' 480 | if len(ing.preperation) > 0: 481 | prep = ', ' + ' and '.join(ing.preperation) 482 | 483 | full_ing = '{}{}{}{}{}'.format(quant, measure, descr, ing.name, prep) 484 | 485 | s += full_ing 486 | 487 | s += '\nInstructions:' 488 | for i, t_inst in enumerate(self.text_instructions[:-1]): 489 | s += textwrap.fill('{}. {}'.format(i+1, t_inst), 80) 490 | return s 491 | 492 | 493 | def compare_to_original(self): 494 | """ 495 | Compares the current recipe to the original recipe the object was instatiated with. 496 | If no changes were made, then they will be identical. 497 | """ 498 | s = "" 499 | try: 500 | s += '\n-----------------------' 501 | s += '\nThe following changes were made to the original recipe: ' 502 | if len(self.original_recipe.ingredients) < len(self.ingredients): 503 | for i in range(len(self.original_recipe.ingredients), len(self.ingredients)): 504 | s += '\n* added {}'.format(self.ingredients[i].name) 505 | else: 506 | for i in range(len(self.original_recipe.ingredients)): 507 | if self.original_recipe.ingredients[i].name != self.ingredients[i].name: 508 | s += '\n* {} ---> {}'.format(self.original_recipe.ingredients[i].name, self.ingredients[i].name) 509 | if len(self.original_recipe.instructions) < len(self.instructions): 510 | for i in range(len(self.original_recipe.instructions), len(self.instructions)): 511 | s += '\n* added {}'.format(self.instructions[i].instruction) 512 | else: 513 | for i in range(len(self.original_recipe.instructions)): 514 | if self.original_recipe.instructions[i].instruction != self.instructions[i].instruction: 515 | s += '\n* {}\n ---> {}'.format(self.original_recipe.instructions[i].instruction, self.instructions[i].instruction) 516 | s += '\n-----------------------' 517 | except: 518 | s += '\n-----------------------' 519 | return s 520 | 521 | 522 | def to_healthy(self): 523 | """ 524 | Transforms the recipe to a more healthy version by removing and/or replacing unhealthy ingredients 525 | """ 526 | for i, ingredient in enumerate(self.ingredients): 527 | if any(name in ingredient.name.split(' ') for name in healthy_substitutes.keys()): 528 | key = next(name for name in ingredient.name.split(' ') if name in healthy_substitutes.keys()) 529 | healthy_sub = Ingredient(healthy_substitutes[key]) 530 | healthy_sub.quantity = ingredient.quantity 531 | self.swap_ingredients(self.ingredients[i], healthy_sub) 532 | 533 | self.name = self.name + ' (healthy)' 534 | 535 | 536 | def from_healthy(self): 537 | """ 538 | Transforms the recipe to a less healthy (more delicous) version by adding unhealthy ingredients and/or replacing 539 | healthy ingredients with unhealthy ingredients from the global unhealthy_substitutes list 540 | """ 541 | for i, ingredient in enumerate(self.ingredients): 542 | if ingredient.type == 'V': 543 | candidates = filter(lambda sub: sub[1] == 'V', unhealthy_substitutes) 544 | new_ingredient = Ingredient(random.choice(candidates)[0]) 545 | self.swap_ingredients(ingredient, new_ingredient) 546 | if ingredient.type == 'M': 547 | candidates = filter(lambda sub: sub[1] == 'M', unhealthy_substitutes) 548 | new_ingredient = Ingredient(random.choice(candidates)[0]) 549 | self.swap_ingredients(ingredient, new_ingredient) 550 | 551 | 552 | self.name = self.name + ' (unhealthy)' 553 | 554 | 555 | def to_vegan(self): 556 | """ 557 | Transforms the recipe to be vegan by removing and/or subsituting all ingredients that are not vegan 558 | """ 559 | # start by making vegetarian 560 | self.to_vegetarian() 561 | 562 | # add a random dairy from the dairy_substitutes dictionary 563 | for i, ingredient in enumerate(self.ingredients): 564 | if ingredient.type == 'D': 565 | idx = random.randint(0, len(dairy_substitutes.keys()) - 1) 566 | dairy_sub = Ingredient(dairy_substitutes[dairy_substitutes.keys()[idx]]) 567 | dairy_sub.quantity = ingredient.quantity 568 | self.swap_ingredients(self.ingredients[i], dairy_sub) 569 | 570 | # update the name of the recipe 571 | self.name = self.name.replace(' (vegetarian)', '') + ' (vegan)' 572 | 573 | 574 | def from_vegan(self): 575 | """ 576 | Transforms the recipe to be non-vegan by adding ingredients that are not vegan 577 | """ 578 | # start by adding random meat 579 | self.from_vegetarian() 580 | 581 | # find random dairy 582 | dairy = random.choice(dairy_list) 583 | 584 | # add it to the ingredients list 585 | self.ingredients.append(Ingredient('3 cups of {}'.format(dairy))) 586 | 587 | # create and add new instructions for making and inserting the dairy 588 | 589 | # update the name of the recipe 590 | self.name = self.name + ' (non-vegan)' 591 | 592 | 593 | def to_vegetarian(self): 594 | """ 595 | Replaces meat or seafood ingredients with vegetarian alternatives. Uses a random integer generator to randomly choose 596 | which substitute from the meat_substitutes list to use. 597 | """ 598 | 599 | for i, ingredient in enumerate(self.ingredients): 600 | if ingredient.type == 'M': 601 | meat_sub = Ingredient(random.choice(meat_substitutes)) 602 | meat_sub.quantity = ingredient.quantity 603 | self.swap_ingredients(self.ingredients[i], meat_sub) 604 | 605 | self.name = self.name + ' (vegetarian)' 606 | 607 | 608 | def from_vegetarian(self): 609 | """ 610 | Adds a random meat from the gloabl meat_list to the recipe, updates instructions and times 611 | accordingly 612 | """ 613 | 614 | # find a random meat from the meat_list to add 615 | meat = random.choice(meat_list).encode('utf-8') 616 | self.ingredients.append(Ingredient('3 cups of boiled {}'.format(meat))) 617 | 618 | 619 | # update/add/build the necessary instructions 620 | boiling_meat = 'Place the {0} in a non-stick pan and fill the pan with water until the {0} are covered.'.format(meat) \ 621 | + ' Simmer uncovered for 5 minutes.' \ 622 | + ' Then, turn off the heat and cover for 15 minutes. Remove the {0} and set aside.'.format(meat) 623 | adding_meat = 'Shred the {0} by pulling the meat apart into thin slices by hand. Stir in the shredded {0}.'.format(meat) 624 | 625 | # Instatiate objects 626 | boiling_meat_instruction = Instruction(boiling_meat) 627 | adding_meat_instruction = Instruction(adding_meat) 628 | 629 | 630 | # add the instructions to the recipe 631 | self.instructions.insert(0, boiling_meat_instruction) 632 | self.instructions.insert(-1, adding_meat_instruction) 633 | 634 | 635 | def to_pescatarian(self): 636 | """ 637 | Replaces meat with seafood ingredients. Uses a random integer generator to randomly choose 638 | which substitute from the seafood_list to use. If no meat, then augment the recipe with a random 639 | seafood 640 | """ 641 | swapped = False 642 | for i, ingredient in enumerate(self.ingredients): 643 | if ingredient.type == 'M': 644 | seafood_sub = Ingredient('3 cups of {}'.format(random.choice(seafood_list))) 645 | seafood_sub.quantity = ingredient.quantity 646 | self.swap_ingredients(self.ingredients[i], seafood_sub) 647 | swapped = True 648 | 649 | if not swapped: 650 | # augment the recipe instead of swapping because no meats in the recipe 651 | seafood_ing = Ingredient('3 cups of {}'.format(random.choice(seafood_list))) 652 | self.ingredients.append(seafood_ing) 653 | 654 | grill_seafood = 'Place the {} in a non-stick pan and fill the pan with oil.'.format(seafood_ing.name) \ 655 | + ' Grill both sides until charred, takes about 7 minutes.' \ 656 | + ' Then, turn off the heat and cover for 15 minutes.' 657 | add_seafood = 'flip the {} onto the plate over the other ingredients.'.format(seafood_ing.name) 658 | 659 | # Instatiate objects 660 | grill_seafood_instruction = Instruction(grill_seafood) 661 | add_seafood_instruction = Instruction(add_seafood) 662 | 663 | 664 | # add the instructions to the recipe 665 | self.instructions.insert(0, grill_seafood_instruction) 666 | self.instructions.insert(-1, add_seafood_instruction) 667 | 668 | self.name = self.name + ' (pescatarian)' 669 | 670 | 671 | def from_pescatarian(self): 672 | """ 673 | Replaces seafood with meat and vegetable ingredients. Uses a random integer generator to randomly choose 674 | which substitute from the meat_list to use. If no seafood, then augment the recipe with a random 675 | meat/dairy/grain 676 | """ 677 | for i, ingredient in enumerate(self.ingredients): 678 | if ingredient.type == 'P': 679 | meat_sub = Ingredient(random.choice(meat_substitutes)) 680 | meat_sub.quantity = ingredient.quantity 681 | self.swap_ingredients(self.ingredients[i], meat_sub) 682 | 683 | self.name = self.name + ' (non-pescatarian)' 684 | 685 | 686 | def to_style(self, style, threshold=1.0): 687 | """ 688 | search all recipes for recipes pertaining to the 'style' parameter and builds frequency dictionary. 689 | Then adds/removes/augemnets ingredients to make it more like the 'style' of cuisine. 690 | """ 691 | 692 | url = 'https://www.allrecipes.com/search/results/?wt={}&sort=re'.format(style) 693 | 694 | # retrieve data from url 695 | result = requests.get(url, timeout=10) 696 | c = result.content 697 | 698 | # store in BeautifulSoup object to parse HTML DOM 699 | soup = BeautifulSoup(c, "lxml") 700 | 701 | # find all urls that point to recipe pages 702 | style_recipes = [urlparse(url['href']) for url in soup.find_all('a', href=True)] # find all urls in HTML DOM 703 | style_recipes = [r.geturl() for r in style_recipes if r.path[1:8] == 'recipe/'] # filter out noise urls 704 | style_recipes = list(set(style_recipes)) # don't double count urls 705 | 706 | # parse the urls and create new Recipe objects 707 | style_recipes = [Recipe(**parse_url(recipe)) for recipe in style_recipes] # instantiate all recipe objects for each found recipe 708 | # print ('found {} recipes cooked {} style'.format(len(style_recipes), style)) 709 | 710 | # unpack all ingredients in total set of new recipes of type 'style' 711 | ingredients_ = [recipe.ingredients for recipe in style_recipes] 712 | ingredients = [] 713 | for ingredient in ingredients_: 714 | ingredients.extend(ingredient) 715 | 716 | # hold reference to just the ingredient names for frequency distrobutions 717 | ingredient_names = [ingredient.name for ingredient in ingredients] 718 | 719 | # hold reference to ingredients from original recipe 720 | current_ingredient_names = [ingredient.name for ingredient in self.ingredients] 721 | # print ('current ingredients from original recipe are {}'.format(current_ingredient_names)) 722 | 723 | # extract only the names and not the freqs -- will be sorted in decreasing order 724 | key_new_ingredients = [freq[0] for freq in self.freq_dist(ingredient_names)] 725 | # remove the ingredients that are already in there 726 | key_new_ingredients = [ingredient for ingredient in key_new_ingredients if not(ingredient in current_ingredient_names)][:10] 727 | # print ('key ingredients from {} recipes found are {}'.format(style, key_new_ingredients)) 728 | 729 | 730 | # get the whole ingredient objects -- this is to change actions accorgingly 731 | # e.g. if we switch from pinches of salt to lemon, we need to change pinches to squeezes 732 | ingredient_changes = [ingredient for ingredient in ingredients if (ingredient.name in key_new_ingredients) and not(ingredient.name in current_ingredient_names)] 733 | 734 | # clear up some memory 735 | del ingredients_ 736 | del ingredients 737 | del ingredient_names 738 | del style_recipes 739 | del soup 740 | 741 | 742 | tmp = [] 743 | new = [] 744 | for ingredient in ingredient_changes: 745 | if ingredient.name in tmp: continue 746 | tmp.append(ingredient.name) 747 | new.append(ingredient) 748 | 749 | ingredient_changes = copy.deepcopy(new) 750 | 751 | # no longer needed --> temporary use 752 | del new 753 | del tmp 754 | 755 | 756 | # Find out most common ingredients from all recipes of type 'style' -- then decide which to switch and/or add to current recipe 757 | try: most_common_sauce = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'S') 758 | except StopIteration: most_comon_sauce = None 759 | try: most_common_meat = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'M') 760 | except StopIteration: most_common_meat = None 761 | try: most_common_vegetable = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'V') 762 | except StopIteration: most_common_vegetable = None 763 | try: most_common_grain = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'G') 764 | except StopIteration: most_common_grain = None 765 | try: most_common_dairy = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'D') 766 | except StopIteration: most_common_dairy = None 767 | try: most_common_herb = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'H') 768 | except StopIteration: most_common_herb = None 769 | try: most_common_fruit = next(ingredient for ingredient in ingredient_changes if ingredient.type == 'F') 770 | except StopIteration: most_common_fruit = None 771 | 772 | 773 | # switch the ingredients 774 | most_commons = filter(lambda mc: mc != None, 775 | [most_common_meat, most_common_vegetable, most_common_sauce, most_common_grain, 776 | most_common_herb, most_common_dairy, most_common_fruit]) 777 | 778 | try: most_commons = most_commons[:int(7*threshold)] 779 | except: pass # this means we didnt find enough to choose -- just keep whole list b/c under threshold anyways 780 | 781 | # print ('most commons {}'.format([m.name for m in most_commons])) 782 | 783 | 784 | for new_ingredient in most_commons: 785 | try: current_ingredient = next(ingredient for ingredient in self.ingredients if ingredient.type == new_ingredient.type) 786 | except StopIteration: continue 787 | self.swap_ingredients(current_ingredient, new_ingredient) 788 | 789 | # update name 790 | self.name = self.name + ' (' + style + ')' 791 | 792 | 793 | def to_method(self, method): 794 | """ 795 | Transforms the recipe into using a method. The supported methods are 796 | 797 | * fry (i.e. fried chicken) 798 | * stirfry 799 | * bake 800 | 801 | If the method parameter is passed an unsupported value, an error message will be displayed and no 802 | transformation will be made to the recipe object. 803 | """ 804 | supported_methods = ['fry', 'stir-fry', 'bake'] 805 | if not method in supported_methods: 806 | print ('Error in to_method call. {} method is not yet supported.\n \ 807 | please look at documentation for supported methods'.format(method)) 808 | return 809 | 810 | 811 | replacements = { 812 | 'to_fry': [('preheated', ''), ('preheat', ''), ('oven', ''), ('degree', ''), ('baking sheet', 'skillet'), ('bake', 'fry'), ('baked', 'fried')], 813 | 'to_bake': [('skillet', 'baking sheet'), ('fry', 'place in oven'), ('drain', 'dry'), ('paper towel', ''), ('dry', 'remove from oven'), ('pot', 'baking sheet'), ('boil', 'crisp'), ('water', 'tin foil')], 814 | 'to_stir-fry': [('preheated', ''), ('preheat', ''), ('oven', ''), ('degree', ''), ('baking sheet', 'skillet'), ('bake', 'cook'), ('baked', 'cooked'), ('fry', 'cook until crisp'), ('drain', 'pour over rice and vegetables'), ('paper towel', '')] 815 | } 816 | 817 | 818 | # 819 | # 820 | # Fry 821 | # 822 | # 823 | 824 | if method == 'fry': 825 | # vegetable and meat booleans 826 | V, M = False, False 827 | 828 | # add flour if there is no flour in the recipe already 829 | flour = [ingredient for ingredient in self.ingredients if 'flour' in ingredient.name.lower().split(' ')] 830 | if not len(flour): 831 | self.ingredients.append(Ingredient('1 1/2 cups of flour')) 832 | 833 | 834 | # add oil if there is no oil in the recipe already 835 | oil = [ingredient for ingredient in self.ingredients if 'oil' in ingredient.name.lower().split(' ')] 836 | if not len(flour): 837 | self.ingredients.append(Ingredient('2 quarts of vegetable oil')) 838 | 839 | 840 | # find if there are vegetables 841 | vegetables = [ingredient for ingredient in self.ingredients if ingredient.type == 'V'] 842 | 843 | # find if there are meats 844 | meats = [ingredient for ingredient in self.ingredients if ingredient.type == 'M'] 845 | 846 | if not len(meats): 847 | if not len(vegetables): 848 | # add meat if there is no meat or vegetables in the recipe 849 | meat = Ingredient('10 ounces of {}'.format(random.choice(meat_list))) 850 | self.ingredients.append(meat) 851 | M = True 852 | else: 853 | V = True 854 | else: 855 | M = True 856 | V = bool(len(vegetables)) 857 | meat = meats[0] 858 | 859 | 860 | if re.search('Preheat oven to', self.instructions[0].instruction): 861 | self.instructions = self.instructions[1:] 862 | 863 | # remove / update all instructions that correlated to previous cooking methods 864 | other_method_regex = '(' + '|'.join(w[0] for w in replacements['to_fry']) + ')' 865 | for i, instruction in enumerate(self.instructions): 866 | inst = ' ' + instruction.instruction.lower() 867 | if re.search(other_method_regex, inst, flags=re.I): 868 | other_words = replacements['to_fry'] 869 | for word in other_words: 870 | if inst.find(word[0]): 871 | inst = inst.replace(word[0], word[1]) 872 | self.instructions[i] = Instruction(inst.strip()) # update instruction object in memory --> make permanent 873 | 874 | 875 | 876 | # add necessary instructions 877 | instruction_meat = 'In a large skillet, heat oil over medium heat. Salt and pepper {0} to taste, then roll in flour to coat. \ 878 | Place {0} in skillet and fry on medium heat until one side is golden brown, \ 879 | then turn and brown other side until {0} is no longer pink inside and its juices run clear.'.format(meat.name) 880 | 881 | # if len(vegetables): 882 | # instruction_vegetables = 'In a large skillet, heat oil over medium heat. Salt and pepper {0} to taste, then roll in flour to coat. \ 883 | # Place {0} in skillet and fry on medium heat until crispy.'.format(' and '.join([v.name for v in vegetables])) 884 | 885 | # # add vegetable instruction if vegetables are in recipe 886 | # self.instructions.insert(-1, Instruction(instruction_vegetables.strip())) 887 | 888 | # frying adds meat to the recipe regardless, 889 | self.instructions.insert(-1, Instruction(instruction_meat.strip())) 890 | 891 | # update cooking tools and methods 892 | self.cooking_tools = ['skillet'] 893 | self.cooking_methods = ['fry'] 894 | 895 | if not re.search('serve', self.instructions[-1].instruction): 896 | self.instructions.append(Instruction('Remove {} from the skillet. Dry on paper towels and serve!'.format(meat.name))) 897 | 898 | 899 | 900 | # 901 | # 902 | # Stir-fry 903 | # 904 | # 905 | 906 | elif method == 'stir-fry': 907 | 908 | # make sure there are vegetables 909 | vegetables = [ingredient for ingredient in self.ingredients if ingredient.type == 'V'] 910 | 911 | if len(vegetables) < 5: 912 | for _ in range(5 - len(vegetables)): 913 | try: self.ingredients.append(Ingredient('{} cups of {}'.format(random.randint(1,4), random.choice(vegetable_list)))) 914 | except: pass 915 | # update after the additions 916 | vegetables = [ingredient for ingredient in self.ingredients if ingredient.type == 'V'] 917 | 918 | # add sesame oil and soy sauce to stirfry the vegetables 919 | self.ingredients.append(Ingredient('1 tablespoon of sesame oil')) 920 | self.ingredients.append(Ingredient('2 tablespoons of soy sauce')) 921 | 922 | # add rice 923 | self.ingredients.append(Ingredient('1 1/2 cups of uncooked rice')) 924 | 925 | if re.search('Preheat oven to', self.instructions[0].instruction): 926 | self.instructions = self.instructions[1:] 927 | 928 | # remove / update all instructions that correlated to previous cooking methods 929 | other_method_regex = '(' + '|'.join(w[0] for w in replacements['to_stir-fry']) + ')' 930 | for i, instruction in enumerate(self.instructions): 931 | inst = ' ' + instruction.instruction.lower() 932 | if re.search(other_method_regex, inst, flags=re.I): 933 | other_words = replacements['to_stir-fry'] 934 | for word in other_words: 935 | if inst.find(word[0]): 936 | inst = inst.replace(word[0], word[1]) 937 | self.instructions[i] = Instruction(inst.strip()) # update instruction object in memory --> make permanent 938 | 939 | 940 | instruction_rice = 'Heat 4 quarts of water to a boil and then place the rice in and let it cook for 8 minutes' 941 | 942 | instruction_vegetables = 'Heat 1 tablespoon sesame oil in a large skillet over medium-high heat. Cook and \ 943 | stir {} until just tender, about 5 minutes. \ 944 | Remove vegetables from skillet and keep warm.'.format(' and '.join([v.name for v in vegetables])) 945 | 946 | # update cooking method and tools 947 | self.cooking_methods = ['stir-fry'] 948 | self.cooking_tools = ['skillet'] 949 | 950 | # update instructions 951 | self.instructions.insert(-1, Instruction(instruction_vegetables.strip())) 952 | self.instructions.insert(-1, Instruction(instruction_rice.strip())) 953 | 954 | # 955 | # 956 | # Baking 957 | # 958 | # 959 | 960 | # otherwise it must be 'bake' due to process of elimination 961 | else: 962 | 963 | begin_instruction = Instruction('Preheat oven to 350 degrees F (175 degrees C). Grease a 9x13-inch baking dish.') 964 | bake_instruction = Instruction('Bake in the preheated oven until the ensemble is crisp, about 30 minutes. Remove from the oven and drizzle with sauce.') 965 | 966 | # update cooking methods and tools 967 | self.cooking_methods = ['Bake'] 968 | current_tools = self.cooking_tools 969 | self.cooking_tools = ['pan', 'oven', 'dish', 'bowl'] 970 | 971 | bake_instruction_idx = 1 972 | 973 | for instruction in self.instructions: 974 | words = instruction.instruction_words 975 | for i, w in enumerate(words): 976 | 977 | # do all stir-fry recipes use skillets? 978 | if re.search('skillet', w, flags=re.I): 979 | instruction.instruction_words[i] = 'pan' 980 | bake_instruction_idx = i 981 | 982 | 983 | 984 | # remove / update all instructions that correlated to previous cooking methods 985 | other_method_regex = '(' + '|'.join(w[0] for w in replacements['to_bake']) + ')' 986 | for i, instruction in enumerate(self.instructions): 987 | inst = ' ' + instruction.instruction.lower() 988 | if re.search(other_method_regex, inst, flags=re.I): 989 | other_words = replacements['to_bake'] 990 | for word in other_words: 991 | if inst.find(word[0]): 992 | inst = inst.replace(word[0], word[1]) 993 | self.instructions[i] = Instruction(inst.strip()) # update instruction object in memory --> make permanent 994 | 995 | 996 | 997 | # update proper instructions 998 | self.instructions.insert(0, begin_instruction) 999 | 1000 | if not re.search('serve', self.instructions[-1].instruction, flags=re.I): 1001 | self.instructions.insert(bake_instruction_idx, bake_instruction) 1002 | 1003 | 1004 | self.update_instructions() 1005 | self.name = self.name + ' (' + method + ')' 1006 | 1007 | 1008 | def to_easy(self): 1009 | """ 1010 | makes recipes easier by replacing freshly made ingredients with store-bought, 1011 | disallowing finely done ingredients, and only allowing one type of chees 1012 | """ 1013 | for i, ingredient in enumerate(self.ingredients): 1014 | if 'freshly' in ingredient.descriptor: 1015 | ingredient.descriptor.remove('freshly') 1016 | ingredient.descriptor.append('store-bought') 1017 | if 'finely' in ingredient.descriptor: 1018 | ingredient.descriptor.remove('finely') 1019 | self.ingredients[i] = ingredient 1020 | 1021 | cheeses = [ing for ing in self.ingredients if re.search('cheese', ing.name)] 1022 | if len(cheeses) > 1: 1023 | for i in range(1, len(cheeses)): 1024 | first_cheese = copy.deepcopy(cheeses[0]) 1025 | first_cheese.measurement = cheeses[i].measurement 1026 | first_cheese.quantity = cheeses[i].quantity 1027 | print cheeses[0].quantity 1028 | self.swap_ingredients(cheeses[i], first_cheese) 1029 | 1030 | 1031 | def freq_dist(self, data): 1032 | """ 1033 | builds a frequncy distrobution dictionary sorted by the most commonly occuring words 1034 | """ 1035 | freqs = defaultdict(lambda: 0) 1036 | for d in data: 1037 | freqs[d] += 1 1038 | return sorted(freqs.items(), key=itemgetter(1), reverse=True) 1039 | 1040 | 1041 | def swap_ingredients(self, current_ingredient, new_ingredient): 1042 | """ 1043 | replaces the current_ingredient with the new_ingredient. 1044 | Updates the associated instructions, times, and ingredients. 1045 | """ 1046 | # (1) switch the ingredients in self.ingredients list 1047 | for i, ingredient in enumerate(self.ingredients): 1048 | if ingredient.name == current_ingredient.name: 1049 | self.ingredients[i] = new_ingredient 1050 | 1051 | # (2) update the instructions that mention it 1052 | name_length = len(current_ingredient.name.split(' ')) 1053 | for i, instruction in enumerate(self.instructions): 1054 | for j in range(len(instruction.instruction_words) - name_length): 1055 | if current_ingredient.name == ' '.join(instruction.instruction_words[j:j+name_length]): 1056 | self.instructions[i].instruction_words[j] = new_ingredient.name 1057 | 1058 | # get rid of any extra words 1059 | for k in range(1, name_length): 1060 | self.instructions[i].instruction_words[j+k] == '' 1061 | self.instructions[i].update_instruction() 1062 | 1063 | 1064 | def remove_non_numerics(string): return re.sub('[^0-9]', '', string) 1065 | 1066 | 1067 | def parse_url(url): 1068 | """ 1069 | reads the url and creates a recipe object. 1070 | Urls are expected to be from AllRecipes.com 1071 | 1072 | 1073 | Builds a dictionary that is passed into a Recipe object's init function and unpacked. 1074 | The dictionary is set up as 1075 | 1076 | { 1077 | name: string 1078 | preptime: int 1079 | cooktime: int 1080 | totaltime: int 1081 | ingredients: list of strings 1082 | instructions: list of strings 1083 | calories: int 1084 | carbs: int 1085 | fat: int 1086 | protien: int 1087 | cholesterol: int 1088 | sodium: int 1089 | 1090 | } 1091 | """ 1092 | # retrieve data from url 1093 | result = requests.get(url, timeout=10) 1094 | c = result.content 1095 | 1096 | # store in BeautifulSoup object to parse HTML DOM 1097 | soup = BeautifulSoup(c, "lxml") 1098 | 1099 | 1100 | # find name 1101 | name = soup.find('h1', {'itemprop': 'name'}).text 1102 | 1103 | # find relavent time information 1104 | # some recipes are missing some of the times 1105 | try: preptime = remove_non_numerics(soup.find('time', {'itemprop': 'prepTime'}).text) 1106 | except: preptime = 0 1107 | try: cooktime = remove_non_numerics(soup.find('time', {'itemprop': 'cookTime'}).text) 1108 | except: cooktime = 0 1109 | try: totaltime = remove_non_numerics(soup.find('time', {'itemprop': 'totalTime'}).text) 1110 | except: totaltime = 0 1111 | 1112 | # find ingredients 1113 | ingredients = [i.text for i in soup.find_all('span', {'class': 'recipe-ingred_txt added'})] 1114 | 1115 | # find instructions 1116 | instructions = [i.text for i in soup.find_all('span', {'class': 'recipe-directions__list--item'})] 1117 | 1118 | 1119 | # nutrition facts 1120 | calories = remove_non_numerics(soup.find('span', {'itemprop': 'calories'}).text) 1121 | carbs = soup.find('span', {'itemprop': 'carbohydrateContent'}).text # measured in grams 1122 | fat = soup.find('span', {'itemprop': 'fatContent'}).text # measured in grams 1123 | protien = soup.find('span', {'itemprop': 'proteinContent'}).text # measured in grams 1124 | cholesterol = soup.find('span', {'itemprop': 'cholesterolContent'}).text # measured in miligrams 1125 | sodium = soup.find('span', {'itemprop': 'sodiumContent'}).text # measured in grams 1126 | 1127 | 1128 | if DEBUG: 1129 | print ('recipe is called {}'.format(name)) 1130 | print ('prep time is {} minutes, cook time is {} minutes and total time is {} minutes'.format(preptime, cooktime, totaltime)) 1131 | print ('it has {} ingredients'.format(len(ingredients))) 1132 | print ('it has {} instructions'.format(len(instructions))) 1133 | print ('it has {} calories, {} g of carbs, {} g of fat, {} g of protien, {} mg of cholesterol, {} mg of sodium'.format(calories, carbs, fat, protien, cholesterol, sodium)) 1134 | 1135 | 1136 | return { 1137 | 'name': name, 1138 | 'preptime': preptime, 1139 | 'cooktime': cooktime, 1140 | 'totaltime': totaltime, 1141 | 'ingredients': ingredients, 1142 | 'instructions': instructions, 1143 | 'calories': calories, 1144 | 'carbs': carbs, 1145 | 'fat': fat, 1146 | 'protien': protien, 1147 | 'cholesterol': cholesterol, 1148 | 'sodium': sodium, 1149 | 'url': url 1150 | } 1151 | 1152 | 1153 | def build_dynamic_lists(): 1154 | """ 1155 | fills the lists of known foods from websites -- used to tag ingredients 1156 | """ 1157 | global vegetable_list 1158 | global sauce_list 1159 | global herbs_spice_list 1160 | global dairy_list 1161 | global meat_list 1162 | global grain_list 1163 | global fruit_list 1164 | global seafood_list 1165 | 1166 | # build vegetable list 1167 | url = 'https://simple.wikipedia.org/wiki/List_of_vegetables' 1168 | result = requests.get(url, timeout=10) 1169 | c = result.content 1170 | 1171 | # store in BeautifulSoup object to parse HTML DOM 1172 | soup = BeautifulSoup(c, "lxml") 1173 | 1174 | lis = [li.text.strip() for li in soup.find_all('li')] 1175 | lis_clean = [] 1176 | for li in lis: 1177 | if li == 'Lists of vegetables': break 1178 | if len(li) == 1: continue 1179 | if re.search('\d', li): continue 1180 | if re.search('\n', li): continue 1181 | lis_clean.append(li.lower()) 1182 | vegetable_list = lis_clean 1183 | 1184 | 1185 | # build herbs and spices list 1186 | url = 'https://en.wikipedia.org/wiki/List_of_culinary_herbs_and_spices' 1187 | result = requests.get(url, timeout=10) 1188 | c = result.content 1189 | 1190 | # store in BeautifulSoup object to parse HTML DOM 1191 | soup = BeautifulSoup(c, "lxml") 1192 | 1193 | lis = [li.text.strip() for li in soup.find_all('li')][3:] 1194 | lis_clean = [] 1195 | for li in lis: 1196 | if len(li) == 1: continue 1197 | if re.search('\d', li): continue 1198 | if re.search('\n', li): continue 1199 | if li == 'Category': break 1200 | lis_clean.append(li.lower()) 1201 | herbs_spice_list = lis_clean 1202 | 1203 | 1204 | # build sauces list 1205 | url = 'https://en.wikipedia.org/wiki/List_of_sauces' 1206 | result = requests.get(url, timeout=10) 1207 | c = result.content 1208 | 1209 | # store in BeautifulSoup object to parse HTML DOM 1210 | soup = BeautifulSoup(c, "lxml") 1211 | 1212 | lis = [li.text.strip() for li in soup.find_all('li')] 1213 | lis_clean = [] 1214 | for li in lis: 1215 | if len(li) == 1: continue 1216 | if re.search('\d', li): continue 1217 | if re.search('\n', li): continue 1218 | if li == 'Category': break 1219 | lis_clean.append(li.lower()) 1220 | sauce_list = lis_clean 1221 | 1222 | 1223 | # build meat list 1224 | url = 'http://naturalhealthtechniques.com/list-of-meats-and-poultry/' 1225 | result = requests.get(url, timeout=10) 1226 | c = result.content 1227 | 1228 | # store in BeautifulSoup object to parse HTML DOM 1229 | soup = BeautifulSoup(c, "lxml") 1230 | 1231 | div = soup.find('div', {'class': 'entry-content'}) 1232 | lis = [li.text.strip() for li in div.find_all('li')] 1233 | lis_clean = [] 1234 | for li in lis: 1235 | if len(li) == 1: continue 1236 | if re.search('\d', li): continue 1237 | if re.search('\n', li): continue 1238 | lis_clean.append(li.lower()) 1239 | meat_list = lis_clean 1240 | 1241 | # build seafood_list and also extend to the meat_list 1242 | url = 'http://naturalhealthtechniques.com/list-of-fish-and-seafood/' 1243 | result = requests.get(url, timeout=10) 1244 | c = result.content 1245 | 1246 | # store in BeautifulSoup object to parse HTML DOM 1247 | soup = BeautifulSoup(c, "lxml") 1248 | 1249 | div = soup.find('div', {'class': 'entry-content'}) 1250 | lis = [li.text.strip() for li in div.find_all('li')] 1251 | lis_clean = [] 1252 | for li in lis: 1253 | if len(li) == 1: continue 1254 | if re.search('\d', li): continue 1255 | if re.search('\n', li): continue 1256 | lis_clean.append(li.lower()) 1257 | meat_list.extend(lis_clean) 1258 | seafood_list = lis_clean 1259 | 1260 | # build dairy list 1261 | url = 'http://naturalhealthtechniques.com/list-of-cheese-dairy-products/' 1262 | result = requests.get(url, timeout=10) 1263 | c = result.content 1264 | 1265 | # store in BeautifulSoup object to parse HTML DOM 1266 | soup = BeautifulSoup(c, "lxml") 1267 | 1268 | div = soup.find('div', {'class': 'entry-content'}) 1269 | lis = [li.text.strip() for li in div.find_all('li')] 1270 | lis_clean = [] 1271 | for li in lis: 1272 | if len(li) == 1: continue 1273 | if re.search('\d', li): continue 1274 | if re.search('\n', li): continue 1275 | lis_clean.append(li.lower()) 1276 | dairy_list = lis_clean 1277 | 1278 | 1279 | # build grains list 1280 | url = 'http://naturalhealthtechniques.com/list-of-grains-cereals-pastas-flours/' 1281 | result = requests.get(url, timeout=10) 1282 | c = result.content 1283 | 1284 | # store in BeautifulSoup object to parse HTML DOM 1285 | soup = BeautifulSoup(c, "lxml") 1286 | 1287 | div = soup.find('div', {'class': 'entry-content'}) 1288 | lis = [li.text.strip() for li in div.find_all('li')] 1289 | lis_clean = [] 1290 | for li in lis: 1291 | if len(li) == 1: continue 1292 | if re.search('\d', li): continue 1293 | if re.search('\n', li): continue 1294 | lis_clean.append(li.lower()) 1295 | grain_list = lis_clean 1296 | 1297 | 1298 | # build grains list 1299 | url = 'http://naturalhealthtechniques.com/list-of-fruits/' 1300 | result = requests.get(url, timeout=10) 1301 | c = result.content 1302 | 1303 | # store in BeautifulSoup object to parse HTML DOM 1304 | soup = BeautifulSoup(c, "lxml") 1305 | 1306 | div = soup.find('div', {'class': 'entry-content'}) 1307 | lis = [li.text.strip() for li in div.find_all('li')] 1308 | lis_clean = [] 1309 | for li in lis: 1310 | if len(li) == 1: continue 1311 | if re.search('\d', li): continue 1312 | if re.search('\n', li): continue 1313 | lis_clean.append(li.lower()) 1314 | fruit_list = lis_clean 1315 | 1316 | 1317 | def timeit(method): 1318 | def timed(*args, **kw): 1319 | ts = time.time() 1320 | result = method(*args, **kw) 1321 | te = time.time() 1322 | if 'log_time' in kw: 1323 | name = kw.get('log_name', method.__name__.upper()) 1324 | kw['log_time'][name] = int((te - ts)) 1325 | else: 1326 | print '%r %2.2f s' % \ 1327 | (method.__name__, (te - ts)) 1328 | return result 1329 | return timed 1330 | 1331 | 1332 | @timeit 1333 | def main(): 1334 | """ 1335 | main function -- runs all initalization and any methods user wants 1336 | """ 1337 | 1338 | # parse websites to build global lists -- used for Ingredient type tagging 1339 | build_dynamic_lists() 1340 | 1341 | URL = 'http://allrecipes.com/recipe/234667/chef-johns-creamy-mushroom-pasta/?internalSource=rotd&referringId=95&referringContentType=recipe%20hub' 1342 | # URL = 'http://allrecipes.com/recipe/21014/good-old-fashioned-pancakes/?internalSource=hub%20recipe&referringId=1&referringContentType=recipe%20hub' 1343 | # URL = 'https://www.allrecipes.com/recipe/60598/vegetarian-korma/?internalSource=hub%20recipe&referringId=1138&referringContentType=recipe%20hub' 1344 | # URL = 'https://www.allrecipes.com/recipe/8836/fried-chicken/?internalSource=hub%20recipe&referringContentType=search%20results&clickId=cardslot%202' 1345 | # URL = 'https://www.allrecipes.com/recipe/52005/tender-italian-baked-chicken/?internalSource=staff%20pick&referringId=201&referringContentType=recipe%20hub' 1346 | 1347 | # URLS = [ 1348 | # 'https://www.allrecipes.com/recipe/213717/chakchouka-shakshouka/?internalSource=hub%20recipe&referringContentType=search%20results&clickId=cardslot%201', 1349 | # 'https://www.allrecipes.com/recipe/216756/baked-ham-and-cheese-party-sandwiches/?internalSource=hub%20recipe&referringContentType=search%20results&clickId=cardslot%205', 1350 | # 'https://www.allrecipes.com/recipe/234592/buffalo-chicken-stuffed-shells/', 1351 | # 'https://www.allrecipes.com/recipe/23109/rainbow-citrus-cake/', 1352 | # 'https://www.allrecipes.com/recipe/219910/homemade-cream-filled-sponge-cakes/', 1353 | # 'https://www.allrecipes.com/recipe/16700/salsa-chicken/?internalSource=hub%20recipe&referringId=1947&referringContentType=recipe%20hub', 1354 | # 'https://www.allrecipes.com/recipe/109190/smooth-sweet-tea/', 1355 | # 'https://www.allrecipes.com/recipe/220943/chef-johns-buttermilk-biscuits/', 1356 | # 'https://www.allrecipes.com/recipe/24501/tangy-honey-glazed-ham/?internalSource=hub%20recipe&referringId=15876&referringContentType=recipe%20hub', 1357 | # 'https://www.allrecipes.com/recipe/247204/red-split-lentils-masoor-dal/?internalSource=staff%20pick&referringId=233&referringContentType=recipe%20hub' 1358 | # 'https://www.allrecipes.com/recipe/233856/mauigirls-smoked-salmon-stuffed-pea-pods/?internalSource=staff%20pick&referringId=416&referringContentType=recipe%20hub', 1359 | # 'https://www.allrecipes.com/recipe/169305/sopapilla-cheesecake-pie/?internalSource=hub%20recipe&referringId=728&referringContentType=recipe%20hub', 1360 | # 'https://www.allrecipes.com/recipe/85389/gourmet-mushroom-risotto/?internalSource=hub%20recipe&referringId=723&referringContentType=recipe%20hub', 1361 | # 'https://www.allrecipes.com/recipe/138020/st-patricks-colcannon/?internalSource=staff%20pick&referringId=197&referringContentType=recipe%20hub', 1362 | # 'https://www.allrecipes.com/recipe/18241/candied-carrots/?internalSource=hub%20recipe&referringId=194&referringContentType=recipe%20hub', 1363 | # 'https://www.allrecipes.com/recipe/18870/roast-leg-of-lamb-with-rosemary/?internalSource=hub%20recipe&referringId=194&referringContentType=recipe%20hub', 1364 | # 'https://www.allrecipes.com/recipe/8270/sams-famous-carrot-cake/?internalSource=hub%20recipe&referringId=188&referringContentType=recipe%20hub', 1365 | # 'https://www.allrecipes.com/recipe/13717/grandmas-green-bean-casserole/?internalSource=hub%20recipe&referringId=188&referringContentType=recipe%20hub' 1366 | 1367 | # ] 1368 | 1369 | # for url in URLS: 1370 | # recipe_attrs = parse_url(url) 1371 | # recipe = Recipe(**recipe_attrs) 1372 | # print(recipe.to_JSON()) 1373 | 1374 | recipe_attrs = parse_url(URL) 1375 | recipe = Recipe(**recipe_attrs) 1376 | print(recipe.to_JSON()) 1377 | # recipe.to_vegan() 1378 | # recipe.from_vegan() 1379 | 1380 | # recipe.to_vegan() 1381 | # recipe.from_vegan() 1382 | # recipe.to_vegetarian() 1383 | # recipe.from_vegetarian() 1384 | # recipe.to_pescatarian() 1385 | # recipe.from_pescatarian() 1386 | # recipe.to_healthy() 1387 | # recipe.from_healthy() 1388 | # recipe.to_style('Thai') 1389 | recipe.to_style('Mexican') 1390 | # recipe.to_method('bake') 1391 | # recipe.to_easy(); 1392 | print(recipe.to_JSON()) 1393 | print(recipe.compare_to_original()) 1394 | # recipe.to_method('fry') 1395 | # # recipe.print_pretty() 1396 | 1397 | 1398 | 1399 | #============================================================================ 1400 | # Start webPy environment 1401 | #============================================================================ 1402 | 1403 | 1404 | def main_gui(url, method, parameter): 1405 | 1406 | # parse websites to build global lists -- used for Ingredient type tagging 1407 | build_dynamic_lists() 1408 | 1409 | 1410 | URL = url 1411 | recipe_attrs = parse_url(URL) 1412 | 1413 | recipe = Recipe(**recipe_attrs) 1414 | s = "" 1415 | s += recipe.to_JSON() 1416 | 1417 | if method == 'to_vegan': 1418 | recipe.to_vegan() 1419 | elif method == 'from_vegan': 1420 | recipe.from_vegan() 1421 | elif method == 'to_vegetarian': 1422 | recipe.to_vegetarian() 1423 | elif method == 'from_vegetarian': 1424 | recipe.from_vegetarian() 1425 | elif method == 'to_pescatarian': 1426 | recipe.to_pescatarian() 1427 | elif method == 'from_pescatarian': 1428 | recipe.from_pescatarian() 1429 | elif method == 'to_healthy': 1430 | recipe.to_healthy() 1431 | elif method == 'from_healthy': 1432 | recipe.from_healthy() 1433 | elif method == 'to_style(Thai)': 1434 | recipe.to_style('Thai') 1435 | elif method == 'to_style(parameter)': 1436 | if not parameter: 1437 | return "This method requires a parameter" 1438 | recipe.to_style(parameter) 1439 | elif method == 'to_method(parameter)': 1440 | if not parameter: 1441 | return "This method requires a parameter" 1442 | recipe.to_method(parameter) 1443 | 1444 | 1445 | 1446 | s += recipe.to_JSON() 1447 | s += recipe.compare_to_original() 1448 | 1449 | return s 1450 | 1451 | render = web.template.render('templates/') 1452 | 1453 | urls = ('/', 'index') 1454 | class RecipeApp(web.application): 1455 | def run(self, port=8080, *middleware): 1456 | func = self.wsgifunc(*middleware) 1457 | return web.httpserver.runsimple(func, ('0.0.0.0', port)) 1458 | 1459 | myform = form.Form( 1460 | form.Textbox("url", 1461 | form.notnull), 1462 | form.Dropdown('transformation', ['to_vegan', 'from_vegan', 'to_vegetarian', 'from_vegetarian', 'to_pescatarian', 'from_pescatarian', 'to_healthy', 'from_healthy', 'to_style(parameter)', 'to_method(parameter)']), 1463 | form.Textbox("parameter (optional)")) 1464 | 1465 | 1466 | 1467 | class index: 1468 | def GET(self): 1469 | form = myform() 1470 | # make sure you create a copy of the form by calling it (line above) 1471 | # Otherwise changes will appear globally 1472 | return render.formtest(form) 1473 | 1474 | def POST(self): 1475 | form = myform() 1476 | if not form.validates(): 1477 | return render.formtest(form) 1478 | else: 1479 | return main_gui(form.d.url, form['transformation'].value, form['parameter (optional)'].value) 1480 | 1481 | 1482 | 1483 | if __name__ == "__main__": 1484 | parser = argparse.ArgumentParser() 1485 | parser.add_argument("--gui", help="run application on locally hosted webpage", action="store_true") 1486 | 1487 | args = parser.parse_args() 1488 | if args.gui: 1489 | sys.argv[1] = '' 1490 | web.internalerror = web.debugerror 1491 | app = RecipeApp(urls, globals()) 1492 | app.run() 1493 | else: 1494 | main() 1495 | -------------------------------------------------------------------------------- /templates/formtest.py: -------------------------------------------------------------------------------- 1 | $def with (form) 2 | 3 | 4 | 5 | Recipe Transformer 6 | 56 | 57 | 58 |

Recipe Transformer

59 |

Take any recipe from allrecipes.com and modify how you like!

60 |
61 |
62 | $:form.render() 63 | 64 |
65 |
66 | 67 | --------------------------------------------------------------------------------