-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmenu.py
More file actions
436 lines (366 loc) · 14 KB
/
Copy pathmenu.py
File metadata and controls
436 lines (366 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
import argparse
import json
import os
import time
import traceback
from dataclasses import asdict, dataclass, field
from typing import List
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support import ui
from webdriver_manager.chrome import ChromeDriverManager
from logger import Logger
from models.base import AbstractAgent, AbstractOrderData, ApiResponseException
CHROME_PATH = (
"/Users/williammurphy/Downloads/Google Chrome.app/Contents/MacOS/Google Chrome"
)
PAGE_LOAD_WAIT_TIME = 5
logger = Logger("menu_logger")
@dataclass
class ItemDetails(AbstractOrderData):
NAME = "name"
DETAIL_PRICE = "detail_price"
name: str
detail_price: float = 0.0
def as_dict(self):
return asdict(self)
@classmethod
def get_schema(cls,
desc="Array of priced item details. Should only contain information relevant to the item."
"Information should not be repeated between the item and items details",
item_desc="A priced item detail."
):
return {
"type": "array",
"description": desc,
"items": {
"type": "object",
"description": item_desc,
"properties": {
f"{cls.NAME}": {"type": "string"},
f"{cls.DETAIL_PRICE}": {"type": "number"},
},
},
"required": [f"{cls.NAME}", f"{cls.DETAIL_PRICE}"],
}
@dataclass
class Option(ItemDetails):
@classmethod
def get_schema(self):
return super().get_schema(
desc="Array of priced options for a given menu item. These replace the base price & should only contain information relevant to the item."
"Information should not be repeated between the item and items details",
item_desc="a single priced option"
)
@dataclass
class Addon(ItemDetails):
@classmethod
def get_schema(self):
return super().get_schema(
desc="Array of priced addons for a given menu item. Does not replace base price of items, but adds on to it",
item_desc="a single priced add on"
)
@dataclass
class MenuAddon(ItemDetails):
CATEGORY = "category"
category: str = ""
@classmethod
def get_schema(cls):
schema = super().get_schema(
desc="Array of priced addons for a given menu. Separate from base menu items",
item_desc="a single priced add on"
)
schema["items"]["properties"]["{cls.CATEGORY}"] = {"type": "string"}
schema["required"].append(f"{cls.CATEGORY}")
return schema
@dataclass
class ScraperItem(AbstractOrderData):
NAME = "name"
OPTIONS = "options"
ADDONS = "addons"
CATEGORY = "category"
ITEM_PRICE = "item_price"
name: str
category: str
options: List[str]
addons: List[str]
item_price: float = None
def as_dict(self):
return {
f"{ScraperItem.NAME}": self.name,
f"{ScraperItem.ITEM_PRICE}": self.item_price,
f"{ScraperItem.CATEGORY}": self.category,
f"{ScraperItem.OPTIONS}": [x.as_dict() for x in self.options],
f"{ScraperItem.ADDONS}": [x.as_dict() for x in self.options],
}
def __post_init__(self, *args, **kwargs):
self.name = self.name.title()
self.options = [Option(**x) for x in self.options]
self.addons = [Addon(**x) for x in self.addons]
def __hash__(self) -> int:
return hash((self.name, (hash(x for x in self.options) or 0), self.quantity))
@classmethod
def get_schema(
cls,
name_desc: str = "The name of the item.",
):
return {
"type": "object",
"description": (
"A priced item menu item, all fields should be extracted from html"
"if an item is to be created at all"
),
"properties": {
f"{ScraperItem.NAME}": {
"type": "string",
"description": name_desc,
},
f"{ScraperItem.ITEM_PRICE}": {
"type": ["number", "null"],
"description": "The price of the item. Can be null if details included in the price",
},
f"{ScraperItem.CATEGORY}": {
"type": ["string", "null"],
"description": "The category of the item.",
},
f"{ScraperItem.OPTIONS}": {
**Option.get_schema(),
},
f"{ScraperItem.ADDONS}": {
**Addon.get_schema(),
}
},
"required": [
f"{ScraperItem.NAME}",
f"{ScraperItem.ITEM_PRICE}",
f"{ScraperItem.CATEGORY}",
f"{ScraperItem.OPTIONS}",
f"{ScraperItem.ADDONS}"
],
}
@dataclass
class ScraperMenu(AbstractOrderData):
RESTAURANT_NAME = "restaurant_name"
RESTAURANT_ADDRESS = "restaurant_address"
MENU_ITEMS = "menu_items"
MENU_ADDONS = "menu_addons"
restaurant_name: str = None
restaurant_address: str = None
menu_items: List[ScraperItem] = field(default_factory=list)
menu_addons: List[MenuAddon] = field(default_factory=list)
def __post_init__(self, *args, **kwargs):
self.menu_items = [ScraperItem(**x) for x in self.menu_items]
self.menu_addons = [MenuAddon(**x) for x in self.menu_addons]
def as_dict(self):
return {
f"{ScraperMenu.RESTAURANT_NAME}": self.restaurant_name,
f"{ScraperMenu.RESTAURANT_ADDRESS}": self.restaurant_address,
f"{ScraperMenu.MENU_ITEMS}": [x.as_dict() for x in self.menu_items],
f"{ScraperMenu.MENU_ADDONS}": [x.as_dict() for x in self.menu_addons]
}
@classmethod
def get_schema(cls):
return {
"type": "object",
"description": (
"Structured menu with prices and details extracted from html. "
),
"properties": {
f"{ScraperMenu.MENU_ITEMS}": {
"type": "array",
"description": (
"Array of menu items extracted from html with all fields filled out according the schema."
"Items should ONLY be in this list if they can be ordered."
),
"items": ScraperItem.get_schema("item name"),
},
f"{ScraperMenu.MENU_ADDONS}": {
"type": "array",
"description": (
"Array of menu items extracted from html with all fields filled out according the schema."
"Addons should only be added to the list if they are not clearly part of menu items."
),
"items": MenuAddon.get_schema(),
},
f"{ScraperMenu.RESTAURANT_NAME}": {
"type": ["string", "null"],
"description": "The name of the restaurant. Only to be filled out if the restaurant name is detected.",
},
f"{ScraperMenu.RESTAURANT_ADDRESS}": {
"type": ["string", "null"],
"description": "The address of the restaurant. Only to be filled out if the restaurant address is detected.",
},
},
"required": [
f"{ScraperMenu.MENU_ITEMS}",
],
}
def extend_menu(self, other_menu: "ScraperMenu"):
self.restaurant_name = self.restaurant_name or other_menu.restaurant_name
self.restaurant_address = (
self.restaurant_address or other_menu.restaurant_address
)
self.menu_items.extend(other_menu.menu_items)
self.menu_addons.extend(other_menu.menu_addons)
SCRAPING_FUNCTIONS = {
"process_scraped_menu": {
"name": "process_scraped_menu",
"description": (
f"Processes a structured menu scraped from the web and uses it to transact. "
),
"parameters": {
**ScraperMenu.get_schema(),
},
},
}
@dataclass
class ScraperAgent(AbstractAgent):
scraper_menu: ScraperMenu = field(default_factory=ScraperMenu)
@property
def functions(self):
return SCRAPING_FUNCTIONS
@property
def logger(self):
return logger
def get_system_message(self):
return {
"role": "system",
"content": (
"You are a 'smart' menu web scraper. Your job is to take "
"unstructured html and return structured menu items. "
),
}
def add_to_menu(self, item: ScraperItem):
self.scraper_menu.menu_items.append(item)
def get_scraping_prompt(self, web_page_html: str):
prompt = f"Here is the html from the web page with the menu items. \n { web_page_html}"
return prompt
def get_retry_prompt(self, web_page_html: str, error: str):
prompt = (
f"This chunk of the restaurants html menu web page failed "
f"to process: \n { web_page_html}\n "
f"due to error: \n {error} \n"
f"please try again."
)
return prompt
def scrape(self, url="https://www.ediningexpress.com/live20/927/1749"):
logger.debug(f"Scraping menu from {url}...")
# Setup Chrome options to run headless
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument("--headless")
chrome_options.binary_location = CHROME_PATH
# Set up WebDriver
webdriver_service = Service(ChromeDriverManager('119.0.6045.19').install())
driver = webdriver.Chrome(service=webdriver_service, options=chrome_options)
# Fetch the webpage
driver.get(url)
try:
ui.WebDriverWait(driver, 5).until(EC.alert_is_present())
alert = driver.switch_to.alert
alert.accept()
print("Alert accepted")
except TimeoutException:
print("No alert present")
# Wait until element is loaded
ui.WebDriverWait(driver, 10).until(
lambda driver: driver.find_element(by=By.TAG_NAME, value="body")
)
# Wait an additional amount of time for JavaScript to execute
time.sleep(10)
# Now you can access the final HTML with JavaScript executed
html = driver.page_source
logger.debug(f"Menu scraped from {url}...: \n\n {html}")
print(html)
driver.quit()
return html
def process_scraped_menu(self, text, chunk_size=32000, max=64000) -> ScraperMenu:
initial_menu = ScraperMenu()
total = max or len(text)
i = 0
while text and i <= total:
current_chunk = text[:chunk_size]
text = text[chunk_size:]
i += chunk_size
logger.debug(
f"\n Processing raw menu chunk of len {len(current_chunk)}. {i} of "
f"total {total}: \n\n {current_chunk}"
)
prompt = self.get_scraping_prompt(current_chunk)
max_tries = 3
tries = 0
error = None
while tries < max_tries:
if error is None:
response = self.get_func_completion_res(
add_user_msg=prompt, fn_name="process_scraped_menu"
)
logger.debug(f"Got response from API: \n\n {response}")
else:
logger.debug(f"Retrying with error: \n\n {error}")
retry_prompt = self.get_retry_prompt(current_chunk, error)
response = self.get_func_completion_res(
add_user_msg=retry_prompt, fn_name="process_scraped_menu"
)
try:
menu_chunk = ScraperMenu.from_api_response(response)
logger.debug(f"Turned into menu chunk: \n\n {menu_chunk}")
break
except ApiResponseException:
error = traceback.format_exc()
logger.error(f"Scraping failed with error: \n\n {error}")
tries += 1
initial_menu.extend_menu(menu_chunk)
logger.debug(f"Extended menu: \n\n {initial_menu}")
return initial_menu
def trim_to_tokens(s, num_tokens=32000):
return s[:num_tokens]
def write_menu(dictionary, filename: str):
filename = filename.lower().replace(" ", "_") + ".json"
try:
with open(filename, "w") as file:
json.dump(dictionary, file)
print("Successfully wrote to", filename)
except Exception as e:
print("Error occurred:", e)
print(traceback.format_exc())
def main():
parser = argparse.ArgumentParser(description="Scrape a menu from the web")
parser.add_argument(
"--chunks",
type=int,
default=2000,
help="Chunk size for scraping the menu",
)
parser.add_argument(
"--max_len",
type=int,
default=32000,
help="Max length of the menu to scrape",
)
parser.add_argument(
"--menu_name",
type=str,
default=None,
help="Name of menu to scrape",
)
parser.add_argument(
"--url",
type=str,
default="https://www.ediningexpress.com/live20/927/1749",
help="URL of menu to scrape",
)
args = parser.parse_args()
ms = ScraperAgent()
# Create a menu from the text
menu_html = ms.scrape(args.url)
menu = ms.process_scraped_menu(menu_html, chunk_size=args.chunks, max=args.max_len)
# Save / print the menu
json.dumps(menu.as_dict(), indent=4)
write_menu(
menu.as_dict(), args.menu_name if args.menu_name else menu.restaurant_name
)
if __name__ == "__main__":
main()