From 4f0f1090d9625eb6747cea29db07b20d383136b9 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:14:11 +0000 Subject: [PATCH 01/14] Add opentable.com restaurant search, profile and review tools --- domains/opentable.com/README.md | 85 ++++ domains/opentable.com/get_restaurant.js | 237 +++++++++ domains/opentable.com/get_reviews.js | 158 ++++++ domains/opentable.com/search_restaurants.js | 176 +++++++ registry.json | 39 ++ tests/opentable-restaurants.test.mjs | 536 ++++++++++++++++++++ 6 files changed, 1231 insertions(+) create mode 100644 domains/opentable.com/README.md create mode 100644 domains/opentable.com/get_restaurant.js create mode 100644 domains/opentable.com/get_reviews.js create mode 100644 domains/opentable.com/search_restaurants.js create mode 100644 tests/opentable-restaurants.test.mjs diff --git a/domains/opentable.com/README.md b/domains/opentable.com/README.md new file mode 100644 index 00000000..c915326d --- /dev/null +++ b/domains/opentable.com/README.md @@ -0,0 +1,85 @@ +# opentable.com + +Tools for the OpenTable US diner site (https://www.opentable.com): restaurant search, restaurant profiles, and diner reviews. All three are read-only page tools and work signed out. + +| tool | file | status | notes | +|------|------|--------|-------| +| search_restaurants | [search_restaurants.js](search_restaurants.js) | draft | Free-text search with optional date, time and party size; one site results page (up to 50) per call with id, url, cuisine, price band, address, coordinates, rating and review count. No bookable time slots. | +| get_restaurant | [get_restaurant.js](get_restaurant.js) | draft | One profile by restaurant_id or profile URL: address, hours text, amenities, ratings by category with distribution, awards, tags, AI review summary, optional published menus. | +| get_reviews | [get_reviews.js](get_reviews.js) | draft | One page of 10 diner reviews, newest first, with ratings, text, dates and vote counts. No reviewer names or photos. | + +Register all three files together as one array with `forceOverwriteNamespace: true`; registering one file that way removes the others. + +## Native WebMCP tools + +None found (checked 2026-09-29, signed out, with `webmcp.listTools()` and `document.modelContext.getTools()` on the home page, a search results page and a restaurant profile page). `document.modelContext` exists with zero registered tools, `navigator.modelContext` is undefined and no element carries a `toolname` attribute, so the `opentable.com` namespace does not overlap with anything the site registers. The site's own "Concierge" AI assistant is a chat widget, not a WebMCP tool. + +## Scope and loading + +The tools read public restaurant information. They never select a time slot, request or hold a table, book, modify or cancel a reservation, set an availability alert, save a favorite, write a review, sign in, or open any account, payment, checkout or private-dining page. Availability (bookable time slots) is deliberately not covered: the site loads it through a persisted GraphQL query with a CSRF token and a slot click can take a hold, which needs its own design. + +```javascript +const fs = await import("node:fs"); +const files = ["search_restaurants", "get_restaurant", "get_reviews"]; +const tools = files.flatMap(name => eval(fs.readFileSync(`domains/opentable.com/${name}.js`, "utf8"))); +await webmcp.addCustomTools({ namespace: "opentable.com", tools, forceOverwriteNamespace: true }); +const ref = async name => (await webmcp.listTools()).find(t => t.source.custom?.namespace === "opentable.com" && t.tool.name === name).tool_ref; +await webmcp.invokeTool(await ref("search_restaurants"), { query: "sushi Chicago", date: "2026-10-16", time: "19:00", party_size: 2 }, { timeoutSec: 60 }); +await webmcp.invokeTool(await ref("get_restaurant"), { restaurant_id: 107461, include_menus: true }, { timeoutSec: 60 }); +await webmcp.invokeTool(await ref("get_reviews"), { restaurant_id: 107461, page: 2 }, { timeoutSec: 60 }); +``` + +Use future dates. Pick the `tool_ref` whose `source.target_id` is the tab you drive, and use `timeoutSec: 60`. + +- **search_restaurants** (`kind: "page"`) fetches the site's own results page (`/s?term=...`, plus `dateTime`, `covers` and `page` when given) from the current `www.opentable.com` document and reads the `multiSearch` results the server rendered into the page. It never navigates. `query` carries the place as words ("pizza Chicago"); the site resolved that to the Chicago metro in live checks. Without a place the site uses the browser's approximate location. `date` alone means 19:00, the site's default. `party_size` (1 to 20, the site's picker) changes which restaurants are returned: a party of 25 cut one query from 117 results to 50. `rank` is the position in the site's ordering across pages of 50. +- **get_restaurant** (`kind: "page"`) fetches the restaurant's profile page (`/restaurant/profile/` for an id, or the given `/r/` or legacy `/` path) and reads `restaurantProfile`. The id path serves the profile directly, without a redirect. A returned id that differs from the requested one is an error. +- **get_reviews** (`kind: "page"`) fetches the same profile page with `?page=N` and reads the page of 10 reviews the server rendered for it. The site sorts newest first and this tool exposes no other order. + +All three are `kind: "page"` because each one only reads a same-origin document and returns a structured result without navigating the tab; none of them needs post-navigation output. + +Outputs: + +- Text from the site (descriptions, hours, review summaries, menu items and diner reviews) is untrusted data. HTML tags are stripped, entities decoded, and every text field is length-capped. +- `observed_at` is on every result. Ratings, counts, "booked today" numbers and the ordering change constantly. +- The tools return only an allowlist of fields. The page state also contains account data when signed in (header profile, loyalty status), tracking identifiers, photo URLs, contact email fields, a Google Maps API key inside `coordinates.mapUrl`, and reviewer names and photos; none of it is returned. + +## URL patterns + +All three tools match only `https://www.opentable.com/*`, not the default `https://opentable.com/*` and `https://*.opentable.com/*`. The other hosts under the registrable domain are separate products: the help centre (`help.`), restaurant-business marketing (`restaurant.`), press (`press.`) and developer pages (`dev.`). The search and profile pages the tools read are same-origin only on `www.opentable.com`, so a page tool on those hosts could not work. Regional OpenTable sites (`opentable.co.uk`, `opentable.ca`, `opentable.com.au`, `opentable.de`, `opentable.jp`, and others) are separate registrable domains and are out of scope. Whether the bare `opentable.com` redirects to `www` was not checked. + +## Site quirks + +- **Bot protection.** OpenTable runs Akamai Bot Manager. After roughly 40 page fetches in about ten minutes from one stealth browser session (unpaced, while probing), the site answered HTTP 403 with an "Access Denied" page and a `Reference #`, and kept doing so for at least five minutes for that session. A new session on Kernel's default stealth egress was not blocked: several dozen calls spaced 6 or more seconds apart went through. From an ordinary cloud VM's IP in a plain Chromium, `/s` searches returned 403 on all three attempts while a profile page and a review page succeeded (a later probe of an unknown slug was also refused). The tools report a refusal as `challenge` and HTTP 429 as `rate_limited`; they never retry. Avoid rapid repeated calls. +- **Same page, different data.** Every read is a server-rendered HTML page (200 to 400 KB) whose ``; + +const signedInState = () => ({ + authentication: { isAuthenticated: true }, + header: { userProfile: { firstName: "Fake", email: FAKE_EMAIL, mobilePhoneNumber: { number: "5550100" }, points: 1234 } } +}); + +const searchRestaurant = (id, name, extra = {}) => ({ + restaurantId: id, name, type: "GuestCenter", slotDiscovery: "on", campaignId: 77, restaurantAvailabilityToken: "TOKEN-" + id, + urls: { profileLink: { link: `https://www.opentable.com/r/test-${id}-chicago?corrid=abc`, __typename: "PageLink" } }, + priceBand: { priceBandId: 3, currencySymbol: "$", name: "$31 to $50" }, neighborhood: { name: "Fake Loop" }, + statistics: { recentReservationCount: 12, reviews: { allTimeTextReviewCount: 340, ratings: { overall: { rating: 4.6 } } } }, + primaryCuisine: { name: "Pizzeria" }, features: { reservationMaxPartySize: 8, bar: true }, privateDining: { hasPrivateDining: true }, + diningStyle: "Casual Dining", hasTakeout: true, coordinates: { latitude: 41.88, longitude: -87.63 }, + address: { line1: "1 Example St", line2: "", city: "Chicago", state: "IL", postCode: "60601" }, + description: "A fake pizza place & bar.
Second line.", photos: { profileV3: { url: "//img.example.test/x" } }, + contactInformation: { phoneNumber: "5550101", formattedPhoneNumber: "(555) 010-1234" }, offers: [], groups: [{ id: 1 }], + ...extra +}); + +const searchState = (restaurants, extra = {}) => ({ + ...signedInState(), + multiSearch: { + pageType: "metro", metroId: 3, latitude: 41.9, longitude: -87.6, metro: { metroId: 3, name: "Chicago / Illinois", timezone: "America/Chicago" }, + isNotFoundError: false, isServerError: false, restaurants, totalRestaurantCount: restaurants.length, pageNumber: 1, limit: 50, freetextTerm: "pizza chicago", ...extra + } +}); + +const review = (id, extra = {}) => ({ + reviewId: id, text: "Fake review text.", rating: { food: 5, service: 4, ambience: 3, value: 2, noise: "MODERATE", overall: 4 }, + user: { nickname: FAKE_NICK, initials: "FD", metro: "Fake Metro", profilePhoto: { url: "//img.example.test/face" }, isLoggedInUser: false }, + helpfulness: { up: 3, down: 0, score: 3 }, publicRestaurantReply: null, submittedDateTime: "2026-09-27T16:36:29Z", dinedDateTime: "2026-09-26T16:30:00-04:00", + type: "OPENTABLEVERIFIED", fromPreviousConcept: false, highlight: null, photos: [], ...extra +}); + +const profileState = (id, extra = {}, reviewData = {}, restaurantExtra = {}) => ({ + ...signedInState(), + restaurantProfile: { + restaurant: { + restaurantId: id, name: "Fake Bistro", description: "A fake bistro & wine bar.
Open late.", diningStyle: "Casual Elegant", executiveChef: "Fake Chef", website: "https://bistro.example.test/", + dressCode: "Smart Casual", crossStreet: "Main St ", parkingInfo: "Street Parking", parkingDetails: "", publicTransit: "Near the fake line.", entertainment: "Jazz on Fridays", + cateringDetails: "Call 555-0102 or events@bistro.example.test", hoursOfOperation: "Mon-Fri 5:00 pm-9:00 pm\n
Sat 5:00 pm-10:00 pm", maxAdvanceDays: 90, hasTakeout: true, + additionalDetails: ["Bar/Lounge", "Wine", "Wine", "Patio/Outdoor Dining"], paymentOptions: ["AMEX", "Visa"], urls: { privateDiningProfileLink: { link: "https://www.opentable.com/private-dining/profile/x" } }, + features: { bar: true, counter: false, highTop: false, outdoor: true, reservationMaxPartySize: 6, permanentlyClosed: false, dinerAiEnabled: true, contactLink: "" }, + privateDining: { hasPrivateDining: false, contact: "Fake Events" }, timeZone: { code: "EST", offsetInMinutes: -240 }, priceBand: { priceBandId: 3, name: "$31 to $50" }, + neighborhood: { name: "Fake Downtown", coordinates: { latitude: 1, longitude: 2 } }, metro: { metroId: 9, displayName: "Fake Metro" }, + address: { line1: "2 Example Ave", line2: "", city: "Fakeville", state: "DC", postCode: "20001", country: "United States" }, + coordinates: { latitude: 38.9, longitude: -77.03, mapUrl: `https://maps.example.test/static?key=${FAKE_KEY}` }, + contactInformation: { phoneNumber: "5550103", formattedPhoneNumber: "(555) 010-3333", email: "owner@bistro.example.test" }, + cuisines: [{ name: "American" }, { name: "Seafood" }], primaryCuisine: { name: "American" }, + statistics: { + recentReservationCount: 24, + reviews: { + allTimeTextReviewCount: 2157, awards: [{ name: "Most Booked", location: "Fake Metro" }], + ratings: { count: 48, overall: { rating: 4.3 }, food: { rating: 4.5, count: 48 }, service: { rating: 4.4, count: 48 }, ambience: { rating: 4.2, count: 48 }, value: { rating: 4, count: 48 }, noise: { rating: 2, count: 48 }, overallRatingsDistribution: { one: 1, two: 2, three: 3, four: 10, five: 32 } }, + categories: [{ category: { name: "Lively", key: "lively" } }, { category: { name: "Innovative", key: "innovative" } }] + } + }, + restaurantReviewSummary: { summary: "Diners praise the fake food." }, popularDishes: { dishes: [] }, experiences: [], + ...restaurantExtra + }, + metaLinks: { canonical: `https://www.opentable.com/r/fake-bistro-fakeville` }, + menus: { menuData: null, menuInfo: { showThirdPartyMenu: true, url: "https://bistro.example.test/menu?utm_source=opentable" } }, + reviewsData: { reviewSearchResults: { reviews: [review("REV-1"), review("REV-2", { publicRestaurantReply: { text: "Thanks from the fake owner" }, photos: [{ photoId: 1 }, { photoId: 2 }], type: "OTHER", rating: { food: 1, service: 1, ambience: 1, value: 1, noise: null, overall: 1 } })], totalPages: 216, page: 1, totalCount: 2157 }, sortBy: "newestReview", ...reviewData }, + isHuman: false, ...extra + } +}); + +const menuData = itemCount => [ + { title: "Dinner", description: "Evening menu", currency: { code: "USD" }, updated: "2026-01-01T00:00:00", provider: { name: "Fake Provider", image: "x" }, sections: [ + { title: "Starters", description: null, items: Array.from({ length: itemCount }, (_, i) => ({ title: `Dish ${i + 1}`, description: i === 0 ? "With "fake" sauce" : null, price: i === 0 ? "12.50" : null, variationGroups: [] })) }, + { title: "Empty", description: null, items: [{ title: " ", description: null, price: null }] } + ] } +]; + +// --- harness --------------------------------------------------------------------------------------- + +async function run(name, input, o = {}) { + const calls = { fetch: [] }; + const context = { + window: { location: { origin: o.origin ?? ORIGIN } }, document: { title: o.title ?? "OpenTable" }, URL, URLSearchParams, + fetch: async (path, init) => { + calls.fetch.push({ path, init }); + const r = typeof o.respond === "function" ? o.respond(path, init) : o.respond; + if (r instanceof Error) throw r; + return { status: r.status ?? 200, url: r.url ?? ORIGIN + path, text: async () => r.html }; + } + }; + const [definition] = runInNewContext(`(${sources[name]})`, {}); + // The runtime serializes a page tool's execute into the page, so run the serialized text against stubbed globals only. + const execute = runInNewContext("(" + definition.execute.toString() + ")", context); + const output = plain(await execute(input)); + return { output, calls, definition }; +} +const definitionOf = name => runInNewContext(`(${sources[name]})`, {})[0]; +const ok = html => ({ respond: () => ({ html }) }); + +function assertConforms(name, output) { + assert.ok(conforms(definitionOf(name).tool.outputSchema, output), "output must conform to outputSchema: " + JSON.stringify(output).slice(0, 400)); +} + +const LEAKS = [FAKE_KEY, FAKE_EMAIL, FAKE_NICK, "owner@bistro.example.test", "TOKEN-", "img.example.test", "maps.example.test", "campaignId", "photoId", "Thanks from the fake owner", "mapUrl", "utm_source"]; +const assertNoLeaks = output => { + const text = JSON.stringify(output); + for (const leak of LEAKS) assert.ok(!text.includes(leak), `output must not contain ${leak}`); +}; + +// --- definitions ----------------------------------------------------------------------------------- + +test("definitions are page tools on www.opentable.com with registry entries", () => { + for (const name of NAMES) { + const d = definitionOf(name); + assert.equal(d.kind, "page"); + assert.deepEqual(plain(d.match.url_patterns), [ORIGIN + "/*"]); + assert.equal(d.tool.name, name); + assert.equal(d.tool.annotations.readOnlyHint, true); + assert.ok(d.tool.inputSchema && d.tool.outputSchema); + const entry = registry.tools.find(t => t.domain === "opentable.com" && t.tool === name); + assert.ok(entry, name + " must have a registry entry"); + assert.equal(entry.kind, "page"); + assert.deepEqual(entry.url_patterns, [ORIGIN + "/*"]); + assert.equal(entry.status, "draft"); + } + assert.equal(registry.tools.filter(t => t.domain === "opentable.com").length, NAMES.length); +}); + +// --- search_restaurants ---------------------------------------------------------------------------- + +test("search_restaurants builds the site's search URL and maps an allowlist of fields", async () => { + const state = searchState([searchRestaurant(101, "Fake Pizza"), searchRestaurant(102, "Second Fake", { urls: { profileLink: { link: "https://evil.example.test/x" } }, primaryCuisine: null, description: "x".repeat(500) })], { totalRestaurantCount: 117 }); + const { output, calls } = await run("search_restaurants", { query: " pizza Chicago ", date: "2026-10-16", time: "18:30", party_size: 4, page: 2 }, ok(pageHtml(state))); + assert.equal(calls.fetch.length, 1); + assert.equal(calls.fetch[0].path, "/s?dateTime=2026-10-16T18%3A30%3A00&covers=4&term=pizza%20Chicago&page=2"); + assert.equal(calls.fetch[0].init.credentials, "same-origin"); + assert.equal(output.status, "ok"); + assert.deepEqual(output.query, { query: "pizza Chicago", date: "2026-10-16", time: "18:30", party_size: 4, page: 2 }); + assert.equal(output.total_count, 117); + assert.equal(output.page, 2); + assert.equal(output.page_size, 50); + assert.equal(output.has_more, true); + assert.deepEqual(output.location, { metro_id: 3, metro_name: "Chicago / Illinois", latitude: 41.9, longitude: -87.6 }); + const [first, second] = output.restaurants; + assert.equal(first.rank, 51); + assert.equal(first.restaurant_id, 101); + assert.equal(first.url, "https://www.opentable.com/r/test-101-chicago"); + assert.equal(first.cuisine, "Pizzeria"); + assert.equal(first.price_level, 3); + assert.equal(first.price_label, "$31 to $50"); + assert.deepEqual(first.address, { line1: "1 Example St", line2: null, city: "Chicago", state: "IL", postal_code: "60601" }); + assert.equal(first.rating, 4.6); + assert.equal(first.review_count, 340); + assert.equal(first.recent_reservation_count, 12); + assert.equal(first.max_party_size, 8); + assert.equal(first.has_private_dining, true); + assert.equal(first.phone, "(555) 010-1234"); + assert.equal(first.description, "A fake pizza place & bar.\nSecond line."); + assert.equal(second.rank, 52); + assert.equal(second.url, "https://www.opentable.com/restaurant/profile/102", "an off-site profile link falls back to the id URL"); + assert.equal(second.cuisine, null); + assert.equal(second.description.length, 300); + assertNoLeaks(output); + assertConforms("search_restaurants", output); +}); + +test("search_restaurants defaults the time, omits absent filters, and reports has_more false on the last page", async () => { + const state = searchState([searchRestaurant(101, "Fake Pizza")]); + const dated = await run("search_restaurants", { query: "pizza", date: "2026-10-16" }, ok(pageHtml(state))); + assert.equal(dated.calls.fetch[0].path, "/s?dateTime=2026-10-16T19%3A00%3A00&term=pizza"); + assert.equal(dated.output.query.time, "19:00"); + assert.equal(dated.output.has_more, false); + const plainSearch = await run("search_restaurants", { query: "C++ pizza & pasta" }, ok(pageHtml(state))); + assert.equal(plainSearch.calls.fetch[0].path, "/s?term=C%2B%2B%20pizza%20%26%20pasta"); + assert.deepEqual([plainSearch.output.query.date, plainSearch.output.query.time, plainSearch.output.query.party_size], [null, null, null]); +}); + +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"); + 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"); + assert.match(beyond.output.message, /page 100/); + const emptyFirstPage = await run("search_restaurants", { query: "pizza chicago" }, ok(pageHtml(searchState([], { totalRestaurantCount: 5 })))); + assert.equal(emptyFirstPage.output.status, "no_results", "an empty first page is never an ok result, even when the site reports a count"); + const unusable = await run("search_restaurants", { query: "pizza" }, ok(pageHtml(searchState([{ name: "No id" }, null])))); + assert.equal(unusable.output.status, "no_results"); +}); + +test("search_restaurants rejects malformed input before touching the site", async () => { + const bad = [ + undefined, null, [], "pizza", {}, { query: "a" }, { query: "x".repeat(101) }, { query: 5 }, { query: "pizza", extra: 1 }, + { query: "pizza", date: "2026-02-30" }, { query: "pizza", date: "16/10/2026" }, { query: "pizza", time: "19:00" }, { query: "pizza", date: "2026-10-16", time: "24:00" }, + { query: "pizza", date: "2026-10-16", time: "7pm" }, { query: "pizza", party_size: 0 }, { query: "pizza", party_size: 21 }, { query: "pizza", party_size: 2.5 }, { query: "pizza", party_size: "2" }, + { query: "pizza", page: 0 }, { query: "pizza", page: 101 }, { query: "pizza", page: "2" } + ]; + for (const input of bad) { + const { output, calls } = await run("search_restaurants", input, ok(pageHtml(searchState([])))); + assert.equal(output.status, "error", JSON.stringify(input)); + assert.equal(output.error_kind, "invalid_input", JSON.stringify(input)); + assert.equal(calls.fetch.length, 0, "no request for " + JSON.stringify(input)); + assertConforms("search_restaurants", output); + } +}); + +test("search_restaurants reports wrong pages, bot protection, rate limits and site changes explicitly", async () => { + const input = { query: "pizza chicago" }; + const state = searchState([searchRestaurant(101, "Fake Pizza")]); + const wrong = await run("search_restaurants", input, { origin: "https://help.opentable.com", ...ok(pageHtml(state)) }); + assert.equal(wrong.output.error_kind, "wrong_page"); + assert.equal(wrong.calls.fetch.length, 0); + const paused = await run("search_restaurants", input, { title: "Access Denied", ...ok(pageHtml(state)) }); + assert.equal(paused.output.error_kind, "challenge"); + assert.equal(paused.calls.fetch.length, 0); + const denied = await run("search_restaurants", input, { respond: () => ({ status: 403, html: "Access DeniedReference #18.abc" }) }); + assert.equal(denied.output.error_kind, "challenge"); + const deniedWith200 = await run("search_restaurants", input, { respond: () => ({ html: "Access Denied" }) }); + assert.equal(deniedWith200.output.error_kind, "challenge"); + const limited = await run("search_restaurants", input, { respond: () => ({ status: 429, html: "" }) }); + assert.equal(limited.output.error_kind, "rate_limited"); + const broken = await run("search_restaurants", input, { respond: () => ({ status: 503, html: "" }) }); + assert.equal(broken.output.error_kind, "site_error"); + const noScript = await run("search_restaurants", input, { respond: () => ({ html: "OpenTablehello" }) }); + assert.equal(noScript.output.error_kind, "site_changed"); + const badJson = await run("search_restaurants", input, { respond: () => ({ html: '' }) }); + assert.equal(badJson.output.error_kind, "site_changed"); + const noSearch = await run("search_restaurants", input, ok(pageHtml({ authentication: {} }))); + assert.equal(noSearch.output.error_kind, "site_changed"); + const serverError = await run("search_restaurants", input, ok(pageHtml(searchState([], { isServerError: true, totalRestaurantCount: 0 })))); + assert.equal(serverError.output.error_kind, "site_error"); + const thrown = await run("search_restaurants", input, { respond: () => new Error("network down") }); + assert.equal(thrown.output.error_kind, "site_error"); + assert.match(thrown.output.error_message, /network down/); + for (const r of [wrong, paused, denied, deniedWith200, limited, broken, noScript, badJson, noSearch, serverError, thrown]) { + assert.equal(r.output.status, "error"); + assertConforms("search_restaurants", r.output); + } +}); + +// --- get_restaurant -------------------------------------------------------------------------------- + +test("get_restaurant reads the profile by id and maps an allowlist of fields", async () => { + const { output, calls } = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(profileState(555)))); + assert.equal(calls.fetch.length, 1); + assert.equal(calls.fetch[0].path, "/restaurant/profile/555"); + assert.equal(output.status, "ok"); + assert.equal(output.restaurant_id, 555); + assert.equal(output.name, "Fake Bistro"); + assert.equal(output.url, "https://www.opentable.com/r/fake-bistro-fakeville"); + assert.equal(output.description, "A fake bistro & wine bar.\nOpen late."); + assert.deepEqual(output.cuisines, ["American", "Seafood"]); + assert.equal(output.price_level, 3); + assert.equal(output.neighborhood, "Fake Downtown"); + assert.equal(output.metro_name, "Fake Metro"); + assert.deepEqual(output.address, { line1: "2 Example Ave", line2: null, city: "Fakeville", state: "DC", postal_code: "20001", country: "United States" }); + assert.equal(output.phone, "(555) 010-3333"); + assert.equal(output.website, "https://bistro.example.test/"); + assert.equal(output.hours_text, "Mon-Fri 5:00 pm-9:00 pm\nSat 5:00 pm-10:00 pm"); + assert.equal(output.cross_street, "Main St"); + assert.equal(output.parking_details, null); + assert.deepEqual(output.amenities, ["Bar/Lounge", "Wine", "Patio/Outdoor Dining"]); + assert.deepEqual(output.seating, { bar: true, counter: false, high_top: false, outdoor: true }); + assert.equal(output.reservation_max_party_size, 6); + assert.equal(output.max_advance_days, 90); + assert.equal(output.permanently_closed, false); + assert.equal(output.has_takeout, true); + assert.equal(output.has_private_dining, false); + assert.deepEqual(output.time_zone, { code: "EST", offset_minutes: -240 }); + assert.deepEqual(output.ratings, { overall: 4.3, food: 4.5, service: 4.4, ambience: 4.2, value: 4, noise: 2, rating_count: 48, distribution: { one: 1, two: 2, three: 3, four: 10, five: 32 } }); + assert.equal(output.review_count, 2157); + assert.equal(output.recent_reservation_count, 24); + assert.deepEqual(output.awards, [{ name: "Most Booked", location: "Fake Metro" }]); + assert.deepEqual(output.tags, ["Lively", "Innovative"]); + assert.equal(output.review_summary_ai, "Diners praise the fake food."); + assert.equal(output.external_menu_url, "https://bistro.example.test/menu"); + assert.equal(output.menus, null); + assert.ok(!("catering" in output) && !JSON.stringify(output).includes("events@bistro"), "catering contact details are not returned"); + assertNoLeaks(output); + assertConforms("get_restaurant", output); +}); + +test("get_restaurant accepts profile URLs and paths, drops query and fragment, and rejects everything else before fetching", async () => { + const html = ok(pageHtml(profileState(555))); + const cases = [ + ["https://www.opentable.com/r/fake-bistro-fakeville?covers=2&dateTime=x#reviews", "/r/fake-bistro-fakeville"], + ["/fake-bistro/", "/fake-bistro"], + [" /r/fake_bistro-2 ", "/r/fake_bistro-2"], + ["https://www.opentable.com/restaurant/profile/555", "/restaurant/profile/555"] + ]; + for (const [url, path] of cases) { + const { output, calls } = await run("get_restaurant", { url }, html); + assert.equal(calls.fetch.length, 1, url); + assert.equal(calls.fetch[0].path, path, url); + assert.equal(output.status, "ok", url); + } + const bad = [ + [{}, "invalid_input"], [{ restaurant_id: 5, url: "/r/x" }, "invalid_input"], [{ restaurant_id: 0 }, "invalid_input"], [{ restaurant_id: 1.5 }, "invalid_input"], [{ restaurant_id: "7" }, "invalid_input"], + [{ restaurant_id: 2147483648 }, "invalid_input"], [{ url: "" }, "invalid_input"], [{ url: 5 }, "invalid_input"], [{ url: "x".repeat(301) }, "invalid_input"], [{ url: "/r/a/b" }, "invalid_input"], + [{ url: "/private-dining/profile/x" }, "invalid_input"], [{ url: "/r/caf%C3%A9" }, "invalid_input"], [{ url: "/" }, "invalid_input"], + [{ url: "https://evil.example.test/r/fake" }, "unsupported_host"], [{ url: "https://www.opentable.ca/r/fake" }, "unsupported_host"], [{ url: "https://help.opentable.com/r/fake" }, "unsupported_host"], + [{ url: "http://www.opentable.com/r/fake" }, "unsupported_host"], [{ url: "//evil.example.test/r/fake" }, "unsupported_host"], [{ url: "https://www.opentable.com.evil.example.test/r/fake" }, "unsupported_host"], + [{ url: "/r/fake", include_menus: "yes" }, "invalid_input"], [{ url: "/r/fake", extra: true }, "invalid_input"] + ]; + for (const [input, kind] of bad) { + const { output, calls } = await run("get_restaurant", input, html); + assert.equal(output.error_kind, kind, JSON.stringify(input)); + assert.equal(calls.fetch.length, 0, "no request for " + JSON.stringify(input)); + assertConforms("get_restaurant", output); + } + const dotted = await run("get_restaurant", { url: "/r/../s" }, ok(pageHtml(searchState([])))); + assert.equal(dotted.calls.fetch[0].path, "/s", "dot segments are normalized before the path is checked"); + assert.equal(dotted.output.error_kind, "not_found"); + for (const input of [undefined, null, [], "/r/fake"]) { + const { output, calls } = await run("get_restaurant", input, html); + assert.equal(output.error_kind, "invalid_input"); + assert.equal(calls.fetch.length, 0); + } +}); + +test("get_restaurant returns menus only on request, bounded to 300 items", async () => { + const small = profileState(555); + small.restaurantProfile.menus.menuData = menuData(3); + const off = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(small))); + assert.equal(off.output.menus, null); + const on = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(small))); + assert.equal(on.output.menus.truncated, false); + assert.equal(on.output.menus.menus.length, 1); + assert.equal(on.output.menus.menus[0].title, "Dinner"); + assert.equal(on.output.menus.menus[0].description, "Evening menu"); + assert.equal(on.output.menus.menus[0].sections.length, 1, "sections whose items have no title are dropped"); + assert.deepEqual(on.output.menus.menus[0].sections[0].items[0], { title: "Dish 1", description: "With \"fake\" sauce", price: "12.50" }); + assertConforms("get_restaurant", on.output); + const big = profileState(555); + big.restaurantProfile.menus.menuData = menuData(350); + const capped = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(big))); + assert.equal(capped.output.menus.truncated, true); + assert.equal(capped.output.menus.menus[0].sections[0].items.length, 300); + assertConforms("get_restaurant", capped.output); + const noMenus = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(profileState(555)))); + assert.deepEqual(noMenus.output.menus, { truncated: false, menus: [] }); +}); + +test("get_restaurant falls back to the id URL, ignores an off-site canonical, and keeps nulls for missing fields", async () => { + const state = profileState(555, { metaLinks: { canonical: "https://evil.example.test/r/x" } }); + const { output } = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(state))); + assert.equal(output.url, "https://www.opentable.com/restaurant/profile/555"); + const sparse = profileState(556, {}, {}, { description: undefined, website: null, hoursOfOperation: "", cuisines: null, additionalDetails: null, paymentOptions: undefined, timeZone: null, features: undefined, statistics: undefined, restaurantReviewSummary: undefined, priceBand: undefined, awards: undefined }); + const sparseOut = (await run("get_restaurant", { restaurant_id: 556 }, ok(pageHtml(sparse)))).output; + assert.equal(sparseOut.status, "ok"); + assert.deepEqual([sparseOut.description, sparseOut.website, sparseOut.hours_text, sparseOut.time_zone, sparseOut.review_count, sparseOut.review_summary_ai, sparseOut.price_level], [null, null, null, null, null, null, null]); + assert.deepEqual([sparseOut.cuisines, sparseOut.amenities, sparseOut.payment_options, sparseOut.awards, sparseOut.tags], [[], [], [], [], []]); + assert.deepEqual(sparseOut.ratings, { overall: null, food: null, service: null, ambience: null, value: null, noise: null, rating_count: null, distribution: null }); + assertConforms("get_restaurant", sparseOut); +}); + +test("get_restaurant reports missing pages, wrong pages, id mismatches and bot protection explicitly", async () => { + const state = profileState(555); + const missing = await run("get_restaurant", { url: "/r/no-such-place" }, { respond: () => ({ status: 404, html: "OpenTable" }) }); + assert.equal(missing.output.error_kind, "not_found"); + const notProfile = await run("get_restaurant", { url: "/s" }, ok(pageHtml(searchState([])))); + assert.equal(notProfile.output.error_kind, "not_found"); + const mismatch = await run("get_restaurant", { restaurant_id: 999 }, ok(pageHtml(state))); + assert.equal(mismatch.output.error_kind, "site_error"); + const noName = profileState(555); + delete noName.restaurantProfile.restaurant.name; + const changed = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(noName))); + assert.equal(changed.output.error_kind, "site_changed"); + const wrong = await run("get_restaurant", { restaurant_id: 555 }, { origin: "https://www.opentable.ca", ...ok(pageHtml(state)) }); + assert.equal(wrong.output.error_kind, "wrong_page"); + assert.equal(wrong.calls.fetch.length, 0); + const paused = await run("get_restaurant", { restaurant_id: 555 }, { title: "Access Denied", ...ok(pageHtml(state)) }); + assert.equal(paused.output.error_kind, "challenge"); + assert.equal(paused.calls.fetch.length, 0); + const denied = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => ({ status: 403, html: "Access Denied" }) }); + assert.equal(denied.output.error_kind, "challenge"); + const limited = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => ({ status: 429, html: "" }) }); + assert.equal(limited.output.error_kind, "rate_limited"); + const noScript = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => ({ html: "x" }) }); + assert.equal(noScript.output.error_kind, "site_changed"); + const thrown = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => new Error("boom") }); + assert.equal(thrown.output.error_kind, "site_error"); + for (const r of [missing, notProfile, mismatch, changed, wrong, paused, denied, limited, noScript, thrown]) assertConforms("get_restaurant", r.output); +}); + +// --- get_reviews ----------------------------------------------------------------------------------- + +test("get_reviews reads page N of the profile and maps reviews without reviewer identity or reply text", async () => { + const first = await run("get_reviews", { restaurant_id: 555 }, ok(pageHtml(profileState(555)))); + assert.equal(first.calls.fetch[0].path, "/restaurant/profile/555"); + const third = await run("get_reviews", { url: "https://www.opentable.com/r/fake-bistro-fakeville?x=1", page: 3 }, ok(pageHtml(profileState(555, {}, { reviewSearchResults: { reviews: [review("REV-9")], totalPages: 216, page: 3, totalCount: 2157 } })))); + assert.equal(third.calls.fetch[0].path, "/r/fake-bistro-fakeville?page=3"); + assert.equal(third.output.page, 3); + assert.equal(third.output.reviews.length, 1); + const { output } = first; + assert.equal(output.status, "ok"); + assert.equal(output.restaurant_id, 555); + assert.equal(output.name, "Fake Bistro"); + assert.equal(output.url, "https://www.opentable.com/r/fake-bistro-fakeville"); + assert.equal(output.page, 1); + assert.equal(output.total_count, 2157); + assert.equal(output.total_pages, 216); + assert.equal(output.sort, "newest"); + assert.deepEqual(output.reviews[0], { + review_id: "REV-1", overall: 4, food: 5, service: 4, ambience: 3, value: 2, noise: "MODERATE", text: "Fake review text.", dined_at: "2026-09-26T16:30:00-04:00", + submitted_at: "2026-09-27T16:36:29Z", verified_by_opentable: true, helpful_votes: 3, photo_count: 0, has_restaurant_reply: false + }); + assert.equal(output.reviews[1].has_restaurant_reply, true); + assert.equal(output.reviews[1].photo_count, 2); + assert.equal(output.reviews[1].verified_by_opentable, false); + assert.equal(output.reviews[1].noise, null); + assertNoLeaks(output); + assertConforms("get_reviews", output); + assertConforms("get_reviews", third.output); +}); + +test("get_reviews treats a page the site does not honour as no_results, never as page data", async () => { + // Past the last page the site returns page 1 with a different page size and totalPages. + const fallback = profileState(555, {}, { reviewSearchResults: { reviews: Array.from({ length: 40 }, (_, i) => review("R" + i)), totalPages: 318, page: 1, totalCount: 12686 } }); + const beyond = await run("get_reviews", { restaurant_id: 555, page: 1270 }, ok(pageHtml(fallback))); + assert.equal(beyond.output.status, "no_results"); + assert.equal(beyond.output.total_count, 12686); + assert.equal(beyond.output.page, 1270); + assertConforms("get_reviews", beyond.output); + const overTotal = profileState(555, {}, { reviewSearchResults: { reviews: [review("R1")], totalPages: 3, page: 4, totalCount: 21 } }); + const over = await run("get_reviews", { restaurant_id: 555, page: 4 }, ok(pageHtml(overTotal))); + assert.equal(over.output.status, "no_results"); + const none = profileState(555, {}, { reviewSearchResults: { reviews: [], totalPages: 0, page: 1, totalCount: 0 } }); + const empty = await run("get_reviews", { restaurant_id: 555 }, ok(pageHtml(none))); + assert.equal(empty.output.status, "no_results"); + assert.equal(empty.output.total_count, 0); + assertConforms("get_reviews", empty.output); + const noIds = profileState(555, {}, { reviewSearchResults: { reviews: [{ text: "no id" }], totalPages: 1, page: 1, totalCount: 1 } }); + const idless = await run("get_reviews", { restaurant_id: 555 }, ok(pageHtml(noIds))); + assert.equal(idless.output.error_kind, "site_changed"); +}); + +test("get_reviews rejects malformed input and reports site problems explicitly", async () => { + const html = ok(pageHtml(profileState(555))); + const bad = [ + [{}, "invalid_input"], [{ restaurant_id: 5, url: "/r/x" }, "invalid_input"], [{ restaurant_id: 0 }, "invalid_input"], [{ restaurant_id: 555, page: 0 }, "invalid_input"], [{ restaurant_id: 555, page: 5001 }, "invalid_input"], + [{ restaurant_id: 555, page: "2" }, "invalid_input"], [{ restaurant_id: 555, page: 1.5 }, "invalid_input"], [{ restaurant_id: 555, sort: "oldest" }, "invalid_input"], + [{ url: "https://evil.example.test/r/x" }, "unsupported_host"], [{ url: "/private-dining/profile/x" }, "invalid_input"], [{ url: "http://www.opentable.com/r/x" }, "unsupported_host"] + ]; + for (const [input, kind] of bad) { + const { output, calls } = await run("get_reviews", input, html); + assert.equal(output.error_kind, kind, JSON.stringify(input)); + assert.equal(calls.fetch.length, 0, "no request for " + JSON.stringify(input)); + assertConforms("get_reviews", output); + } + const cases = [ + [{ origin: "https://help.opentable.com", ...html }, "wrong_page"], + [{ title: "Access Denied", ...html }, "challenge"], + [{ respond: () => ({ status: 403, html: "Access Denied" }) }, "challenge"], + [{ respond: () => ({ status: 429, html: "" }) }, "rate_limited"], + [{ respond: () => ({ status: 404, html: "" }) }, "not_found"], + [{ respond: () => ({ status: 500, html: "" }) }, "site_error"], + [{ respond: () => ({ html: "" }) }, "site_changed"], + [ok(pageHtml(searchState([]))), "not_found"], + [ok(pageHtml(profileState(555, {}, { reviewSearchResults: null }))), "site_changed"], + [{ respond: () => new Error("boom") }, "site_error"] + ]; + for (const [env, kind] of cases) { + const { output } = await run("get_reviews", { restaurant_id: 555 }, env); + assert.equal(output.error_kind, kind); + assertConforms("get_reviews", output); + } + const reported = await run("get_reviews", { restaurant_id: 555 }, ok(pageHtml(profileState(555, {}, { error: true, reviewSearchResults: null })))); + assert.equal(reported.output.error_kind, "site_error"); + assertConforms("get_reviews", reported.output); + const mismatch = await run("get_reviews", { restaurant_id: 999 }, html); + assert.equal(mismatch.output.error_kind, "site_error"); +}); + +test("the tools never navigate, click, submit, or send anything but page GETs", async () => { + for (const [name, input, state] of [["search_restaurants", { query: "pizza chicago" }, searchState([searchRestaurant(1, "A")])], ["get_restaurant", { restaurant_id: 555 }, profileState(555)], ["get_reviews", { restaurant_id: 555 }, profileState(555)]]) { + const { calls } = await run(name, input, ok(pageHtml(state))); + assert.equal(calls.fetch.length, 1); + assert.equal(calls.fetch[0].init.method, undefined, "requests are plain GETs"); + assert.equal(calls.fetch[0].init.body, undefined); + assert.ok(calls.fetch[0].path.startsWith("/"), "requests stay same-origin"); + assert.ok(!/restref|booking|authenticate|dapi|user/.test(calls.fetch[0].path), "no booking, auth, GraphQL or account paths"); + } +}); From e4fee55152850c76b66c5fa8eb481f922f919e1f Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:57:09 +0000 Subject: [PATCH 02/14] Restrict opentable.com url input to restaurant pages and cap string fields --- domains/opentable.com/README.md | 45 ++++++---- domains/opentable.com/get_restaurant.js | 31 ++++--- domains/opentable.com/get_reviews.js | 27 +++--- domains/opentable.com/search_restaurants.js | 13 +-- registry.json | 2 +- tests/opentable-restaurants.test.mjs | 96 ++++++++++++++++++--- 6 files changed, 147 insertions(+), 67 deletions(-) diff --git a/domains/opentable.com/README.md b/domains/opentable.com/README.md index c915326d..82200978 100644 --- a/domains/opentable.com/README.md +++ b/domains/opentable.com/README.md @@ -5,7 +5,7 @@ Tools for the OpenTable US diner site (https://www.opentable.com): restaurant se | tool | file | status | notes | |------|------|--------|-------| | search_restaurants | [search_restaurants.js](search_restaurants.js) | draft | Free-text search with optional date, time and party size; one site results page (up to 50) per call with id, url, cuisine, price band, address, coordinates, rating and review count. No bookable time slots. | -| get_restaurant | [get_restaurant.js](get_restaurant.js) | draft | One profile by restaurant_id or profile URL: address, hours text, amenities, ratings by category with distribution, awards, tags, AI review summary, optional published menus. | +| get_restaurant | [get_restaurant.js](get_restaurant.js) | draft | One profile by restaurant_id or /r/ URL: address, hours text, amenities, ratings by category with distribution, awards, tags, AI review summary, optional published menus. | | get_reviews | [get_reviews.js](get_reviews.js) | draft | One page of 10 diner reviews, newest first, with ratings, text, dates and vote counts. No reviewer names or photos. | Register all three files together as one array with `forceOverwriteNamespace: true`; registering one file that way removes the others. @@ -16,7 +16,7 @@ None found (checked 2026-09-29, signed out, with `webmcp.listTools()` and `docum ## Scope and loading -The tools read public restaurant information. They never select a time slot, request or hold a table, book, modify or cancel a reservation, set an availability alert, save a favorite, write a review, sign in, or open any account, payment, checkout or private-dining page. Availability (bookable time slots) is deliberately not covered: the site loads it through a persisted GraphQL query with a CSRF token and a slot click can take a hold, which needs its own design. +The tools read public restaurant information. They never select a time slot, request or hold a table, book, modify or cancel a reservation, set an availability alert, save a favorite, write a review, sign in, or open any account, payment, checkout or private-dining page. The only paths the tools ever request are `/s` (search), `/restaurant/profile/` and `/r/`; a `url` input that is anything else is rejected before any request. Availability (bookable time slots) is deliberately not covered: the site loads it through a persisted GraphQL query with a CSRF token and a slot click can take a hold, which needs its own design. ```javascript const fs = await import("node:fs"); @@ -32,14 +32,14 @@ await webmcp.invokeTool(await ref("get_reviews"), { restaurant_id: 107461, page: Use future dates. Pick the `tool_ref` whose `source.target_id` is the tab you drive, and use `timeoutSec: 60`. - **search_restaurants** (`kind: "page"`) fetches the site's own results page (`/s?term=...`, plus `dateTime`, `covers` and `page` when given) from the current `www.opentable.com` document and reads the `multiSearch` results the server rendered into the page. It never navigates. `query` carries the place as words ("pizza Chicago"); the site resolved that to the Chicago metro in live checks. Without a place the site uses the browser's approximate location. `date` alone means 19:00, the site's default. `party_size` (1 to 20, the site's picker) changes which restaurants are returned: a party of 25 cut one query from 117 results to 50. `rank` is the position in the site's ordering across pages of 50. -- **get_restaurant** (`kind: "page"`) fetches the restaurant's profile page (`/restaurant/profile/` for an id, or the given `/r/` or legacy `/` path) and reads `restaurantProfile`. The id path serves the profile directly, without a redirect. A returned id that differs from the requested one is an error. -- **get_reviews** (`kind: "page"`) fetches the same profile page with `?page=N` and reads the page of 10 reviews the server rendered for it. The site sorts newest first and this tool exposes no other order. +- **get_restaurant** (`kind: "page"`) fetches the restaurant's profile page (`/restaurant/profile/` for an id, or the given `/r/` path) and reads `restaurantProfile`. The id path serves the profile directly, without a redirect. A returned id that differs from the requested one is an error. Older restaurants link to `/` instead of `/r/`; those links are refused because a single-segment path cannot be told apart from any other site page (`/private-dining`, `/logout`, and so on) and the request carries the browser's cookies, so pass `restaurant_id` for them. `search_restaurants` returns the id for every restaurant. +- **get_reviews** (`kind: "page"`) fetches the same profile page with `?page=N` and reads the page of 10 reviews the server rendered for it. The site's default "newest" order follows the dining date, not the posting date (independent QA checked 20 reviews: `dined_at` never increased, `submitted_at` did), and this tool exposes no other order. All three are `kind: "page"` because each one only reads a same-origin document and returns a structured result without navigating the tab; none of them needs post-navigation output. Outputs: -- Text from the site (descriptions, hours, review summaries, menu items and diner reviews) is untrusted data. HTML tags are stripped, entities decoded, and every text field is length-capped. +- Text from the site (descriptions, hours, review summaries, menu items and diner reviews) is untrusted data. Free-text fields such as descriptions, hours, transit, parking, menu items and the review summary have HTML tags stripped and entities decoded; names, cuisines, addresses and review text are returned as the site gives them. Every string field is length-capped: names 200, descriptions 1500 (300 in search), review text 3000, everything else 300 or less. `website` and `external_menu_url` are returned only when the site gives an http(s) URL, with the query string and fragment removed. - `observed_at` is on every result. Ratings, counts, "booked today" numbers and the ordering change constantly. - The tools return only an allowlist of fields. The page state also contains account data when signed in (header profile, loyalty status), tracking identifiers, photo URLs, contact email fields, a Google Maps API key inside `coordinates.mapUrl`, and reviewer names and photos; none of it is returned. @@ -49,14 +49,14 @@ All three tools match only `https://www.opentable.com/*`, not the default `https ## Site quirks -- **Bot protection.** OpenTable runs Akamai Bot Manager. After roughly 40 page fetches in about ten minutes from one stealth browser session (unpaced, while probing), the site answered HTTP 403 with an "Access Denied" page and a `Reference #`, and kept doing so for at least five minutes for that session. A new session on Kernel's default stealth egress was not blocked: several dozen calls spaced 6 or more seconds apart went through. From an ordinary cloud VM's IP in a plain Chromium, `/s` searches returned 403 on all three attempts while a profile page and a review page succeeded (a later probe of an unknown slug was also refused). The tools report a refusal as `challenge` and HTTP 429 as `rate_limited`; they never retry. Avoid rapid repeated calls. -- **Same page, different data.** Every read is a server-rendered HTML page (200 to 400 KB) whose ``; +const slot = extra => ({ isAvailable: true, type: "Standard", timeOffsetMinutes: 0, attributes: ["default", "bar"], isMandatory: false, isMandatoryBySeating: [], pointsValue: 100, slotHash: "PRIVATE-HASH", slotAvailabilityToken: "PRIVATE-SLOT", ...extra }); +const availability = (slots = [slot()], extra = {}) => ({ data: { availability: [{ restaurantId: 555, availabilityDays: [{ dayOffset: 0, slots, noTimesReasons: [], ...extra }] }] } }); +function conforms(schema, value) { + if (schema.const !== undefined && schema.const !== value) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if (schema.type && ![schema.type].flat().some(t => t === "null" ? value === null : t === "array" ? Array.isArray(value) : t === "integer" ? Number.isInteger(value) : t === "object" ? value !== null && typeof value === "object" && !Array.isArray(value) : typeof value === t)) return false; + if (Array.isArray(value) && (value.length > (schema.maxItems ?? Infinity) || value.length < (schema.minItems ?? 0) || schema.items && value.some(x => !conforms(schema.items, x)))) return false; + if (value && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(k => !(k in value))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(k => !schema.properties?.[k])) return false; + if (schema.properties && Object.entries(value).some(([k, v]) => schema.properties[k] && !conforms(schema.properties[k], v))) return false; + } + if (typeof value === "string" && (value.length > (schema.maxLength ?? Infinity) || value.length < (schema.minLength ?? 0) || schema.pattern && !new RegExp(schema.pattern).test(value))) return false; + if (typeof value === "number" && (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity))) return false; + return true; +} +async function run(name, input, options = {}) { + const requests = []; + const signal = options.signal; + let dashboards = 0; + const fetch = async (path, init = {}) => { + requests.push({ path, ...init }); + let body, status = options.status ?? 200; + if (options.respond) return options.respond(path, init, requests.length); + if (path === "/user/dining-dashboard") body = html(dashboards++ && options.after ? options.after : options.dashboard ?? dashboard); + else if (path.startsWith("/restaurant/profile/")) body = options.profileHtml ?? html(options.profile ?? profile); + else if (path === "/user/favorites") body = html({}, "user-favorites-FAKE"); + else if (path.startsWith("/static/")) body = options.module ?? documentQuery(name === "list_saved_restaurants" ? "UserWishlist" : "RestaurantsAvailability"); + else if (path.includes("RestaurantsAvailability")) body = JSON.stringify(options.availability ?? availability()); + else if (path.includes("UserWishlist")) body = JSON.stringify(options.wishlist ?? { data: { userWishlist: null } }); + else throw new Error("Unexpected request: " + path); + if (options.onRequest) options.onRequest(path, signal); + return { status, ok: status >= 200 && status < 300, text: async () => body }; + }; + const execute = runInNewContext(`(${definitions[name].execute.toString()})`, { window: { location: { origin: options.origin ?? origin } }, document: { title: options.title ?? "OpenTable", querySelector: () => options.challenge ? {} : null }, URL, URLSearchParams, fetch }); + const result = plain(await execute(input, { signal })); + assert.ok(conforms(definitions[name].tool.outputSchema, result), JSON.stringify(result)); + return { result, requests }; +} +const failure = async (name, input, kind, options = {}, requestCount) => { + const out = await run(name, input, options); + assert.equal(out.result.error_kind, kind); + if (requestCount !== undefined) assert.equal(out.requests.length, requestCount); + return out; +}; + +test("definitions are page-local read tools; namespace has no booking or fake approval execution", () => { + for (const name of names) { + assert.equal(definitions[name].kind, "page"); + assert.deepEqual(plain(definitions[name].match.url_patterns), [origin + "/*"]); + assert.equal(definitions[name].tool.annotations.readOnlyHint, true); + assert.equal(definitions[name].tool.inputSchema.additionalProperties, false); + } + const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url))); + const siteNames = registry.tools.filter(t => t.domain === "opentable.com").map(t => t.tool); + assert.equal(siteNames.length, 7); + assert.ok(!siteNames.some(n => /book_reservation|cancel_reservation|notify|set_favorite/.test(n))); +}); + +test("all malformed inputs are rejected before any page or network interaction", async () => { + for (const name of names) { + const valid = name.includes("account") || name.includes("saved") ? account : query; + for (const bad of [null, [], "bad", {}, { ...valid, confirmed: true }, { ...valid, extra: "x" }]) await failure(name, bad, "invalid_input", {}, 0); + } + for (const name of ["get_availability", "prepare_reservation"]) { + for (const [key, values] of Object.entries({ restaurant_id: [0, -1, 1.5, "555", 2147483648], date: ["2026-13-45", "2099-02-30", "2020-01-01", "x", 2099], time: ["24:00", "19:60", 19], party_size: [0, 21, "2", 1.5] })) for (const value of values) await failure(name, { ...query, [key]: value }, "invalid_input", {}, 0); + } + for (const bad of [-1, 121, "60", 1.5]) await failure("get_availability", { ...query, window_minutes: bad }, "invalid_input", {}, 0); + await failure("prepare_reservation", { ...query, seating_type: "private" }, "invalid_input", {}, 0); + for (const name of ["get_account_summary", "list_saved_restaurants"]) for (const bad of ["", "x", "a b@example.test", 7, "x".repeat(255)]) await failure(name, { expected_account_email: bad }, "invalid_input", {}, 0); + for (const page of [0, 201, "1", 1.5]) await failure("list_saved_restaurants", { ...account, page }, "invalid_input", {}, 0); +}); + +test("wrong host, challenge and cancellation guards issue zero requests", async () => { + for (const name of names) { + const valid = name.includes("account") || name.includes("saved") ? account : query; + await failure(name, valid, "wrong_page", { origin: "https://example.test" }, 0); + await failure(name, valid, "challenge", { title: "Access Denied" }, 0); + await failure(name, valid, "challenge", { challenge: true }, 0); + await failure(name, valid, "canceled", { signal: { aborted: true } }, 0); + } +}); + +test("availability only sends the observed read query and never returns credentials or raw state", async () => { + const out = await run("get_availability", query); + assert.equal(out.result.status, "ok"); + assert.deepEqual(out.result.slots[0], { date: query.date, time: "19:00", seating_types: ["default", "bar"], points: 100, requires_experience: false }); + const req = out.requests.at(-1); + const body = JSON.parse(req.body); + assert.equal(body.operationName, "RestaurantsAvailability"); + assert.equal(req.method, "POST"); + assert.equal(body.variables.forwardDays, 0); + assert.deepEqual(body.variables.requireTypes, ["Standard"]); + assert.deepEqual(body.variables.privilegedAccess, []); + assert.equal(req.headers["x-csrf-token"], "PRIVATE-CSRF"); + assert.ok(out.requests.every(r => r.credentials === "same-origin" && r.redirect === "error")); + assert.ok(!JSON.stringify(out.result).includes("PRIVATE")); + assert.ok(!JSON.stringify(out.result).includes("private@example")); +}); + +test("availability time offsets, unsupported inventory, no results and response bounds", async () => { + const out = await run("get_availability", { ...query, window_minutes: 60 }, { availability: availability([slot({ timeOffsetMinutes: -30 }), slot({ timeOffsetMinutes: 45 }), slot({ isAvailable: false }), slot({ type: "Experience" })]) }); + assert.deepEqual(out.result.slots.map(s => s.time), ["18:30", "19:45"]); + const none = await run("get_availability", query, { availability: availability([], { noTimesReasons: ["TooFarInAdvance"] }) }); + assert.equal(none.result.status, "no_results"); + assert.deepEqual(none.result.no_times_reasons, ["TooFarInAdvance"]); + const bounded = await run("get_availability", query, { availability: availability(Array.from({ length: 250 }, () => slot({ attributes: Array(30).fill("default") })), { noTimesReasons: Array(30).fill("x".repeat(500)) }) }); + assert.equal(bounded.result.slots.length, 50); + assert.equal(bounded.result.slots[0].seating_types.length, 10); + assert.equal(bounded.result.no_times_reasons.length, 10); + assert.equal(bounded.result.no_times_reasons[0].length, 100); + assert.equal(bounded.result.truncated, true); + for (const bad of [{ timeOffsetMinutes: 121 }, { attributes: null }, { isMandatory: undefined }]) await failure("get_availability", query, "site_changed", { availability: availability([slot(bad)]) }); + const wrong = availability(); wrong.data.availability[0].restaurantId = 666; + await failure("get_availability", query, "site_changed", { availability: wrong }); + await failure("get_availability", query, "site_error", { availability: { errors: [{ message: "PRIVATE" }], data: availability().data } }); +}); + +test("query discovery is bounded, uses only first-party static imports and never executes module text", async () => { + const source = 'import"/static/cfe/15/js/chunk-NEXT.js";import"https://evil.example/SECRET.js";globalThis.mutated=true;'; + let staticReads = 0; + const out = await run("get_availability", query, { respond: async path => ({ status: 200, ok: true, text: async () => path.startsWith("/restaurant/") ? html(profile) : path.startsWith("/static/") ? ++staticReads === 1 ? source : documentQuery("RestaurantsAvailability") : JSON.stringify(availability()) }) }); + assert.equal(out.result.status, "ok"); + assert.equal(staticReads, 2); + assert.ok(out.requests.every(r => !r.path.includes("evil"))); + await failure("get_availability", query, "site_changed", { module: documentQuery("UserWishlist") }); + await failure("get_availability", query, "site_changed", { module: `kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"RestaurantsAvailability"};kind:"OperationDefinition",operation:"mutation",name:{kind:"Name",value:"CreateHold"};q.documentId="${hash}"` }); +}); + +test("preparation requires the exact time and seating, does not substitute, and stays non-executing", async () => { + const out = await run("prepare_reservation", { ...query, seating_type: "bar" }); + assert.equal(out.result.status, "prepared"); + assert.deepEqual(out.result.selected_slot.seating_types, ["bar"]); + assert.equal(out.result.account_verified, false); + assert.equal(out.result.terms_status, "not_checked"); + assert.equal(out.result.hold_created, false); + assert.equal(out.result.reservation_created, false); + assert.equal(out.result.execution_blocker, "trusted_human_approval_unavailable"); + assert.match(out.result.handoff_url, /^https:\/\/www\.opentable\.com\/restaurant\/profile\/555\?/); + for (const options of [{ availability: availability([slot({ timeOffsetMinutes: 15 })]) }, { availability: availability([slot({ attributes: ["bar"] })]) }]) { + const unavailable = await run("prepare_reservation", query, options); + assert.equal(unavailable.result.status, "unavailable"); + assert.equal(unavailable.result.selected_slot, null); + } + await failure("prepare_reservation", query, "gated", { availability: availability([slot({ isMandatory: true })]) }); + await failure("prepare_reservation", query, "gated", { availability: availability([slot({ isMandatoryBySeating: [{ tableCategory: "bar", isMandatory: true }] })]) }); + assert.ok(out.requests.every(r => !/booking|hold|reservation\/create|notify/i.test(r.path))); +}); + +test("account summary is grounded in containers, does not return private items, and rejects wrong accounts", async () => { + const out = await run("get_account_summary", account); + assert.equal(out.result.points, 1200); + assert.equal(out.result.upcoming_reservation_count, 1); + assert.equal(out.result.past_reservation_count_on_page, 0); + assert.equal(out.result.has_more_past, false); + assert.ok(!JSON.stringify(out.result).includes("PRIVATE")); + assert.ok(!JSON.stringify(out.result).includes("example.test")); + for (const name of ["get_account_summary", "list_saved_restaurants"]) { + await failure(name, { expected_account_email: "different@example.test" }, "wrong_account", {}, 1); + await failure(name, account, "login_required", { dashboard: { ...dashboard, authentication: { isAuthenticated: false } } }, 1); + await failure(name, account, "site_error", { dashboard: { ...dashboard, diningDashboard: { ...dashboard.diningDashboard, hasError: true } } }, 1); + } + await failure("get_account_summary", account, "site_changed", { dashboard: { ...dashboard, diningDashboard: { ...dashboard.diningDashboard, upcomingReservations: null } } }); +}); + +test("Favorites uses observed rid schema with paging and validates account again before release", async () => { + const restaurants = Array.from({ length: 101 }, (_, i) => ({ rid: i + 1, private: "PRIVATE" })); + const out = await run("list_saved_restaurants", { ...account, page: 2 }, { wishlist: { data: { userWishlist: { restaurants } } } }); + assert.equal(out.result.status, "ok"); + assert.equal(out.result.total_count, 101); + assert.deepEqual(out.result.restaurant_ids, Array.from({ length: 50 }, (_, i) => i + 51)); + assert.equal(out.result.has_more, true); + const body = JSON.parse(out.requests.find(r => r.method === "POST").body); + assert.equal(body.operationName, "UserWishlist"); + assert.deepEqual(body.variables, { wishlistName: "Favorites", gpid: 0 }); + assert.equal(out.requests.filter(r => r.path === "/user/dining-dashboard").length, 2); + assert.ok(!JSON.stringify(out.result).includes("PRIVATE")); + const empty = await run("list_saved_restaurants", account); + assert.equal(empty.result.status, "no_results"); + assert.equal(empty.result.total_count, 0); + const changed = { ...dashboard, header: { userProfile: { ...dashboard.header.userProfile, email: "changed@example.test" } } }; + await failure("list_saved_restaurants", account, "wrong_account", { after: changed }); + await failure("list_saved_restaurants", account, "site_changed", { wishlist: { data: {} } }); + await failure("list_saved_restaurants", account, "site_changed", { wishlist: { data: { userWishlist: { restaurants: [{ rid: "555" }] } } } }); + await failure("list_saved_restaurants", account, "site_error", { wishlist: { errors: [{ message: "PRIVATE" }], data: { userWishlist: null } } }); +}); + +test("network failures, challenges and aborts are explicit, with no automatic retry", async () => { + for (const name of names) { + 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); + 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 b7f64379..f58bd34e 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -132,7 +132,7 @@ const menuData = itemCount => [ async function run(name, input, o = {}) { const calls = { fetch: [] }; const context = { - window: { location: { origin: o.origin ?? ORIGIN } }, document: { title: o.title ?? "OpenTable" }, URL, URLSearchParams, + window: { location: { origin: o.origin ?? ORIGIN } }, document: { title: o.title ?? "OpenTable" }, URL, URLSearchParams, TextEncoder, fetch: async (path, init) => { calls.fetch.push({ path, init }); const r = typeof o.respond === "function" ? o.respond(path, init) : o.respond; @@ -174,7 +174,7 @@ test("definitions are page tools on www.opentable.com with registry entries", () assert.equal(entry.kind, "page"); assert.deepEqual(entry.url_patterns, [ORIGIN + "/*"]); } - assert.equal(registry.tools.filter(t => t.domain === "opentable.com").length, NAMES.length); + assert.equal(registry.tools.filter(t => t.domain === "opentable.com").length, 7); }); // --- search_restaurants ---------------------------------------------------------------------------- @@ -432,7 +432,7 @@ test("get_restaurant caps every string field and only returns query-free http(s) } }); -test("get_restaurant returns menus only on request, bounded to 300 items", async () => { +test("get_restaurant returns menus only on request, bounded to 100 items", async () => { const small = profileState(555); small.restaurantProfile.menus.menuData = menuData(3); const off = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(small))); @@ -449,7 +449,7 @@ test("get_restaurant returns menus only on request, bounded to 300 items", async big.restaurantProfile.menus.menuData = menuData(350); const capped = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(big))); assert.equal(capped.output.menus.truncated, true); - assert.equal(capped.output.menus.menus[0].sections[0].items.length, 300); + assert.equal(capped.output.menus.menus[0].sections[0].items.length, 100); assertConforms("get_restaurant", capped.output); const noMenus = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(profileState(555)))); assert.deepEqual(noMenus.output.menus, { truncated: false, menus: [] }); @@ -657,6 +657,31 @@ test("get_reviews requires the returned page to equal the requested page and hol assert.equal(ten.output.reviews.length, 10); }); +test("profile arrays and total UTF-8 output are bounded", async () => { + const values = Array.from({ length: 70 }, (_, i) => "Value " + i); + const state = profileState(555, {}, {}, { cuisines: values.map(name => ({ name })), paymentOptions: values, additionalDetails: values }); + state.restaurantProfile.restaurant.statistics.reviews.awards = values.map(name => ({ name, location: "Fake" })); + state.restaurantProfile.restaurant.statistics.reviews.categories = values.map(name => ({ category: { name } })); + const out = await run("get_restaurant", { restaurant_id: 555 }, ok(pageHtml(state))); + for (const field of ["cuisines", "payment_options", "amenities", "tags", "awards"]) assert.equal(out.output[field].length, 50); + state.restaurantProfile.menus.menuData = [{ title: "Menu", sections: [{ title: "Section", items: Array.from({ length: 100 }, () => ({ title: "🍕".repeat(100), description: "🍕".repeat(250), price: "🍕".repeat(150) })) }] }]; + const oversized = await run("get_restaurant", { restaurant_id: 555, include_menus: true }, ok(pageHtml(state))); + assert.equal(oversized.output.error_kind, "site_changed"); +}); + +test("invalid search dates fail before fetch and impossible review timestamps become null", async () => { + for (const date of ["2099-13-45", "2099-02-30", "2099-00-01"]) { + const out = await run("search_restaurants", { query: "pizza Chicago", date }, ok(pageHtml(searchState([])))); + assert.equal(out.output.error_kind, "invalid_input"); + assert.equal(out.calls.fetch.length, 0); + } + const reviews = [review("D1", { dinedDateTime: "2099-02-30T19:00:00Z", submittedDateTime: "2099-10-10T24:00:00Z" })]; + const state = profileState(555, {}, { reviewSearchResults: { reviews, totalPages: 1, page: 1, totalCount: 1 } }); + const out = await run("get_reviews", { restaurant_id: 555 }, ok(pageHtml(state))); + assert.equal(out.output.reviews[0].dined_at, null); + assert.equal(out.output.reviews[0].submitted_at, null); +}); + test("the tools never navigate, click, submit, or send anything but page GETs", async () => { for (const [name, input, state] of [["search_restaurants", { query: "pizza chicago" }, searchState([searchRestaurant(1, "A")])], ["get_restaurant", { restaurant_id: 555 }, profileState(555)], ["get_reviews", { restaurant_id: 555 }, profileState(555)]]) { const { calls } = await run(name, input, ok(pageHtml(state))); From 52d625fac2f9f515ec83d8ba71a30f5095b15975 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:24:27 +0000 Subject: [PATCH 06/14] Align page tool source formatting --- domains/opentable.com/get_account_summary.js | 4 +--- domains/opentable.com/get_availability.js | 5 ++--- domains/opentable.com/list_saved_restaurants.js | 5 ++--- domains/opentable.com/prepare_reservation.js | 5 ++--- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/domains/opentable.com/get_account_summary.js b/domains/opentable.com/get_account_summary.js index 81f03b23..dbc83e30 100644 --- a/domains/opentable.com/get_account_summary.js +++ b/domains/opentable.com/get_account_summary.js @@ -119,15 +119,13 @@ "idempotentHint": true, "openWorldHint": true } - } -, + }, execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); const allowed = ["expected_account_email"]; if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); if (typeof input.expected_account_email !== "string" || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "expected_account_email must be the email of the explicitly authorized account."); - if (signal?.aborted) return fail("canceled", "The account read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.opentable.com page first."); if (/access denied/i.test(document.title || "") || document.querySelector("#sec-if-cpt-container, #sec-bc-tile-parent")) return fail("challenge", "OpenTable is challenging this browser; no request was sent."); diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index 2e66c002..4ac7bc72 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -202,12 +202,11 @@ "idempotentHint": true, "openWorldHint": true } - } -, + }, execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id","date","time","party_size","window_minutes"]; + const allowed = ["restaurant_id", "date", "time", "party_size", "window_minutes"]; if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); const date = input.date; diff --git a/domains/opentable.com/list_saved_restaurants.js b/domains/opentable.com/list_saved_restaurants.js index 92dc665e..031553e4 100644 --- a/domains/opentable.com/list_saved_restaurants.js +++ b/domains/opentable.com/list_saved_restaurants.js @@ -117,12 +117,11 @@ "idempotentHint": true, "openWorldHint": true } - } -, + }, execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["expected_account_email","page"]; + const allowed = ["expected_account_email", "page"]; if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); if (typeof input.expected_account_email !== "string" || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "expected_account_email must be the email of the explicitly authorized account."); const page = input.page ?? 1; diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index b280a6fb..bf112057 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -228,12 +228,11 @@ "idempotentHint": true, "openWorldHint": true } - } -, + }, execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id","date","time","party_size","seating_type"]; + const allowed = ["restaurant_id", "date", "time", "party_size", "seating_type"]; if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); const date = input.date; From 93dd9c6df1cd74a6de0076d1cdf26da0b9d82ff5 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:04:14 +0000 Subject: [PATCH 07/14] Honor cancellation and fail closed on exact inventory shapes --- domains/opentable.com/README.md | 25 +++++++++++----- domains/opentable.com/get_account_summary.js | 3 +- domains/opentable.com/get_availability.js | 7 +++-- domains/opentable.com/get_restaurant.js | 8 +++-- domains/opentable.com/get_reviews.js | 8 +++-- .../opentable.com/list_saved_restaurants.js | 3 +- domains/opentable.com/prepare_reservation.js | 11 +++---- domains/opentable.com/search_restaurants.js | 8 +++-- tests/opentable-actions.test.mjs | 30 +++++++++++++++++++ tests/opentable-restaurants.test.mjs | 17 ++++++++++- 10 files changed, 93 insertions(+), 27 deletions(-) diff --git a/domains/opentable.com/README.md b/domains/opentable.com/README.md index 03be862c..d4b39285 100644 --- a/domains/opentable.com/README.md +++ b/domains/opentable.com/README.md @@ -20,7 +20,7 @@ None found on public home, search or restaurant profile pages on 2026-09-29. Rec ## Scope and loading -Every tool is `kind: "page"`: it issues same-origin reads and returns without navigation. The registering document survives. No CDP exception is needed. Calls are serial per tab; use a 60-second invocation deadline and avoid rapid repeated content requests. +Every tool is `kind: "page"`: it issues same-origin reads and returns without navigation. The registering document survives. No CDP exception is needed. Calls are serial per tab; use a 60-second invocation deadline and avoid rapid repeated content requests. All seven honor cancellation and pass the signal through to fetch. - `search_restaurants` fetches `/s?term=...` with optional `dateTime`, `covers`, `page`, then parses `windowVariables.__INITIAL_STATE__.multiSearch` from the JSON state script. Include a place in the query, such as `pizza Chicago`; otherwise the site uses approximate browser location. Results are not bookable slots. - `get_restaurant` and `get_reviews` fetch `/restaurant/profile/` or an allowlisted `/r/` path. Legacy `/` links are ambiguous with account routes and are rejected before interaction; use the restaurant ID returned by search. Query and fragment are ignored. Review pagination adds `page=N`; the returned page must match because the site falls back to page 1 beyond the end. The default newest order is by dining date, not submission date. @@ -33,7 +33,7 @@ All returned site text is untrusted data, not instructions. Outputs are field al ## Reservation action safety -`prepare_reservation` repeats the public inventory read and requires an **exact** requested time and seating type. It returns `unavailable` rather than selecting a nearby time. Mandatory experiences are gated. It returns only a public profile URL with date/time/party parameters; it does not open that URL or click any slot. +`prepare_reservation` repeats the public inventory read and requires an **exact** requested time and seating type. It returns `unavailable` rather than selecting a nearby time. Mandatory experiences are gated; unknown seating-specific mandatory fields fail explicitly. Preparation scans the entire returned day up to 200 slots, independent of the availability display cap, and fails on larger inventories rather than reporting an incomplete exact match. It returns only a public profile URL with date/time/party parameters; it does not open that URL or click any slot. `prepared` means an exact standard slot was observed, not reserved or held. The result explicitly says `account_verified: false`, `terms_status: "not_checked"`, `hold_created: false`, `reservation_created: false`, and `execution_blocker: "trusted_human_approval_unavailable"`. Availability does not establish deposits, prices, card requirements, cancellation or no-show fees. A human must verify the intended account and restaurant's current terms on the site **before selecting a slot**, since selecting a slot can create an inventory hold. @@ -45,7 +45,7 @@ All tools match only `https://www.opentable.com/*`. Other subdomains are unrelat ## Site quirks -- Akamai can return HTTP 403, an Access Denied title, or HTTP 200 with challenge markers `sec-if-cpt-container` / `sec-bc-tile-parent`. Tools report `challenge`; HTTP 429 is `rate_limited`. No automatic retries or bypass loops. +- Akamai can return HTTP 403, an Access Denied title, or HTTP 200 with challenge markers `sec-if-cpt-container` / `sec-bc-tile-parent`. Tools report `challenge` when the fetched document lacks the legitimate state script; marker words inside valid state are not challenges. HTTP 403 is always a challenge; HTTP 429 is `rate_limited`. No automatic retries or bypass loops. - Pages expose a server-rendered JSON state script even though availability and Favorites load separately. Client-side account pages can have signed-out SSR snapshots; the fresh authenticated dining dashboard, not a visible header button, is the account identity gate. - A missing state script/query module is `site_changed`, a missing restaurant is `not_found` where observed, and query errors are not converted into no-results. - Published menu prices are passed through as listed, often strings or null, without per-item currency. Menu links have query/fragment removed; no external menu is fetched. @@ -64,11 +64,22 @@ All tools match only `https://www.opentable.com/*`. Other subdomains are unrelat The original three-tool source passed historical independent public QA on 2026-09-29 at revision `49f3289`. That evidence does **not** approve this revised source. Prior findings reviewed before changes included an unsafe profile URL allowlist (already fixed), uncapped profile arrays (now capped), calendar-impossible timestamps (now rejected), and test-strength gaps. All seven current entries are initially draft pending fresh independent QA. -Author checks on 2026-09-30, current source: +Author checks on 2026-09-30, revision `52d625f` (before subsequent review fixes): - Kernel: all seven definitions registered together and discovered once each. From the public home page, availability returned five standard slots; exact preparation returned `prepared` without navigation or any slot/hold/booking request. A party above the restaurant's limit returned `no_results` with `AboveMaxPartySize`. - Authorized account, from its home page: account summary returned zero reservation/invitation counts and current points; Favorites returned `no_results` with total 0. No account item or token was returned, and no list or account state was changed. The empty account is not live evidence for populated item shapes. -- Automated: registry validator passed (86 tools); full repository tool suite passed (1608 tests before the two extra regression tests), and the site suite passed all 33 tests afterward. Go tests and all 14 extension unit tests passed. Fixtures cover malformed inputs before requests, wrong host/account, missing login, cancellation, query errors, namespace membership, strict query-only discovery, output bounds, populated Favorites, exact preparation, and the absence of execution tools. -- Local extension end-to-end verification of this revision is pending independent QA. Unit tests are not live runtime verification. +- Automated: registry validator passed (86 tools); repository and site test results are recorded per revision below. Go tests and all 14 extension unit tests passed. Fixtures cover malformed inputs before requests, wrong host/account, missing login, cancellation, query errors, namespace membership, strict query-only discovery, output bounds, populated Favorites, exact preparation, and the absence of execution tools. -Historical account access used only the already authorized session, without renewing its expiry or making account changes. Temporary public browsers are separate and deleted after testing. Independent final-source results will be recorded separately below. +Account access used only the already authorized session, without renewing its expiry or making account changes. Temporary public browsers are separate and deleted after testing. + +### Independent revision review — 2026-09-30 + +Revision `52d625f`: **FAIL**, 1610 repository tests, 33 site test blocks, 14 extension unit tests and Go tests passed. An independent reviewer completed 153 additional Node-fixture probes and schema-validated 153 fixture, seven Kernel-live and eight extension-live outputs. Findings: exact preparation could miss a slot after the 50-slot display cap; the retained three tools ignored cancellation; malformed seating-specific mandatory data was treated as optional; marker words inside valid state could be misclassified as a challenge. These are fixed in the subsequent source, pending a final-source recheck. + +- Kernel live: all seven discovered; from `/private-dining`, availability, exact preparation, profile/menu, reviews and search succeeded, plus unknown-ID and out-of-range errors. No navigation or writes. A first browser became renderer-unresponsive and returned `outcome_unknown`; it was not automatically retried. A separate public browser completed these calls. Namespace replacement was accepted; discovery on the unresponsive document was inconclusive. +- Extension live: all seven discovered together; availability, preparation, party-size no-results, profile/menu, reviews, search and two failure cases succeeded without navigation or writes. Forced namespace replacement end-to-end was not completed in this pass. +- Account success, populated Favorites, wrong-account and identity-change paths were independent **Node-fixture-only**. No extension account fixture matrix or independent live account success was obtained in this pass. Author live account evidence was empty-only and is not evidence for populated item schemas. +- Inventory reads traversed 173 static modules per invocation, within the bound. The three inventory POSTs in the extension were read queries. +- Both owned public browsers and the isolated extension profile/processes were cleaned up. No original account browser was used by the reviewer. + +Final-source review is pending. Historical approvals and this intermediate live evidence do not approve changed source. All seven registry statuses remain draft until the final recheck. diff --git a/domains/opentable.com/get_account_summary.js b/domains/opentable.com/get_account_summary.js index dbc83e30..cf95939a 100644 --- a/domains/opentable.com/get_account_summary.js +++ b/domains/opentable.com/get_account_summary.js @@ -132,7 +132,8 @@ const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the read; nothing was retried."); if (res.status === 401) return fail("login_required", "Sign in manually to the authorized OpenTable account first."); - if (res.status === 403 || /]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))) return fail("challenge", "OpenTable refused or challenged the read."); + const hasState = /]*\bid="primary-window-vars"/.test(text); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the read."); if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; }; diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index 4ac7bc72..cc401692 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -220,7 +220,8 @@ if (/access denied/i.test(document.title || "") || document.querySelector("#sec-if-cpt-container, #sec-bc-tile-parent")) return fail("challenge", "OpenTable is challenging this browser; no request was sent."); const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); - if (res.status === 403 || /]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); + const hasState = /]*\bid="primary-window-vars"/.test(text); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); if (res.status === 404) return fail("not_found", "The requested restaurant or site resource was not found."); if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; @@ -286,11 +287,11 @@ const slots = []; for (const s of day.slots.slice(0, 200)) { if (s?.isAvailable !== true || s.type !== "Standard") continue; - if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > (input.window_minutes ?? 120) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean") return fail("site_changed", "An available slot did not have the supported fields."); + if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > (input.window_minutes ?? 120) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return fail("site_changed", "An available slot did not have the supported fields."); const minutes = startMinutes + s.timeOffsetMinutes; if (minutes < 0 || minutes >= 1440) continue; const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); - const mandatory = s.isMandatory || (Array.isArray(s.isMandatoryBySeating) && s.isMandatoryBySeating.some(x => x?.isMandatory === true)); + const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); if (slots.length === 50) break; } diff --git a/domains/opentable.com/get_restaurant.js b/domains/opentable.com/get_restaurant.js index 0a1d7382..8d0b3b0f 100644 --- a/domains/opentable.com/get_restaurant.js +++ b/domains/opentable.com/get_restaurant.js @@ -115,7 +115,7 @@ type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], properties: { status: { const: "error" }, - error_kind: { enum: ["invalid_input", "unsupported_host", "wrong_page", "not_found", "challenge", "rate_limited", "site_changed", "site_error"] }, + error_kind: { enum: ["invalid_input", "unsupported_host", "wrong_page", "not_found", "challenge", "rate_limited", "site_changed", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } } @@ -123,7 +123,7 @@ }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, - execute: async (input) => { + execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); const allowed = ["restaurant_id", "url", "include_menus"]; @@ -162,10 +162,11 @@ const strings = v => Array.isArray(v) ? [...new Set(v.map(x => clean(x, 120)).filter(Boolean))].slice(0, 50) : []; try { + if (signal?.aborted) return fail("canceled", "The profile read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); - const res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" } }); + const res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); const html = await res.text(); const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); @@ -236,6 +237,7 @@ if (new TextEncoder().encode(JSON.stringify(output)).length > 200000) return fail("site_changed", "The restaurant output exceeded the supported 200 KB bound."); return output; } catch (error) { + if (signal?.aborted) return fail("canceled", "The profile read was canceled."); return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); } } diff --git a/domains/opentable.com/get_reviews.js b/domains/opentable.com/get_reviews.js index 20486bb6..7df18605 100644 --- a/domains/opentable.com/get_reviews.js +++ b/domains/opentable.com/get_reviews.js @@ -70,7 +70,7 @@ type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], properties: { status: { const: "error" }, - error_kind: { enum: ["invalid_input", "unsupported_host", "wrong_page", "not_found", "challenge", "rate_limited", "site_changed", "site_error"] }, + error_kind: { enum: ["invalid_input", "unsupported_host", "wrong_page", "not_found", "challenge", "rate_limited", "site_changed", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } } @@ -78,7 +78,7 @@ }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, - execute: async (input) => { + execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const PAGE_SIZE = 10; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); @@ -116,10 +116,11 @@ const observedAt = () => new Date().toISOString(); try { + if (signal?.aborted) return fail("canceled", "The review read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); - const res = await fetch(page > 1 ? path + "?page=" + page : path, { credentials: "same-origin", headers: { accept: "text/html" } }); + const res = await fetch(page > 1 ? path + "?page=" + page : path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); const html = await res.text(); const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); @@ -163,6 +164,7 @@ if (!reviews.length) return fail("site_changed", "The review page listed reviews without the ids this tool reads."); return { status: "ok", restaurant_id: r.restaurantId, name, url, page, total_count: results.totalCount, total_pages: results.totalPages, sort: "newest", observed_at: observedAt(), reviews }; } catch (error) { + if (signal?.aborted) return fail("canceled", "The review read was canceled."); return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); } } diff --git a/domains/opentable.com/list_saved_restaurants.js b/domains/opentable.com/list_saved_restaurants.js index 031553e4..bf33bf4c 100644 --- a/domains/opentable.com/list_saved_restaurants.js +++ b/domains/opentable.com/list_saved_restaurants.js @@ -132,7 +132,8 @@ const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the read; nothing was retried."); if (res.status === 401) return fail("login_required", "Sign in manually to the authorized OpenTable account first."); - if (res.status === 403 || /]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))) return fail("challenge", "OpenTable refused or challenged the read."); + const hasState = /]*\bid="primary-window-vars"/.test(text); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the read."); if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; }; diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index bf112057..884d9d64 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -246,7 +246,8 @@ if (/access denied/i.test(document.title || "") || document.querySelector("#sec-if-cpt-container, #sec-bc-tile-parent")) return fail("challenge", "OpenTable is challenging this browser; no request was sent."); const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); - if (res.status === 403 || /]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); + const hasState = /]*\bid="primary-window-vars"/.test(text); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); if (res.status === 404) return fail("not_found", "The requested restaurant or site resource was not found."); if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; @@ -310,15 +311,15 @@ if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return fail("site_changed", "The requested day's availability shape changed."); const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); const slots = []; - for (const s of day.slots.slice(0, 200)) { + if (day.slots.length > 200) return fail("site_changed", "The exact-day inventory exceeded the supported 200-slot preparation bound."); + for (const s of day.slots) { if (s?.isAvailable !== true || s.type !== "Standard") continue; - if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > 120 || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean") return fail("site_changed", "An available slot did not have the supported fields."); + if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > 120 || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return fail("site_changed", "An available slot did not have the supported fields."); const minutes = startMinutes + s.timeOffsetMinutes; if (minutes < 0 || minutes >= 1440) continue; const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); - const mandatory = s.isMandatory || (Array.isArray(s.isMandatoryBySeating) && s.isMandatoryBySeating.some(x => x?.isMandatory === true)); + const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); - if (slots.length === 50) break; } const reasons = day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)); const query = { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }; diff --git a/domains/opentable.com/search_restaurants.js b/domains/opentable.com/search_restaurants.js index a147447e..38bc29a6 100644 --- a/domains/opentable.com/search_restaurants.js +++ b/domains/opentable.com/search_restaurants.js @@ -84,7 +84,7 @@ type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], properties: { status: { const: "error" }, - error_kind: { enum: ["invalid_input", "wrong_page", "challenge", "rate_limited", "site_changed", "site_error"] }, + error_kind: { enum: ["invalid_input", "wrong_page", "challenge", "rate_limited", "site_changed", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } } @@ -92,7 +92,7 @@ }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, - execute: async (input) => { + execute: async (input, { signal } = {}) => { const ORIGIN = "https://www.opentable.com"; const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); const allowed = ["query", "date", "time", "party_size", "page"]; @@ -123,6 +123,7 @@ const observedAt = new Date().toISOString(); try { + if (signal?.aborted) return fail("canceled", "The search was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the site's search page from that page."); if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); @@ -131,7 +132,7 @@ if (partySize) params.set("covers", String(partySize)); params.set("term", query); if (page > 1) params.set("page", String(page)); - const res = await fetch("/s?" + params.toString().replace(/\+/g, "%20"), { credentials: "same-origin", headers: { accept: "text/html" } }); + const res = await fetch("/s?" + params.toString().replace(/\+/g, "%20"), { credentials: "same-origin", headers: { accept: "text/html" }, signal }); const html = await res.text(); const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before searching again."); @@ -174,6 +175,7 @@ has_more: search.totalRestaurantCount > (page - 1) * pageSize + shown.length, location, observed_at: observedAt, restaurants }; } catch (error) { + if (signal?.aborted) return fail("canceled", "The search was canceled."); return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); } } diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index b856d35b..2bff3b0f 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -164,6 +164,36 @@ test("preparation requires the exact time and seating, does not substitute, and assert.ok(out.requests.every(r => !/booking|hold|reservation\/create|notify/i.test(r.path))); }); +test("unknown seating-specific experience data fails closed", async () => { + for (const name of ["get_availability", "prepare_reservation"]) { + for (const value of [undefined, null, "unknown", [{}], [{ isMandatory: "unknown" }]]) { + await failure(name, query, "site_changed", { availability: availability([slot({ isMandatoryBySeating: value })]) }); + } + } +}); + +test("challenge words inside valid profile and account state are not challenge pages", async () => { + for (const marker of ["sec-if-cpt-container", "sec-bc-tile-parent"]) { + const p = { restaurantProfile: { ...profile.restaurantProfile, restaurant: { ...profile.restaurantProfile.restaurant, name: marker } } }; + for (const name of ["get_availability", "prepare_reservation"]) { + const out = await run(name, query, { profile: p }); + assert.equal(out.result.status, name === "get_availability" ? "ok" : "prepared"); + } + const d = { ...dashboard, header: { userProfile: { ...dashboard.header.userProfile, firstName: marker } } }; + assert.equal((await run("get_account_summary", account, { dashboard: d })).result.status, "ok"); + assert.equal((await run("list_saved_restaurants", account, { dashboard: d })).result.status, "no_results"); + } +}); + +test("exact preparation searches beyond the display cap and refuses incomplete oversized inventory", async () => { + const slots = Array.from({ length: 50 }, () => slot({ timeOffsetMinutes: -15 })); + slots.push(slot()); + const out = await run("prepare_reservation", query, { availability: availability(slots) }); + assert.equal(out.result.status, "prepared"); + assert.equal(out.result.selected_slot.time, query.time); + await failure("prepare_reservation", query, "site_changed", { availability: availability(Array.from({ length: 201 }, () => slot())) }); +}); + test("account summary is grounded in containers, does not return private items, and rejects wrong accounts", async () => { const out = await run("get_account_summary", account); assert.equal(out.result.points, 1200); diff --git a/tests/opentable-restaurants.test.mjs b/tests/opentable-restaurants.test.mjs index f58bd34e..269911e7 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -143,7 +143,7 @@ async function run(name, input, o = {}) { const [definition] = runInNewContext(`(${sources[name]})`, {}); // The runtime serializes a page tool's execute into the page, so run the serialized text against stubbed globals only. const execute = runInNewContext("(" + definition.execute.toString() + ")", context); - const output = plain(await execute(input)); + const output = plain(await execute(input, { signal: o.signal })); return { output, calls, definition }; } const definitionOf = name => runInNewContext(`(${sources[name]})`, {})[0]; @@ -682,6 +682,21 @@ test("invalid search dates fail before fetch and impossible review timestamps be assert.equal(out.output.reviews[0].submitted_at, null); }); +test("retained reads honor cancellation before requests and during a failed fetch", async () => { + for (const [name, input] of [["search_restaurants", { query: "pizza Chicago" }], ["get_restaurant", { restaurant_id: 555 }], ["get_reviews", { restaurant_id: 555 }]]) { + const before = await run(name, input, { signal: { aborted: true }, ...ok(pageHtml(profileState(555))) }); + assert.equal(before.output.error_kind, "canceled"); + assert.equal(before.calls.fetch.length, 0); + assertConforms(name, before.output); + const signal = { aborted: false }; + const during = await run(name, input, { signal, respond: () => { signal.aborted = true; return new Error("Abort"); } }); + assert.equal(during.output.error_kind, "canceled"); + assert.equal(during.calls.fetch.length, 1); + assert.equal(during.calls.fetch[0].init.signal, signal); + assertConforms(name, during.output); + } +}); + test("the tools never navigate, click, submit, or send anything but page GETs", async () => { for (const [name, input, state] of [["search_restaurants", { query: "pizza chicago" }, searchState([searchRestaurant(1, "A")])], ["get_restaurant", { restaurant_id: 555 }, profileState(555)], ["get_reviews", { restaurant_id: 555 }, profileState(555)]]) { const { calls } = await run(name, input, ok(pageHtml(state))); From 0a71fc8acbfc0bcedaad4378e0f12bd9974a679e Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:33:07 +0000 Subject: [PATCH 08/14] Record source QA and live verification limits --- domains/opentable.com/README.md | 30 +++++++++++++------- registry.json | 49 +++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/domains/opentable.com/README.md b/domains/opentable.com/README.md index d4b39285..f9995e86 100644 --- a/domains/opentable.com/README.md +++ b/domains/opentable.com/README.md @@ -4,13 +4,13 @@ OpenTable US diner tools on `https://www.opentable.com`. Public restaurant reads | tool | file | status | notes | |------|------|--------|-------| -| get_account_summary | [get_account_summary.js](get_account_summary.js) | draft | Authorized dining points, eligibility and reservation/invitation counts on the current dashboard page. No private item details. | -| get_availability | [get_availability.js](get_availability.js) | draft | Up to 50 standard slots within 120 minutes of a restaurant-local date/time. No slot selection or hold. | -| get_restaurant | [get_restaurant.js](get_restaurant.js) | draft | Public profile, ratings, hours, features and optional published menus. Arrays capped at 50, menus at 100 items, output at 200 KB. | -| get_reviews | [get_reviews.js](get_reviews.js) | draft | One page of 10 reviews, newest dining date first, without diner identity. | -| list_saved_restaurants | [list_saved_restaurants.js](list_saved_restaurants.js) | draft | Favorites restaurant IDs, 50 per page, fresh account validation before and after the read. | -| prepare_reservation | [prepare_reservation.js](prepare_reservation.js) | draft | Exact date/time/party/seating preparation with public profile handoff; no reservation or inventory hold. | -| search_restaurants | [search_restaurants.js](search_restaurants.js) | draft | Free-text search with optional date/time/party, up to 50 per page; does not prove availability. | +| get_account_summary | [get_account_summary.js](get_account_summary.js) | qa_passed | Authorized dining points, eligibility and reservation/invitation counts on the current dashboard page. No private item details. | +| get_availability | [get_availability.js](get_availability.js) | qa_passed | Up to 50 standard slots within 120 minutes of a restaurant-local date/time. No slot selection or hold. | +| get_restaurant | [get_restaurant.js](get_restaurant.js) | qa_passed | Public profile, ratings, hours, features and optional published menus. Arrays capped at 50, menus at 100 items, output at 200 KB. | +| get_reviews | [get_reviews.js](get_reviews.js) | qa_passed | One page of 10 reviews, newest dining date first, without diner identity. | +| list_saved_restaurants | [list_saved_restaurants.js](list_saved_restaurants.js) | qa_passed | Favorites restaurant IDs, 50 per page, fresh account validation before and after the read. | +| prepare_reservation | [prepare_reservation.js](prepare_reservation.js) | qa_passed | Exact date/time/party/seating preparation with public profile handoff; no reservation or inventory hold. | +| search_restaurants | [search_restaurants.js](search_restaurants.js) | qa_passed | Free-text search with optional date/time/party, up to 50 per page; does not prove availability. | Register **all seven files together** when replacing the `opentable.com` namespace. `forceOverwriteNamespace` replaces the entire namespace atomically, not a single tool. @@ -62,7 +62,7 @@ All tools match only `https://www.opentable.com/*`. Other subdomains are unrelat ## QA -The original three-tool source passed historical independent public QA on 2026-09-29 at revision `49f3289`. That evidence does **not** approve this revised source. Prior findings reviewed before changes included an unsafe profile URL allowlist (already fixed), uncapped profile arrays (now capped), calendar-impossible timestamps (now rejected), and test-strength gaps. All seven current entries are initially draft pending fresh independent QA. +The original three-tool source passed historical independent public QA on 2026-09-29 at revision `49f3289`. That evidence does **not** approve this revised source. Prior findings reviewed before changes included an unsafe profile URL allowlist (already fixed), uncapped profile arrays (now capped), calendar-impossible timestamps (now rejected), and test-strength gaps. All seven entries were downgraded to draft for fresh independent revision QA. Author checks on 2026-09-30, revision `52d625f` (before subsequent review fixes): @@ -74,7 +74,7 @@ Account access used only the already authorized session, without renewing its ex ### Independent revision review — 2026-09-30 -Revision `52d625f`: **FAIL**, 1610 repository tests, 33 site test blocks, 14 extension unit tests and Go tests passed. An independent reviewer completed 153 additional Node-fixture probes and schema-validated 153 fixture, seven Kernel-live and eight extension-live outputs. Findings: exact preparation could miss a slot after the 50-slot display cap; the retained three tools ignored cancellation; malformed seating-specific mandatory data was treated as optional; marker words inside valid state could be misclassified as a challenge. These are fixed in the subsequent source, pending a final-source recheck. +Revision `52d625f`: **FAIL**, 1610 repository tests, 33 site test blocks, 14 extension unit tests and Go tests passed. An independent reviewer completed 153 additional Node-fixture probes and schema-validated 153 fixture, seven Kernel-live and eight extension-live outputs. Findings: exact preparation could miss a slot after the 50-slot display cap; the retained three tools ignored cancellation; malformed seating-specific mandatory data was treated as optional; marker words inside valid state could be misclassified as a challenge. These were fixed in revision `93dd9c6` and independently confirmed in the final-source recheck below. - Kernel live: all seven discovered; from `/private-dining`, availability, exact preparation, profile/menu, reviews and search succeeded, plus unknown-ID and out-of-range errors. No navigation or writes. A first browser became renderer-unresponsive and returned `outcome_unknown`; it was not automatically retried. A separate public browser completed these calls. Namespace replacement was accepted; discovery on the unresponsive document was inconclusive. - Extension live: all seven discovered together; availability, preparation, party-size no-results, profile/menu, reviews, search and two failure cases succeeded without navigation or writes. Forced namespace replacement end-to-end was not completed in this pass. @@ -82,4 +82,14 @@ Revision `52d625f`: **FAIL**, 1610 repository tests, 33 site test blocks, 14 ext - Inventory reads traversed 173 static modules per invocation, within the bound. The three inventory POSTs in the extension were read queries. - Both owned public browsers and the isolated extension profile/processes were cleaned up. No original account browser was used by the reviewer. -Final-source review is pending. Historical approvals and this intermediate live evidence do not approve changed source. All seven registry statuses remain draft until the final recheck. +### Final-source recheck — 2026-09-30 + +Revision `93dd9c6`: **PASS for source QA; live runtime verification incomplete.** The same independent reviewer confirmed all four findings fixed, completed 127 independent Node-fixture invocations with 127 schema-valid outputs, and reran the registry validator (86 tools), all 1614 repository tests (37 OpenTable), 14 extension unit tests and Go tests. No concrete open source defect was found. Exact matches beyond the display cap, the 200-slot bound, cancellation before/during fetch/body reads, malformed seating-specific mandatory data, mandatory experience gating and legitimate-state challenge exclusion were independently checked. + +- Kernel final-source attempt: no native tools; initial registration returned `Internal_error`. One forced namespace replacement accepted seven definitions, but discovery returned zero and a renderer read timed out. No final-source tool invocation completed; no automatic retry or browser churn followed. +- Extension final-source attempt: isolated startup reached the permission-grant step, which timed out with outcome unobserved. Final-source registration, replacement, discovery and live invocation remain unverified. +- Account success, populated Favorites, wrong-account and identity-change coverage is **fabricated Node-only**. Extension account fixtures and independent live signed-in/populated account paths remain unverified. Earlier empty author evidence does not validate populated item schemas or changed account source. +- Live mandatory experiences, real challenge/429 query responses and cross-host redirects remain unverified. Destructive actions are absent and were not tested live. +- Owned Kernel browser deleted and confirmed absent; isolated local processes/profile/scratch removed and debugging port closed. The original account browser and its expiry/cleanup were untouched by the reviewer. + +All seven entries are **qa_passed**, not `verified`, with the independent-review provenance in the registry. This is source-level independent success/failure QA, not a claim of final-source live parity. Historical live results above do not verify the changed source. Subsequent changes are README/registry metadata only; all seven tool sources and tests are unchanged from the reviewed revision. The PR remains draft. diff --git a/registry.json b/registry.json index d9504a02..f69ec433 100644 --- a/registry.json +++ b/registry.json @@ -632,9 +632,10 @@ "kind": "page", "namespace": "opentable.com", "url_patterns": ["https://www.opentable.com/*"], - "status": "draft", - "last_verified": null, - "notes": "Authorized account dining points and current-page reservation/invitation counts; expected_account_email is checked against fresh account state. No private item details or writes." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Authorized dining points and current-page reservation/invitation counts; fresh expected-account check. Independent success/failure QA is Node-fixture-only; final-source live runtime unverified. No private item details or writes.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -643,9 +644,10 @@ "kind": "page", "namespace": "opentable.com", "url_patterns": ["https://www.opentable.com/*"], - "status": "draft", - "last_verified": null, - "notes": "Public standard inventory within 120 minutes of restaurant-local time; same-origin read query, no slot selection or inventory hold. Excludes experience-only and privileged inventory." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -656,9 +658,10 @@ "url_patterns": [ "https://www.opentable.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Public restaurant profile by id or allowlisted profile URL. Array fields capped at 50, menus at 100 items, total output at 200 KB. Revised source awaits independent QA." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -669,9 +672,10 @@ "url_patterns": [ "https://www.opentable.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Calendar timestamp validation revised; awaits independent QA." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -680,9 +684,10 @@ "kind": "page", "namespace": "opentable.com", "url_patterns": ["https://www.opentable.com/*"], - "status": "draft", - "last_verified": null, - "notes": "Authorized Favorites restaurant IDs, 50 per page; identity checked before and after read query. Empty live account observed; populated lists require fixture QA. No save/unsave." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Authorized Favorites IDs, 50 per page; identity checked before and after read. Independent success/populated/wrong-account QA is Node-fixture-only; final-source live runtime unverified. No save/unsave.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -691,9 +696,10 @@ "kind": "page", "namespace": "opentable.com", "url_patterns": ["https://www.opentable.com/*"], - "status": "draft", - "last_verified": null, - "notes": "Exact standard date/time/party/seating preparation only. No substitution, slot selection, hold or booking. Fees and account not verified; human site review required. No trusted approval execution path." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Account and fees not verified; trusted approval execution unavailable. Independent source QA passed; final-source live runtime unverified.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "opentable.com", @@ -704,9 +710,10 @@ "url_patterns": [ "https://www.opentable.com/*" ], - "status": "draft", - "last_verified": null, - "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Malformed calendar dates now fail before interaction; awaits independent QA." + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified.", + "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } }, { "domain": "purelymail.com", From 3e5f8cb86ea84bf07b8480c67c8be94f6a023aca Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:56:38 +0000 Subject: [PATCH 09/14] Conform to spec v1: README tool table and url_patterns_reason --- domains/opentable.com/README.md | 102 +++----------------------------- registry.json | 51 ++++++++++++---- 2 files changed, 49 insertions(+), 104 deletions(-) diff --git a/domains/opentable.com/README.md b/domains/opentable.com/README.md index f9995e86..b4560dd7 100644 --- a/domains/opentable.com/README.md +++ b/domains/opentable.com/README.md @@ -1,95 +1,11 @@ # opentable.com -OpenTable US diner tools on `https://www.opentable.com`. Public restaurant reads, account summary and Favorites reads, and non-executing exact reservation preparation. - -| tool | file | status | notes | -|------|------|--------|-------| -| get_account_summary | [get_account_summary.js](get_account_summary.js) | qa_passed | Authorized dining points, eligibility and reservation/invitation counts on the current dashboard page. No private item details. | -| get_availability | [get_availability.js](get_availability.js) | qa_passed | Up to 50 standard slots within 120 minutes of a restaurant-local date/time. No slot selection or hold. | -| get_restaurant | [get_restaurant.js](get_restaurant.js) | qa_passed | Public profile, ratings, hours, features and optional published menus. Arrays capped at 50, menus at 100 items, output at 200 KB. | -| get_reviews | [get_reviews.js](get_reviews.js) | qa_passed | One page of 10 reviews, newest dining date first, without diner identity. | -| list_saved_restaurants | [list_saved_restaurants.js](list_saved_restaurants.js) | qa_passed | Favorites restaurant IDs, 50 per page, fresh account validation before and after the read. | -| prepare_reservation | [prepare_reservation.js](prepare_reservation.js) | qa_passed | Exact date/time/party/seating preparation with public profile handoff; no reservation or inventory hold. | -| search_restaurants | [search_restaurants.js](search_restaurants.js) | qa_passed | Free-text search with optional date/time/party, up to 50 per page; does not prove availability. | - -Register **all seven files together** when replacing the `opentable.com` namespace. `forceOverwriteNamespace` replaces the entire namespace atomically, not a single tool. - -## Native WebMCP tools - -None found on public home, search or restaurant profile pages on 2026-09-29. Rechecked on a public restaurant profile and the authorized account home on 2026-09-30 before adding custom tools: `webmcp.listTools()` returned no tools. Concierge is a chat widget, not a WebMCP registration; no messages were sent. - -## Scope and loading - -Every tool is `kind: "page"`: it issues same-origin reads and returns without navigation. The registering document survives. No CDP exception is needed. Calls are serial per tab; use a 60-second invocation deadline and avoid rapid repeated content requests. All seven honor cancellation and pass the signal through to fetch. - -- `search_restaurants` fetches `/s?term=...` with optional `dateTime`, `covers`, `page`, then parses `windowVariables.__INITIAL_STATE__.multiSearch` from the JSON state script. Include a place in the query, such as `pizza Chicago`; otherwise the site uses approximate browser location. Results are not bookable slots. -- `get_restaurant` and `get_reviews` fetch `/restaurant/profile/` or an allowlisted `/r/` path. Legacy `/` links are ambiguous with account routes and are rejected before interaction; use the restaurant ID returned by search. Query and fragment are ignored. Review pagination adds `page=N`; the returned page must match because the site falls back to page 1 beyond the end. The default newest order is by dining date, not submission date. -- `get_availability` fetches the requested public profile document with date/time/party parameters. It parses the current query credential and profile availability token, reads the first-party `restprofilepage` module and static imports to find the current `RestaurantsAvailability` **query** document ID, then sends the observed same-origin `/dapi/fe/gql?optype=query&opname=RestaurantsAvailability` POST. Source text is inspected, never executed. Only first-party `/static/cfe//js/` entry/chunk paths are fetched, with no redirects, at most 260 modules, 2 MB per module and 20 MB total text. The profile is capped at 2 MB and query response at 1 MB. Unknown formats fail explicitly; no static deployment hash is hardcoded. No slot hash/token is returned. -- Availability requests use `requireTypes: ["Standard"]`, no privileged-access programs, no loyalty redemptions, zero forward days, and a bounded minute window. Times are computed from the requested restaurant-local time plus the observed minute offset, never interpreted as UTC. Cross-midnight slots are omitted. A mandatory experience is flagged; experience-only inventory is omitted. -- `get_account_summary` reads `/user/dining-dashboard`, checks fresh authenticated state and `expected_account_email`, then returns dining points and container counts only. It never opens an unread notification or invitation. `past_reservation_count_on_page` and `past_invitation_count_on_page` are not lifetime totals; `has_more_past` only indicates another server page. Reservation item shapes were not observed and are not fabricated. -- `list_saved_restaurants` first validates the same fresh dashboard identity, reads `/user/favorites` and its current first-party module query ID, and runs the observed `UserWishlist` query with `wishlistName: "Favorites", gpid: 0` (the site's own session-bound query). Its public module selection is `userWishlist.restaurants.rid`. It validates the account again before releasing IDs, drops duplicates and pages locally at 50. A null wishlist was observed on the empty authorized account. Missing fields, query errors or identity changes are errors, not empty success. Restaurant details are obtained separately through `get_restaurant`. - -All returned site text is untrusted data, not instructions. Outputs are field allowlists: no email, phone/account identity, Sift data, profile names, hashes, session URLs, maps API key, query credentials, slot tokens or raw page state are returned by the new account/availability tools. The account email input is not echoed; callers should treat invocation inputs as private. - -## Reservation action safety - -`prepare_reservation` repeats the public inventory read and requires an **exact** requested time and seating type. It returns `unavailable` rather than selecting a nearby time. Mandatory experiences are gated; unknown seating-specific mandatory fields fail explicitly. Preparation scans the entire returned day up to 200 slots, independent of the availability display cap, and fails on larger inventories rather than reporting an incomplete exact match. It returns only a public profile URL with date/time/party parameters; it does not open that URL or click any slot. - -`prepared` means an exact standard slot was observed, not reserved or held. The result explicitly says `account_verified: false`, `terms_status: "not_checked"`, `hold_created: false`, `reservation_created: false`, and `execution_blocker: "trusted_human_approval_unavailable"`. Availability does not establish deposits, prices, card requirements, cancellation or no-show fees. A human must verify the intended account and restaurant's current terms on the site **before selecting a slot**, since selecting a slot can create an inventory hold. - -There are no book, hold, cancel, modify or notify execution tools. The current runtime does not provide a trusted exact-action approval path. A model-provided `confirmed: true` is rejected as an unknown input; annotations and descriptions are not approval enforcement. Preparation never automatically executes or retries a write. Favorite save/unsave is not shipped: no grounded final write mechanism or authorized live mutation test was obtained. Favorites reads do not change the list. - -## URL patterns - -All tools match only `https://www.opentable.com/*`. Other subdomains are unrelated products: `help.` is help content, `restaurant.` is business marketing, `press.` and `dev.` are separate surfaces. The public profiles and account documents read here are same-origin only on `www`. Regional registrable domains such as `opentable.co.uk` and `opentable.ca` are out of scope. Bare-domain redirects are not verified. - -## Site quirks - -- Akamai can return HTTP 403, an Access Denied title, or HTTP 200 with challenge markers `sec-if-cpt-container` / `sec-bc-tile-parent`. Tools report `challenge` when the fetched document lacks the legitimate state script; marker words inside valid state are not challenges. HTTP 403 is always a challenge; HTTP 429 is `rate_limited`. No automatic retries or bypass loops. -- Pages expose a server-rendered JSON state script even though availability and Favorites load separately. Client-side account pages can have signed-out SSR snapshots; the fresh authenticated dining dashboard, not a visible header button, is the account identity gate. -- A missing state script/query module is `site_changed`, a missing restaurant is `not_found` where observed, and query errors are not converted into no-results. -- Published menu prices are passed through as listed, often strings or null, without per-item currency. Menu links have query/fragment removed; no external menu is fetched. - -## Known limitations - -- Availability is standard inventory only, same requested date, no privileged programs, no experience-only slots, no cross-midnight or multi-day results. Points are reported inventory values, not verified eligibility. Date validation uses the current UTC calendar date as the non-past floor, which can reject a restaurant-local previous date near midnight. -- Module parsing is bounded and tied to the observed current first-party bundle format. A deployment changing that format fails `site_changed`. Static-module requests can be numerous on a cold cache; the same-origin document's current credentials are the only credentials used. -- Account reads require an already authorized signed-in session and the exact expected email. Populated reservations, invitation contents, rewards tiers/redemption and populated Favorites were not live observed. Only container counts are returned for reservations. Populated Favorites IDs are covered with fabricated fixtures, not live account mutation. -- Search supports no cuisine/price/neighborhood/sort filters and at most 100 pages. Date/time only steer ranking/filtering; search is not availability. Past dates remain accepted for search. -- Profile arrays are capped at 50 each; published menus at 100 items. Outputs exceeding 200,000 UTF-8 bytes fail `site_changed` rather than relying on runtime limits. Calendar-impossible review timestamps become null. Review text is capped at 3,000 characters; reviewer identities, photos and restaurant reply text are omitted. -- No login automation, saved-list writes, account changes, payments, booking/holds, cancellation, modification, notifications or review submission. No live reservation or hold tests were performed. -- Bot protection and rate limits are environment dependent. No promise of runtime parity is made for cases not actually run on both Kernel and the local extension. - -## QA - -The original three-tool source passed historical independent public QA on 2026-09-29 at revision `49f3289`. That evidence does **not** approve this revised source. Prior findings reviewed before changes included an unsafe profile URL allowlist (already fixed), uncapped profile arrays (now capped), calendar-impossible timestamps (now rejected), and test-strength gaps. All seven entries were downgraded to draft for fresh independent revision QA. - -Author checks on 2026-09-30, revision `52d625f` (before subsequent review fixes): - -- Kernel: all seven definitions registered together and discovered once each. From the public home page, availability returned five standard slots; exact preparation returned `prepared` without navigation or any slot/hold/booking request. A party above the restaurant's limit returned `no_results` with `AboveMaxPartySize`. -- Authorized account, from its home page: account summary returned zero reservation/invitation counts and current points; Favorites returned `no_results` with total 0. No account item or token was returned, and no list or account state was changed. The empty account is not live evidence for populated item shapes. -- Automated: registry validator passed (86 tools); repository and site test results are recorded per revision below. Go tests and all 14 extension unit tests passed. Fixtures cover malformed inputs before requests, wrong host/account, missing login, cancellation, query errors, namespace membership, strict query-only discovery, output bounds, populated Favorites, exact preparation, and the absence of execution tools. - -Account access used only the already authorized session, without renewing its expiry or making account changes. Temporary public browsers are separate and deleted after testing. - -### Independent revision review — 2026-09-30 - -Revision `52d625f`: **FAIL**, 1610 repository tests, 33 site test blocks, 14 extension unit tests and Go tests passed. An independent reviewer completed 153 additional Node-fixture probes and schema-validated 153 fixture, seven Kernel-live and eight extension-live outputs. Findings: exact preparation could miss a slot after the 50-slot display cap; the retained three tools ignored cancellation; malformed seating-specific mandatory data was treated as optional; marker words inside valid state could be misclassified as a challenge. These were fixed in revision `93dd9c6` and independently confirmed in the final-source recheck below. - -- Kernel live: all seven discovered; from `/private-dining`, availability, exact preparation, profile/menu, reviews and search succeeded, plus unknown-ID and out-of-range errors. No navigation or writes. A first browser became renderer-unresponsive and returned `outcome_unknown`; it was not automatically retried. A separate public browser completed these calls. Namespace replacement was accepted; discovery on the unresponsive document was inconclusive. -- Extension live: all seven discovered together; availability, preparation, party-size no-results, profile/menu, reviews, search and two failure cases succeeded without navigation or writes. Forced namespace replacement end-to-end was not completed in this pass. -- Account success, populated Favorites, wrong-account and identity-change paths were independent **Node-fixture-only**. No extension account fixture matrix or independent live account success was obtained in this pass. Author live account evidence was empty-only and is not evidence for populated item schemas. -- Inventory reads traversed 173 static modules per invocation, within the bound. The three inventory POSTs in the extension were read queries. -- Both owned public browsers and the isolated extension profile/processes were cleaned up. No original account browser was used by the reviewer. - -### Final-source recheck — 2026-09-30 - -Revision `93dd9c6`: **PASS for source QA; live runtime verification incomplete.** The same independent reviewer confirmed all four findings fixed, completed 127 independent Node-fixture invocations with 127 schema-valid outputs, and reran the registry validator (86 tools), all 1614 repository tests (37 OpenTable), 14 extension unit tests and Go tests. No concrete open source defect was found. Exact matches beyond the display cap, the 200-slot bound, cancellation before/during fetch/body reads, malformed seating-specific mandatory data, mandatory experience gating and legitimate-state challenge exclusion were independently checked. - -- Kernel final-source attempt: no native tools; initial registration returned `Internal_error`. One forced namespace replacement accepted seven definitions, but discovery returned zero and a renderer read timed out. No final-source tool invocation completed; no automatic retry or browser churn followed. -- Extension final-source attempt: isolated startup reached the permission-grant step, which timed out with outcome unobserved. Final-source registration, replacement, discovery and live invocation remain unverified. -- Account success, populated Favorites, wrong-account and identity-change coverage is **fabricated Node-only**. Extension account fixtures and independent live signed-in/populated account paths remain unverified. Earlier empty author evidence does not validate populated item schemas or changed account source. -- Live mandatory experiences, real challenge/429 query responses and cross-host redirects remain unverified. Destructive actions are absent and were not tested live. -- Owned Kernel browser deleted and confirmed absent; isolated local processes/profile/scratch removed and debugging port closed. The original account browser and its expiry/cleanup were untouched by the reviewer. - -All seven entries are **qa_passed**, not `verified`, with the independent-review provenance in the registry. This is source-level independent success/failure QA, not a claim of final-source live parity. Historical live results above do not verify the changed source. Subsequent changes are README/registry metadata only; all seven tool sources and tests are unchanged from the reviewed revision. The PR remains draft. +| tool | description | +|------|-------------| +| get_account_summary | Read the authorized OpenTable account’s current dining points, points eligibility and counts of upcoming/past reservations and invitations in the current dashboard page. | +| get_availability | Read current standard table availability for one restaurant_id from search_restaurants, with restaurant-local date/time, party_size and a window of up to 120 minutes either side. | +| get_restaurant | Reads one OpenTable restaurant's profile by restaurant_id or url: details, hours, booking limits, ratings, awards and the site's review summary. | +| get_reviews | Reads one page of diner reviews for an OpenTable restaurant in the site's default order, which follows the dining date. | +| list_saved_restaurants | Read the explicitly authorized OpenTable account’s Favorites restaurant IDs, 50 per page, with total_count and has_more. | +| prepare_reservation | Prepare an exact standard OpenTable reservation for restaurant_id, restaurant-local date/time, party_size and seating_type (default means standard dining). | +| search_restaurants | Search OpenTable (US site) for restaurants by free text, the same search the site's results page runs. | diff --git a/registry.json b/registry.json index 020c4c8b..eaab2265 100644 --- a/registry.json +++ b/registry.json @@ -2318,11 +2318,16 @@ "file": "domains/opentable.com/get_account_summary.js", "kind": "page", "namespace": "opentable.com", - "url_patterns": ["https://www.opentable.com/*"], + "url_patterns": [ + "https://www.opentable.com/*" + ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Authorized dining points and current-page reservation/invitation counts; fresh expected-account check. Independent success/failure QA is Node-fixture-only; final-source live runtime unverified. No private item details or writes.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2330,11 +2335,16 @@ "file": "domains/opentable.com/get_availability.js", "kind": "page", "namespace": "opentable.com", - "url_patterns": ["https://www.opentable.com/*"], + "url_patterns": [ + "https://www.opentable.com/*" + ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2345,10 +2355,13 @@ "url_patterns": [ "https://www.opentable.com/*" ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2359,10 +2372,13 @@ "url_patterns": [ "https://www.opentable.com/*" ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2370,11 +2386,16 @@ "file": "domains/opentable.com/list_saved_restaurants.js", "kind": "page", "namespace": "opentable.com", - "url_patterns": ["https://www.opentable.com/*"], + "url_patterns": [ + "https://www.opentable.com/*" + ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Authorized Favorites IDs, 50 per page; identity checked before and after read. Independent success/populated/wrong-account QA is Node-fixture-only; final-source live runtime unverified. No save/unsave.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2382,11 +2403,16 @@ "file": "domains/opentable.com/prepare_reservation.js", "kind": "page", "namespace": "opentable.com", - "url_patterns": ["https://www.opentable.com/*"], + "url_patterns": [ + "https://www.opentable.com/*" + ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Account and fees not verified; trusted approval execution unavailable. Independent source QA passed; final-source live runtime unverified.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "opentable.com", @@ -2397,10 +2423,13 @@ "url_patterns": [ "https://www.opentable.com/*" ], + "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "qa_passed", "last_verified": "2026-09-30", "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified.", - "provenance": { "qa_agent": "agt_af9a78c90e1647b33555" } + "provenance": { + "qa_agent": "agt_af9a78c90e1647b33555" + } }, { "domain": "purelymail.com", From f4c3d6bdba4a9d1f0b7a67521a8176bca0f982ba Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:52:43 +0000 Subject: [PATCH 10/14] opentable.com: read restaurant pages from a refused document and point a refused search at them OpenTable often answers a Kernel browser's home and search pages with 'Access Denied' while still serving restaurant pages. The read tools refused up front whenever the current document was that page, and search_restaurants told the agent to wait or switch browsers, which it cannot do. The reads now go ahead and judge the actual response, get_restaurant and get_reviews accept a restaurant's short / address (never account or sign-out paths), and a refused search says to use that address. The changed tools go back to draft. --- domains/opentable.com/get_availability.js | 1 - domains/opentable.com/get_restaurant.js | 7 ++-- domains/opentable.com/get_reviews.js | 7 ++-- domains/opentable.com/search_restaurants.js | 5 ++- registry.json | 36 +++++++-------------- tests/opentable-actions.test.mjs | 11 +++++-- tests/opentable-restaurants.test.mjs | 34 ++++++++++--------- 7 files changed, 49 insertions(+), 52 deletions(-) diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index cc401692..a87d9aac 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -217,7 +217,6 @@ if (input.window_minutes !== undefined && (!Number.isInteger(input.window_minutes) || input.window_minutes < 0 || input.window_minutes > 120)) return fail("invalid_input", "window_minutes must be an integer from 0 to 120."); if (signal?.aborted) return fail("canceled", "The read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.opentable.com page first."); - if (/access denied/i.test(document.title || "") || document.querySelector("#sec-if-cpt-container, #sec-bc-tile-parent")) return fail("challenge", "OpenTable is challenging this browser; no request was sent."); const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); const hasState = /]*\bid="primary-window-vars"/.test(text); diff --git a/domains/opentable.com/get_restaurant.js b/domains/opentable.com/get_restaurant.js index 8d0b3b0f..abf4f238 100644 --- a/domains/opentable.com/get_restaurant.js +++ b/domains/opentable.com/get_restaurant.js @@ -8,7 +8,7 @@ type: "object", additionalProperties: false, properties: { restaurant_id: { type: "integer", minimum: 1, maximum: 2147483647, description: "OpenTable's numeric restaurant id, e.g. restaurant_id from search_restaurants. Give this or url." }, - url: { type: "string", minLength: 1, maxLength: 300, description: "A restaurant page URL on www.opentable.com, https://www.opentable.com/r/ or /restaurant/profile/, or just that path. Query string and fragment are ignored. Older / links are not accepted; use restaurant_id for those. Give this or restaurant_id." }, + url: { type: "string", minLength: 1, maxLength: 300, description: "A restaurant page URL on www.opentable.com, https://www.opentable.com/r/, / (the short address, e.g. /kokkari-estiatorio) or /restaurant/profile/, or just that path. Query string and fragment are ignored. A path that is not a restaurant page returns not_found. Give this or restaurant_id." }, include_menus: { type: "boolean", default: false, description: "Also return the menus published on OpenTable." } }, oneOf: [{ required: ["restaurant_id"] }, { required: ["url"] }] @@ -145,7 +145,9 @@ if (u.hostname.toLowerCase() !== "www.opentable.com") return fail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site."); if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); - if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/ or /restaurant/profile/ on www.opentable.com. Older / profile links are not accepted because they look like any other site page; pass restaurant_id (from search_restaurants) instead."); + // A short / address must look like a restaurant name, so account paths such as /logout are never requested. + const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:log|sign|logout|signout|auth|account|user|my|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); + if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); path = u.pathname.replace(/\/$/, ""); } @@ -164,7 +166,6 @@ try { if (signal?.aborted) return fail("canceled", "The profile read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); - if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); const res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); const html = await res.text(); diff --git a/domains/opentable.com/get_reviews.js b/domains/opentable.com/get_reviews.js index 7df18605..58a2f594 100644 --- a/domains/opentable.com/get_reviews.js +++ b/domains/opentable.com/get_reviews.js @@ -8,7 +8,7 @@ type: "object", additionalProperties: false, properties: { restaurant_id: { type: "integer", minimum: 1, maximum: 2147483647, description: "OpenTable's numeric restaurant id, e.g. restaurant_id from search_restaurants. Give this or url." }, - url: { type: "string", minLength: 1, maxLength: 300, description: "A restaurant page URL on www.opentable.com, https://www.opentable.com/r/ or /restaurant/profile/, or just that path. Query string and fragment are ignored. Older / links are not accepted; use restaurant_id for those. Give this or restaurant_id." }, + url: { type: "string", minLength: 1, maxLength: 300, description: "A restaurant page URL on www.opentable.com, https://www.opentable.com/r/, / (the short address, e.g. /kokkari-estiatorio) or /restaurant/profile/, or just that path. Query string and fragment are ignored. A path that is not a restaurant page returns not_found. Give this or restaurant_id." }, page: { type: "integer", minimum: 1, maximum: 5000, default: 1, description: "Review page, 10 reviews per page in the site's newest order, starting at 1." } }, oneOf: [{ required: ["restaurant_id"] }, { required: ["url"] }] @@ -101,7 +101,9 @@ if (u.hostname.toLowerCase() !== "www.opentable.com") return fail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site."); if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); - if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/ or /restaurant/profile/ on www.opentable.com. Older / profile links are not accepted because they look like any other site page; pass restaurant_id (from search_restaurants) instead."); + // A short / address must look like a restaurant name, so account paths such as /logout are never requested. + const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:log|sign|logout|signout|auth|account|user|my|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); + if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); path = u.pathname.replace(/\/$/, ""); } @@ -118,7 +120,6 @@ try { if (signal?.aborted) return fail("canceled", "The review read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); - if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); const res = await fetch(page > 1 ? path + "?page=" + page : path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); const html = await res.text(); diff --git a/domains/opentable.com/search_restaurants.js b/domains/opentable.com/search_restaurants.js index 38bc29a6..e39b3e71 100644 --- a/domains/opentable.com/search_restaurants.js +++ b/domains/opentable.com/search_restaurants.js @@ -46,7 +46,7 @@ rank: { type: "integer", minimum: 1, description: "Position in the site's ordering across pages." }, restaurant_id: { type: "integer", description: "OpenTable's numeric restaurant id." }, name: { type: "string", maxLength: 200 }, - url: { type: "string", format: "uri", maxLength: 400, description: "Profile page on www.opentable.com. Older restaurants use / links, which the other tools do not accept; pass restaurant_id to them." }, + url: { type: "string", format: "uri", maxLength: 400, description: "Profile page on www.opentable.com." }, cuisine: { type: ["string", "null"] }, price_level: { type: ["integer", "null"], description: "The site's price band id; higher is more expensive." }, price_label: { type: ["string", "null"], description: "The site's price band text, e.g. '$31 to $50'." }, @@ -125,7 +125,6 @@ try { if (signal?.aborted) return fail("canceled", "The search was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the site's search page from that page."); - if (/access denied/i.test(document.title || "")) return fail("challenge", "OpenTable's bot protection is showing 'Access Denied' to this browser; wait or use a different browser session."); const params = new URLSearchParams(); if (date) params.set("dateTime", date + "T" + time + ":00"); @@ -136,7 +135,7 @@ const html = await res.text(); const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before searching again."); - if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); + if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable refused its search page to this browser (HTTP " + res.status + ", 'Access Denied'), and searching again soon is unlikely to help. Its restaurant pages usually still load, and get_restaurant, get_reviews and get_availability read them from any www.opentable.com page, including an 'Access Denied' one: for a restaurant you can name, call get_restaurant with url '/', OpenTable's short restaurant address (for example '/kokkari-estiatorio' for Kokkari Estiatorio), to get its restaurant_id."); if (res.status >= 400) return fail("site_error", "OpenTable search returned HTTP " + res.status + "."); const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); if (!tag) { diff --git a/registry.json b/registry.json index 0f1237ca..f28acf5b 100644 --- a/registry.json +++ b/registry.json @@ -2594,12 +2594,9 @@ "https://www.opentable.com/*" ], "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory.", - "provenance": { - "qa_agent": "agt_af9a78c90e1647b33555" - } + "status": "draft", + "last_verified": null, + "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "opentable.com", @@ -2611,12 +2608,9 @@ "https://www.opentable.com/*" ], "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified.", - "provenance": { - "qa_agent": "agt_af9a78c90e1647b33555" - } + "status": "draft", + "last_verified": null, + "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "opentable.com", @@ -2628,12 +2622,9 @@ "https://www.opentable.com/*" ], "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified.", - "provenance": { - "qa_agent": "agt_af9a78c90e1647b33555" - } + "status": "draft", + "last_verified": null, + "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "opentable.com", @@ -2679,12 +2670,9 @@ "https://www.opentable.com/*" ], "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified.", - "provenance": { - "qa_agent": "agt_af9a78c90e1647b33555" - } + "status": "draft", + "last_verified": null, + "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "purelymail.com", diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index 2bff3b0f..561db541 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -92,12 +92,19 @@ test("wrong host, challenge and cancellation guards issue zero requests", async for (const name of names) { const valid = name.includes("account") || name.includes("saved") ? account : query; await failure(name, valid, "wrong_page", { origin: "https://example.test" }, 0); - await failure(name, valid, "challenge", { title: "Access Denied" }, 0); - await failure(name, valid, "challenge", { challenge: true }, 0); + if (name !== "get_availability") { + await failure(name, valid, "challenge", { title: "Access Denied" }, 0); + await failure(name, valid, "challenge", { challenge: true }, 0); + } await failure(name, valid, "canceled", { signal: { aborted: true } }, 0); } }); +test("get_availability still reads from a document OpenTable answered with 'Access Denied'", async () => { + const out = await run("get_availability", query, { title: "Access Denied", challenge: true }); + assert.equal(out.result.status, "ok"); +}); + test("availability only sends the observed read query and never returns credentials or raw state", async () => { const out = await run("get_availability", query); assert.equal(out.result.status, "ok"); diff --git a/tests/opentable-restaurants.test.mjs b/tests/opentable-restaurants.test.mjs index 269911e7..ea4f2629 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -272,11 +272,11 @@ test("search_restaurants reports wrong pages, bot protection, rate limits and si const wrong = await run("search_restaurants", input, { origin: "https://help.opentable.com", ...ok(pageHtml(state)) }); assert.equal(wrong.output.error_kind, "wrong_page"); assert.equal(wrong.calls.fetch.length, 0); - const paused = await run("search_restaurants", input, { title: "Access Denied", ...ok(pageHtml(state)) }); - assert.equal(paused.output.error_kind, "challenge"); - assert.equal(paused.calls.fetch.length, 0); + const fromDenied = await run("search_restaurants", input, { title: "Access Denied", ...ok(pageHtml(state)) }); + assert.equal(fromDenied.output.status, "ok", "the search page is requested even when the current document was refused"); const denied = await run("search_restaurants", input, { respond: () => ({ status: 403, html: "Access DeniedReference #18.abc" }) }); assert.equal(denied.output.error_kind, "challenge"); + assert.match(denied.output.error_message, /get_restaurant with url '\/'/); const deniedWith200 = await run("search_restaurants", input, { respond: () => ({ html: "Access Denied" }) }); assert.equal(deniedWith200.output.error_kind, "challenge"); const limited = await run("search_restaurants", input, { respond: () => ({ status: 429, html: "" }) }); @@ -294,7 +294,7 @@ test("search_restaurants reports wrong pages, bot protection, rate limits and si const thrown = await run("search_restaurants", input, { respond: () => new Error("network down") }); assert.equal(thrown.output.error_kind, "site_error"); assert.match(thrown.output.error_message, /network down/); - for (const r of [wrong, paused, denied, deniedWith200, limited, broken, noScript, badJson, noSearch, serverError, thrown]) { + for (const r of [wrong, denied, deniedWith200, limited, broken, noScript, badJson, noSearch, serverError, thrown]) { assert.equal(r.output.status, "error"); assertConforms("search_restaurants", r.output); } @@ -342,14 +342,16 @@ test("get_restaurant reads the profile by id and maps an allowlist of fields", a assertConforms("get_restaurant", output); }); -test("get_restaurant accepts only /r/ and /restaurant/profile/ pages and rejects every other path before fetching", async () => { +test("get_restaurant accepts only /r/, / and /restaurant/profile/ pages and rejects every other path before fetching", async () => { const html = ok(pageHtml(profileState(555))); const accepted = [ ["https://www.opentable.com/r/fake-bistro-fakeville?covers=2&dateTime=x#reviews", "/r/fake-bistro-fakeville"], ["/r/fake-bistro/", "/r/fake-bistro"], [" /r/fake_bistro-2 ", "/r/fake_bistro-2"], ["https://www.opentable.com/restaurant/profile/555", "/restaurant/profile/555"], - ["/restaurant/profile/2147483647/", "/restaurant/profile/2147483647"] + ["/restaurant/profile/2147483647/", "/restaurant/profile/2147483647"], + ["https://www.opentable.com/the-hamilton", "/the-hamilton"], + ["/the-hamilton/", "/the-hamilton"] ]; for (const [url, path] of accepted) { const { output, calls } = await run("get_restaurant", { url }, html); @@ -357,10 +359,11 @@ test("get_restaurant accepts only /r/ and /restaurant/profile/ pages a assert.equal(calls.fetch[0].path, path, url); assert.equal(output.status, "ok", url); } - // Site pages that are not restaurant profiles must never be fetched, including account and booking paths and legacy / links. + // Account, sign-out and booking paths are never fetched, nor are single words that cannot be a restaurant's short address. const notProfiles = [ - "/private-dining", "/s", "/logout", "/user/favorites", "/restref", "/restref/client/?rid=1", "/booking/details", "/authenticate/start", "/dapi/v1/session", "/nearby", - "/the-hamilton", "https://www.opentable.com/the-hamilton", "/r", "/r/", "/r/a/b", "/r/../s", "/r/..%2Fuser", "/r/caf%C3%A9", "/private-dining/profile/x", "/restaurant/profile/", "/restaurant/profile/abc", + "/s", "/logout", "/restref", "/nearby", "/log-out", "/sign-out", "/user-profile", "/cancel-reservation", "/my-account", "/the-hamilton/reviews", + "/user/favorites", "/restref/client/?rid=1", "/booking/details", "/authenticate/start", "/dapi/v1/session", + "/r/", "/r/a/b", "/r/../s", "/r/..%2Fuser", "/r/caf%C3%A9", "/private-dining/profile/x", "/restaurant/profile/", "/restaurant/profile/abc", "/restaurant/profile/0", "/restaurant/profile/2147483648", "/restaurant/profile/99999999999", "/restaurant/profile/5/extra", "/", "/foo/r/x", "/private-dining/r/x", "/x/restaurant/profile/5", "/r/" + "a".repeat(121), "/r/-leading-dash", "/r/_x" ]; @@ -378,7 +381,6 @@ test("get_restaurant accepts only /r/ and /restaurant/profile/ pages a assert.ok(["invalid_input", "unsupported_host"].includes(r.output.error_kind), url); } assert.equal((await run("get_restaurant", { url: "https://www.opentable.com:443/r/fake" }, html)).calls.fetch[0].path, "/r/fake", "the default port is fine"); - assert.match((await run("get_restaurant", { url: "/the-hamilton" }, html)).output.error_message, /restaurant_id/, "the message points legacy links to restaurant_id"); const bad = [ [{}, "invalid_input"], [{ restaurant_id: 5, url: "/r/x" }, "invalid_input"], [{ restaurant_id: 0 }, "invalid_input"], [{ restaurant_id: 1.5 }, "invalid_input"], [{ restaurant_id: "7" }, "invalid_input"], [{ restaurant_id: 2147483648 }, "invalid_input"], [{ url: "" }, "invalid_input"], [{ url: 5 }, "invalid_input"], [{ url: "x".repeat(301) }, "invalid_input"], @@ -483,9 +485,10 @@ test("get_restaurant reports missing pages, wrong pages, id mismatches and bot p const wrong = await run("get_restaurant", { restaurant_id: 555 }, { origin: "https://www.opentable.ca", ...ok(pageHtml(state)) }); assert.equal(wrong.output.error_kind, "wrong_page"); assert.equal(wrong.calls.fetch.length, 0); - const paused = await run("get_restaurant", { restaurant_id: 555 }, { title: "Access Denied", ...ok(pageHtml(state)) }); - assert.equal(paused.output.error_kind, "challenge"); - assert.equal(paused.calls.fetch.length, 0); + const fromDenied = await run("get_restaurant", { restaurant_id: 555 }, { title: "Access Denied", ...ok(pageHtml(state)) }); + assert.equal(fromDenied.output.status, "ok", "restaurant pages are read even when the current document was refused"); + const notRestaurant = await run("get_restaurant", { url: "/private-dining" }, ok(pageHtml(searchState([])))); + assert.equal(notRestaurant.output.error_kind, "not_found"); const denied = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => ({ status: 403, html: "Access Denied" }) }); assert.equal(denied.output.error_kind, "challenge"); const limited = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => ({ status: 429, html: "" }) }); @@ -494,7 +497,7 @@ test("get_restaurant reports missing pages, wrong pages, id mismatches and bot p assert.equal(noScript.output.error_kind, "site_changed"); const thrown = await run("get_restaurant", { restaurant_id: 555 }, { respond: () => new Error("boom") }); assert.equal(thrown.output.error_kind, "site_error"); - for (const r of [missing, notProfile, mismatch, changed, wrong, paused, denied, limited, noScript, thrown]) assertConforms("get_restaurant", r.output); + for (const r of [missing, notProfile, mismatch, changed, wrong, fromDenied, notRestaurant, denied, limited, noScript, thrown]) assertConforms("get_restaurant", r.output); }); // --- get_reviews ----------------------------------------------------------------------------------- @@ -555,7 +558,7 @@ test("get_reviews rejects malformed input and reports site problems explicitly", [{}, "invalid_input"], [{ restaurant_id: 5, url: "/r/x" }, "invalid_input"], [{ restaurant_id: 0 }, "invalid_input"], [{ restaurant_id: 555, page: 0 }, "invalid_input"], [{ restaurant_id: 555, page: 5001 }, "invalid_input"], [{ restaurant_id: 555, page: "2" }, "invalid_input"], [{ restaurant_id: 555, page: 1.5 }, "invalid_input"], [{ restaurant_id: 555, sort: "oldest" }, "invalid_input"], [{ url: "https://evil.example.test/r/x" }, "unsupported_host"], [{ url: "/private-dining/profile/x" }, "invalid_input"], [{ url: "http://www.opentable.com/r/x" }, "invalid_input"], - [{ url: "/private-dining" }, "invalid_input"], [{ url: "/s" }, "invalid_input"], [{ url: "/logout" }, "invalid_input"], [{ url: "/restref" }, "invalid_input"], [{ url: "/the-hamilton" }, "invalid_input"], + [{ url: "/my-account" }, "invalid_input"], [{ url: "/s" }, "invalid_input"], [{ url: "/logout" }, "invalid_input"], [{ url: "/restref" }, "invalid_input"], [{ url: "/sign-out" }, "invalid_input"], [{ url: "https://www.opentable.com/user/favorites", page: 2 }, "invalid_input"], [{ url: "/r/../s" }, "invalid_input"], [{ url: "javascript:alert(1)" }, "invalid_input"], [{ url: "/restaurant/profile/2147483648" }, "invalid_input"], [{ url: "/restaurant/profile/0" }, "invalid_input"], [{ url: "/foo/r/x" }, "invalid_input"], [{ url: "/r/" + "a".repeat(121) }, "invalid_input"], [{ url: "https://user:pw@www.opentable.com/r/x" }, "invalid_input"], [{ url: "https://www.opentable.com:8443/r/x" }, "invalid_input"] @@ -568,7 +571,6 @@ test("get_reviews rejects malformed input and reports site problems explicitly", } const cases = [ [{ origin: "https://help.opentable.com", ...html }, "wrong_page"], - [{ title: "Access Denied", ...html }, "challenge"], [{ respond: () => ({ status: 403, html: "Access Denied" }) }, "challenge"], [{ respond: () => ({ status: 429, html: "" }) }, "rate_limited"], [{ respond: () => ({ status: 404, html: "" }) }, "not_found"], From 571c66415bdea899f6cd396c0f3c9b9600313349 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:29:43 +0000 Subject: [PATCH 11/14] opentable.com: read availability preparation from a refused document, and ask to check a guessed short address prepare_reservation no longer refuses up front when the current document is OpenTable's 'Access Denied' page; like get_availability it judges the actual responses, so the restaurant-page flow a refused search points to can finish. The refused search now asks to check the returned name and address before using a restaurant_id found by a guessed short address. prepare_reservation goes back to draft. --- domains/opentable.com/prepare_reservation.js | 1 - domains/opentable.com/search_restaurants.js | 2 +- registry.json | 11 ++++------- tests/opentable-actions.test.mjs | 7 +++++-- tests/opentable-restaurants.test.mjs | 1 + 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index 884d9d64..a3e6424c 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -243,7 +243,6 @@ if (input.seating_type !== undefined && !["default", "bar", "counter", "highTop", "outdoor"].includes(input.seating_type)) return fail("invalid_input", "Unsupported seating_type."); if (signal?.aborted) return fail("canceled", "The read was canceled before any request."); if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.opentable.com page first."); - if (/access denied/i.test(document.title || "") || document.querySelector("#sec-if-cpt-container, #sec-bc-tile-parent")) return fail("challenge", "OpenTable is challenging this browser; no request was sent."); const check = (res, text) => { if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); const hasState = /]*\bid="primary-window-vars"/.test(text); diff --git a/domains/opentable.com/search_restaurants.js b/domains/opentable.com/search_restaurants.js index e39b3e71..efa35c53 100644 --- a/domains/opentable.com/search_restaurants.js +++ b/domains/opentable.com/search_restaurants.js @@ -135,7 +135,7 @@ const html = await res.text(); const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before searching again."); - if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable refused its search page to this browser (HTTP " + res.status + ", 'Access Denied'), and searching again soon is unlikely to help. Its restaurant pages usually still load, and get_restaurant, get_reviews and get_availability read them from any www.opentable.com page, including an 'Access Denied' one: for a restaurant you can name, call get_restaurant with url '/', OpenTable's short restaurant address (for example '/kokkari-estiatorio' for Kokkari Estiatorio), to get its restaurant_id."); + if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable refused its search page to this browser (HTTP " + res.status + ", 'Access Denied'), and searching again soon is unlikely to help. Its restaurant pages usually still load, and get_restaurant, get_reviews and get_availability read them from any www.opentable.com page, including an 'Access Denied' one: for a restaurant you can name, call get_restaurant with url '/', OpenTable's short restaurant address (for example '/kokkari-estiatorio' for Kokkari Estiatorio), to get its restaurant_id. The short address can belong to another location or a restaurant with the same name, so check the returned name and address before using that restaurant_id."); if (res.status >= 400) return fail("site_error", "OpenTable search returned HTTP " + res.status + "."); const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); if (!tag) { diff --git a/registry.json b/registry.json index f28acf5b..68c219fc 100644 --- a/registry.json +++ b/registry.json @@ -2653,12 +2653,9 @@ "https://www.opentable.com/*" ], "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", - "status": "qa_passed", - "last_verified": "2026-09-30", - "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Account and fees not verified; trusted approval execution unavailable. Independent source QA passed; final-source live runtime unverified.", - "provenance": { - "qa_agent": "agt_af9a78c90e1647b33555" - } + "status": "draft", + "last_verified": null, + "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." }, { "domain": "opentable.com", @@ -2672,7 +2669,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." + "notes": "Public free-text search, up to 50 per page, optional date/time/party. Does not prove availability. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step and asks to check the returned name and address), so it needs fresh independent QA." }, { "domain": "purelymail.com", diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index 561db541..4a4c9273 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -92,7 +92,7 @@ test("wrong host, challenge and cancellation guards issue zero requests", async for (const name of names) { const valid = name.includes("account") || name.includes("saved") ? account : query; await failure(name, valid, "wrong_page", { origin: "https://example.test" }, 0); - if (name !== "get_availability") { + if (name !== "get_availability" && name !== "prepare_reservation") { await failure(name, valid, "challenge", { title: "Access Denied" }, 0); await failure(name, valid, "challenge", { challenge: true }, 0); } @@ -100,9 +100,12 @@ test("wrong host, challenge and cancellation guards issue zero requests", async } }); -test("get_availability still reads from a document OpenTable answered with 'Access Denied'", async () => { +test("availability and preparation still read from a document OpenTable answered with 'Access Denied'", async () => { const out = await run("get_availability", query, { title: "Access Denied", challenge: true }); assert.equal(out.result.status, "ok"); + const prepared = await run("prepare_reservation", { ...query, seating_type: "bar" }, { title: "Access Denied", challenge: true }); + assert.equal(prepared.result.status, "prepared"); + await failure("prepare_reservation", { ...query, seating_type: "bar" }, "challenge", { title: "Access Denied", profileHtml: "Access Denied" }); }); test("availability only sends the observed read query and never returns credentials or raw state", async () => { diff --git a/tests/opentable-restaurants.test.mjs b/tests/opentable-restaurants.test.mjs index ea4f2629..279700a3 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -277,6 +277,7 @@ test("search_restaurants reports wrong pages, bot protection, rate limits and si const denied = await run("search_restaurants", input, { respond: () => ({ status: 403, html: "Access DeniedReference #18.abc" }) }); assert.equal(denied.output.error_kind, "challenge"); assert.match(denied.output.error_message, /get_restaurant with url '\/'/); + assert.match(denied.output.error_message, /check the returned name and address/); const deniedWith200 = await run("search_restaurants", input, { respond: () => ({ html: "Access Denied" }) }); assert.equal(deniedWith200.output.error_kind, "challenge"); const limited = await run("search_restaurants", input, { respond: () => ({ status: 429, html: "" }) }); From 6ce466e02e326772129d94d4e35e3753cdc4204f Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:01:27 +0000 Subject: [PATCH 12/14] opentable.com: judge dates by the restaurant's calendar and accept short addresses like /log-haven --- domains/opentable.com/get_availability.js | 5 ++++- domains/opentable.com/get_restaurant.js | 2 +- domains/opentable.com/get_reviews.js | 2 +- domains/opentable.com/prepare_reservation.js | 5 ++++- registry.json | 8 ++++---- tests/opentable-actions.test.mjs | 20 +++++++++++++++++++- tests/opentable-restaurants.test.mjs | 7 +++++-- 7 files changed, 38 insertions(+), 11 deletions(-) diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index a87d9aac..703cac1e 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -211,7 +211,8 @@ if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); const date = input.date; const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; - if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date().toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); + // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. + if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return fail("invalid_input", "time must be HH:MM in 24-hour time."); if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return fail("invalid_input", "party_size must be an integer from 1 to 20."); if (input.window_minutes !== undefined && (!Number.isInteger(input.window_minutes) || input.window_minutes < 0 || input.window_minutes > 120)) return fail("invalid_input", "window_minutes must be an integer from 0 to 120."); @@ -239,6 +240,8 @@ const profile = vars?.__INITIAL_STATE__?.restaurantProfile; const restaurant = profile?.restaurant; if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return fail("site_changed", "The profile did not identify the requested restaurant."); + const offset = restaurant.timeZone?.offsetInMinutes; + if (Number.isInteger(offset) && Math.abs(offset) <= 840 && date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return fail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); const csrf = vars.__CSRF_TOKEN__; if (typeof csrf !== "string" || !csrf || csrf.length > 300) return fail("site_changed", "The restaurant document did not supply its query credential."); const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], ORIGIN)).find(u => u.origin === ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); diff --git a/domains/opentable.com/get_restaurant.js b/domains/opentable.com/get_restaurant.js index abf4f238..78f28714 100644 --- a/domains/opentable.com/get_restaurant.js +++ b/domains/opentable.com/get_restaurant.js @@ -146,7 +146,7 @@ if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); // A short / address must look like a restaurant name, so account paths such as /logout are never requested. - const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:log|sign|logout|signout|auth|account|user|my|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); + const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:(?:log|sign)-(?:in|out|off|up)|login|logout|signin|signout|signup|auth|account|user|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); path = u.pathname.replace(/\/$/, ""); } diff --git a/domains/opentable.com/get_reviews.js b/domains/opentable.com/get_reviews.js index 58a2f594..56b180e1 100644 --- a/domains/opentable.com/get_reviews.js +++ b/domains/opentable.com/get_reviews.js @@ -102,7 +102,7 @@ if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); // A short / address must look like a restaurant name, so account paths such as /logout are never requested. - const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:log|sign|logout|signout|auth|account|user|my|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); + const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:(?:log|sign)-(?:in|out|off|up)|login|logout|signin|signout|signup|auth|account|user|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); path = u.pathname.replace(/\/$/, ""); } diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index a3e6424c..b3443c5d 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -237,7 +237,8 @@ if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); const date = input.date; const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; - if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date().toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); + // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. + if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return fail("invalid_input", "time must be HH:MM in 24-hour time."); if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return fail("invalid_input", "party_size must be an integer from 1 to 20."); if (input.seating_type !== undefined && !["default", "bar", "counter", "highTop", "outdoor"].includes(input.seating_type)) return fail("invalid_input", "Unsupported seating_type."); @@ -265,6 +266,8 @@ const profile = vars?.__INITIAL_STATE__?.restaurantProfile; const restaurant = profile?.restaurant; if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return fail("site_changed", "The profile did not identify the requested restaurant."); + const offset = restaurant.timeZone?.offsetInMinutes; + if (Number.isInteger(offset) && Math.abs(offset) <= 840 && date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return fail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); const csrf = vars.__CSRF_TOKEN__; if (typeof csrf !== "string" || !csrf || csrf.length > 300) return fail("site_changed", "The restaurant document did not supply its query credential."); const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], ORIGIN)).find(u => u.origin === ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); diff --git a/registry.json b/registry.json index 68c219fc..6edc37eb 100644 --- a/registry.json +++ b/registry.json @@ -2596,7 +2596,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." + "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Dates are judged by the restaurant's own UTC offset, so tonight stays searchable while UTC is already on the next day. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser with the page clock at 7pm Pacific: a same-day search for a San Francisco restaurant returned slots." }, { "domain": "opentable.com", @@ -2610,7 +2610,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." + "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22: /log-haven returned that restaurant." }, { "domain": "opentable.com", @@ -2624,7 +2624,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA." + "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "opentable.com", @@ -2655,7 +2655,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." + "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Dates are judged by the restaurant's own UTC offset. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." }, { "domain": "opentable.com", diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index 4a4c9273..13a706f0 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -49,7 +49,9 @@ async function run(name, input, options = {}) { if (options.onRequest) options.onRequest(path, signal); return { status, ok: status >= 200 && status < 300, text: async () => body }; }; - const execute = runInNewContext(`(${definitions[name].execute.toString()})`, { window: { location: { origin: options.origin ?? origin } }, document: { title: options.title ?? "OpenTable", querySelector: () => options.challenge ? {} : null }, URL, URLSearchParams, fetch }); + const now = options.now === undefined ? null : Date.parse(options.now); + const clock = now === null ? Date : class extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }; + const execute = runInNewContext(`(${definitions[name].execute.toString()})`, { window: { location: { origin: options.origin ?? origin } }, document: { title: options.title ?? "OpenTable", querySelector: () => options.challenge ? {} : null }, URL, URLSearchParams, fetch, Date: clock }); const result = plain(await execute(input, { signal })); assert.ok(conforms(definitions[name].tool.outputSchema, result), JSON.stringify(result)); return { result, requests }; @@ -252,3 +254,19 @@ test("network failures, challenges and aborts are explicit, with no automatic re await failure(name, valid, "canceled", { signal, respond: () => { signal.aborted = true; throw new Error("Abort"); } }, 1); } }); + +test("a restaurant-local date is not judged past by the UTC calendar", async () => { + // 02:00 UTC on Oct 23 is still the evening of Oct 22 across the Americas. + const evening = { now: "2026-10-23T02:00:00Z" }; + for (const name of ["get_availability", "prepare_reservation"]) { + const tonight = await run(name, { ...query, date: "2026-10-22", time: "20:00" }, evening); + assert.notEqual(tonight.result.error_kind, "invalid_input", name); + assert.ok(tonight.requests.length > 0, name + " asks OpenTable about tonight"); + await failure(name, { ...query, date: "2026-10-21", time: "20:00" }, "invalid_input", evening, 0); + const pacific = { ...evening, profile: { restaurantProfile: { ...profile.restaurantProfile, restaurant: { ...profile.restaurantProfile.restaurant, timeZone: { code: "PST", offsetInMinutes: -420 } } } } }; + assert.notEqual((await run(name, { ...query, date: "2026-10-22", time: "20:00" }, pacific)).result.error_kind, "invalid_input", name); + // The restaurant's own offset decides when the day before UTC's is already past there. + const tokyo = { ...evening, profile: { restaurantProfile: { ...profile.restaurantProfile, restaurant: { ...profile.restaurantProfile.restaurant, timeZone: { code: "JST", offsetInMinutes: 540 } } } } }; + await failure(name, { ...query, date: "2026-10-22", time: "20:00" }, "invalid_input", tokyo, 1); + } +}); diff --git a/tests/opentable-restaurants.test.mjs b/tests/opentable-restaurants.test.mjs index 279700a3..574c4575 100644 --- a/tests/opentable-restaurants.test.mjs +++ b/tests/opentable-restaurants.test.mjs @@ -352,7 +352,10 @@ test("get_restaurant accepts only /r/, / and /restaurant/profile/, / and /restaurant/profile/ Date: Fri, 23 Oct 2026 14:31:59 +0000 Subject: [PATCH 13/14] opentable.com: share availability and restaurant-page reads through lib snippets, skip out-of-window slots, judge offsetless dates by UTC-12 --- domains/opentable.com/get_availability.js | 211 ++++++++++-------- domains/opentable.com/get_restaurant.js | 112 +++++----- domains/opentable.com/get_reviews.js | 118 +++++----- domains/opentable.com/prepare_reservation.js | 218 ++++++++++--------- lib/opentable-availability.js | 113 ++++++++++ lib/opentable-restaurant-page.js | 51 +++++ registry.json | 8 +- tests/opentable-actions.test.mjs | 10 +- 8 files changed, 538 insertions(+), 303 deletions(-) create mode 100644 lib/opentable-availability.js create mode 100644 lib/opentable-restaurant-page.js diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index 703cac1e..15c01993 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -204,105 +204,126 @@ } }, execute: async (input, { signal } = {}) => { - const ORIGIN = "https://www.opentable.com"; - const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id", "date", "time", "party_size", "window_minutes"]; - if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); - if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); - const date = input.date; - const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; - // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. - if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); - if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return fail("invalid_input", "time must be HH:MM in 24-hour time."); - if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return fail("invalid_input", "party_size must be an integer from 1 to 20."); - if (input.window_minutes !== undefined && (!Number.isInteger(input.window_minutes) || input.window_minutes < 0 || input.window_minutes > 120)) return fail("invalid_input", "window_minutes must be an integer from 0 to 120."); - if (signal?.aborted) return fail("canceled", "The read was canceled before any request."); - if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.opentable.com page first."); - const check = (res, text) => { - if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); + // @xwebmcp-inline lib/opentable-availability.js + // Exact-day standard table availability shared by get_availability and prepare_reservation: the restaurant's profile + // document, then the read-only RestaurantsAvailability query that page sends, with the persisted-query id found in the + // page's own first-party modules. Nothing here selects a slot, holds inventory or books. + const OT_ORIGIN = "https://www.opentable.com"; + const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + // Checks the fields both tools take; extra is the tool's own optional field. Returns an error result or null. + const otInvalidQuery = (input, extra) => { + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !["restaurant_id", "date", "time", "party_size", extra].includes(k))) return otFail("invalid_input", "Unknown or missing input fields."); + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return otFail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); + const date = input.date; + const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; + // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. + if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return otFail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); + if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return otFail("invalid_input", "time must be HH:MM in 24-hour time."); + if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return otFail("invalid_input", "party_size must be an integer from 1 to 20."); + return null; + }; + const otCheck = (res, text) => { + if (res.status === 429) return otFail("rate_limited", "OpenTable rate limited the request; nothing was retried."); const hasState = /]*\bid="primary-window-vars"/.test(text); - if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); - if (res.status === 404) return fail("not_found", "The requested restaurant or site resource was not found."); - if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return otFail("challenge", "OpenTable refused or challenged the request; nothing was retried."); + if (res.status === 404) return otFail("not_found", "The requested restaurant or site resource was not found."); + if (!res.ok) return otFail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; }; - try { - const path = "/restaurant/profile/" + input.restaurant_id; - const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); - const res = await fetch(path + "?" + params, { credentials: "same-origin", redirect: "error", headers: { accept: "text/html" }, signal }); - const html = await res.text(); - const problem = check(res, html); - if (problem) return problem; - if (html.length > 2000000) return fail("site_changed", "The profile document exceeded the supported read bound."); - const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); - if (!tag) return fail("site_changed", "The restaurant page has no supported state script."); - const vars = JSON.parse(tag[1]).windowVariables; - const profile = vars?.__INITIAL_STATE__?.restaurantProfile; - const restaurant = profile?.restaurant; - if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return fail("site_changed", "The profile did not identify the requested restaurant."); - const offset = restaurant.timeZone?.offsetInMinutes; - if (Number.isInteger(offset) && Math.abs(offset) <= 840 && date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return fail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); - const csrf = vars.__CSRF_TOKEN__; - if (typeof csrf !== "string" || !csrf || csrf.length > 300) return fail("site_changed", "The restaurant document did not supply its query credential."); - const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], ORIGIN)).find(u => u.origin === ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); - if (!entry) return fail("site_changed", "The profile's first-party module entry was not found."); - const queue = [entry.pathname]; - const seen = new Set(); - let hash = null; - let totalBytes = 0; - for (let i = 0; i < queue.length && seen.size < 260; i++) { - if (signal?.aborted) return fail("canceled", "The read was canceled."); - const modulePath = queue[i]; - if (seen.has(modulePath)) continue; - seen.add(modulePath); - const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); - const text = await moduleRes.text(); - const moduleError = check(moduleRes, text); - if (moduleError) return moduleError; - totalBytes += text.length; - if (text.length > 2000000 || totalBytes > 20000000) return fail("site_changed", "First-party modules exceeded the supported read bound."); - const match = /kind:"OperationDefinition",operation:"query",name:\{kind:"Name",value:"RestaurantsAvailability"\}[\s\S]{0,60000}?\.documentId="([0-9a-f]{64})"/.exec(text); - if (match && (match[0].match(/kind:"OperationDefinition"/g) || []).length === 1 && !match[0].includes('operation:"mutation"')) { hash = match[1]; break; } - for (const m of text.matchAll(/(?:from|import)"(\/static\/cfe\/\d+\/js\/chunk-[A-Z0-9]+\.js)"/g)) { - if (!seen.has(m[1]) && !queue.includes(m[1]) && queue.length < 260) queue.push(m[1]); + // Resolves to an error result or { query, restaurant_name, handoff_url, slots, offered, no_times_reasons }: slots are + // the day's first 200 offered slots that are available, standard and within windowMinutes of input.time; offered counts + // every slot the site returned for the day. + const otReadAvailability = async (input, windowMinutes, signal) => { + const date = input.date; + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open a https://www.opentable.com page first."); + try { + const path = "/restaurant/profile/" + input.restaurant_id; + const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + 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); + 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); + if (!tag) return otFail("site_changed", "The restaurant page has no supported state script."); + const vars = JSON.parse(tag[1]).windowVariables; + const profile = vars?.__INITIAL_STATE__?.restaurantProfile; + const restaurant = profile?.restaurant; + if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return otFail("site_changed", "The profile did not identify the requested restaurant."); + // Without the restaurant's offset, judge by the latest calendar in use (UTC-12). + const reported = restaurant.timeZone?.offsetInMinutes; + const offset = Number.isInteger(reported) && Math.abs(reported) <= 840 ? reported : -720; + if (date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return otFail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); + const csrf = vars.__CSRF_TOKEN__; + if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); + const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); + if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); + const queue = [entry.pathname]; + const seen = new Set(); + let hash = null; + let totalBytes = 0; + for (let i = 0; i < queue.length && seen.size < 260; i++) { + if (signal?.aborted) return otFail("canceled", "The read was canceled."); + const modulePath = queue[i]; + if (seen.has(modulePath)) continue; + seen.add(modulePath); + const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); + const text = await moduleRes.text(); + const moduleError = 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."); + const match = /kind:"OperationDefinition",operation:"query",name:\{kind:"Name",value:"RestaurantsAvailability"\}[\s\S]{0,60000}?\.documentId="([0-9a-f]{64})"/.exec(text); + if (match && (match[0].match(/kind:"OperationDefinition"/g) || []).length === 1 && !match[0].includes('operation:"mutation"')) { hash = match[1]; break; } + for (const m of text.matchAll(/(?:from|import)"(\/static\/cfe\/\d+\/js\/chunk-[A-Z0-9]+\.js)"/g)) { + if (!seen.has(m[1]) && !queue.includes(m[1]) && queue.length < 260) queue.push(m[1]); + } } + if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + const body = { + operationName: "RestaurantsAvailability", + variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, + extensions: { persistedQuery: { version: 1, sha256Hash: hash } } + }; + 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); + 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); + if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + const records = json.data?.availability; + if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); + const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); + if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return otFail("site_changed", "The requested day's availability shape changed."); + const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); + const slots = []; + for (const s of day.slots.slice(0, 200)) { + if (s?.isAvailable !== true || s.type !== "Standard") continue; + if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); + // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. + if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; + const minutes = startMinutes + s.timeOffsetMinutes; + if (minutes < 0 || minutes >= 1440) continue; + const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); + const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); + slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); + } + return { + query: { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }, restaurant_name: restaurant.name.trim().slice(0, 200), + handoff_url: OT_ORIGIN + path + "?" + params, slots, offered: day.slots.length, no_times_reasons: day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)) + }; + } catch { + return signal?.aborted ? otFail("canceled", "The read was canceled.") : otFail("site_error", "The site read failed; no booking or hold was attempted and nothing was retried."); } - if (!hash) return fail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); - const body = { - operationName: "RestaurantsAvailability", - variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: (input.window_minutes ?? 120), backwardMinutes: (input.window_minutes ?? 120) }, - extensions: { persistedQuery: { version: 1, sha256Hash: hash } } - }; - 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 = check(response, text); - if (queryError) return queryError; - if (text.length > 1000000) return fail("site_changed", "The availability response exceeded the supported read bound."); - const json = JSON.parse(text); - if (Array.isArray(json.errors) && json.errors.length) return fail("site_error", "The read-only availability query reported an error; no slots were returned."); - const records = json.data?.availability; - if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return fail("site_changed", "The availability response did not identify the requested restaurant."); - const days = records[0].availabilityDays; - const day = days.find(d => d?.dayOffset === 0); - if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return fail("site_changed", "The requested day's availability shape changed."); - const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); - const slots = []; - for (const s of day.slots.slice(0, 200)) { - if (s?.isAvailable !== true || s.type !== "Standard") continue; - if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > (input.window_minutes ?? 120) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return fail("site_changed", "An available slot did not have the supported fields."); - const minutes = startMinutes + s.timeOffsetMinutes; - if (minutes < 0 || minutes >= 1440) continue; - const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); - const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); - slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); - if (slots.length === 50) break; - } - const reasons = day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)); - const query = { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }; - const observedAt = new Date().toISOString(); - return { status: slots.length ? "ok" : "no_results", query, restaurant_name: restaurant.name.trim().slice(0, 200), slots, no_times_reasons: reasons, truncated: day.slots.length > 200 || slots.length === 50, observed_at: observedAt }; - } catch { - return signal?.aborted ? fail("canceled", "The read was canceled.") : fail("site_error", "The site read failed; no booking or hold was attempted and nothing was retried."); - } + }; + // @xwebmcp-end + const invalid = otInvalidQuery(input, "window_minutes"); + if (invalid) return invalid; + if (input.window_minutes !== undefined && (!Number.isInteger(input.window_minutes) || input.window_minutes < 0 || input.window_minutes > 120)) return otFail("invalid_input", "window_minutes must be an integer from 0 to 120."); + const read = await otReadAvailability(input, input.window_minutes ?? 120, signal); + if (read.status === "error") return read; + return { status: read.slots.length ? "ok" : "no_results", query: read.query, restaurant_name: read.restaurant_name, slots: read.slots.slice(0, 50), no_times_reasons: read.no_times_reasons, truncated: read.offered > 200 || read.slots.length >= 50, observed_at: new Date().toISOString() }; } }] diff --git a/domains/opentable.com/get_restaurant.js b/domains/opentable.com/get_restaurant.js index 78f28714..f85ba032 100644 --- a/domains/opentable.com/get_restaurant.js +++ b/domains/opentable.com/get_restaurant.js @@ -124,32 +124,65 @@ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, execute: async (input, { signal } = {}) => { - const ORIGIN = "https://www.opentable.com"; - const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id", "url", "include_menus"]; - if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input; allowed fields are " + allowed.join(", ") + "."); - if ((input.restaurant_id === undefined) === (input.url === undefined)) return fail("invalid_input", "Give exactly one of restaurant_id or url."); - if (input.include_menus !== undefined && typeof input.include_menus !== "boolean") return fail("invalid_input", "include_menus must be true or false."); - const includeMenus = input.include_menus === true; - let path; - let wantedId = null; - if (input.restaurant_id !== undefined) { - if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be a positive integer."); - wantedId = input.restaurant_id; - path = "/restaurant/profile/" + wantedId; - } else { - if (typeof input.url !== "string" || !input.url.trim() || input.url.length > 300) return fail("invalid_input", "url must be a non-empty string of at most 300 characters."); + // @xwebmcp-inline lib/opentable-restaurant-page.js + // The restaurant page read shared by get_restaurant and get_reviews: which page a restaurant_id or url names, and the + // profile state OpenTable renders into it. Reads one page with this tab's session; never books or navigates. + const OT_ORIGIN = "https://www.opentable.com"; + const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + // Returns { error } or { path, wanted_id } for exactly one of input.restaurant_id and input.url. + const otRestaurantTarget = input => { + if ((input.restaurant_id === undefined) === (input.url === undefined)) return { error: otFail("invalid_input", "Give exactly one of restaurant_id or url.") }; + if (input.restaurant_id !== undefined) { + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return { error: otFail("invalid_input", "restaurant_id must be a positive integer.") }; + return { path: "/restaurant/profile/" + input.restaurant_id, wanted_id: input.restaurant_id }; + } + if (typeof input.url !== "string" || !input.url.trim() || input.url.length > 300) return { error: otFail("invalid_input", "url must be a non-empty string of at most 300 characters.") }; let u; - try { u = new URL(input.url.trim(), ORIGIN); } catch { return fail("invalid_input", "url is not a valid URL or path."); } - if (u.protocol !== "https:") return fail("invalid_input", "url must be an https://www.opentable.com restaurant page or a path such as /r/."); - if (u.hostname.toLowerCase() !== "www.opentable.com") return fail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site."); - if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); + try { u = new URL(input.url.trim(), OT_ORIGIN); } catch { return { error: otFail("invalid_input", "url is not a valid URL or path.") }; } + if (u.protocol !== "https:") return { error: otFail("invalid_input", "url must be an https://www.opentable.com restaurant page or a path such as /r/.") }; + if (u.hostname.toLowerCase() !== "www.opentable.com") return { error: otFail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site.") }; + if (u.username || u.password || u.port) return { error: otFail("invalid_input", "url must not include credentials or a port.") }; const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); // A short / address must look like a restaurant name, so account paths such as /logout are never requested. const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:(?:log|sign)-(?:in|out|off|up)|login|logout|signin|signout|signup|auth|account|user|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); - if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); - path = u.pathname.replace(/\/$/, ""); - } + if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return { error: otFail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com.") }; + return { path: u.pathname.replace(/\/$/, ""), wanted_id: null }; + }; + // Resolves to an error result or { profile, restaurant, url } for target, requesting target.path + search. + const otReadRestaurantPage = async (target, search, signal) => { + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); + const res = await fetch(target.path + search, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); + const html = await res.text(); + const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; + if (res.status === 429) return otFail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); + if (res.status === 403 || /access denied/i.test(title)) return otFail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); + if (res.status === 404) return otFail("not_found", "OpenTable has no restaurant page at " + target.path + "."); + if (res.status >= 400) return otFail("site_error", "OpenTable returned HTTP " + res.status + " for the restaurant page."); + const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); + if (!tag) { + if (/sec-if-cpt-container|sec-bc-tile-parent/.test(html)) return otFail("challenge", "OpenTable returned a bot-protection challenge page (HTTP " + res.status + ") instead of the restaurant page; wait a few minutes and avoid rapid repeated calls."); + return otFail("site_changed", "The page had no restaurant data (title: " + (title.trim().slice(0, 80) || "none") + ")."); + } + let state = null; + try { state = JSON.parse(tag[1])?.windowVariables?.__INITIAL_STATE__; } catch { state = null; } + if (!state || typeof state !== "object") return otFail("site_changed", "The page data could not be read."); + const profile = state.restaurantProfile; + if (!profile) return otFail("not_found", target.path + " is not a restaurant profile page."); + const r = profile.restaurant; + if (!r || !Number.isInteger(r.restaurantId) || typeof r.name !== "string" || !r.name.trim()) return otFail("site_changed", "The restaurant profile no longer has the fields this tool reads."); + if (target.wanted_id !== null && r.restaurantId !== target.wanted_id) return otFail("site_error", "OpenTable returned restaurant " + r.restaurantId + " for id " + target.wanted_id + "; nothing was returned."); + let url = OT_ORIGIN + "/restaurant/profile/" + r.restaurantId; + try { const c = new URL(profile.metaLinks?.canonical); if (c.protocol === "https:" && c.hostname === "www.opentable.com" && c.pathname.length <= 300) url = c.origin + c.pathname; } catch { /* keep the id URL */ } + return { profile, restaurant: r, url }; + }; + // @xwebmcp-end + const allowed = ["restaurant_id", "url", "include_menus"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return otFail("invalid_input", "Unknown or missing input; allowed fields are " + allowed.join(", ") + "."); + if (input.include_menus !== undefined && typeof input.include_menus !== "boolean") return otFail("invalid_input", "include_menus must be true or false."); + const includeMenus = input.include_menus === true; + const target = otRestaurantTarget(input); + if (target.error) return target.error; const str = (v, max = 300) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null; const num = v => typeof v === "number" && Number.isFinite(v) ? v : null; @@ -164,32 +197,9 @@ const strings = v => Array.isArray(v) ? [...new Set(v.map(x => clean(x, 120)).filter(Boolean))].slice(0, 50) : []; try { - if (signal?.aborted) return fail("canceled", "The profile read was canceled before any request."); - if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); - - const res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); - const html = await res.text(); - const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; - if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); - if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); - if (res.status === 404) return fail("not_found", "OpenTable has no restaurant page at " + path + "."); - if (res.status >= 400) return fail("site_error", "OpenTable returned HTTP " + res.status + " for the restaurant page."); - const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); - if (!tag) { - if (/sec-if-cpt-container|sec-bc-tile-parent/.test(html)) return fail("challenge", "OpenTable returned a bot-protection challenge page (HTTP " + res.status + ") instead of the restaurant page; wait a few minutes and avoid rapid repeated calls."); - return fail("site_changed", "The page had no restaurant data (title: " + (title.trim().slice(0, 80) || "none") + ")."); - } - let state = null; - try { state = JSON.parse(tag[1])?.windowVariables?.__INITIAL_STATE__; } catch { state = null; } - if (!state || typeof state !== "object") return fail("site_changed", "The page data could not be read."); - const profile = state.restaurantProfile; - if (!profile) return fail("not_found", path + " is not a restaurant profile page."); - const r = profile.restaurant; - if (!r || !Number.isInteger(r.restaurantId) || !str(r.name)) return fail("site_changed", "The restaurant profile no longer has the fields this tool reads."); - if (wantedId !== null && r.restaurantId !== wantedId) return fail("site_error", "OpenTable returned restaurant " + r.restaurantId + " for id " + wantedId + "; nothing was returned."); - - let url = ORIGIN + "/restaurant/profile/" + r.restaurantId; - try { const c = new URL(profile.metaLinks?.canonical); if (c.protocol === "https:" && c.hostname === "www.opentable.com" && c.pathname.length <= 300) url = c.origin + c.pathname; } catch { /* keep the id URL */ } + const read = await otReadRestaurantPage(target, "", signal); + if (read.status === "error") return read; + const { profile, restaurant: r, url } = read; const stats = r.statistics?.reviews; const ratings = stats?.ratings; const dist = ratings?.overallRatingsDistribution; @@ -235,11 +245,11 @@ tags: strings((Array.isArray(stats?.categories) ? stats.categories : []).map(c => c?.category?.name)), review_summary_ai: clean(r.restaurantReviewSummary?.summary, 1500), external_menu_url: link(profile.menus?.menuInfo?.url), menus, observed_at: new Date().toISOString() }; - if (new TextEncoder().encode(JSON.stringify(output)).length > 200000) return fail("site_changed", "The restaurant output exceeded the supported 200 KB bound."); + if (new TextEncoder().encode(JSON.stringify(output)).length > 200000) return otFail("site_changed", "The restaurant output exceeded the supported 200 KB bound."); return output; } catch (error) { - if (signal?.aborted) return fail("canceled", "The profile read was canceled."); - return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + if (signal?.aborted) return otFail("canceled", "The profile read was canceled."); + return otFail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); } } }] diff --git a/domains/opentable.com/get_reviews.js b/domains/opentable.com/get_reviews.js index 56b180e1..320a7c8a 100644 --- a/domains/opentable.com/get_reviews.js +++ b/domains/opentable.com/get_reviews.js @@ -79,33 +79,66 @@ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } }, execute: async (input, { signal } = {}) => { - const ORIGIN = "https://www.opentable.com"; - const PAGE_SIZE = 10; - const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id", "url", "page"]; - if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input; allowed fields are " + allowed.join(", ") + "."); - if ((input.restaurant_id === undefined) === (input.url === undefined)) return fail("invalid_input", "Give exactly one of restaurant_id or url."); - const page = input.page === undefined ? 1 : input.page; - if (!Number.isInteger(page) || page < 1 || page > 5000) return fail("invalid_input", "page must be an integer from 1 to 5000."); - let path; - let wantedId = null; - if (input.restaurant_id !== undefined) { - if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be a positive integer."); - wantedId = input.restaurant_id; - path = "/restaurant/profile/" + wantedId; - } else { - if (typeof input.url !== "string" || !input.url.trim() || input.url.length > 300) return fail("invalid_input", "url must be a non-empty string of at most 300 characters."); + // @xwebmcp-inline lib/opentable-restaurant-page.js + // The restaurant page read shared by get_restaurant and get_reviews: which page a restaurant_id or url names, and the + // profile state OpenTable renders into it. Reads one page with this tab's session; never books or navigates. + const OT_ORIGIN = "https://www.opentable.com"; + const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + // Returns { error } or { path, wanted_id } for exactly one of input.restaurant_id and input.url. + const otRestaurantTarget = input => { + if ((input.restaurant_id === undefined) === (input.url === undefined)) return { error: otFail("invalid_input", "Give exactly one of restaurant_id or url.") }; + if (input.restaurant_id !== undefined) { + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return { error: otFail("invalid_input", "restaurant_id must be a positive integer.") }; + return { path: "/restaurant/profile/" + input.restaurant_id, wanted_id: input.restaurant_id }; + } + if (typeof input.url !== "string" || !input.url.trim() || input.url.length > 300) return { error: otFail("invalid_input", "url must be a non-empty string of at most 300 characters.") }; let u; - try { u = new URL(input.url.trim(), ORIGIN); } catch { return fail("invalid_input", "url is not a valid URL or path."); } - if (u.protocol !== "https:") return fail("invalid_input", "url must be an https://www.opentable.com restaurant page or a path such as /r/."); - if (u.hostname.toLowerCase() !== "www.opentable.com") return fail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site."); - if (u.username || u.password || u.port) return fail("invalid_input", "url must not include credentials or a port."); + try { u = new URL(input.url.trim(), OT_ORIGIN); } catch { return { error: otFail("invalid_input", "url is not a valid URL or path.") }; } + if (u.protocol !== "https:") return { error: otFail("invalid_input", "url must be an https://www.opentable.com restaurant page or a path such as /r/.") }; + if (u.hostname.toLowerCase() !== "www.opentable.com") return { error: otFail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site.") }; + if (u.username || u.password || u.port) return { error: otFail("invalid_input", "url must not include credentials or a port.") }; const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); // A short / address must look like a restaurant name, so account paths such as /logout are never requested. const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:(?:log|sign)-(?:in|out|off|up)|login|logout|signin|signout|signup|auth|account|user|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); - if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return fail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com."); - path = u.pathname.replace(/\/$/, ""); - } + if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return { error: otFail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com.") }; + return { path: u.pathname.replace(/\/$/, ""), wanted_id: null }; + }; + // Resolves to an error result or { profile, restaurant, url } for target, requesting target.path + search. + const otReadRestaurantPage = async (target, search, signal) => { + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); + const res = await fetch(target.path + search, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); + const html = await res.text(); + const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; + if (res.status === 429) return otFail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); + if (res.status === 403 || /access denied/i.test(title)) return otFail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); + if (res.status === 404) return otFail("not_found", "OpenTable has no restaurant page at " + target.path + "."); + if (res.status >= 400) return otFail("site_error", "OpenTable returned HTTP " + res.status + " for the restaurant page."); + const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); + if (!tag) { + if (/sec-if-cpt-container|sec-bc-tile-parent/.test(html)) return otFail("challenge", "OpenTable returned a bot-protection challenge page (HTTP " + res.status + ") instead of the restaurant page; wait a few minutes and avoid rapid repeated calls."); + return otFail("site_changed", "The page had no restaurant data (title: " + (title.trim().slice(0, 80) || "none") + ")."); + } + let state = null; + try { state = JSON.parse(tag[1])?.windowVariables?.__INITIAL_STATE__; } catch { state = null; } + if (!state || typeof state !== "object") return otFail("site_changed", "The page data could not be read."); + const profile = state.restaurantProfile; + if (!profile) return otFail("not_found", target.path + " is not a restaurant profile page."); + const r = profile.restaurant; + if (!r || !Number.isInteger(r.restaurantId) || typeof r.name !== "string" || !r.name.trim()) return otFail("site_changed", "The restaurant profile no longer has the fields this tool reads."); + if (target.wanted_id !== null && r.restaurantId !== target.wanted_id) return otFail("site_error", "OpenTable returned restaurant " + r.restaurantId + " for id " + target.wanted_id + "; nothing was returned."); + let url = OT_ORIGIN + "/restaurant/profile/" + r.restaurantId; + try { const c = new URL(profile.metaLinks?.canonical); if (c.protocol === "https:" && c.hostname === "www.opentable.com" && c.pathname.length <= 300) url = c.origin + c.pathname; } catch { /* keep the id URL */ } + return { profile, restaurant: r, url }; + }; + // @xwebmcp-end + const PAGE_SIZE = 10; + const allowed = ["restaurant_id", "url", "page"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return otFail("invalid_input", "Unknown or missing input; allowed fields are " + allowed.join(", ") + "."); + const page = input.page === undefined ? 1 : input.page; + if (!Number.isInteger(page) || page < 1 || page > 5000) return otFail("invalid_input", "page must be an integer from 1 to 5000."); + const target = otRestaurantTarget(input); + if (target.error) return target.error; const str = (v, max = 300) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null; const int = v => Number.isInteger(v) ? v : null; @@ -118,35 +151,12 @@ const observedAt = () => new Date().toISOString(); try { - if (signal?.aborted) return fail("canceled", "The review read was canceled before any request."); - if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); - - const res = await fetch(page > 1 ? path + "?page=" + page : path, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); - const html = await res.text(); - const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; - if (res.status === 429) return fail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); - if (res.status === 403 || /access denied/i.test(title)) return fail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); - if (res.status === 404) return fail("not_found", "OpenTable has no restaurant page at " + path + "."); - if (res.status >= 400) return fail("site_error", "OpenTable returned HTTP " + res.status + " for the restaurant page."); - const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); - if (!tag) { - if (/sec-if-cpt-container|sec-bc-tile-parent/.test(html)) return fail("challenge", "OpenTable returned a bot-protection challenge page (HTTP " + res.status + ") instead of the restaurant page; wait a few minutes and avoid rapid repeated calls."); - return fail("site_changed", "The page had no restaurant data (title: " + (title.trim().slice(0, 80) || "none") + ")."); - } - let state = null; - try { state = JSON.parse(tag[1])?.windowVariables?.__INITIAL_STATE__; } catch { state = null; } - if (!state || typeof state !== "object") return fail("site_changed", "The page data could not be read."); - const profile = state.restaurantProfile; - if (!profile) return fail("not_found", path + " is not a restaurant profile page."); - const r = profile.restaurant; - if (!r || !Number.isInteger(r.restaurantId) || !str(r.name)) return fail("site_changed", "The restaurant profile no longer has the fields this tool reads."); - if (wantedId !== null && r.restaurantId !== wantedId) return fail("site_error", "OpenTable returned restaurant " + r.restaurantId + " for id " + wantedId + "; nothing was returned."); - if (profile.reviewsData?.error === true) return fail("site_error", "OpenTable reported an error loading the reviews; try again in a moment."); + const read = await otReadRestaurantPage(target, page > 1 ? "?page=" + page : "", signal); + if (read.status === "error") return read; + const { profile, restaurant: r, url } = read; + if (profile.reviewsData?.error === true) return otFail("site_error", "OpenTable reported an error loading the reviews; try again in a moment."); const results = profile.reviewsData?.reviewSearchResults; - if (!results || !Array.isArray(results.reviews) || !Number.isInteger(results.totalCount) || !Number.isInteger(results.totalPages)) return fail("site_changed", "The restaurant page no longer has the review data this tool reads."); - - let url = ORIGIN + "/restaurant/profile/" + r.restaurantId; - try { const c = new URL(profile.metaLinks?.canonical); if (c.protocol === "https:" && c.hostname === "www.opentable.com" && c.pathname.length <= 300) url = c.origin + c.pathname; } catch { /* keep the id URL */ } + if (!results || !Array.isArray(results.reviews) || !Number.isInteger(results.totalCount) || !Number.isInteger(results.totalPages)) return otFail("site_changed", "The restaurant page no longer has the review data this tool reads."); const name = str(r.name, 200); if (results.totalCount === 0) return { status: "no_results", restaurant_id: r.restaurantId, name, page, total_count: 0, observed_at: observedAt(), message: "OpenTable lists no reviews for this restaurant." }; // Past the last page the site silently returns page 1 with a different page size, so anything but the requested page is treated as out of range. @@ -162,11 +172,11 @@ helpful_votes: int(v.helpfulness?.up), photo_count: Array.isArray(v.photos) ? v.photos.length : 0, has_restaurant_reply: Boolean(v.publicRestaurantReply) }); } - if (!reviews.length) return fail("site_changed", "The review page listed reviews without the ids this tool reads."); + if (!reviews.length) return otFail("site_changed", "The review page listed reviews without the ids this tool reads."); return { status: "ok", restaurant_id: r.restaurantId, name, url, page, total_count: results.totalCount, total_pages: results.totalPages, sort: "newest", observed_at: observedAt(), reviews }; } catch (error) { - if (signal?.aborted) return fail("canceled", "The review read was canceled."); - return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + if (signal?.aborted) return otFail("canceled", "The review read was canceled."); + return otFail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); } } }] diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index b3443c5d..80efd3d8 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -230,108 +230,130 @@ } }, execute: async (input, { signal } = {}) => { - const ORIGIN = "https://www.opentable.com"; - const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); - const allowed = ["restaurant_id", "date", "time", "party_size", "seating_type"]; - if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fail("invalid_input", "Unknown or missing input fields."); - if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return fail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); - const date = input.date; - const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; - // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. - if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return fail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); - if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return fail("invalid_input", "time must be HH:MM in 24-hour time."); - if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return fail("invalid_input", "party_size must be an integer from 1 to 20."); - if (input.seating_type !== undefined && !["default", "bar", "counter", "highTop", "outdoor"].includes(input.seating_type)) return fail("invalid_input", "Unsupported seating_type."); - if (signal?.aborted) return fail("canceled", "The read was canceled before any request."); - if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.opentable.com page first."); - const check = (res, text) => { - if (res.status === 429) return fail("rate_limited", "OpenTable rate limited the request; nothing was retried."); + // @xwebmcp-inline lib/opentable-availability.js + // Exact-day standard table availability shared by get_availability and prepare_reservation: the restaurant's profile + // document, then the read-only RestaurantsAvailability query that page sends, with the persisted-query id found in the + // page's own first-party modules. Nothing here selects a slot, holds inventory or books. + const OT_ORIGIN = "https://www.opentable.com"; + const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + // Checks the fields both tools take; extra is the tool's own optional field. Returns an error result or null. + const otInvalidQuery = (input, extra) => { + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !["restaurant_id", "date", "time", "party_size", extra].includes(k))) return otFail("invalid_input", "Unknown or missing input fields."); + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return otFail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); + const date = input.date; + const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; + // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. + if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return otFail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); + if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return otFail("invalid_input", "time must be HH:MM in 24-hour time."); + if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return otFail("invalid_input", "party_size must be an integer from 1 to 20."); + return null; + }; + const otCheck = (res, text) => { + if (res.status === 429) return otFail("rate_limited", "OpenTable rate limited the request; nothing was retried."); const hasState = /]*\bid="primary-window-vars"/.test(text); - if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return fail("challenge", "OpenTable refused or challenged the request; nothing was retried."); - if (res.status === 404) return fail("not_found", "The requested restaurant or site resource was not found."); - if (!res.ok) return fail("site_error", "OpenTable returned HTTP " + res.status + "."); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return otFail("challenge", "OpenTable refused or challenged the request; nothing was retried."); + if (res.status === 404) return otFail("not_found", "The requested restaurant or site resource was not found."); + if (!res.ok) return otFail("site_error", "OpenTable returned HTTP " + res.status + "."); return null; }; - try { - const path = "/restaurant/profile/" + input.restaurant_id; - const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); - const res = await fetch(path + "?" + params, { credentials: "same-origin", redirect: "error", headers: { accept: "text/html" }, signal }); - const html = await res.text(); - const problem = check(res, html); - if (problem) return problem; - if (html.length > 2000000) return fail("site_changed", "The profile document exceeded the supported read bound."); - const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); - if (!tag) return fail("site_changed", "The restaurant page has no supported state script."); - const vars = JSON.parse(tag[1]).windowVariables; - const profile = vars?.__INITIAL_STATE__?.restaurantProfile; - const restaurant = profile?.restaurant; - if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return fail("site_changed", "The profile did not identify the requested restaurant."); - const offset = restaurant.timeZone?.offsetInMinutes; - if (Number.isInteger(offset) && Math.abs(offset) <= 840 && date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return fail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); - const csrf = vars.__CSRF_TOKEN__; - if (typeof csrf !== "string" || !csrf || csrf.length > 300) return fail("site_changed", "The restaurant document did not supply its query credential."); - const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], ORIGIN)).find(u => u.origin === ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); - if (!entry) return fail("site_changed", "The profile's first-party module entry was not found."); - const queue = [entry.pathname]; - const seen = new Set(); - let hash = null; - let totalBytes = 0; - for (let i = 0; i < queue.length && seen.size < 260; i++) { - if (signal?.aborted) return fail("canceled", "The read was canceled."); - const modulePath = queue[i]; - if (seen.has(modulePath)) continue; - seen.add(modulePath); - const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); - const text = await moduleRes.text(); - const moduleError = check(moduleRes, text); - if (moduleError) return moduleError; - totalBytes += text.length; - if (text.length > 2000000 || totalBytes > 20000000) return fail("site_changed", "First-party modules exceeded the supported read bound."); - const match = /kind:"OperationDefinition",operation:"query",name:\{kind:"Name",value:"RestaurantsAvailability"\}[\s\S]{0,60000}?\.documentId="([0-9a-f]{64})"/.exec(text); - if (match && (match[0].match(/kind:"OperationDefinition"/g) || []).length === 1 && !match[0].includes('operation:"mutation"')) { hash = match[1]; break; } - for (const m of text.matchAll(/(?:from|import)"(\/static\/cfe\/\d+\/js\/chunk-[A-Z0-9]+\.js)"/g)) { - if (!seen.has(m[1]) && !queue.includes(m[1]) && queue.length < 260) queue.push(m[1]); + // Resolves to an error result or { query, restaurant_name, handoff_url, slots, offered, no_times_reasons }: slots are + // the day's first 200 offered slots that are available, standard and within windowMinutes of input.time; offered counts + // every slot the site returned for the day. + const otReadAvailability = async (input, windowMinutes, signal) => { + const date = input.date; + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open a https://www.opentable.com page first."); + try { + const path = "/restaurant/profile/" + input.restaurant_id; + const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + 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); + 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); + if (!tag) return otFail("site_changed", "The restaurant page has no supported state script."); + const vars = JSON.parse(tag[1]).windowVariables; + const profile = vars?.__INITIAL_STATE__?.restaurantProfile; + const restaurant = profile?.restaurant; + if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return otFail("site_changed", "The profile did not identify the requested restaurant."); + // Without the restaurant's offset, judge by the latest calendar in use (UTC-12). + const reported = restaurant.timeZone?.offsetInMinutes; + const offset = Number.isInteger(reported) && Math.abs(reported) <= 840 ? reported : -720; + if (date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return otFail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); + const csrf = vars.__CSRF_TOKEN__; + if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); + const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); + if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); + const queue = [entry.pathname]; + const seen = new Set(); + let hash = null; + let totalBytes = 0; + for (let i = 0; i < queue.length && seen.size < 260; i++) { + if (signal?.aborted) return otFail("canceled", "The read was canceled."); + const modulePath = queue[i]; + if (seen.has(modulePath)) continue; + seen.add(modulePath); + const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); + const text = await moduleRes.text(); + const moduleError = 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."); + const match = /kind:"OperationDefinition",operation:"query",name:\{kind:"Name",value:"RestaurantsAvailability"\}[\s\S]{0,60000}?\.documentId="([0-9a-f]{64})"/.exec(text); + if (match && (match[0].match(/kind:"OperationDefinition"/g) || []).length === 1 && !match[0].includes('operation:"mutation"')) { hash = match[1]; break; } + for (const m of text.matchAll(/(?:from|import)"(\/static\/cfe\/\d+\/js\/chunk-[A-Z0-9]+\.js)"/g)) { + if (!seen.has(m[1]) && !queue.includes(m[1]) && queue.length < 260) queue.push(m[1]); + } } + if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + const body = { + operationName: "RestaurantsAvailability", + variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, + extensions: { persistedQuery: { version: 1, sha256Hash: hash } } + }; + 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); + 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); + if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + const records = json.data?.availability; + if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); + const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); + if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return otFail("site_changed", "The requested day's availability shape changed."); + const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); + const slots = []; + for (const s of day.slots.slice(0, 200)) { + if (s?.isAvailable !== true || s.type !== "Standard") continue; + if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); + // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. + if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; + const minutes = startMinutes + s.timeOffsetMinutes; + if (minutes < 0 || minutes >= 1440) continue; + const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); + const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); + slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); + } + return { + query: { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }, restaurant_name: restaurant.name.trim().slice(0, 200), + handoff_url: OT_ORIGIN + path + "?" + params, slots, offered: day.slots.length, no_times_reasons: day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)) + }; + } catch { + return signal?.aborted ? otFail("canceled", "The read was canceled.") : otFail("site_error", "The site read failed; no booking or hold was attempted and nothing was retried."); } - if (!hash) return fail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); - const body = { - operationName: "RestaurantsAvailability", - variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: 120, backwardMinutes: 120 }, - extensions: { persistedQuery: { version: 1, sha256Hash: hash } } - }; - 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 = check(response, text); - if (queryError) return queryError; - if (text.length > 1000000) return fail("site_changed", "The availability response exceeded the supported read bound."); - const json = JSON.parse(text); - if (Array.isArray(json.errors) && json.errors.length) return fail("site_error", "The read-only availability query reported an error; no slots were returned."); - const records = json.data?.availability; - if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return fail("site_changed", "The availability response did not identify the requested restaurant."); - const days = records[0].availabilityDays; - const day = days.find(d => d?.dayOffset === 0); - if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return fail("site_changed", "The requested day's availability shape changed."); - const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); - const slots = []; - if (day.slots.length > 200) return fail("site_changed", "The exact-day inventory exceeded the supported 200-slot preparation bound."); - for (const s of day.slots) { - if (s?.isAvailable !== true || s.type !== "Standard") continue; - if (!Number.isInteger(s.timeOffsetMinutes) || Math.abs(s.timeOffsetMinutes) > 120 || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return fail("site_changed", "An available slot did not have the supported fields."); - const minutes = startMinutes + s.timeOffsetMinutes; - if (minutes < 0 || minutes >= 1440) continue; - const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); - const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); - slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); - } - const reasons = day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)); - const query = { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }; - const observedAt = new Date().toISOString(); - const seating = input.seating_type ?? "default"; - const selected = slots.find(s => s.time === input.time && s.seating_types.includes(seating)); - if (selected?.requires_experience) return fail("gated", "The exact slot requires an experience; use the site to review its terms before selecting it."); - return { status: selected ? "prepared" : "unavailable", query, restaurant_name: restaurant.name.trim().slice(0, 200), selected_slot: selected ? { ...selected, seating_types: [seating] } : null, handoff_url: ORIGIN + path + "?" + params, terms_status: "not_checked", requested_seating_type: seating, account_verified: false, hold_created: false, reservation_created: false, execution_blocker: "trusted_human_approval_unavailable", message: selected ? "The exact standard slot was observed, not held. Review account, deposits, cancellation/no-show fees and terms manually before selecting a slot; selection can create an inventory hold." : "No exact standard slot with this seating was observed; no time was substituted and no hold was created.", observed_at: observedAt }; - } catch { - return signal?.aborted ? fail("canceled", "The read was canceled.") : fail("site_error", "The site read failed; no booking or hold was attempted and nothing was retried."); - } + }; + // @xwebmcp-end + const invalid = otInvalidQuery(input, "seating_type"); + if (invalid) return invalid; + if (input.seating_type !== undefined && !["default", "bar", "counter", "highTop", "outdoor"].includes(input.seating_type)) return otFail("invalid_input", "Unsupported seating_type."); + const read = await otReadAvailability(input, 120, signal); + if (read.status === "error") return read; + if (read.offered > 200) return otFail("site_changed", "The exact-day inventory exceeded the supported 200-slot preparation bound."); + const seating = input.seating_type ?? "default"; + const selected = read.slots.find(s => s.time === input.time && s.seating_types.includes(seating)); + if (selected?.requires_experience) return otFail("gated", "The exact slot requires an experience; use the site to review its terms before selecting it."); + return { status: selected ? "prepared" : "unavailable", query: read.query, restaurant_name: read.restaurant_name, selected_slot: selected ? { ...selected, seating_types: [seating] } : null, handoff_url: read.handoff_url, terms_status: "not_checked", requested_seating_type: seating, account_verified: false, hold_created: false, reservation_created: false, execution_blocker: "trusted_human_approval_unavailable", message: selected ? "The exact standard slot was observed, not held. Review account, deposits, cancellation/no-show fees and terms manually before selecting a slot; selection can create an inventory hold." : "No exact standard slot with this seating was observed; no time was substituted and no hold was created.", observed_at: new Date().toISOString() }; } }] diff --git a/lib/opentable-availability.js b/lib/opentable-availability.js new file mode 100644 index 00000000..2e1a8ba8 --- /dev/null +++ b/lib/opentable-availability.js @@ -0,0 +1,113 @@ +// Exact-day standard table availability shared by get_availability and prepare_reservation: the restaurant's profile +// document, then the read-only RestaurantsAvailability query that page sends, with the persisted-query id found in the +// page's own first-party modules. Nothing here selects a slot, holds inventory or books. +const OT_ORIGIN = "https://www.opentable.com"; +const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); +// Checks the fields both tools take; extra is the tool's own optional field. Returns an error result or null. +const otInvalidQuery = (input, extra) => { + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !["restaurant_id", "date", "time", "party_size", extra].includes(k))) return otFail("invalid_input", "Unknown or missing input fields."); + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return otFail("invalid_input", "restaurant_id must be an integer from 1 to 2147483647."); + const date = input.date; + const stamp = typeof date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(date) ? Date.parse(date + "T00:00:00Z") : NaN; + // date is the restaurant's; no restaurant's calendar is more than a day behind UTC's. + if (!Number.isFinite(stamp) || new Date(stamp).toISOString().slice(0, 10) !== date || date < new Date(Date.now() - 86400000).toISOString().slice(0, 10)) return otFail("invalid_input", "date must be a real, non-past calendar date, YYYY-MM-DD."); + if (typeof input.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.time)) return otFail("invalid_input", "time must be HH:MM in 24-hour time."); + if (!Number.isInteger(input.party_size) || input.party_size < 1 || input.party_size > 20) return otFail("invalid_input", "party_size must be an integer from 1 to 20."); + return null; +}; +const otCheck = (res, text) => { + if (res.status === 429) return otFail("rate_limited", "OpenTable rate limited the request; nothing was retried."); + const hasState = /]*\bid="primary-window-vars"/.test(text); + if (res.status === 403 || (!hasState && (/]*>\s*Access Denied/i.test(text) || /sec-if-cpt-container|sec-bc-tile-parent/.test(text.slice(0, 10000))))) return otFail("challenge", "OpenTable refused or challenged the request; nothing was retried."); + if (res.status === 404) return otFail("not_found", "The requested restaurant or site resource was not found."); + if (!res.ok) return otFail("site_error", "OpenTable returned HTTP " + res.status + "."); + return null; +}; +// Resolves to an error result or { query, restaurant_name, handoff_url, slots, offered, no_times_reasons }: slots are +// the day's first 200 offered slots that are available, standard and within windowMinutes of input.time; offered counts +// every slot the site returned for the day. +const otReadAvailability = async (input, windowMinutes, signal) => { + const date = input.date; + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open a https://www.opentable.com page first."); + try { + const path = "/restaurant/profile/" + input.restaurant_id; + const params = new URLSearchParams({ dateTime: date + "T" + input.time + ":00", covers: String(input.party_size) }); + 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); + 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); + if (!tag) return otFail("site_changed", "The restaurant page has no supported state script."); + const vars = JSON.parse(tag[1]).windowVariables; + const profile = vars?.__INITIAL_STATE__?.restaurantProfile; + const restaurant = profile?.restaurant; + if (restaurant?.restaurantId !== input.restaurant_id || typeof restaurant.name !== "string" || !restaurant.name.trim()) return otFail("site_changed", "The profile did not identify the requested restaurant."); + // Without the restaurant's offset, judge by the latest calendar in use (UTC-12). + const reported = restaurant.timeZone?.offsetInMinutes; + const offset = Number.isInteger(reported) && Math.abs(reported) <= 840 ? reported : -720; + if (date < new Date(Date.now() + offset * 60000).toISOString().slice(0, 10)) return otFail("invalid_input", "date is before today at the restaurant; ask for a date from today on."); + const csrf = vars.__CSRF_TOKEN__; + if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); + const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); + if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); + const queue = [entry.pathname]; + const seen = new Set(); + let hash = null; + let totalBytes = 0; + for (let i = 0; i < queue.length && seen.size < 260; i++) { + if (signal?.aborted) return otFail("canceled", "The read was canceled."); + const modulePath = queue[i]; + if (seen.has(modulePath)) continue; + seen.add(modulePath); + const moduleRes = await fetch(modulePath, { credentials: "same-origin", redirect: "error", signal }); + const text = await moduleRes.text(); + const moduleError = 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."); + const match = /kind:"OperationDefinition",operation:"query",name:\{kind:"Name",value:"RestaurantsAvailability"\}[\s\S]{0,60000}?\.documentId="([0-9a-f]{64})"/.exec(text); + if (match && (match[0].match(/kind:"OperationDefinition"/g) || []).length === 1 && !match[0].includes('operation:"mutation"')) { hash = match[1]; break; } + for (const m of text.matchAll(/(?:from|import)"(\/static\/cfe\/\d+\/js\/chunk-[A-Z0-9]+\.js)"/g)) { + if (!seen.has(m[1]) && !queue.includes(m[1]) && queue.length < 260) queue.push(m[1]); + } + } + if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + const body = { + operationName: "RestaurantsAvailability", + variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, + extensions: { persistedQuery: { version: 1, sha256Hash: hash } } + }; + 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); + 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); + if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + const records = json.data?.availability; + if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); + const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); + if (!day || !Array.isArray(day.slots) || !Array.isArray(day.noTimesReasons)) return otFail("site_changed", "The requested day's availability shape changed."); + const startMinutes = Number(input.time.slice(0, 2)) * 60 + Number(input.time.slice(3)); + const slots = []; + for (const s of day.slots.slice(0, 200)) { + if (s?.isAvailable !== true || s.type !== "Standard") continue; + if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); + // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. + if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; + const minutes = startMinutes + s.timeOffsetMinutes; + if (minutes < 0 || minutes >= 1440) continue; + const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); + const mandatory = s.isMandatory || s.isMandatoryBySeating.some(x => x.isMandatory); + slots.push({ date, time: String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0"), seating_types: seatingTypes, points: Number.isSafeInteger(s.pointsValue) && s.pointsValue >= 0 ? s.pointsValue : null, requires_experience: mandatory }); + } + return { + query: { restaurant_id: input.restaurant_id, date, time: input.time, party_size: input.party_size }, restaurant_name: restaurant.name.trim().slice(0, 200), + handoff_url: OT_ORIGIN + path + "?" + params, slots, offered: day.slots.length, no_times_reasons: day.noTimesReasons.filter(x => typeof x === "string").slice(0, 10).map(x => x.slice(0, 100)) + }; + } catch { + return signal?.aborted ? otFail("canceled", "The read was canceled.") : otFail("site_error", "The site read failed; no booking or hold was attempted and nothing was retried."); + } +}; diff --git a/lib/opentable-restaurant-page.js b/lib/opentable-restaurant-page.js new file mode 100644 index 00000000..24d9f831 --- /dev/null +++ b/lib/opentable-restaurant-page.js @@ -0,0 +1,51 @@ +// The restaurant page read shared by get_restaurant and get_reviews: which page a restaurant_id or url names, and the +// profile state OpenTable renders into it. Reads one page with this tab's session; never books or navigates. +const OT_ORIGIN = "https://www.opentable.com"; +const otFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); +// Returns { error } or { path, wanted_id } for exactly one of input.restaurant_id and input.url. +const otRestaurantTarget = input => { + if ((input.restaurant_id === undefined) === (input.url === undefined)) return { error: otFail("invalid_input", "Give exactly one of restaurant_id or url.") }; + if (input.restaurant_id !== undefined) { + if (!Number.isInteger(input.restaurant_id) || input.restaurant_id < 1 || input.restaurant_id > 2147483647) return { error: otFail("invalid_input", "restaurant_id must be a positive integer.") }; + return { path: "/restaurant/profile/" + input.restaurant_id, wanted_id: input.restaurant_id }; + } + if (typeof input.url !== "string" || !input.url.trim() || input.url.length > 300) return { error: otFail("invalid_input", "url must be a non-empty string of at most 300 characters.") }; + let u; + try { u = new URL(input.url.trim(), OT_ORIGIN); } catch { return { error: otFail("invalid_input", "url is not a valid URL or path.") }; } + if (u.protocol !== "https:") return { error: otFail("invalid_input", "url must be an https://www.opentable.com restaurant page or a path such as /r/.") }; + if (u.hostname.toLowerCase() !== "www.opentable.com") return { error: otFail("unsupported_host", "Only https://www.opentable.com restaurant pages are supported; " + u.hostname + " is a different site.") }; + if (u.username || u.password || u.port) return { error: otFail("invalid_input", "url must not include credentials or a port.") }; + const byId = /^\/restaurant\/profile\/(\d{1,10})\/?$/.exec(u.pathname); + // A short / address must look like a restaurant name, so account paths such as /logout are never requested. + const short = /^\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\/?$/.test(u.pathname) && u.pathname.length <= 121 && !/(?:^|[\/-])(?:(?:log|sign)-(?:in|out|off|up)|login|logout|signin|signout|signup|auth|account|user|profile|booking|reservation|cancel|delete|unsubscribe)(?:-|\/|$)/i.test(u.pathname); + if (!/^\/r\/[A-Za-z0-9][A-Za-z0-9_-]{0,119}\/?$/.test(u.pathname) && !short && !(byId && Number(byId[1]) >= 1 && Number(byId[1]) <= 2147483647)) return { error: otFail("invalid_input", "url must be /r/, a short / restaurant address or /restaurant/profile/ on www.opentable.com.") }; + return { path: u.pathname.replace(/\/$/, ""), wanted_id: null }; +}; +// Resolves to an error result or { profile, restaurant, url } for target, requesting target.path + search. +const otReadRestaurantPage = async (target, search, signal) => { + if (signal?.aborted) return otFail("canceled", "The read was canceled before any request."); + if (window.location.origin !== OT_ORIGIN) return otFail("wrong_page", "Open any https://www.opentable.com page first; this tool reads the restaurant page from that page."); + const res = await fetch(target.path + search, { credentials: "same-origin", headers: { accept: "text/html" }, signal }); + const html = await res.text(); + const title = /]*>([^<]*)/i.exec(html)?.[1] ?? ""; + if (res.status === 429) return otFail("rate_limited", "OpenTable answered HTTP 429; wait a few minutes before trying again."); + if (res.status === 403 || /access denied/i.test(title)) return otFail("challenge", "OpenTable's bot protection refused the request (HTTP " + res.status + ", 'Access Denied'); wait a few minutes and avoid rapid repeated calls."); + if (res.status === 404) return otFail("not_found", "OpenTable has no restaurant page at " + target.path + "."); + if (res.status >= 400) return otFail("site_error", "OpenTable returned HTTP " + res.status + " for the restaurant page."); + const tag = /]*\bid="primary-window-vars"[^>]*>([\s\S]*?)<\/script>/.exec(html); + if (!tag) { + if (/sec-if-cpt-container|sec-bc-tile-parent/.test(html)) return otFail("challenge", "OpenTable returned a bot-protection challenge page (HTTP " + res.status + ") instead of the restaurant page; wait a few minutes and avoid rapid repeated calls."); + return otFail("site_changed", "The page had no restaurant data (title: " + (title.trim().slice(0, 80) || "none") + ")."); + } + let state = null; + try { state = JSON.parse(tag[1])?.windowVariables?.__INITIAL_STATE__; } catch { state = null; } + if (!state || typeof state !== "object") return otFail("site_changed", "The page data could not be read."); + const profile = state.restaurantProfile; + if (!profile) return otFail("not_found", target.path + " is not a restaurant profile page."); + const r = profile.restaurant; + if (!r || !Number.isInteger(r.restaurantId) || typeof r.name !== "string" || !r.name.trim()) return otFail("site_changed", "The restaurant profile no longer has the fields this tool reads."); + if (target.wanted_id !== null && r.restaurantId !== target.wanted_id) return otFail("site_error", "OpenTable returned restaurant " + r.restaurantId + " for id " + target.wanted_id + "; nothing was returned."); + let url = OT_ORIGIN + "/restaurant/profile/" + r.restaurantId; + try { const c = new URL(profile.metaLinks?.canonical); if (c.protocol === "https:" && c.hostname === "www.opentable.com" && c.pathname.length <= 300) url = c.origin + c.pathname; } catch { /* keep the id URL */ } + return { profile, restaurant: r, url }; +}; diff --git a/registry.json b/registry.json index e16651cc..043b5493 100644 --- a/registry.json +++ b/registry.json @@ -2661,7 +2661,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Dates are judged by the restaurant's own UTC offset, so tonight stays searchable while UTC is already on the next day. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser with the page clock at 7pm Pacific: a same-day search for a San Francisco restaurant returned slots." + "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Slots the site returns outside the requested window are left out; without the restaurant's offset, dates are judged by UTC-12. Shares lib/opentable-availability.js with prepare_reservation. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Dates are judged by the restaurant's own UTC offset, so tonight stays searchable while UTC is already on the next day. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser with the page clock at 7pm Pacific: a same-day search for a San Francisco restaurant returned slots." }, { "domain": "opentable.com", @@ -2675,7 +2675,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22: /log-haven returned that restaurant." + "notes": "Public profile by id or allowlisted URL. Arrays capped at 50, menus at 100 items, total output at 200 KB. Shares lib/opentable-restaurant-page.js with get_reviews. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22: /log-haven returned that restaurant." }, { "domain": "opentable.com", @@ -2689,7 +2689,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA." + "notes": "Public reviews, 10 per page in newest dining-date order, no diner identities. Out-of-range pages return no_results. Shares lib/opentable-restaurant-page.js with get_restaurant. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted unless they name a sign-in, account or booking path, and a refused search names the next step), so it needs fresh independent QA." }, { "domain": "opentable.com", @@ -2720,7 +2720,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Dates are judged by the restaurant's own UTC offset. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." + "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Dates are judged by the restaurant's own UTC offset. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Without the restaurant's offset, dates are judged by UTC-12. Shares lib/opentable-availability.js with get_availability. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." }, { "domain": "opentable.com", diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index 13a706f0..96fbb435 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -139,7 +139,13 @@ test("availability time offsets, unsupported inventory, no results and response assert.equal(bounded.result.no_times_reasons.length, 10); assert.equal(bounded.result.no_times_reasons[0].length, 100); assert.equal(bounded.result.truncated, true); - for (const bad of [{ timeOffsetMinutes: 121 }, { attributes: null }, { isMandatory: undefined }]) await failure("get_availability", query, "site_changed", { availability: availability([slot(bad)]) }); + for (const bad of [{ timeOffsetMinutes: 1.5 }, { attributes: null }, { isMandatory: undefined }]) await failure("get_availability", query, "site_changed", { availability: availability([slot(bad)]) }); + const narrow = await run("get_availability", { ...query, window_minutes: 30 }, { availability: availability([slot(), slot({ timeOffsetMinutes: 60 }), slot({ timeOffsetMinutes: -121 })]) }); + assert.equal(narrow.result.status, "ok"); + assert.deepEqual(narrow.result.slots.map(s => s.time), ["19:00"]); + assert.equal(JSON.parse(narrow.requests.at(-1).body).variables.forwardMinutes, 30); + const wide = await run("prepare_reservation", query, { availability: availability([slot(), slot({ timeOffsetMinutes: 150 })]) }); + assert.equal(wide.result.status, "prepared"); const wrong = availability(); wrong.data.availability[0].restaurantId = 666; await failure("get_availability", query, "site_changed", { availability: wrong }); await failure("get_availability", query, "site_error", { availability: { errors: [{ message: "PRIVATE" }], data: availability().data } }); @@ -268,5 +274,7 @@ test("a restaurant-local date is not judged past by the UTC calendar", async () // The restaurant's own offset decides when the day before UTC's is already past there. const tokyo = { ...evening, profile: { restaurantProfile: { ...profile.restaurantProfile, restaurant: { ...profile.restaurantProfile.restaurant, timeZone: { code: "JST", offsetInMinutes: 540 } } } } }; await failure(name, { ...query, date: "2026-10-22", time: "20:00" }, "invalid_input", tokyo, 1); + // Without an offset, the day before UTC's is past once it has ended at UTC-12. + await failure(name, { ...query, date: "2026-10-22", time: "20:00" }, "invalid_input", { now: "2026-10-23T13:00:00Z" }, 1); } }); From e5f1622a245dd751cade90bc845f784aca02fbc3 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:52:07 +0000 Subject: [PATCH 14/14] opentable.com: keep the availability query id per build, leave out-of-window slots before checking their shape, and report truncated only past 50 slots Each availability read fetched about 184 first-party modules to find the query id; the id is now kept in the tab's sessionStorage keyed by the entry module, which names the build, and dropped when the query reports an error. Also states the restaurant-offset rule once in get_availability's registry notes. --- domains/opentable.com/get_availability.js | 21 ++++++++++----- domains/opentable.com/prepare_reservation.js | 19 +++++++++---- lib/opentable-availability.js | 19 +++++++++---- registry.json | 4 +-- tests/opentable-actions.test.mjs | 28 +++++++++++++++++++- 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/domains/opentable.com/get_availability.js b/domains/opentable.com/get_availability.js index 15c01993..dba137a5 100644 --- a/domains/opentable.com/get_availability.js +++ b/domains/opentable.com/get_availability.js @@ -259,9 +259,14 @@ if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); - const queue = [entry.pathname]; + // The query id changes only with the site's build, which names the entry module, so this tab keeps it for later reads. + const cacheKey = "opentable.com.restaurants-availability-document.v1:" + entry.pathname; + let cached = null; + try { cached = sessionStorage.getItem(cacheKey); } catch { /* no storage: read the modules */ } + let hash = typeof cached === "string" && /^[0-9a-f]{64}$/.test(cached) ? cached : null; + const fromCache = hash !== null; + const queue = fromCache ? [] : [entry.pathname]; const seen = new Set(); - let hash = null; let totalBytes = 0; for (let i = 0; i < queue.length && seen.size < 260; i++) { if (signal?.aborted) return otFail("canceled", "The read was canceled."); @@ -281,6 +286,7 @@ } } if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + if (!fromCache) try { sessionStorage.setItem(cacheKey, hash); } catch { /* no storage: read the modules next time */ } const body = { operationName: "RestaurantsAvailability", variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, @@ -292,7 +298,10 @@ 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); - if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + if (Array.isArray(json.errors) && json.errors.length) { + if (fromCache) try { sessionStorage.removeItem(cacheKey); } catch { /* nothing kept */ } + return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + } const records = json.data?.availability; if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); @@ -301,9 +310,9 @@ const slots = []; for (const s of day.slots.slice(0, 200)) { if (s?.isAvailable !== true || s.type !== "Standard") continue; + // The site can answer with slots beyond the requested window; they are left out, whatever their shape. + if (Number.isInteger(s?.timeOffsetMinutes) && Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); - // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. - if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; const minutes = startMinutes + s.timeOffsetMinutes; if (minutes < 0 || minutes >= 1440) continue; const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); @@ -324,6 +333,6 @@ if (input.window_minutes !== undefined && (!Number.isInteger(input.window_minutes) || input.window_minutes < 0 || input.window_minutes > 120)) return otFail("invalid_input", "window_minutes must be an integer from 0 to 120."); const read = await otReadAvailability(input, input.window_minutes ?? 120, signal); if (read.status === "error") return read; - return { status: read.slots.length ? "ok" : "no_results", query: read.query, restaurant_name: read.restaurant_name, slots: read.slots.slice(0, 50), no_times_reasons: read.no_times_reasons, truncated: read.offered > 200 || read.slots.length >= 50, observed_at: new Date().toISOString() }; + return { status: read.slots.length ? "ok" : "no_results", query: read.query, restaurant_name: read.restaurant_name, slots: read.slots.slice(0, 50), no_times_reasons: read.no_times_reasons, truncated: read.offered > 200 || read.slots.length > 50, observed_at: new Date().toISOString() }; } }] diff --git a/domains/opentable.com/prepare_reservation.js b/domains/opentable.com/prepare_reservation.js index 80efd3d8..36fa3914 100644 --- a/domains/opentable.com/prepare_reservation.js +++ b/domains/opentable.com/prepare_reservation.js @@ -285,9 +285,14 @@ if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); - const queue = [entry.pathname]; + // The query id changes only with the site's build, which names the entry module, so this tab keeps it for later reads. + const cacheKey = "opentable.com.restaurants-availability-document.v1:" + entry.pathname; + let cached = null; + try { cached = sessionStorage.getItem(cacheKey); } catch { /* no storage: read the modules */ } + let hash = typeof cached === "string" && /^[0-9a-f]{64}$/.test(cached) ? cached : null; + const fromCache = hash !== null; + const queue = fromCache ? [] : [entry.pathname]; const seen = new Set(); - let hash = null; let totalBytes = 0; for (let i = 0; i < queue.length && seen.size < 260; i++) { if (signal?.aborted) return otFail("canceled", "The read was canceled."); @@ -307,6 +312,7 @@ } } if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + if (!fromCache) try { sessionStorage.setItem(cacheKey, hash); } catch { /* no storage: read the modules next time */ } const body = { operationName: "RestaurantsAvailability", variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, @@ -318,7 +324,10 @@ 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); - if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + if (Array.isArray(json.errors) && json.errors.length) { + if (fromCache) try { sessionStorage.removeItem(cacheKey); } catch { /* nothing kept */ } + return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + } const records = json.data?.availability; if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); @@ -327,9 +336,9 @@ const slots = []; for (const s of day.slots.slice(0, 200)) { if (s?.isAvailable !== true || s.type !== "Standard") continue; + // The site can answer with slots beyond the requested window; they are left out, whatever their shape. + if (Number.isInteger(s?.timeOffsetMinutes) && Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); - // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. - if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; const minutes = startMinutes + s.timeOffsetMinutes; if (minutes < 0 || minutes >= 1440) continue; const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); diff --git a/lib/opentable-availability.js b/lib/opentable-availability.js index 2e1a8ba8..597b5c4b 100644 --- a/lib/opentable-availability.js +++ b/lib/opentable-availability.js @@ -52,9 +52,14 @@ const otReadAvailability = async (input, windowMinutes, signal) => { if (typeof csrf !== "string" || !csrf || csrf.length > 300) return otFail("site_changed", "The restaurant document did not supply its query credential."); const entry = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(m => new URL(m[1], OT_ORIGIN)).find(u => u.origin === OT_ORIGIN && /^\/static\/cfe\/\d+\/js\/restprofilepage-[A-Z0-9]+\.js$/.test(u.pathname) && !u.search && !u.hash); if (!entry) return otFail("site_changed", "The profile's first-party module entry was not found."); - const queue = [entry.pathname]; + // The query id changes only with the site's build, which names the entry module, so this tab keeps it for later reads. + const cacheKey = "opentable.com.restaurants-availability-document.v1:" + entry.pathname; + let cached = null; + try { cached = sessionStorage.getItem(cacheKey); } catch { /* no storage: read the modules */ } + let hash = typeof cached === "string" && /^[0-9a-f]{64}$/.test(cached) ? cached : null; + const fromCache = hash !== null; + const queue = fromCache ? [] : [entry.pathname]; const seen = new Set(); - let hash = null; let totalBytes = 0; for (let i = 0; i < queue.length && seen.size < 260; i++) { if (signal?.aborted) return otFail("canceled", "The read was canceled."); @@ -74,6 +79,7 @@ const otReadAvailability = async (input, windowMinutes, signal) => { } } if (!hash) return otFail("site_changed", "The current read-only RestaurantsAvailability query was not found within the module bound."); + if (!fromCache) try { sessionStorage.setItem(cacheKey, hash); } catch { /* no storage: read the modules next time */ } const body = { operationName: "RestaurantsAvailability", variables: { restaurantIds: [input.restaurant_id], date, time: input.time, partySize: input.party_size, databaseRegion: "NA", onlyPop: false, forwardDays: 0, requireTimes: false, requireTypes: ["Standard"], privilegedAccess: [], includeAvailableSpaces: false, restaurantAvailabilityTokens: [profile.availabilityToken ?? null], loyaltyRedemptionTiers: [], forwardMinutes: windowMinutes, backwardMinutes: windowMinutes }, @@ -85,7 +91,10 @@ const otReadAvailability = async (input, windowMinutes, signal) => { 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); - if (Array.isArray(json.errors) && json.errors.length) return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + if (Array.isArray(json.errors) && json.errors.length) { + if (fromCache) try { sessionStorage.removeItem(cacheKey); } catch { /* nothing kept */ } + return otFail("site_error", "The read-only availability query reported an error; no slots were returned."); + } const records = json.data?.availability; if (!Array.isArray(records) || records.length !== 1 || records[0]?.restaurantId !== input.restaurant_id || !Array.isArray(records[0].availabilityDays)) return otFail("site_changed", "The availability response did not identify the requested restaurant."); const day = records[0].availabilityDays.find(d => d?.dayOffset === 0); @@ -94,9 +103,9 @@ const otReadAvailability = async (input, windowMinutes, signal) => { const slots = []; for (const s of day.slots.slice(0, 200)) { if (s?.isAvailable !== true || s.type !== "Standard") continue; + // The site can answer with slots beyond the requested window; they are left out, whatever their shape. + if (Number.isInteger(s?.timeOffsetMinutes) && Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; if (!Number.isInteger(s.timeOffsetMinutes) || !Array.isArray(s.attributes) || typeof s.isMandatory !== "boolean" || !Array.isArray(s.isMandatoryBySeating) || s.isMandatoryBySeating.some(x => !x || typeof x.isMandatory !== "boolean")) return otFail("site_changed", "An available slot did not have the supported fields."); - // The site can answer with slots beyond the requested window; they are left out, not treated as a shape change. - if (Math.abs(s.timeOffsetMinutes) > windowMinutes) continue; const minutes = startMinutes + s.timeOffsetMinutes; if (minutes < 0 || minutes >= 1440) continue; const seatingTypes = s.attributes.filter(x => typeof x === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(x)).slice(0, 10); diff --git a/registry.json b/registry.json index 043b5493..434a7d66 100644 --- a/registry.json +++ b/registry.json @@ -2661,7 +2661,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Slots the site returns outside the requested window are left out; without the restaurant's offset, dates are judged by UTC-12. Shares lib/opentable-availability.js with prepare_reservation. Independent source QA passed; final-source live runtime unverified. Excludes experience-only and privileged inventory. Dates are judged by the restaurant's own UTC offset, so tonight stays searchable while UTC is already on the next day. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, and a refused search names the next step), so it needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser with the page clock at 7pm Pacific: a same-day search for a San Francisco restaurant returned slots." + "notes": "Public standard inventory within 120 minutes of restaurant-local time; read query, no slot selection or hold. Slots the site returns outside the requested window are left out before their shape is checked. Dates are judged by the restaurant's own UTC offset, so tonight stays searchable while UTC is already on the next day; without the restaurant's offset, by UTC-12. Excludes experience-only and privileged inventory. Shares lib/opentable-availability.js with prepare_reservation. The query id found in the profile's first-party modules (184 module requests in a live read on 2026-10-23) is kept in the tab's sessionStorage per entry module, so later reads skip the modules, and is dropped when the query reports an error. Independent source QA passed; final-source live runtime unverified. Source changed after that QA (reads now proceed from a page OpenTable answered with 'Access Denied', short / addresses are accepted, a refused search names the next step, and the query id is kept), so it needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser with the page clock at 7pm Pacific: a same-day search for a San Francisco restaurant returned slots." }, { "domain": "opentable.com", @@ -2720,7 +2720,7 @@ "url_patterns_reason": "Narrowed to www.opentable.com, OpenTable's US diner site: the tools fetch its restaurant pages and same-origin GraphQL endpoint and refuse other origins; other opentable.com hosts serve restaurants and other regions.", "status": "draft", "last_verified": null, - "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Dates are judged by the restaurant's own UTC offset. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Without the restaurant's offset, dates are judged by UTC-12. Shares lib/opentable-availability.js with get_availability. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." + "notes": "Exact standard date/time/party/seating preparation, no substitution or hold/booking. Dates are judged by the restaurant's own UTC offset. Account and fees not verified; trusted approval execution unavailable. Reads proceed from a page OpenTable answered with 'Access Denied' and judge OpenTable's actual responses. Without the restaurant's offset, dates are judged by UTC-12. Shares lib/opentable-availability.js with get_availability, including the query id kept in the tab's sessionStorage per entry module. Independent source QA passed on earlier source; the source has changed since and needs fresh independent QA. Author live check on 2026-10-22 in a Kernel browser whose home page showed 'Access Denied': get_restaurant by short address, get_availability and prepare_reservation returned prepared with no hold." }, { "domain": "opentable.com", diff --git a/tests/opentable-actions.test.mjs b/tests/opentable-actions.test.mjs index 96fbb435..c60cf922 100644 --- a/tests/opentable-actions.test.mjs +++ b/tests/opentable-actions.test.mjs @@ -51,7 +51,7 @@ async function run(name, input, options = {}) { }; const now = options.now === undefined ? null : Date.parse(options.now); const clock = now === null ? Date : class extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }; - const execute = runInNewContext(`(${definitions[name].execute.toString()})`, { window: { location: { origin: options.origin ?? origin } }, document: { title: options.title ?? "OpenTable", querySelector: () => options.challenge ? {} : null }, URL, URLSearchParams, fetch, Date: clock }); + const execute = runInNewContext(`(${definitions[name].execute.toString()})`, { window: { location: { origin: options.origin ?? origin } }, document: { title: options.title ?? "OpenTable", querySelector: () => options.challenge ? {} : null }, URL, URLSearchParams, fetch, Date: clock, sessionStorage: options.storage }); const result = plain(await execute(input, { signal })); assert.ok(conforms(definitions[name].tool.outputSchema, result), JSON.stringify(result)); return { result, requests }; @@ -278,3 +278,29 @@ test("a restaurant-local date is not judged past by the UTC calendar", async () await failure(name, { ...query, date: "2026-10-22", time: "20:00" }, "invalid_input", { now: "2026-10-23T13:00:00Z" }, 1); } }); + +const memoryStorage = () => { const m = new Map(); return { map: m, getItem: k => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: k => m.delete(k) }; }; + +test("availability keeps the query id for the entry module, so a later read skips the modules, and drops it when the query fails", async () => { + const storage = memoryStorage(); + const modules = r => r.requests.filter(q => q.path.startsWith("/static/")).length; + const first = await run("get_availability", query, { storage }); + assert.equal(first.result.status, "ok"); assert.equal(modules(first), 1); + assert.deepEqual([...storage.map.values()], [hash]); + const second = await run("prepare_reservation", query, { storage }); + assert.equal(second.result.status, "prepared"); assert.equal(modules(second), 0); + assert.equal(JSON.parse(second.requests.at(-1).body).extensions.persistedQuery.sha256Hash, hash); + await failure("get_availability", query, "site_error", { storage, availability: { errors: [{ message: "PersistedQueryNotFound" }] } }); + assert.equal(storage.map.size, 0, "a failed query does not keep the id"); + storage.setItem("opentable.com.restaurants-availability-document.v1:/static/cfe/15/js/restprofilepage-FAKE.js", "not-a-hash"); + assert.equal(modules(await run("get_availability", query, { storage })), 1, "a malformed kept id is ignored"); + const newBuild = await run("get_availability", query, { storage, profileHtml: html(profile, "restprofilepage-NEWER") }); + assert.equal(modules(newBuild), 1, "another build's entry module is read again"); +}); + +test("availability leaves out slots beyond the window before checking their shape, and is not truncated at exactly 50 slots", async () => { + const out = await run("get_availability", { ...query, window_minutes: 30 }, { availability: availability([slot(), slot({ timeOffsetMinutes: 90, attributes: null })]) }); + assert.equal(out.result.status, "ok"); assert.deepEqual(out.result.slots.map(s => s.time), ["19:00"]); + const fifty = await run("get_availability", query, { availability: availability(Array.from({ length: 50 }, () => slot())) }); + assert.equal(fifty.result.slots.length, 50); assert.equal(fifty.result.truncated, false); +});