Add Grubhub menu import via API
Detect Grubhub URLs and fetch menu data directly via their REST API instead of scraping HTML. Gets anonymous auth token, then fetches full restaurant data including categories, items, modifiers, prices, hours, lat/lng, tax rate, and item images. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2f9bb2b869
commit
c40e5c0181
1 changed files with 229 additions and 0 deletions
|
|
@ -55,6 +55,235 @@
|
||||||
<cfset targetUrl = "https://" & targetUrl>
|
<cfset targetUrl = "https://" & targetUrl>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
|
<!--- ========== GRUBHUB FAST PATH ========== --->
|
||||||
|
<cfif reFindNoCase("grubhub\.com/restaurant/", targetUrl)>
|
||||||
|
<cfset arrayAppend(response.steps, "Grubhub URL detected - using API")>
|
||||||
|
|
||||||
|
<!--- Extract restaurant ID from URL (last path segment or ?classicAffiliateId param) --->
|
||||||
|
<cfset ghRestaurantId = "">
|
||||||
|
<cfset ghIdMatch = reMatchNoCase("/(\d+)(\?|$)", targetUrl)>
|
||||||
|
<cfif arrayLen(ghIdMatch)>
|
||||||
|
<cfset ghRestaurantId = reReplaceNoCase(ghIdMatch[1], "[^0-9]", "", "all")>
|
||||||
|
</cfif>
|
||||||
|
<cfif NOT len(ghRestaurantId)>
|
||||||
|
<cfthrow message="Could not extract Grubhub restaurant ID from URL">
|
||||||
|
</cfif>
|
||||||
|
<cfset arrayAppend(response.steps, "Grubhub restaurant ID: " & ghRestaurantId)>
|
||||||
|
|
||||||
|
<!--- Step 1: Get anonymous access token --->
|
||||||
|
<cfhttp url="https://api-gtm.grubhub.com/auth" method="POST" timeout="15" result="ghAuthResult">
|
||||||
|
<cfhttpparam type="header" name="Content-Type" value="application/json">
|
||||||
|
<cfhttpparam type="body" value='{"brand":"GRUBHUB","client_id":"beta_UmWlpstzQSFmocLy3h1UieYcVST","scope":"anonymous"}'>
|
||||||
|
</cfhttp>
|
||||||
|
<cfif NOT ghAuthResult.statusCode CONTAINS "200">
|
||||||
|
<cfthrow message="Grubhub auth failed: #ghAuthResult.statusCode#">
|
||||||
|
</cfif>
|
||||||
|
<cfset ghAuth = deserializeJSON(ghAuthResult.fileContent)>
|
||||||
|
<cfset ghToken = ghAuth.session_handle.access_token>
|
||||||
|
<cfset arrayAppend(response.steps, "Got Grubhub anonymous token")>
|
||||||
|
|
||||||
|
<!--- Step 2: Fetch restaurant with full menu data including modifiers --->
|
||||||
|
<cfhttp url="https://api-gtm.grubhub.com/restaurants/#ghRestaurantId#?hideChoiceCategories=false&version=4&orderType=standard&hideUnavailableMenuItems=false&hideMenuItems=false" method="GET" timeout="30" result="ghMenuResult">
|
||||||
|
<cfhttpparam type="header" name="Authorization" value="Bearer #ghToken#">
|
||||||
|
</cfhttp>
|
||||||
|
<cfif NOT ghMenuResult.statusCode CONTAINS "200">
|
||||||
|
<cfthrow message="Grubhub restaurant fetch failed: #ghMenuResult.statusCode#">
|
||||||
|
</cfif>
|
||||||
|
<cfset ghData = deserializeJSON(ghMenuResult.fileContent)>
|
||||||
|
<cfset ghRestaurant = ghData.restaurant>
|
||||||
|
<cfset arrayAppend(response.steps, "Fetched Grubhub restaurant data (" & len(ghMenuResult.fileContent) & " bytes)")>
|
||||||
|
|
||||||
|
<!--- Parse business info --->
|
||||||
|
<cfset ghBusiness = structNew()>
|
||||||
|
<cfset ghBusiness["name"] = ghRestaurant.name>
|
||||||
|
<cfif structKeyExists(ghRestaurant, "address") AND isStruct(ghRestaurant.address)>
|
||||||
|
<cfset ghAddr = ghRestaurant.address>
|
||||||
|
<cfif structKeyExists(ghAddr, "street_address")><cfset ghBusiness["addressLine1"] = ghAddr.street_address></cfif>
|
||||||
|
<cfif structKeyExists(ghAddr, "locality")><cfset ghBusiness["city"] = ghAddr.locality></cfif>
|
||||||
|
<cfif structKeyExists(ghAddr, "region")><cfset ghBusiness["state"] = ghAddr.region></cfif>
|
||||||
|
<cfif structKeyExists(ghAddr, "zip")><cfset ghBusiness["zip"] = ghAddr.zip></cfif>
|
||||||
|
<cfset ghBusiness["address"] = (ghBusiness.addressLine1 ?: "") & ", " & (ghBusiness.city ?: "") & ", " & (ghBusiness.state ?: "") & " " & (ghBusiness.zip ?: "")>
|
||||||
|
</cfif>
|
||||||
|
<cfif structKeyExists(ghRestaurant, "latitude") AND isNumeric(ghRestaurant.latitude)>
|
||||||
|
<cfset ghBusiness["latitude"] = ghRestaurant.latitude>
|
||||||
|
</cfif>
|
||||||
|
<cfif structKeyExists(ghRestaurant, "longitude") AND isNumeric(ghRestaurant.longitude)>
|
||||||
|
<cfset ghBusiness["longitude"] = ghRestaurant.longitude>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Phone --->
|
||||||
|
<cfif structKeyExists(ghRestaurant, "phone_number") AND len(ghRestaurant.phone_number)>
|
||||||
|
<cfset ghBusiness["phone"] = reReplace(ghRestaurant.phone_number, "[^0-9]", "", "all")>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Description --->
|
||||||
|
<cfif structKeyExists(ghRestaurant, "description") AND len(trim(ghRestaurant.description))>
|
||||||
|
<cfset ghBusiness["description"] = trim(ghRestaurant.description)>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Hours from restaurant_availability or restaurant --->
|
||||||
|
<cfset ghHoursParts = []>
|
||||||
|
<cfset ghDayOrder = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]>
|
||||||
|
<cfset ghDayAbbrev = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]>
|
||||||
|
<cfif structKeyExists(ghRestaurant, "restaurant_managed_hours_list_v2") AND isArray(ghRestaurant.restaurant_managed_hours_list_v2)>
|
||||||
|
<cfloop array="#ghRestaurant.restaurant_managed_hours_list_v2#" index="ghDayHours">
|
||||||
|
<cfif structKeyExists(ghDayHours, "day") AND structKeyExists(ghDayHours, "start_time") AND structKeyExists(ghDayHours, "end_time")>
|
||||||
|
<cfset ghDayIdx = arrayFind(ghDayOrder, ghDayHours.day)>
|
||||||
|
<cfif ghDayIdx GT 0>
|
||||||
|
<!--- Convert HH:mm:ss to 12h format --->
|
||||||
|
<cfset ghOpenH = val(listFirst(ghDayHours.start_time, ":"))>
|
||||||
|
<cfset ghOpenM = val(listGetAt(ghDayHours.start_time, 2, ":"))>
|
||||||
|
<cfset ghCloseH = val(listFirst(ghDayHours.end_time, ":"))>
|
||||||
|
<cfset ghCloseM = val(listGetAt(ghDayHours.end_time, 2, ":"))>
|
||||||
|
<cfset ghOpenAmPm = ghOpenH GTE 12 ? "pm" : "am">
|
||||||
|
<cfset ghCloseAmPm = ghCloseH GTE 12 ? "pm" : "am">
|
||||||
|
<cfif ghOpenH GT 12><cfset ghOpenH = ghOpenH - 12></cfif>
|
||||||
|
<cfif ghOpenH EQ 0><cfset ghOpenH = 12></cfif>
|
||||||
|
<cfif ghCloseH GT 12><cfset ghCloseH = ghCloseH - 12></cfif>
|
||||||
|
<cfif ghCloseH EQ 0><cfset ghCloseH = 12></cfif>
|
||||||
|
<cfset ghOpenStr = ghOpenH & (ghOpenM GT 0 ? ":" & numberFormat(ghOpenM, "00") : "") & ghOpenAmPm>
|
||||||
|
<cfset ghCloseStr = ghCloseH & (ghCloseM GT 0 ? ":" & numberFormat(ghCloseM, "00") : "") & ghCloseAmPm>
|
||||||
|
<cfset arrayAppend(ghHoursParts, ghDayAbbrev[ghDayIdx] & " " & ghOpenStr & "-" & ghCloseStr)>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
<cfif arrayLen(ghHoursParts) GT 0>
|
||||||
|
<cfset ghBusiness["hours"] = arrayToList(ghHoursParts, ", ")>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Tax rate from availability --->
|
||||||
|
<cfif structKeyExists(ghData, "restaurant_availability") AND structKeyExists(ghData.restaurant_availability, "sales_tax")>
|
||||||
|
<cfset ghBusiness["taxRate"] = ghData.restaurant_availability.sales_tax>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Parse categories and items --->
|
||||||
|
<cfset ghCategories = []>
|
||||||
|
<cfset ghItems = []>
|
||||||
|
<cfset ghItemId = 1>
|
||||||
|
<cfset ghModifierGroups = structNew()><!--- dedup by name --->
|
||||||
|
<cfset ghImageMappings = []>
|
||||||
|
|
||||||
|
<cfif structKeyExists(ghRestaurant, "menu_category_list") AND isArray(ghRestaurant.menu_category_list)>
|
||||||
|
<cfloop array="#ghRestaurant.menu_category_list#" index="ghCat">
|
||||||
|
<cfset ghCatName = structKeyExists(ghCat, "name") ? trim(ghCat.name) : "Menu">
|
||||||
|
<cfset ghCatItemCount = 0>
|
||||||
|
|
||||||
|
<cfif structKeyExists(ghCat, "menu_item_list") AND isArray(ghCat.menu_item_list)>
|
||||||
|
<cfloop array="#ghCat.menu_item_list#" index="ghItem">
|
||||||
|
<cfset ghItemName = structKeyExists(ghItem, "name") ? trim(ghItem.name) : "">
|
||||||
|
<cfif NOT len(ghItemName)><cfcontinue></cfif>
|
||||||
|
|
||||||
|
<!--- Price in cents -> dollars --->
|
||||||
|
<cfset ghPrice = 0>
|
||||||
|
<cfif structKeyExists(ghItem, "price") AND isStruct(ghItem.price) AND structKeyExists(ghItem.price, "amount")>
|
||||||
|
<cfset ghPrice = val(ghItem.price.amount) / 100>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Description --->
|
||||||
|
<cfset ghDesc = structKeyExists(ghItem, "description") ? trim(ghItem.description) : "">
|
||||||
|
|
||||||
|
<!--- Image URL --->
|
||||||
|
<cfset ghImageUrl = "">
|
||||||
|
<cfif structKeyExists(ghItem, "media_image") AND isStruct(ghItem.media_image)>
|
||||||
|
<cfset ghImg = ghItem.media_image>
|
||||||
|
<cfif structKeyExists(ghImg, "base_url") AND structKeyExists(ghImg, "public_id") AND structKeyExists(ghImg, "format")>
|
||||||
|
<cfset ghImageUrl = ghImg.base_url & "w_400,h_400,c_fill/" & ghImg.public_id & "." & ghImg.format>
|
||||||
|
</cfif>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Modifiers from choice_category_list --->
|
||||||
|
<cfset ghItemModifiers = []>
|
||||||
|
<cfif structKeyExists(ghItem, "choice_category_list") AND isArray(ghItem.choice_category_list)>
|
||||||
|
<cfloop array="#ghItem.choice_category_list#" index="ghChoiceCat">
|
||||||
|
<cfset ghModName = structKeyExists(ghChoiceCat, "name") ? trim(ghChoiceCat.name) : "">
|
||||||
|
<cfif NOT len(ghModName)><cfcontinue></cfif>
|
||||||
|
<cfset arrayAppend(ghItemModifiers, ghModName)>
|
||||||
|
|
||||||
|
<!--- Build modifier template if not seen --->
|
||||||
|
<cfif NOT structKeyExists(ghModifierGroups, ghModName)>
|
||||||
|
<cfset ghModOptions = []>
|
||||||
|
<cfif structKeyExists(ghChoiceCat, "choice_option_list") AND isArray(ghChoiceCat.choice_option_list)>
|
||||||
|
<cfloop array="#ghChoiceCat.choice_option_list#" index="ghOpt">
|
||||||
|
<cfset ghOptName = structKeyExists(ghOpt, "description") ? trim(ghOpt.description) : "">
|
||||||
|
<cfset ghOptPrice = 0>
|
||||||
|
<cfif structKeyExists(ghOpt, "price") AND isStruct(ghOpt.price) AND structKeyExists(ghOpt.price, "amount")>
|
||||||
|
<cfset ghOptPrice = val(ghOpt.price.amount) / 100>
|
||||||
|
</cfif>
|
||||||
|
<cfif len(ghOptName)>
|
||||||
|
<cfset arrayAppend(ghModOptions, { "name": ghOptName, "price": ghOptPrice })>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
<cfset ghMinSel = structKeyExists(ghChoiceCat, "min_choice_options") ? val(ghChoiceCat.min_choice_options) : 0>
|
||||||
|
<cfset ghMaxSel = structKeyExists(ghChoiceCat, "max_choice_options") ? val(ghChoiceCat.max_choice_options) : 0>
|
||||||
|
<cfset ghModifierGroups[ghModName] = {
|
||||||
|
"name": ghModName,
|
||||||
|
"required": ghMinSel GT 0,
|
||||||
|
"minSelections": ghMinSel,
|
||||||
|
"maxSelections": ghMaxSel,
|
||||||
|
"options": ghModOptions
|
||||||
|
}>
|
||||||
|
</cfif>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<cfset ghItemObj = {
|
||||||
|
"id": "item_" & ghItemId,
|
||||||
|
"name": ghItemName,
|
||||||
|
"price": ghPrice,
|
||||||
|
"description": ghDesc,
|
||||||
|
"category": ghCatName,
|
||||||
|
"imageUrl": ghImageUrl,
|
||||||
|
"hasModifiers": arrayLen(ghItemModifiers) GT 0,
|
||||||
|
"modifiers": ghItemModifiers
|
||||||
|
}>
|
||||||
|
<cfset arrayAppend(ghItems, ghItemObj)>
|
||||||
|
<cfset ghCatItemCount++>
|
||||||
|
|
||||||
|
<!--- Track image mapping --->
|
||||||
|
<cfif len(ghImageUrl)>
|
||||||
|
<cfset arrayAppend(ghImageMappings, { "itemId": "item_" & ghItemId, "url": ghImageUrl })>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<cfset ghItemId++>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<cfset arrayAppend(ghCategories, { "name": ghCatName, "itemCount": ghCatItemCount })>
|
||||||
|
</cfloop>
|
||||||
|
</cfif>
|
||||||
|
|
||||||
|
<!--- Build modifier templates array --->
|
||||||
|
<cfset ghModifiers = []>
|
||||||
|
<cfloop collection="#ghModifierGroups#" item="ghModKey">
|
||||||
|
<cfset arrayAppend(ghModifiers, ghModifierGroups[ghModKey])>
|
||||||
|
</cfloop>
|
||||||
|
|
||||||
|
<cfset arrayAppend(response.steps, "Parsed " & arrayLen(ghItems) & " items in " & arrayLen(ghCategories) & " categories with " & arrayLen(ghModifiers) & " modifier groups")>
|
||||||
|
|
||||||
|
<!--- Build and return response --->
|
||||||
|
<cfset menuData = structNew()>
|
||||||
|
<cfset menuData["business"] = ghBusiness>
|
||||||
|
<cfset menuData["categories"] = ghCategories>
|
||||||
|
<cfset menuData["items"] = ghItems>
|
||||||
|
<cfset menuData["modifiers"] = ghModifiers>
|
||||||
|
<cfset menuData["imageUrls"] = arrayNew(1)>
|
||||||
|
<cfset menuData["imageMappings"] = ghImageMappings>
|
||||||
|
<cfset menuData["headerCandidateIndices"] = arrayNew(1)>
|
||||||
|
|
||||||
|
<cfset response["OK"] = true>
|
||||||
|
<cfset response["DATA"] = menuData>
|
||||||
|
<cfset response["sourceUrl"] = targetUrl>
|
||||||
|
<cfset response["pagesProcessed"] = 1>
|
||||||
|
<cfset response["imagesFound"] = arrayLen(ghImageMappings)>
|
||||||
|
<cfset response["parsedVia"] = "grubhub_api">
|
||||||
|
<cfcontent type="application/json" reset="true">
|
||||||
|
<cfoutput>#serializeJSON(response)#</cfoutput>
|
||||||
|
<cfabort>
|
||||||
|
</cfif>
|
||||||
|
<!--- ========== END GRUBHUB FAST PATH ========== --->
|
||||||
|
|
||||||
<!--- Check if this is a local temp file (ZIP upload) - read directly, skip Playwright --->
|
<!--- Check if this is a local temp file (ZIP upload) - read directly, skip Playwright --->
|
||||||
<cfif findNoCase("/temp/menu-import/", targetUrl)>
|
<cfif findNoCase("/temp/menu-import/", targetUrl)>
|
||||||
<cfset localFilePath = expandPath(reReplaceNoCase(targetUrl, "https?://[^/]+(/temp/menu-import/.*)", "\1"))>
|
<cfset localFilePath = expandPath(reReplaceNoCase(targetUrl, "https?://[^/]+(/temp/menu-import/.*)", "\1"))>
|
||||||
|
|
|
||||||
Reference in a new issue