From 35253139001328bb01c1f194881e81676c6b52e8 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:25:32 +0000 Subject: [PATCH] opentable.com: name the searched area on no_results and point refused availability reads at the restaurant page --- domains/opentable.com/get_availability.js | 8 +++++--- domains/opentable.com/prepare_reservation.js | 8 +++++--- domains/opentable.com/search_restaurants.js | 4 +++- lib/opentable-availability.js | 8 +++++--- tests/opentable-actions.test.mjs | 4 ++++ tests/opentable-restaurants.test.mjs | 1 + 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index dba137a5..ad40a614 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -240,9 +240,11 @@ try { const path = "/restaurant/profile/" + input.restaurant_id; const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + // The site can refuse these reads while the restaurant's own page still shows times in its booking widget. + const refused = problem => (problem?.error_kind === "challenge" ? otFail("challenge", "OpenTable refused or challenged the request; nothing was retried. The restaurant's page often still shows times: open " + OT_ORIGIN + path + "?" + params + " and read its booking widget without selecting a time.") : problem); const res = await fetch(path + "?" + params, { credentials: "same-origin", redirect: "error", headers: { accept: "text/html" }, signal }); const html = await res.text(); - const problem = otCheck(res, html); + const problem = refused(otCheck(res, html)); if (problem) return problem; if (html.length > 2000000) return otFail("site_changed", "The profile document exceeded the supported read bound."); const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); @@ -275,7 +277,7 @@ seen.add(modulePath); const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); const text = await moduleRes.text(); - const moduleError = otCheck(moduleRes, text); + const moduleError = refused(otCheck(moduleRes, text)); if (moduleError) return moduleError; totalBytes += text.length; if (text.length > 2000000 || totalBytes > 20000000) return otFail("site_changed", "First-party modules exceeded the supported read bound."); @@ -294,7 +296,7 @@ }; const response = await fetch("/dapi/fe/gql?optype=query&opname=RestaurantsAvailability", { method: "POST", credentials: "same-origin", redirect: "error", signal, headers: { "content-type": "application/json", "x-csrf-token": csrf, "ot-page-type": "restprofilepage", "ot-page-group": "rest-profile", "x-query-timeout": "5500" }, body: JSON.stringify(body) }); const text = await response.text(); - const queryError = otCheck(response, text); + const queryError = refused(otCheck(response, text)); if (queryError) return queryError; if (text.length > 1000000) return otFail("site_changed", "The availability response exceeded the supported read bound."); const json = JSON.parse(text); diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index 36fa3914..65b9c369 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -266,9 +266,11 @@ try { const path = "/restaurant/profile/" + input.restaurant_id; const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + // The site can refuse these reads while the restaurant's own page still shows times in its booking widget. + const refused = problem => (problem?.error_kind === "challenge" ? otFail("challenge", "OpenTable refused or challenged the request; nothing was retried. The restaurant's page often still shows times: open " + OT_ORIGIN + path + "?" + params + " and read its booking widget without selecting a time.") : problem); const res = await fetch(path + "?" + params, { credentials: "same-origin", redirect: "error", headers: { accept: "text/html" }, signal }); const html = await res.text(); - const problem = otCheck(res, html); + const problem = refused(otCheck(res, html)); if (problem) return problem; if (html.length > 2000000) return otFail("site_changed", "The profile document exceeded the supported read bound."); const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); @@ -301,7 +303,7 @@ seen.add(modulePath); const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); const text = await moduleRes.text(); - const moduleError = otCheck(moduleRes, text); + const moduleError = refused(otCheck(moduleRes, text)); if (moduleError) return moduleError; totalBytes += text.length; if (text.length > 2000000 || totalBytes > 20000000) return otFail("site_changed", "First-party modules exceeded the supported read bound."); @@ -320,7 +322,7 @@ }; const response = await fetch("/dapi/fe/gql?optype=query&opname=RestaurantsAvailability", { method: "POST", credentials: "same-origin", redirect: "error", signal, headers: { "content-type": "application/json", "x-csrf-token": csrf, "ot-page-type": "restprofilepage", "ot-page-group": "rest-profile", "x-query-timeout": "5500" }, body: JSON.stringify(body) }); const text = await response.text(); - const queryError = otCheck(response, text); + const queryError = refused(otCheck(response, text)); if (queryError) return queryError; if (text.length > 1000000) return otFail("site_changed", "The availability response exceeded the supported read bound."); const json = JSON.parse(text); diff --git a/domains/opentable.com/search_restaurants.js b/domains/opentable.com/search_restaurants.js index efa35c53..7d002b7a 100644 --- a/domains/opentable.com/search_restaurants.js +++ b/domains/opentable.com/search_restaurants.js @@ -166,7 +166,9 @@ max_party_size: int(r.features?.reservationMaxPartySize), has_takeout: flag(r.hasTakeout), has_private_dining: flag(r.privateDining?.hasPrivateDining), phone: str(r.contactInformation?.formattedPhoneNumber) }); } - if (!restaurants.length) return { status: "no_results", query: normalized, observed_at: observedAt, message: page === 1 ? "OpenTable found no restaurants matching this search." : "OpenTable returned no restaurants on page " + page + "; the search has fewer pages, so try a lower page." }; + // OpenTable reads a place in the text only when it recognizes it, and otherwise searches near the browser's location. + const near = str(search.metro?.name, 100); + if (!restaurants.length) return { status: "no_results", query: normalized, observed_at: observedAt, message: page === 1 ? "OpenTable found no restaurants matching this search" + (near ? " in " + near : "") + ". It searches near the browser's location when it does not recognize the place in the query; if you meant somewhere else, search again with a shorter name and the city, or call get_restaurant with the restaurant's short address (for example '/kokkari-estiatorio')." : "OpenTable returned no restaurants on page " + page + "; the search has fewer pages, so try a lower page." }; const metro = search.metro && typeof search.metro === "object" ? search.metro : null; const location = (metro || num(search.latitude) !== null) ? { metro_id: int(metro?.metroId ?? search.metroId), metro_name: str(metro?.name), latitude: num(search.latitude), longitude: num(search.longitude) } : null; return { diff --git a/lib/opentable-availability.js b/lib/opentable-availability.js index 597b5c4b..fdb8d8a4 100644 --- a/lib/opentable-availability.js +++ b/lib/opentable-availability.js @@ -33,9 +33,11 @@ const otReadAvailability = async (input, windowMinutes, signal) => { try { const path = "/restaurant/profile/" + input.restaurant_id; const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + // The site can refuse these reads while the restaurant's own page still shows times in its booking widget. + const refused = problem => (problem?.error_kind === "challenge" ? otFail("challenge", "OpenTable refused or challenged the request; nothing was retried. The restaurant's page often still shows times: open " + OT_ORIGIN + path + "?" + params + " and read its booking widget without selecting a time.") : problem); const res = await fetch(path + "?" + params, { credentials: "same-origin", redirect: "error", headers: { accept: "text/html" }, signal }); const html = await res.text(); - const problem = otCheck(res, html); + const problem = refused(otCheck(res, html)); if (problem) return problem; if (html.length > 2000000) return otFail("site_changed", "The profile document exceeded the supported read bound."); const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); @@ -68,7 +70,7 @@ const otReadAvailability = async (input, windowMinutes, signal) => { seen.add(modulePath); const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); const text = await moduleRes.text(); - const moduleError = otCheck(moduleRes, text); + const moduleError = refused(otCheck(moduleRes, text)); if (moduleError) return moduleError; totalBytes += text.length; if (text.length > 2000000 || totalBytes > 20000000) return otFail("site_changed", "First-party modules exceeded the supported read bound."); @@ -87,7 +89,7 @@ const otReadAvailability = async (input, windowMinutes, signal) => { }; const response = await fetch("/dapi/fe/gql?optype=query&opname=RestaurantsAvailability", { method: "POST", credentials: "same-origin", redirect: "error", signal, headers: { "content-type": "application/json", "x-csrf-token": csrf, "ot-page-type": "restprofilepage", "ot-page-group": "rest-profile", "x-query-timeout": "5500" }, body: JSON.stringify(body) }); const text = await response.text(); - const queryError = otCheck(response, text); + const queryError = refused(otCheck(response, text)); if (queryError) return queryError; if (text.length > 1000000) return otFail("site_changed", "The availability response exceeded the supported read bound."); const json = JSON.parse(text); diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index c60cf922..4fbaa0cf 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -255,6 +255,10 @@ test("network failures, challenges and aborts are explicit, with no automatic re const valid = name.includes("account") || name.includes("saved") ? account : query; await failure(name, valid, "rate_limited", { status: 429 }, 1); await failure(name, valid, "challenge", { status: 403 }, 1); + if (name === "get_availability" || name === "prepare_reservation") { + const refused = await run(name, valid, { status: 403 }); + assert.match(refused.result.error_message, /open https:\/\/www\.opentable\.com\/restaurant\/profile\/\d+\?dateTime=.*&covers=\d+ and read its booking widget/, name + " points at the restaurant's page"); + } await failure(name, valid, "site_error", { respond: () => { throw new Error("https://secret.example/PRIVATE"); } }, 1); const signal = { aborted: false }; await failure(name, valid, "canceled", { signal, respond: () => { signal.aborted = true; throw new Error("Abort"); } }, 1); diff --git a/tests/opentable-restaurants.test.mjs b/tests/opentable-restaurants.test.mjs index 574c4575..d7deb467 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -240,6 +240,7 @@ test("search_restaurants defaults the time, omits absent filters, and reports ha test("search_restaurants returns no_results for empty or out-of-range pages and never an empty success", async () => { const none = await run("search_restaurants", { query: "zzzz nowhere" }, ok(pageHtml(searchState([], { totalRestaurantCount: 0 })))); assert.equal(none.output.status, "no_results"); + assert.match(none.output.message, /in Chicago \/ Illinois\. It searches near the browser's location .*get_restaurant/, "names the area the site searched"); assertConforms("search_restaurants", none.output); const beyond = await run("search_restaurants", { query: "pizza chicago", page: 100 }, ok(pageHtml(searchState([], { totalRestaurantCount: 0, pageNumber: 100 })))); assert.equal(beyond.output.status, "no_results");