diff --git a/domains/fandango.com/README.md b/domains/fandango.com/README.md new file mode 100644 index 00000000..d1ab95ca --- /dev/null +++ b/domains/fandango.com/README.md @@ -0,0 +1,12 @@ +# fandango.com + +| tool | description | +|------|-------------| +| get_movie | Reads one movie's synopsis, runtime, rating, directors, cast and scores from its overview page. | +| get_movie_showtimes | Lists one movie's showtimes at the theaters near a required ZIP on one date, with venue-local times and formats. | +| get_theater_showtimes | Lists the movies and showtimes at one theater on one date. | +| get_ticket_offers | Reads the noninteractive reserved-seat preview of one exact screening: areas, ticket types, prices and fees in cents, and seat statuses; never holds a seat. | +| list_movies | Lists one page of the now-playing or coming-soon catalog. | +| prepare_ticket_plan | Revalidates ticket quantities and optional proposed seats for one screening and returns a preview subtotal as a plan only; no cart, hold or checkout. | +| search | Reads the header search suggestions for a query as movie and theater ids with canonical URLs. | +| search_theaters | Lists the theaters near a ZIP or a city and state. | diff --git a/domains/fandango.com/get_movie.js b/domains/fandango.com/get_movie.js new file mode 100644 index 00000000..2c9c53aa --- /dev/null +++ b/domains/fandango.com/get_movie.js @@ -0,0 +1,266 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + "name": "get_movie", + "description": "Read one Fandango movie: synopsis, rating, runtime, release date, genres, directors, up to 40 cast members and critic/audience scores. Get movie_id from search, list_movies or get_theater_showtimes. Fetches the public movie overview HTML without executing it or navigating; optional cast/score failures are reported as warnings, not invented values. Does not play media, rate, favorite, post reviews or enter checkout.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + } + } + }, + "outputSchema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "movie_id", + "title", + "url", + "description", + "release_date", + "rating", + "runtime_minutes", + "genres", + "directors", + "cast", + "cast_truncated", + "critics_score", + "audience_score", + "certified_fresh", + "warnings", + "observed_at" + ], + "properties": { + "status": { + "const": "ok" + }, + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "release_date": { + "type": [ + "string", + "null" + ] + }, + "rating": { + "type": [ + "string", + "null" + ] + }, + "runtime_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 1440 + }, + "genres": { + "type": "array", + "maxItems": 12, + "items": { + "type": "string" + } + }, + "directors": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string" + } + }, + "cast": { + "type": "array", + "maxItems": 40, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "role" + ], + "properties": { + "name": { + "type": "string" + }, + "role": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "cast_truncated": { + "type": "boolean" + }, + "critics_score": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 100 + }, + "audience_score": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 100 + }, + "certified_fresh": { + "type": [ + "boolean", + "null" + ] + }, + "warnings": { + "type": "array", + "maxItems": 5, + "items": { + "type": "string" + } + }, + "observed_at": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "error_kind", + "error_message" + ], + "properties": { + "status": { + "const": "error" + }, + "error_kind": { + "enum": [ + "invalid_input", + "wrong_page", + "region_blocked", + "challenge", + "rate_limited", + "canceled", + "not_found", + "site_changed", + "site_error" + ] + }, + "error_message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const text = (v, cap = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, cap) : null; + const isDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + const pageError = () => { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a www.fandango.com page first."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "Fandango did not accept this browser's network location; the site is US-only."); + if (context.signal?.aborted) return fail("canceled", "The lookup was canceled before any request."); + return null; + }; + const api = async (path, html = false) => { + const signal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000); + const response = await fetch(path, { headers: { accept: html ? "text/html" : "application/json" }, signal }); + if (response.status === 429) throw { kind: "rate_limited" }; + if (response.status === 403) throw { kind: "challenge" }; + if (new URL(response.url || path, ORIGIN).origin !== ORIGIN) throw { kind: "site_changed" }; + if (new URL(response.url || path, ORIGIN).pathname === "/region-error") throw { kind: "region_blocked" }; + if (response.status === 404) throw { kind: "not_found" }; + if (!response.ok) throw { kind: "site_error" }; + return html ? { body: await response.text(), url: response.url || ORIGIN + path } : await response.json(); + }; + const caught = error => { + const kind = context.signal?.aborted || error?.name === "AbortError" ? "canceled" : error?.kind || "site_error"; + const messages = { canceled: "The lookup was canceled; no selection or checkout request was made.", rate_limited: "Fandango returned HTTP 429. Stop and retry later, not automatically.", challenge: "Fandango refused the request. Stop; do not bypass the challenge.", not_found: "Fandango could not find the requested record.", region_blocked: "Fandango is unavailable for this browser's network location.", site_changed: "Fandango returned an unexpected destination or record; the site may have changed." }; + return fail(kind, messages[kind] || "Fandango did not return usable data. No automatic retry was made."); + }; + const pageUrl = (link, suffix) => { + if (typeof link !== "string") return null; + try { const u = new URL(link, ORIGIN); return u.origin === ORIGIN && !u.username && !u.password && suffix.test(u.pathname) ? ORIGIN + u.pathname : null; } catch { return null; } + }; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "movie_id") || typeof input.movie_id !== "string" || !/^\d{1,9}$/.test(input.movie_id)) return fail("invalid_input", "movie_id must be a Fandango id of 1–9 digits; no other inputs are accepted."); + const stop = pageError(); if (stop) return stop; + try { + const page = await api("/movie-" + input.movie_id + "/movie-overview", true); + if (page.body.length > 2000000) return fail("site_changed", "The movie page exceeded the parsing limit."); + const records = []; + // Extract only JSON-LD text; do not build a document that could load embedded resources. + for (const match of page.body.matchAll(/]*)>([\s\S]*?)<\/script\s*>/gi)) { + if (!/\btype\s*=\s*(["'])application\/ld\+json\1/i.test(match[1])) continue; + try { const j = JSON.parse(match[2]); records.push(...(Array.isArray(j) ? j : Array.isArray(j?.["@graph"]) ? j["@graph"] : [j])); } catch {} + } + const suffix = new RegExp("-" + input.movie_id + "/movie-overview/?$"); + const movie = records.find(j => j?.["@type"] === "Movie" && pageUrl(j.url, suffix)); + const url = pageUrl(page.url, suffix); + if (!movie || !url || !text(movie.name)) return fail("site_changed", "The page did not contain a matching public movie record."); + const warnings = []; + let cast = [], castTruncated = false, scores = null; + try { + const people = await api("/napi/movie/cast/" + input.movie_id); + if (!Array.isArray(people) || people.some(p => !text(p?.name))) throw { kind: "site_changed" }; + cast = people.slice(0, 40).map(p => ({ name: text(p.name), role: text(p.role) })); castTruncated = people.length > 40; + } catch (error) { if (["canceled", "challenge", "rate_limited", "region_blocked"].includes(caught(error).error_kind)) return caught(error); warnings.push("Cast data was unavailable or changed."); } + try { + const r = await api("/napi/fanAndCriticRatings/" + input.movie_id); + if (String(r?.movieId) !== input.movie_id || !r.data || r.error) throw { kind: "site_changed" }; + scores = r.data; + } catch (error) { if (["canceled", "challenge", "rate_limited", "region_blocked"].includes(caught(error).error_kind)) return caught(error); warnings.push("Critic and audience scores were unavailable or changed."); } + const score = v => Number.isInteger(v) && v >= 0 && v <= 100 ? v : null; + const duration = movie.duration; + const iso = typeof duration === "string" ? /^PT(?:(\d+)H)?(?:(\d+)M)?$/.exec(duration) : null; + const minutes = Number.isInteger(duration) ? duration : iso ? Number(iso[1] || 0) * 60 + Number(iso[2] || 0) : null; + return { status: "ok", movie_id: input.movie_id, title: text(movie.name), url, description: text(movie.description, 6000), release_date: isDate(movie.datePublished) ? movie.datePublished : null, rating: text(movie.contentRating, 30), runtime_minutes: Number.isInteger(minutes) && minutes > 0 && minutes <= 1440 ? minutes : null, genres: (Array.isArray(movie.genre) ? movie.genre : typeof movie.genre === "string" ? movie.genre.split(",") : []).map(g => text(g, 100)).filter(Boolean).slice(0, 12), directors: (Array.isArray(movie.director) ? movie.director : movie.director ? [movie.director] : []).map(d => text(d?.name)).filter(Boolean).slice(0, 20), cast, cast_truncated: castTruncated, critics_score: score(scores?.criticsScore), audience_score: score(scores?.audienceScore), certified_fresh: typeof scores?.certifiedFresh === "boolean" ? scores.certifiedFresh : null, warnings, observed_at: new Date().toISOString() }; + } catch (error) { return caught(error); } + } +}] diff --git a/domains/fandango.com/get_movie_showtimes.js b/domains/fandango.com/get_movie_showtimes.js new file mode 100644 index 00000000..002a8add --- /dev/null +++ b/domains/fandango.com/get_movie_showtimes.js @@ -0,0 +1,220 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + name: "get_movie_showtimes", + title: "Get a movie's showtimes at Fandango theaters near a ZIP code", + description: "Get one movie's showtimes on Fandango for one local calendar date at the theaters near a US ZIP code, in the site's order (nearest first when checked), as the movie page's showtimes list shows them. Get movie_id from search or get_theater_showtimes. Each theater lists screenings grouped by format (Standard, 3D, IMAX and so on) with the amenities that apply; each showtime has its local date and time, a status (available, sold_out, past, unavailable, restricted or unknown) and Fandango's showtime id when the site gives one. The ZIP is required because the site otherwise guesses a location from the network address. Without date the site's current showing day for that movie and ZIP is used and returned as date. Only lists showtimes: it does not open ticket pages, pick seats, or report prices, fees or seat availability, and an 'available' status does not guarantee tickets can be bought. Runs inside the current www.fandango.com page without navigating. Read-only; works signed out and does not return favorite or account state.", + inputSchema: { + type: "object", additionalProperties: false, required: ["movie_id", "zip"], + properties: { + movie_id: { type: "string", pattern: "^\\d{1,9}$", description: "Fandango movie id, the number at the end of a movie page's path segment (for example 243819 in /some-title-243819/movie-overview), from search or get_theater_showtimes." }, + zip: { type: "string", pattern: "^\\d{5}$", description: "5-digit US ZIP code the theaters are near." }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Local calendar date at the theaters, YYYY-MM-DD. Default: the site's current showing day for the movie and ZIP." }, + limit: { type: "integer", minimum: 1, maximum: 40, default: 10, description: "Maximum theaters to return, in the site's order." }, + offset: { type: "integer", minimum: 0, maximum: 999, default: 0, description: "Index of the first theater to return; use next_offset from the previous call." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "movie_id", "zip", "date", "observed_at", "total_theaters", "offset", "next_offset", "truncated", "warnings", "theaters", "available_dates"], + properties: { + status: { const: "ok" }, + movie_id: { type: "string", pattern: "^\\d{1,9}$" }, + zip: { type: "string", pattern: "^\\d{5}$" }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "The local date the showtimes are for." }, + observed_at: { type: "string", description: "ISO timestamp of the lookup; showtimes and sold-out flags change during the day." }, + total_theaters: { type: "integer", minimum: 1, description: "Theaters with showtimes on the first page the site returned, before limit was applied." }, + offset: { type: "integer", minimum: 0, maximum: 999 }, + next_offset: { type: ["integer", "null"], minimum: 1, description: "offset for the next call when theaters follow this page; null on the last page." }, + truncated: { type: "boolean", description: "True when offset or limit left theaters out of this response." }, + warnings: { type: "array", items: { type: "string" }, description: "Limits of what was read, such as further theater pages the site reports but this tool does not read." }, + theaters: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, + required: ["theater_id", "name", "url", "distance_miles", "address", "chain_code", "ticketing_available", "screenings"], + properties: { + theater_id: { type: "string", pattern: "^[A-Z0-9]{3,12}$", description: "Fandango theater id; pass it to get_theater_showtimes for the theater's other movies." }, + name: { type: "string" }, + url: { type: "string", format: "uri", description: "The theater page on www.fandango.com." }, + distance_miles: { type: ["number", "null"], description: "Distance from the ZIP as reported by the site." }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "zip"], + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, zip: { type: ["string", "null"] } } + }, + chain_code: { type: ["string", "null"], description: "The site's chain code, for example AMC." }, + ticketing_available: { type: "boolean", description: "True when Fandango sells tickets online for this theater." }, + screenings: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, required: ["format", "amenities", "reserved_seating", "showtimes"], + properties: { + format: { type: ["string", "null"], description: "Format heading such as Standard or 3D." }, + amenities: { type: "array", items: { type: "string" }, description: "Amenity and policy names the site attaches to these showtimes." }, + reserved_seating: { type: "boolean" }, + showtimes: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, required: ["local_date", "local_time", "time_text", "status", "site_status", "showtime_id"], + properties: { + local_date: { type: ["string", "null"], description: "YYYY-MM-DD at the theater." }, + local_time: { type: ["string", "null"], description: "HH:MM 24-hour at the theater." }, + time_text: { type: "string", description: "The time as the site prints it, for example 7:00p." }, + status: { enum: ["available", "sold_out", "past", "unavailable", "restricted", "unknown"], description: "unavailable is a showtime the site lists but marks disabled and restricted one it marks restricted; the site gives no reason for either, so do not read restricted as available or unavailable. unknown is any other site word (see site_status)." }, + site_status: { type: ["string", "null"], description: "The site's own status word." }, + showtime_id: { type: ["integer", "null"], description: "Fandango's showtime id; null when the site omits it, which it does for some theaters." } + } + } + } + } + } + } + } + } + }, + available_dates: { type: ["array", "null"], items: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, description: "Dates with showtimes for this movie near the ZIP, or null when the site's calendar could not be read." } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "movie_id", "zip", "date", "observed_at", "available_dates", "message"], + properties: { + status: { const: "no_showtimes" }, + movie_id: { type: "string", pattern: "^\\d{1,9}$" }, + zip: { type: "string", pattern: "^\\d{5}$" }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + observed_at: { type: "string" }, + available_dates: { type: "array", items: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, description: "Dates that do have showtimes for this movie near the ZIP." }, + message: { type: "string" } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { status: { const: "error" }, error_kind: { enum: ["invalid_input", "wrong_page", "region_blocked", "challenge", "rate_limited", "not_found", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const allowed = ["movie_id", "zip", "date", "limit", "offset"]; + 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 (typeof input.movie_id !== "string" || !/^\d{1,9}$/.test(input.movie_id)) return fail("invalid_input", "movie_id must be a Fandango movie id of 1 to 9 digits, such as 243819."); + if (typeof input.zip !== "string" || !/^\d{5}$/.test(input.zip)) return fail("invalid_input", "zip must be a 5-digit US ZIP code."); + const isDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + if (input.date !== undefined && !isDate(input.date)) return fail("invalid_input", "date must be a real calendar date, YYYY-MM-DD."); + const limit = input.limit === undefined ? 10 : input.limit; + if (!Number.isInteger(limit) || limit < 1 || limit > 40) return fail("invalid_input", "limit must be an integer from 1 to 40."); + const offset = input.offset === undefined ? 0 : input.offset; + if (!Number.isInteger(offset) || offset < 0 || offset > 999) return fail("invalid_input", "offset must be an integer from 0 to 999."); + const movieId = input.movie_id; + const zip = input.zip; + const observedAt = new Date().toISOString(); + + const api = async path => { + const res = await fetch(path, { headers: { accept: "application/json" }, signal: context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000) }); + let body = null; + try { body = JSON.parse(await res.text()); } catch {} + return { status: res.status, body: body && typeof body === "object" ? body : null }; + }; + const blocked = response => { + if (response.status === 429) return fail("rate_limited", "Fandango returned HTTP 429; wait a few minutes before trying again."); + if (response.status === 403) return fail("challenge", "Fandango refused the request (HTTP 403), which usually means bot protection; wait or use another browser session."); + return null; + }; + const text = v => typeof v === "string" && v.trim() ? v.trim() : null; + const pathOf = (link, suffix) => { + if (typeof link !== "string" || !link.startsWith("/") || link.startsWith("//")) return null; + try { const u = new URL(link, ORIGIN); return u.origin === ORIGIN && suffix.test(u.pathname) ? u.pathname : null; } catch { return null; } + }; + // The site's calendar for this movie near the ZIP. The title segment of its path is not used by the site (a + // placeholder returns the same dates), so it is fixed here; an unknown movie or ZIP comes back as hasCalendar false. + const readCalendar = async () => { + const res = await api("/napi/movieCalendar/movie-" + movieId + "?postalCode=" + zip + "&zip=" + zip); + const stop = blocked(res); + if (stop) return { error: stop }; + if (res.status >= 400 || !res.body) return { error: fail("site_error", "Fandango's showtime calendar returned HTTP " + res.status + (res.body ? " with an unexpected response shape." : " with a non-JSON body.")) }; + if (typeof res.body.hasCalendar !== "boolean") return { error: fail("site_error", "Fandango's showtime calendar had an unexpected shape; the site may have changed.") }; + const cal = res.body.movieCalendar; + const dates = res.body.hasCalendar === true && Array.isArray(cal?.showtimeDates) ? cal.showtimeDates.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).slice(0, 120) : null; + const selected = dates && /^\d{4}-\d{2}-\d{2}$/.test(cal.selectedDate ?? "") ? cal.selectedDate : null; + return { dates, selected }; + }; + const notFound = () => fail("not_found", "Fandango has no showtime calendar for movie_id " + movieId + " near ZIP " + zip + ". The movie id or ZIP is not recognized, or the movie has no showtimes near that ZIP."); + + try { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.fandango.com page first; this tool calls the site's showtimes from that page."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "This tab is on Fandango's /region-error page: the site is US-only and did not accept this browser's network location. Use a US network location and reload."); + + const groupingsPath = day => "/napi/theaterShowtimeGroupings/" + movieId + "/" + day + "?isdesktop=true&postalCode=" + zip + "&zip=" + zip + "&isDesktopMOP=true&partnerRestrictedTicketing="; + let date = input.date ?? null; + let calendar, res; + if (date) { + [res, calendar] = await Promise.all([api(groupingsPath(date)), readCalendar()]); + } else { + calendar = await readCalendar(); + if (calendar.error) return calendar.error; + if (!calendar.selected) return notFound(); + date = calendar.selected; + res = await api(groupingsPath(date)); + } + const stop = blocked(res); + if (stop) return stop; + if (res.status >= 400 || !res.body) return fail("site_error", "Fandango's showtimes returned HTTP " + res.status + (res.body ? " with an unexpected response shape." : " with a non-JSON body.")); + const list = res.body.theaterShowtimes?.theaters; + // hasShowtimes false covers an empty day (empty theaters list) and an unknown movie or ZIP (no list at all); + // the calendar tells them apart. + if (res.body.hasShowtimes === false) { + if (calendar.error) return calendar.error; + if (!calendar.dates) return notFound(); + return { status: "no_showtimes", movie_id: movieId, zip, date, observed_at: observedAt, available_dates: calendar.dates, message: "Fandango lists no showtimes for movie_id " + movieId + " near ZIP " + zip + " on " + date + (calendar.dates.length ? ". Dates with showtimes are in available_dates." : ".") }; + } + if (!Array.isArray(list) || !list.length) return fail("site_error", "Fandango's showtimes response had no theater list; the site may have changed."); + const dates = calendar.error ? null : calendar.dates; + // For a date far enough in the past the site answers with its current day instead of the requested one. + if (res.body.theaterShowtimes.date !== date) { + if (dates && !dates.includes(date)) return { status: "no_showtimes", movie_id: movieId, zip, date, observed_at: observedAt, available_dates: dates, message: "Fandango has no showtimes for movie_id " + movieId + " near ZIP " + zip + " on " + date + " (it answered with " + res.body.theaterShowtimes.date + " instead, which it does for dates it does not list). Dates with showtimes are in available_dates." }; + return fail("site_error", "Fandango returned showtimes for " + res.body.theaterShowtimes.date + " instead of the requested " + date + "."); + } + + const STATUS = { available: "available", soldout: "sold_out", pastshowtime: "past", disabled: "unavailable", restricted: "restricted" }; + const theaters = list.flatMap(t => { + const id = typeof t?.id === "string" && /^[A-Za-z0-9]{3,12}$/.test(t.id) ? t.id.toUpperCase() : null; + const path = pathOf(t?.theaterPageUrl, /\/theater-page\/?$/); + if (!id || !path || !text(t.name)) return []; + const screenings = (Array.isArray(t.variants) ? t.variants : []).flatMap(v => (Array.isArray(v?.amenityGroups) ? v.amenityGroups : []).flatMap(group => { + const showtimes = (Array.isArray(group?.showtimes) ? group.showtimes : []).flatMap(s => { + if (typeof s?.date !== "string") return []; + const when = /^(\d{4}-\d{2}-\d{2})\+(\d{2}:\d{2})$/.exec(s.ticketingDate ?? ""); + const status = s.expired === true && s.type !== "soldout" ? "past" : STATUS[s.type] ?? "unknown"; + return [{ local_date: when ? when[1] : null, local_time: when ? when[2] : null, time_text: s.date, status, site_status: text(s.type), showtime_id: Number.isInteger(s.id) ? s.id : null }]; + }); + if (!showtimes.length) return []; + return [{ format: text(v.filmFormatHeader), amenities: Array.isArray(group.amenities) ? group.amenities.map(a => text(a?.name)).filter(Boolean).slice(0, 20) : [], reserved_seating: group.hasReservedSeating === true, showtimes }]; + })); + if (!screenings.length) return []; + return [{ + theater_id: id, name: text(t.name), url: ORIGIN + path, + distance_miles: Number.isFinite(t.distance) ? Math.round(t.distance * 100) / 100 : null, + address: { street: [t.address1, t.address2].map(text).filter(Boolean).join(", ") || null, city: text(t.city), state: text(t.state), zip: text(t.zip) }, + chain_code: text(t.chainCode), ticketing_available: t.isTicketing === true, screenings + }]; + }); + if (!theaters.length) return { status: "no_showtimes", movie_id: movieId, zip, date, observed_at: observedAt, available_dates: dates ?? [], message: "Fandango returned theaters for movie_id " + movieId + " near ZIP " + zip + " on " + date + " but none had showtimes." }; + if (offset >= theaters.length) return fail("invalid_input", "offset " + offset + " is past the " + theaters.length + " theater(s) with showtimes; use an offset below that."); + const warnings = []; + const pages = res.body.theaterShowtimes.paginated?.totalPages; + if (Number.isInteger(pages) && pages > 1) warnings.push("Fandango reports " + pages + " pages of theaters for this search; only the first page was read, so farther theaters are not listed."); + return { status: "ok", movie_id: movieId, zip, date, observed_at: observedAt, total_theaters: theaters.length, offset, next_offset: offset + limit < theaters.length ? offset + limit : null, truncated: offset > 0 || offset + limit < theaters.length, warnings, theaters: theaters.slice(offset, offset + limit), available_dates: dates }; + } catch (error) { + if (context.signal?.aborted) return fail("canceled", "The read was canceled."); + if (error && error.name === "TimeoutError") return fail("site_error", "Fandango did not answer within 20 seconds."); + return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + } + } +}] diff --git a/domains/fandango.com/get_theater_showtimes.js b/domains/fandango.com/get_theater_showtimes.js new file mode 100644 index 00000000..dba6da77 --- /dev/null +++ b/domains/fandango.com/get_theater_showtimes.js @@ -0,0 +1,224 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + name: "get_theater_showtimes", + title: "Get a Fandango theater's showtimes for one day", + description: "Get every movie and showtime one US theater has on Fandango for one local calendar date, plus the theater's details and the dates that have showtimes. Get theater_id from search or search_theaters. Each movie lists screenings grouped by format (Standard, 3D, IMAX and so on) with the amenities that apply (reserved seating, closed caption, recliners and so on); each showtime has its local date and time, a status (available, sold_out, past, unavailable, restricted or unknown) and Fandango's showtime id when the site gives one. Without date the site's current showing day is used and returned as date. Only lists showtimes: it does not open ticket pages, pick seats, or report prices, fees or seat availability, and an 'available' status does not guarantee tickets can be bought. Runs inside the current www.fandango.com page without navigating. Read-only; works signed out and does not return favorite or account state.", + inputSchema: { + type: "object", additionalProperties: false, required: ["theater_id"], + properties: { + theater_id: { type: "string", pattern: "^[A-Za-z0-9]{3,12}$", description: "Fandango theater id such as AAODQ, from search or search_theaters. Case does not matter." }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Local calendar date at the theater, YYYY-MM-DD. Default: the site's current showing day." }, + limit: { type: "integer", minimum: 1, maximum: 60, default: 40, description: "Maximum movies to return, in the site's order." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "date", "observed_at", "theater", "total_movies", "truncated", "movies", "available_dates"], + properties: { + status: { const: "ok" }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "The local date the showtimes are for." }, + observed_at: { type: "string", description: "ISO timestamp of the lookup; showtimes and sold-out flags change during the day." }, + theater: { + type: "object", additionalProperties: false, + required: ["theater_id", "name", "url", "address", "chain", "phone", "ticketing_available", "has_reserved_seating"], + properties: { + theater_id: { type: "string", pattern: "^[A-Z0-9]{3,12}$" }, + name: { type: "string" }, + url: { type: "string", format: "uri", description: "The theater page on www.fandango.com." }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "zip"], + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, zip: { type: ["string", "null"] } } + }, + chain: { type: ["string", "null"] }, + phone: { type: ["string", "null"] }, + ticketing_available: { type: "boolean", description: "True when Fandango sells tickets online for this theater." }, + has_reserved_seating: { type: "boolean" } + } + }, + total_movies: { type: "integer", minimum: 1, description: "Movies with showtimes on that date before limit was applied." }, + truncated: { type: "boolean", description: "True when limit cut the movie list." }, + movies: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, + required: ["movie_id", "title", "rating", "runtime_minutes", "release_date", "genres", "url", "screenings"], + properties: { + movie_id: { type: "string", pattern: "^\\d{1,9}$", description: "Fandango movie id; pass it with a ZIP to get_movie_showtimes for nearby theaters." }, + title: { type: "string" }, + rating: { type: ["string", "null"], description: "MPAA-style rating as listed, for example PG-13." }, + runtime_minutes: { type: ["integer", "null"] }, + release_date: { type: ["string", "null"], description: "YYYY-MM-DD, as listed by the site." }, + genres: { type: "array", items: { type: "string" } }, + url: { type: ["string", "null"], format: "uri", description: "The movie page on www.fandango.com." }, + screenings: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, required: ["format", "amenities", "reserved_seating", "showtimes"], + properties: { + format: { type: ["string", "null"], description: "Format heading such as Standard or 3D." }, + amenities: { type: "array", items: { type: "string" }, description: "Amenity and policy names the site attaches to these showtimes." }, + reserved_seating: { type: "boolean" }, + showtimes: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, required: ["local_date", "local_time", "time_text", "status", "site_status", "showtime_id"], + properties: { + local_date: { type: ["string", "null"], description: "YYYY-MM-DD at the theater." }, + local_time: { type: ["string", "null"], description: "HH:MM 24-hour at the theater." }, + time_text: { type: "string", description: "The time as the site prints it, for example 7:00p." }, + status: { enum: ["available", "sold_out", "past", "unavailable", "restricted", "unknown"], description: "unavailable is a showtime the site lists but marks disabled and restricted one it marks restricted; the site gives no reason for either, so do not read restricted as available or unavailable. unknown is any other site word (see site_status)." }, + site_status: { type: ["string", "null"], description: "The site's own status word." }, + showtime_id: { type: ["integer", "null"], description: "Fandango's showtime id; null when the site omits it, which it does for some theaters." } + } + } + } + } + } + } + } + } + }, + available_dates: { type: ["array", "null"], items: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, description: "Dates with showtimes at this theater, or null when the site's calendar could not be read." } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "date", "observed_at", "theater", "available_dates", "message"], + properties: { + status: { const: "no_showtimes" }, + date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + observed_at: { type: "string" }, + theater: { + type: "object", additionalProperties: false, + required: ["theater_id", "name", "url", "address", "chain", "phone", "ticketing_available", "has_reserved_seating"], + properties: { + theater_id: { type: "string", pattern: "^[A-Z0-9]{3,12}$" }, + name: { type: "string" }, + url: { type: "string", format: "uri", description: "The theater page on www.fandango.com." }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "zip"], + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, zip: { type: ["string", "null"] } } + }, + chain: { type: ["string", "null"] }, + phone: { type: ["string", "null"] }, + ticketing_available: { type: "boolean", description: "True when Fandango sells tickets online for this theater." }, + has_reserved_seating: { type: "boolean" } + } + }, + available_dates: { type: ["array", "null"], items: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" } }, + message: { type: "string" } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { status: { const: "error" }, error_kind: { enum: ["invalid_input", "wrong_page", "region_blocked", "challenge", "rate_limited", "theater_not_found", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const allowed = ["theater_id", "date", "limit"]; + 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 (typeof input.theater_id !== "string" || !/^[A-Za-z0-9]{3,12}$/.test(input.theater_id)) return fail("invalid_input", "theater_id must be a Fandango theater id of 3 to 12 letters or digits, such as AAODQ."); + const theaterId = input.theater_id.toUpperCase(); + const isDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + if (input.date !== undefined && !isDate(input.date)) return fail("invalid_input", "date must be a real calendar date, YYYY-MM-DD."); + const limit = input.limit === undefined ? 40 : input.limit; + if (!Number.isInteger(limit) || limit < 1 || limit > 60) return fail("invalid_input", "limit must be an integer from 1 to 60."); + const observedAt = new Date().toISOString(); + + const api = async path => { + const res = await fetch(path, { headers: { accept: "application/json" }, signal: context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000) }); + let body = null; + try { body = JSON.parse(await res.text()); } catch {} + return { status: res.status, body: body && typeof body === "object" ? body : null }; + }; + const blocked = response => { + if (response.status === 429) return fail("rate_limited", "Fandango returned HTTP 429; wait a few minutes before trying again."); + if (response.status === 403) return fail("challenge", "Fandango refused the request (HTTP 403), which usually means bot protection; wait or use another browser session."); + return null; + }; + const text = v => typeof v === "string" && v.trim() ? v.trim() : null; + const pathOf = (link, suffix) => { + if (typeof link !== "string" || !link.startsWith("/") || link.startsWith("//")) return null; + try { const u = new URL(link, ORIGIN); return u.origin === ORIGIN && suffix.test(u.pathname) ? u.pathname : null; } catch { return null; } + }; + + try { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.fandango.com page first; this tool calls the site's showtimes from that page."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "This tab is on Fandango's /region-error page: the site is US-only and did not accept this browser's network location. Use a US network location and reload."); + const showtimesPath = "/napi/theaterMovieShowtimes/" + theaterId + "?chainCode=&" + (input.date ? "startDate=" + input.date + "&" : "") + "isdesktop=true&partnerRestrictedTicketing="; + const [shows, calendar] = await Promise.all([api(showtimesPath), api("/napi/theaterCalendar/" + theaterId)]); + const stop = blocked(shows) || blocked(calendar); + if (stop) return stop; + if (shows.status >= 400 || !shows.body) return fail("site_error", "Fandango's showtimes returned HTTP " + shows.status + (shows.body ? " with an unexpected response shape." : " with a non-JSON body.")); + const vm = shows.body.viewModel; + if (!vm) { + if (shows.body.error === true) return fail("theater_not_found", "Fandango does not recognize theater_id " + theaterId + ". Get ids from search or search_theaters."); + return fail("site_error", "Fandango's showtimes response had no showtime data; the site may have changed."); + } + const details = vm.theater?.details; + if (!details || !Array.isArray(vm.movies)) return fail("site_error", "Fandango's showtimes response had an unexpected shape; the site may have changed."); + // With no showtimes on the requested day the site leaves date null. + const shown = /^\d{4}-\d{2}-\d{2}$/.test(vm.date ?? "") ? vm.date : null; + if (vm.movies.length && !shown) return fail("site_error", "Fandango's showtimes response had movies but no date; the site may have changed."); + const day = shown ?? input.date ?? (/^\d{4}-\d{2}-\d{2}$/.test(calendar.body?.selectedDate ?? "") ? calendar.body.selectedDate : null); + if (!day) return fail("site_error", "Fandango's showtimes response did not say which date it covers; pass date explicitly."); + + const path = pathOf(details.theaterPageUrl, /\/theater-page\/?$/); + if (!text(details.name) || !path) return fail("site_error", "Fandango's showtimes response had an unexpected theater record; the site may have changed."); + const theater = { + theater_id: theaterId, name: text(details.name), url: ORIGIN + path, + address: { street: [details.address1, details.address2].map(text).filter(Boolean).join(", ") || null, city: text(details.city), state: text(details.state), zip: text(details.zip) }, + chain: text(details.chainName), phone: text(details.phone), + ticketing_available: details.isTicketing === true, has_reserved_seating: details.hasReservedSeating === true + }; + const dates = Array.isArray(calendar.body?.showtimeDates) && calendar.body.isEmpty !== true + ? calendar.body.showtimeDates.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).slice(0, 120) : null; + + // For a date far enough in the past the site answers with its current day instead of the requested one. + if (shown && input.date && shown !== input.date) { + if (dates && !dates.includes(input.date)) return { status: "no_showtimes", date: input.date, observed_at: observedAt, theater, available_dates: dates, message: "Fandango has no showtimes at " + theater.name + " on " + input.date + " (it answered with " + shown + " instead, which it does for dates it does not list). Dates with showtimes are in available_dates." }; + return fail("site_error", "Fandango returned showtimes for " + shown + " instead of the requested " + input.date + "."); + } + + const STATUS = { available: "available", soldout: "sold_out", pastshowtime: "past", disabled: "unavailable", restricted: "restricted" }; + const movies = vm.movies.flatMap(m => { + const id = Number.isInteger(m?.id) && m.id > 0 ? String(m.id) : null; + if (!id || !text(m.title)) return []; + const screenings = (Array.isArray(m.variants) ? m.variants : []).flatMap(v => (Array.isArray(v?.amenityGroups) ? v.amenityGroups : []).flatMap(group => { + const showtimes = (Array.isArray(group?.showtimes) ? group.showtimes : []).flatMap(s => { + if (typeof s?.date !== "string") return []; + const when = /^(\d{4}-\d{2}-\d{2})\+(\d{2}:\d{2})$/.exec(s.ticketingDate ?? ""); + const status = s.expired === true && s.type !== "soldout" ? "past" : STATUS[s.type] ?? "unknown"; + return [{ local_date: when ? when[1] : null, local_time: when ? when[2] : null, time_text: s.date, status, site_status: text(s.type), showtime_id: Number.isInteger(s.id) ? s.id : null }]; + }); + if (!showtimes.length) return []; + return [{ format: text(v.filmFormatHeader), amenities: Array.isArray(group.amenities) ? group.amenities.map(a => text(a?.name)).filter(Boolean).slice(0, 20) : [], reserved_seating: group.hasReservedSeating === true, showtimes }]; + })); + if (!screenings.length) return []; + const moviePath = pathOf(m.mopURI, /\/movie-overview\/?$/); + return [{ + movie_id: id, title: text(m.title), rating: text(m.rating), + runtime_minutes: Number.isInteger(m.runtime) && m.runtime > 0 ? m.runtime : null, + release_date: /^\d{4}-\d{2}-\d{2}$/.test(m.releaseDate ?? "") ? m.releaseDate : null, + genres: Array.isArray(m.genres) ? m.genres.filter(g => typeof g === "string") : [], + url: moviePath ? ORIGIN + moviePath : null, screenings + }]; + }); + if (!movies.length) return { status: "no_showtimes", date: day, observed_at: observedAt, theater, available_dates: dates, message: "Fandango lists no showtimes at " + theater.name + " on " + day + (dates?.length ? ". Dates with showtimes are in available_dates." : ".") }; + return { status: "ok", date: day, observed_at: observedAt, theater, total_movies: movies.length, truncated: movies.length > limit, movies: movies.slice(0, limit), available_dates: dates }; + } catch (error) { + if (context.signal?.aborted) return fail("canceled", "The read was canceled."); + if (error && error.name === "TimeoutError") return fail("site_error", "Fandango did not answer within 20 seconds."); + return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + } + } +}] diff --git a/domains/fandango.com/get_ticket_offers.js b/domains/fandango.com/get_ticket_offers.js new file mode 100644 index 00000000..4b310fc5 --- /dev/null +++ b/domains/fandango.com/get_ticket_offers.js @@ -0,0 +1,466 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + "name": "get_ticket_offers", + "description": "Read the noninteractive reserved-seat preview and ticket prices for one exact Fandango screening. Supply movie_id, theater_id, date, 24-hour local_time and exact format from fresh showtimes; add showtime_id if ambiguous. For a next-day screening after midnight, showing_date is the enclosing showtimes day and date is the screening's local date. Screenings without a valid numeric site id are unsupported. Resolves the showtime from a fresh theater-day GET, then reads only /napi/seatMap. Never opens checkout, selects seats, validates a selection, creates a hold or changes cart/account state. USD prices and per-ticket fees are preview estimates only: taxes, discounts, surcharges and final checkout parity are not established. Seats come in the site's order, seat_limit at a time; continue with next_seat_offset. A seat marked available is not reserved or guaranteed; status H is conservatively unknown. General-admission screenings return an explicit failure.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id", + "theater_id", + "date", + "local_time", + "format" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "theater_id": { + "type": "string", + "pattern": "^[A-Za-z0-9]{3,12}$" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "local_time": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "format": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "showing_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Enclosing showtimes day, when different from the screening date after midnight. Defaults to date; must be date or the previous day." + }, + "showtime_id": { + "type": "integer", + "minimum": 1, + "maximum": 999999999999 + }, + "seat_limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 100 + }, + "seat_offset": { + "type": "integer", + "minimum": 0, + "maximum": 9999, + "default": 0, + "description": "Index of the first seat to return, in the site's seat order; use next_seat_offset from the previous call." + } + } + }, + "outputSchema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "screening", + "currency", + "price_basis", + "areas", + "seats", + "total_seats_read", + "seat_offset", + "next_seat_offset", + "seats_truncated", + "max_ticket_limit", + "surcharges_present", + "observed_at", + "warnings" + ], + "properties": { + "status": { + "const": "ok" + }, + "screening": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id", + "movie_title", + "theater_id", + "theater_name", + "showing_date", + "date", + "local_time", + "format", + "showtime_id", + "reserved_seating", + "theater_url" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "movie_title": { + "type": "string" + }, + "theater_id": { + "type": "string", + "pattern": "^[A-Z0-9]{3,12}$" + }, + "theater_name": { + "type": "string" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "local_time": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "format": { + "type": "string" + }, + "showtime_id": { "type": "integer", "minimum": 1, "maximum": 999999999999 }, + "showing_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "reserved_seating": { + "type": "boolean" + }, + "theater_url": { + "type": "string", + "format": "uri" + } + } + }, + "currency": { + "const": "USD" + }, + "price_basis": { + "const": "seat_map_preview_not_final" + }, + "areas": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "area_code", + "name", + "available_seats", + "total_seats", + "tickets" + ], + "properties": { + "area_code": { + "type": "string", + "maxLength": 40 + }, + "name": { + "type": "string" + }, + "available_seats": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "total_seats": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "tickets": { + "type": "array", + "maxItems": 30, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "ticket_code", + "name", + "price_cents", + "fee_cents", + "admissions_per_ticket" + ], + "properties": { + "ticket_code": { + "type": "string", + "maxLength": 40 + }, + "name": { + "type": "string" + }, + "price_cents": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "fee_cents": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "admissions_per_ticket": { + "type": "integer", + "minimum": 1, + "maximum": 20 + } + } + } + } + } + } + }, + "seats": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "seat_id", + "area_code", + "type", + "status", + "site_status" + ], + "properties": { + "seat_id": { + "type": "string", + "maxLength": 40 + }, + "area_code": { + "type": "string", + "maxLength": 40 + }, + "type": { + "type": "string", + "maxLength": 40 + }, + "status": { + "enum": [ + "available", + "unavailable", + "unknown" + ] + }, + "site_status": { + "type": "string", + "maxLength": 10 + } + } + } + }, + "total_seats_read": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "seat_offset": { + "type": "integer", + "minimum": 0, + "maximum": 9999 + }, + "next_seat_offset": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 10000, + "description": "seat_offset for the next call when the preview has seats after this page; null on the last page." + }, + "seats_truncated": { + "type": "boolean" + }, + "max_ticket_limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "surcharges_present": { + "type": "boolean" + }, + "observed_at": { + "type": "string" + }, + "warnings": { + "type": "array", + "maxItems": 5, + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "error_kind", + "error_message" + ], + "properties": { + "status": { + "const": "error" + }, + "error_kind": { + "enum": [ + "invalid_input", + "wrong_page", + "region_blocked", + "challenge", + "rate_limited", + "canceled", + "not_found", + "site_changed", + "site_error", + "showtime_unavailable", + "ambiguous_showtime", + "general_admission", + "selection_unavailable", + "price_unavailable" + ] + }, + "error_message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + execute: async (input, context = {}) => { + // @xwebmcp-inline lib/fandango-screening.js + // The exact Fandango screening shared by get_ticket_offers and prepare_ticket_plan: the screening inputs, the showtime + // they name in a fresh theater-day GET, and the site's noninteractive reserved-seat preview for it. Reads never navigate, + // select seats, validate a selection or open checkout. + const FD_ORIGIN = "https://www.fandango.com"; + const fdFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const fdText = (v, cap = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, cap) : null; + const fdIsDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + const fdPageError = signal => { + if (window.location.origin !== FD_ORIGIN) return fdFail("wrong_page", "Open a www.fandango.com page first."); + if (window.location.pathname === "/region-error") return fdFail("region_blocked", "Fandango did not accept this browser's network location; the site is US-only."); + if (signal?.aborted) return fdFail("canceled", "The lookup was canceled before any request."); + return null; + }; + const fdApi = async (path, signal) => { + const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000); + const response = await fetch(path, { headers: { accept: "application/json" }, redirect: "manual", signal: bounded }); + if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) throw { kind: "site_changed" }; + if (response.status === 429) throw { kind: "rate_limited" }; + if (response.status === 403) throw { kind: "challenge" }; + if (new URL(response.url || path, FD_ORIGIN).origin !== FD_ORIGIN) throw { kind: "site_changed" }; + if (new URL(response.url || path, FD_ORIGIN).pathname === "/region-error") throw { kind: "region_blocked" }; + if (response.status === 404) throw { kind: "not_found" }; + if (!response.ok) throw { kind: "site_error" }; + return await response.json(); + }; + const fdCaught = (error, signal) => { + const kind = signal?.aborted || error?.name === "AbortError" ? "canceled" : error?.kind || "site_error"; + const messages = { showtime_unavailable: "The exact screening is missing, past, sold out or not available for ticketing. Refresh showtimes.", ambiguous_showtime: "More than one screening matches; supply showtime_id from fresh showtimes.", general_admission: "No reserved-seat preview is available. General-admission prices require checkout, which this tool never opens.", selection_unavailable: "The proposed tickets or seats are not available in this preview; nothing was selected or reserved.", price_unavailable: "The price cannot safely be calculated from this preview; complete it manually.", canceled: "The lookup was canceled; no selection or checkout request was made.", rate_limited: "Fandango returned HTTP 429. Stop and retry later, not automatically.", challenge: "Fandango refused the request. Stop; do not bypass the challenge.", not_found: "Fandango could not find the requested record.", region_blocked: "Fandango is unavailable for this browser's network location.", site_changed: "Fandango returned an unexpected destination or record; the site may have changed." }; + return fdFail(kind, messages[kind] || "Fandango did not return usable data. No automatic retry was made."); + }; + const fdPageUrl = (link, suffix) => { + if (typeof link !== "string") return null; + try { const u = new URL(link, FD_ORIGIN); return u.origin === FD_ORIGIN && !u.username && !u.password && suffix.test(u.pathname) ? FD_ORIGIN + u.pathname : null; } catch { return null; } + }; + const fdCode = v => typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v); + const fdScreeningInput = input => { + if (typeof input.movie_id !== "string" || !/^\d{1,9}$/.test(input.movie_id) || typeof input.theater_id !== "string" || !/^[A-Za-z0-9]{3,12}$/.test(input.theater_id) || !fdIsDate(input.date) || typeof input.local_time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.local_time) || typeof input.format !== "string" || !input.format.trim() || input.format.length > 100 || (input.showtime_id !== undefined && (!Number.isSafeInteger(input.showtime_id) || input.showtime_id < 1 || input.showtime_id > 999999999999))) return { error: fdFail("invalid_input", "Supply valid movie_id, theater_id, date, 24-hour local_time and exact format; showtime_id, if supplied, must be a positive integer.") }; + const showingDate = input.showing_date === undefined ? input.date : input.showing_date; + if (!fdIsDate(showingDate) || ![0, 86400000].includes(Date.parse(input.date) - Date.parse(showingDate))) return { error: fdFail("invalid_input", "showing_date must be date or the previous calendar day.") }; + return { showingDate }; + }; + const fdResolveScreening = async (input, showingDate, signal) => { + const data = await fdApi("/napi/theaterMovieShowtimes/" + input.theater_id.toUpperCase() + "?chainCode=&startDate=" + showingDate + "&isdesktop=true&partnerRestrictedTicketing=", signal); + const vm = data?.viewModel, details = vm?.theater?.details; + if (!vm && data?.error === true) throw { kind: "not_found" }; + const theaterId = input.theater_id.toUpperCase(); + const theaterUrl = fdPageUrl(details?.theaterPageUrl, new RegExp("-" + theaterId + "/theater-page/?$", "i")); + if (!details || String(details.id).toUpperCase() !== theaterId || !theaterUrl || !fdText(details.name) || !Array.isArray(vm.movies)) throw { kind: "site_changed" }; + if (vm.date !== showingDate || !vm.movies.length) throw { kind: "showtime_unavailable" }; + if (details.isTicketing !== true) throw { kind: "showtime_unavailable" }; + const matches = []; + for (const m of vm.movies) { + if (String(m?.id) !== input.movie_id || !fdText(m.title)) continue; + for (const v of Array.isArray(m.variants) ? m.variants : []) { + if (v?.filmFormatHeader !== input.format) continue; + for (const g of Array.isArray(v.amenityGroups) ? v.amenityGroups : []) for (const s of Array.isArray(g?.showtimes) ? g.showtimes : []) { + if (s?.ticketingDate !== input.date + "+" + input.local_time || (input.showtime_id !== undefined && s.id !== input.showtime_id)) continue; + matches.push({ movie: m, group: g, show: s }); + } + } + } + if (!matches.length) throw { kind: "showtime_unavailable" }; + if (matches.length !== 1) throw { kind: "ambiguous_showtime" }; + const { movie, group, show } = matches[0]; + if (!Number.isSafeInteger(show.id) || show.id < 1 || show.id > 999999999999 || typeof show.expired !== "boolean" || typeof show.isSoldOut !== "boolean" || typeof group.hasReservedSeating !== "boolean") throw { kind: "site_changed" }; + if (show.type !== "available" || show.expired || show.isSoldOut) throw { kind: "showtime_unavailable" }; + const screening = { movie_id: input.movie_id, movie_title: fdText(movie.title), theater_id: theaterId, theater_name: fdText(details.name), showing_date: showingDate, date: input.date, local_time: input.local_time, format: fdText(input.format, 100), showtime_id: show.id, reserved_seating: group.hasReservedSeating, theater_url: theaterUrl }; + return { screening, show }; + }; + const fdMoney = v => typeof v === "string" && /^\d{1,4}(?:\.\d{1,2})?$/.test(v) ? Math.round(Number(v) * 100) : null; + const fdReadOffers = async ({ screening, show }, signal) => { + if (!screening.reserved_seating) throw { kind: "general_admission" }; + if (typeof show.showtimeHashCode !== "string" || !show.showtimeHashCode || show.showtimeHashCode.length > 300 || /[\u0000-\u0020\u007f]/.test(show.showtimeHashCode)) throw { kind: "site_changed" }; + const previewPath = "/napi/seatMap/" + encodeURIComponent(show.showtimeHashCode); + if (new URL(previewPath, FD_ORIGIN).pathname !== previewPath) throw { kind: "site_changed" }; + // This is the site's noninteractive preview GET, not its seat validation or ticket-selection flow. + const map = await fdApi(previewPath, signal); + if (typeof map?.tmsId !== "string" || map.tmsId.toUpperCase() !== screening.theater_id || !["string", "number"].includes(typeof map.showtimeId) || String(map.showtimeId) !== String(screening.showtime_id) || !Array.isArray(map.areas) || !map.areas.length || map.areas.length > 20 || !Array.isArray(map.seats) || map.seats.length > 10000 || !Array.isArray(map.seatBlocks) || map.seatBlocks.length > 10000 || !Number.isInteger(map.maxTicketLimit) || map.maxTicketLimit < 1 || map.maxTicketLimit > 100 || !Array.isArray(map.surcharges)) throw { kind: "site_changed" }; + if (map.seatBlocks.some(b => !fdCode(b?.id)) || new Set(map.seatBlocks.map(b => b.id)).size !== map.seatBlocks.length) throw { kind: "site_changed" }; + const areas = map.areas.map(a => { + if (!fdCode(a?.code) || !fdText(a.name) || !Number.isInteger(a.availableSeatCount) || !Number.isInteger(a.totalSeatCount) || a.availableSeatCount < 0 || a.totalSeatCount < a.availableSeatCount || a.totalSeatCount > 10000 || !Array.isArray(a.ticketInfo) || !a.ticketInfo.length || a.ticketInfo.length > 30) throw { kind: "site_changed" }; + const tickets = a.ticketInfo.map(t => { + const price = fdMoney(t?.price), fee = fdMoney(t?.fee); + if (!fdCode(t?.code) || !fdText(t.desc) || price === null || fee === null || !Number.isInteger(t.admissionsPerTicket) || t.admissionsPerTicket < 1 || t.admissionsPerTicket > 20) throw { kind: "site_changed" }; + return { ticket_code: t.code, name: fdText(t.desc), price_cents: price, fee_cents: fee, admissions_per_ticket: t.admissionsPerTicket }; + }); + if (new Set(tickets.map(t => t.ticket_code)).size !== tickets.length) throw { kind: "site_changed" }; + return { area_code: a.code, name: fdText(a.name), available_seats: a.availableSeatCount, total_seats: a.totalSeatCount, tickets }; + }); + if (new Set(areas.map(a => a.area_code)).size !== areas.length) throw { kind: "site_changed" }; + const seats = map.seats.map(s => { + if (!fdCode(s?.id) || !fdCode(s.areaCode) || !areas.some(a => a.area_code === s.areaCode) || typeof s.status !== "string" || !s.status || s.status.length > 10 || !fdCode(s.type)) throw { kind: "site_changed" }; + const blockId = s.seatBlockId; + if (blockId !== undefined && blockId !== null && blockId !== "" && (!fdCode(blockId) || !map.seatBlocks.some(b => b.id === blockId))) throw { kind: "site_changed" }; + if (s.sellIndividuallyWithinSeatBlock !== undefined && typeof s.sellIndividuallyWithinSeatBlock !== "boolean") throw { kind: "site_changed" }; + return { seat_id: s.id, area_code: s.areaCode, type: blockId ? "linked" : s.type, status: s.status === "A" ? "available" : ["O", "R", "X"].includes(s.status) ? "unavailable" : "unknown", site_status: s.status }; + }); + if (new Set(seats.map(s => s.seat_id)).size !== seats.length) throw { kind: "site_changed" }; + return { areas, seats, maxTicketLimit: map.maxTicketLimit, hasSurcharges: map.surcharges.length > 0 }; + }; + // @xwebmcp-end + const signal = context.signal; + const allowed = ["movie_id", "theater_id", "date", "showing_date", "local_time", "format", "showtime_id", "seat_limit", "seat_offset"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fdFail("invalid_input", "Use only the documented screening and selection inputs."); + const checked = fdScreeningInput(input); + if (checked.error) return checked.error; + const limit = input.seat_limit === undefined ? 100 : input.seat_limit; + if (!Number.isInteger(limit) || limit < 1 || limit > 200) return fdFail("invalid_input", "seat_limit must be 1–200."); + const offset = input.seat_offset === undefined ? 0 : input.seat_offset; + if (!Number.isInteger(offset) || offset < 0 || offset > 9999) return fdFail("invalid_input", "seat_offset must be 0–9999."); + const stop = fdPageError(signal); if (stop) return stop; + try { + const selected = await fdResolveScreening(input, checked.showingDate, signal), offers = await fdReadOffers(selected, signal); + const total = offers.seats.length, next = offset + limit < total ? offset + limit : null; + return { status: "ok", screening: selected.screening, currency: "USD", price_basis: "seat_map_preview_not_final", areas: offers.areas, seats: offers.seats.slice(offset, offset + limit), total_seats_read: total, seat_offset: offset, next_seat_offset: next, seats_truncated: offset > 0 || next !== null, max_ticket_limit: offers.maxTicketLimit, surcharges_present: offers.hasSurcharges, observed_at: new Date().toISOString(), warnings: ["Preview only, not a final quote. Taxes, discounts and checkout price parity are unverified.", "No seats are held. Availability, accessibility eligibility and seating rules must be rechecked by the human in checkout."] }; + } catch (error) { return fdCaught(error, signal); } + } +}] diff --git a/domains/fandango.com/list_movies.js b/domains/fandango.com/list_movies.js new file mode 100644 index 00000000..efe3802d --- /dev/null +++ b/domains/fandango.com/list_movies.js @@ -0,0 +1,224 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + "name": "list_movies", + "description": "List the Fandango US now-playing or coming-soon catalog by page. Movie ids feed get_movie and get_movie_showtimes. Uses one same-origin GET, no navigation or account state. The endpoint supplies no total count: next_page is a candidate only when a full page was returned, not proof that more results exist. Does not play trailers, create alerts or enter checkout.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "list" + ], + "properties": { + "list": { + "enum": [ + "now_playing", + "coming_soon" + ] + }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 48, + "default": 20 + } + } + }, + "outputSchema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "list", + "page", + "page_size", + "observed_at", + "movies", + "next_page" + ], + "properties": { + "status": { + "enum": [ + "ok", + "no_results" + ] + }, + "list": { + "enum": [ + "now_playing", + "coming_soon" + ] + }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 48, + "default": 20 + }, + "observed_at": { + "type": "string" + }, + "movies": { + "type": "array", + "maxItems": 48, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id", + "title", + "url", + "release_date", + "rating", + "genres" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "release_date": { + "type": [ + "string", + "null" + ] + }, + "rating": { + "type": [ + "string", + "null" + ] + }, + "genres": { + "type": "array", + "maxItems": 12, + "items": { + "type": "string" + } + } + } + } + }, + "next_page": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "error_kind", + "error_message" + ], + "properties": { + "status": { + "const": "error" + }, + "error_kind": { + "enum": [ + "invalid_input", + "wrong_page", + "region_blocked", + "challenge", + "rate_limited", + "canceled", + "not_found", + "site_changed", + "site_error" + ] + }, + "error_message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const text = (v, cap = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, cap) : null; + const isDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + const pageError = () => { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open a www.fandango.com page first."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "Fandango did not accept this browser's network location; the site is US-only."); + if (context.signal?.aborted) return fail("canceled", "The lookup was canceled before any request."); + return null; + }; + const api = async path => { + const signal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000); + const response = await fetch(path, { headers: { accept: "application/json" }, signal }); + if (response.status === 429) throw { kind: "rate_limited" }; + if (response.status === 403) throw { kind: "challenge" }; + if (new URL(response.url || path, ORIGIN).origin !== ORIGIN) throw { kind: "site_changed" }; + if (new URL(response.url || path, ORIGIN).pathname === "/region-error") throw { kind: "region_blocked" }; + if (response.status === 404) throw { kind: "not_found" }; + if (!response.ok) throw { kind: "site_error" }; + return await response.json(); + }; + const caught = error => { + const kind = context.signal?.aborted || error?.name === "AbortError" ? "canceled" : error?.kind || "site_error"; + const messages = { canceled: "The lookup was canceled; no selection or checkout request was made.", rate_limited: "Fandango returned HTTP 429. Stop and retry later, not automatically.", challenge: "Fandango refused the request. Stop; do not bypass the challenge.", not_found: "Fandango could not find the requested record.", region_blocked: "Fandango is unavailable for this browser's network location.", site_changed: "Fandango returned an unexpected destination or record; the site may have changed." }; + return fail(kind, messages[kind] || "Fandango did not return usable data. No automatic retry was made."); + }; + const pageUrl = (link, suffix) => { + if (typeof link !== "string") return null; + try { const u = new URL(link, ORIGIN); return u.origin === ORIGIN && !u.username && !u.password && suffix.test(u.pathname) ? ORIGIN + u.pathname : null; } catch { return null; } + }; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !["list", "page", "page_size"].includes(k)) || !["now_playing", "coming_soon"].includes(input.list)) return fail("invalid_input", "Choose list now_playing or coming_soon and use only the documented fields."); + const page = input.page === undefined ? 1 : input.page, pageSize = input.page_size === undefined ? 20 : input.page_size; + if (!Number.isInteger(page) || page < 1 || page > 100 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 48) return fail("invalid_input", "page must be 1–100 and page_size 1–48."); + const stop = pageError(); if (stop) return stop; + try { + const data = await api((input.list === "now_playing" ? "/napi/moviesInTheaters" : "/napi/mweb/comingSoon") + "?page=" + page + "&pageSize=" + pageSize); + if (!Array.isArray(data) || data.length > pageSize) return fail("site_changed", "Fandango's catalog is not the expected bounded list."); + const movies = data.map(m => { + const id = String(m?.id ?? ""); + const url = pageUrl(m?.mopLink, new RegExp("-" + id + "/movie-overview/?$")); + if (!/^\d{1,9}$/.test(id) || !text(m?.title) || !url) return null; + return { movie_id: id, title: text(m.title), url, release_date: isDate(m.releaseDateQueryParam) ? m.releaseDateQueryParam : null, rating: text(m.rating, 30), genres: Array.isArray(m.genres) ? m.genres.map(g => text(g, 100)).filter(Boolean).slice(0, 12) : [] }; + }); + if (movies.some(m => !m)) return fail("site_changed", "A catalog item lacks a matching id, title or safe movie URL."); + return { status: movies.length ? "ok" : "no_results", list: input.list, page, page_size: pageSize, observed_at: new Date().toISOString(), movies, next_page: movies.length === pageSize && page < 100 ? page + 1 : null }; + } catch (error) { return caught(error); } + } +}] diff --git a/domains/fandango.com/prepare_ticket_plan.js b/domains/fandango.com/prepare_ticket_plan.js new file mode 100644 index 00000000..9a7587ae --- /dev/null +++ b/domains/fandango.com/prepare_ticket_plan.js @@ -0,0 +1,452 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + "name": "prepare_ticket_plan", + "description": "Prepare an uncommitted ticket plan for an exact reserved-seat Fandango screening. Supply fresh screening ids/date/time/format and ticket area/code/quantity from get_ticket_offers; showing_date is the enclosing showtimes day for an after-midnight screening while date is its local calendar date. A valid numeric site showtime id is required internally; optional seat_ids must match quantity and currently be standard A-status seats in the chosen area. Revalidates the exact screening and current preview prices; returns a USD price-plus-fee subtotal, NOT a final quote, purchase or reservation. Computes a plan only: it never writes cart, browser storage, selected seats or account state, opens checkout, validates/reserves seats, applies promotions or submits payment. Mixed areas, linked/accessibility seats, multi-admission tickets or unpriced surcharges fail closed. Human handoff is to the public theater page; no automatic next action. Final execution is unavailable because this runtime has no trusted exact-action approval path.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id", + "theater_id", + "date", + "local_time", + "format", + "tickets" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "theater_id": { + "type": "string", + "pattern": "^[A-Za-z0-9]{3,12}$" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "local_time": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "format": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "showing_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Enclosing showtimes day, when different from the screening date after midnight. Defaults to date; must be date or the previous day." + }, + "showtime_id": { + "type": "integer", + "minimum": 1, + "maximum": 999999999999 + }, + "tickets": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "area_code", + "ticket_code", + "quantity" + ], + "properties": { + "area_code": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "ticket_code": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 20 + } + } + } + }, + "seat_ids": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + } + } + } + }, + "outputSchema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "screening", + "tickets", + "quantity", + "proposed_seat_ids", + "currency", + "price_basis", + "price_plus_fee_subtotal_cents", + "final_total_cents", + "inventory_reserved", + "cart_changed", + "execution_available", + "execution_blocker", + "handoff_url", + "observed_at", + "warnings" + ], + "properties": { + "status": { + "const": "prepared" + }, + "screening": { + "type": "object", + "additionalProperties": false, + "required": [ + "movie_id", + "movie_title", + "theater_id", + "theater_name", + "showing_date", + "date", + "local_time", + "format", + "showtime_id", + "reserved_seating", + "theater_url" + ], + "properties": { + "movie_id": { + "type": "string", + "pattern": "^\\d{1,9}$" + }, + "movie_title": { + "type": "string" + }, + "theater_id": { + "type": "string", + "pattern": "^[A-Z0-9]{3,12}$" + }, + "theater_name": { + "type": "string" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "local_time": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "format": { + "type": "string" + }, + "showtime_id": { "type": "integer", "minimum": 1, "maximum": 999999999999 }, + "showing_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "reserved_seating": { + "type": "boolean" + }, + "theater_url": { + "type": "string", + "format": "uri" + } + } + }, + "tickets": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "area_code", + "ticket_code", + "name", + "quantity", + "unit_price_cents", + "unit_fee_cents", + "line_subtotal_cents" + ], + "properties": { + "area_code": { + "type": "string" + }, + "ticket_code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 20 + }, + "unit_price_cents": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "unit_fee_cents": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "line_subtotal_cents": { + "type": "integer", + "minimum": 0, + "maximum": 40000000 + } + } + } + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 20 + }, + "proposed_seat_ids": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string" + } + }, + "currency": { + "const": "USD" + }, + "price_basis": { + "const": "seat_map_preview_not_final" + }, + "price_plus_fee_subtotal_cents": { + "type": "integer", + "minimum": 0, + "maximum": 40000000 + }, + "final_total_cents": { + "type": "null" + }, + "inventory_reserved": { + "const": false + }, + "cart_changed": { + "const": false + }, + "execution_available": { + "const": false + }, + "execution_blocker": { + "type": "string" + }, + "handoff_url": { + "type": "string", + "format": "uri" + }, + "observed_at": { + "type": "string" + }, + "warnings": { + "type": "array", + "maxItems": 5, + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "error_kind", + "error_message" + ], + "properties": { + "status": { + "const": "error" + }, + "error_kind": { + "enum": [ + "invalid_input", + "wrong_page", + "region_blocked", + "challenge", + "rate_limited", + "canceled", + "not_found", + "site_changed", + "site_error", + "showtime_unavailable", + "ambiguous_showtime", + "general_admission", + "selection_unavailable", + "price_unavailable" + ] + }, + "error_message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + execute: async (input, context = {}) => { + // @xwebmcp-inline lib/fandango-screening.js + // The exact Fandango screening shared by get_ticket_offers and prepare_ticket_plan: the screening inputs, the showtime + // they name in a fresh theater-day GET, and the site's noninteractive reserved-seat preview for it. Reads never navigate, + // select seats, validate a selection or open checkout. + const FD_ORIGIN = "https://www.fandango.com"; + const fdFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const fdText = (v, cap = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, cap) : null; + const fdIsDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; + const fdPageError = signal => { + if (window.location.origin !== FD_ORIGIN) return fdFail("wrong_page", "Open a www.fandango.com page first."); + if (window.location.pathname === "/region-error") return fdFail("region_blocked", "Fandango did not accept this browser's network location; the site is US-only."); + if (signal?.aborted) return fdFail("canceled", "The lookup was canceled before any request."); + return null; + }; + const fdApi = async (path, signal) => { + const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000); + const response = await fetch(path, { headers: { accept: "application/json" }, redirect: "manual", signal: bounded }); + if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) throw { kind: "site_changed" }; + if (response.status === 429) throw { kind: "rate_limited" }; + if (response.status === 403) throw { kind: "challenge" }; + if (new URL(response.url || path, FD_ORIGIN).origin !== FD_ORIGIN) throw { kind: "site_changed" }; + if (new URL(response.url || path, FD_ORIGIN).pathname === "/region-error") throw { kind: "region_blocked" }; + if (response.status === 404) throw { kind: "not_found" }; + if (!response.ok) throw { kind: "site_error" }; + return await response.json(); + }; + const fdCaught = (error, signal) => { + const kind = signal?.aborted || error?.name === "AbortError" ? "canceled" : error?.kind || "site_error"; + const messages = { showtime_unavailable: "The exact screening is missing, past, sold out or not available for ticketing. Refresh showtimes.", ambiguous_showtime: "More than one screening matches; supply showtime_id from fresh showtimes.", general_admission: "No reserved-seat preview is available. General-admission prices require checkout, which this tool never opens.", selection_unavailable: "The proposed tickets or seats are not available in this preview; nothing was selected or reserved.", price_unavailable: "The price cannot safely be calculated from this preview; complete it manually.", canceled: "The lookup was canceled; no selection or checkout request was made.", rate_limited: "Fandango returned HTTP 429. Stop and retry later, not automatically.", challenge: "Fandango refused the request. Stop; do not bypass the challenge.", not_found: "Fandango could not find the requested record.", region_blocked: "Fandango is unavailable for this browser's network location.", site_changed: "Fandango returned an unexpected destination or record; the site may have changed." }; + return fdFail(kind, messages[kind] || "Fandango did not return usable data. No automatic retry was made."); + }; + const fdPageUrl = (link, suffix) => { + if (typeof link !== "string") return null; + try { const u = new URL(link, FD_ORIGIN); return u.origin === FD_ORIGIN && !u.username && !u.password && suffix.test(u.pathname) ? FD_ORIGIN + u.pathname : null; } catch { return null; } + }; + const fdCode = v => typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v); + const fdScreeningInput = input => { + if (typeof input.movie_id !== "string" || !/^\d{1,9}$/.test(input.movie_id) || typeof input.theater_id !== "string" || !/^[A-Za-z0-9]{3,12}$/.test(input.theater_id) || !fdIsDate(input.date) || typeof input.local_time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.local_time) || typeof input.format !== "string" || !input.format.trim() || input.format.length > 100 || (input.showtime_id !== undefined && (!Number.isSafeInteger(input.showtime_id) || input.showtime_id < 1 || input.showtime_id > 999999999999))) return { error: fdFail("invalid_input", "Supply valid movie_id, theater_id, date, 24-hour local_time and exact format; showtime_id, if supplied, must be a positive integer.") }; + const showingDate = input.showing_date === undefined ? input.date : input.showing_date; + if (!fdIsDate(showingDate) || ![0, 86400000].includes(Date.parse(input.date) - Date.parse(showingDate))) return { error: fdFail("invalid_input", "showing_date must be date or the previous calendar day.") }; + return { showingDate }; + }; + const fdResolveScreening = async (input, showingDate, signal) => { + const data = await fdApi("/napi/theaterMovieShowtimes/" + input.theater_id.toUpperCase() + "?chainCode=&startDate=" + showingDate + "&isdesktop=true&partnerRestrictedTicketing=", signal); + const vm = data?.viewModel, details = vm?.theater?.details; + if (!vm && data?.error === true) throw { kind: "not_found" }; + const theaterId = input.theater_id.toUpperCase(); + const theaterUrl = fdPageUrl(details?.theaterPageUrl, new RegExp("-" + theaterId + "/theater-page/?$", "i")); + if (!details || String(details.id).toUpperCase() !== theaterId || !theaterUrl || !fdText(details.name) || !Array.isArray(vm.movies)) throw { kind: "site_changed" }; + if (vm.date !== showingDate || !vm.movies.length) throw { kind: "showtime_unavailable" }; + if (details.isTicketing !== true) throw { kind: "showtime_unavailable" }; + const matches = []; + for (const m of vm.movies) { + if (String(m?.id) !== input.movie_id || !fdText(m.title)) continue; + for (const v of Array.isArray(m.variants) ? m.variants : []) { + if (v?.filmFormatHeader !== input.format) continue; + for (const g of Array.isArray(v.amenityGroups) ? v.amenityGroups : []) for (const s of Array.isArray(g?.showtimes) ? g.showtimes : []) { + if (s?.ticketingDate !== input.date + "+" + input.local_time || (input.showtime_id !== undefined && s.id !== input.showtime_id)) continue; + matches.push({ movie: m, group: g, show: s }); + } + } + } + if (!matches.length) throw { kind: "showtime_unavailable" }; + if (matches.length !== 1) throw { kind: "ambiguous_showtime" }; + const { movie, group, show } = matches[0]; + if (!Number.isSafeInteger(show.id) || show.id < 1 || show.id > 999999999999 || typeof show.expired !== "boolean" || typeof show.isSoldOut !== "boolean" || typeof group.hasReservedSeating !== "boolean") throw { kind: "site_changed" }; + if (show.type !== "available" || show.expired || show.isSoldOut) throw { kind: "showtime_unavailable" }; + const screening = { movie_id: input.movie_id, movie_title: fdText(movie.title), theater_id: theaterId, theater_name: fdText(details.name), showing_date: showingDate, date: input.date, local_time: input.local_time, format: fdText(input.format, 100), showtime_id: show.id, reserved_seating: group.hasReservedSeating, theater_url: theaterUrl }; + return { screening, show }; + }; + const fdMoney = v => typeof v === "string" && /^\d{1,4}(?:\.\d{1,2})?$/.test(v) ? Math.round(Number(v) * 100) : null; + const fdReadOffers = async ({ screening, show }, signal) => { + if (!screening.reserved_seating) throw { kind: "general_admission" }; + if (typeof show.showtimeHashCode !== "string" || !show.showtimeHashCode || show.showtimeHashCode.length > 300 || /[\u0000-\u0020\u007f]/.test(show.showtimeHashCode)) throw { kind: "site_changed" }; + const previewPath = "/napi/seatMap/" + encodeURIComponent(show.showtimeHashCode); + if (new URL(previewPath, FD_ORIGIN).pathname !== previewPath) throw { kind: "site_changed" }; + // This is the site's noninteractive preview GET, not its seat validation or ticket-selection flow. + const map = await fdApi(previewPath, signal); + if (typeof map?.tmsId !== "string" || map.tmsId.toUpperCase() !== screening.theater_id || !["string", "number"].includes(typeof map.showtimeId) || String(map.showtimeId) !== String(screening.showtime_id) || !Array.isArray(map.areas) || !map.areas.length || map.areas.length > 20 || !Array.isArray(map.seats) || map.seats.length > 10000 || !Array.isArray(map.seatBlocks) || map.seatBlocks.length > 10000 || !Number.isInteger(map.maxTicketLimit) || map.maxTicketLimit < 1 || map.maxTicketLimit > 100 || !Array.isArray(map.surcharges)) throw { kind: "site_changed" }; + if (map.seatBlocks.some(b => !fdCode(b?.id)) || new Set(map.seatBlocks.map(b => b.id)).size !== map.seatBlocks.length) throw { kind: "site_changed" }; + const areas = map.areas.map(a => { + if (!fdCode(a?.code) || !fdText(a.name) || !Number.isInteger(a.availableSeatCount) || !Number.isInteger(a.totalSeatCount) || a.availableSeatCount < 0 || a.totalSeatCount < a.availableSeatCount || a.totalSeatCount > 10000 || !Array.isArray(a.ticketInfo) || !a.ticketInfo.length || a.ticketInfo.length > 30) throw { kind: "site_changed" }; + const tickets = a.ticketInfo.map(t => { + const price = fdMoney(t?.price), fee = fdMoney(t?.fee); + if (!fdCode(t?.code) || !fdText(t.desc) || price === null || fee === null || !Number.isInteger(t.admissionsPerTicket) || t.admissionsPerTicket < 1 || t.admissionsPerTicket > 20) throw { kind: "site_changed" }; + return { ticket_code: t.code, name: fdText(t.desc), price_cents: price, fee_cents: fee, admissions_per_ticket: t.admissionsPerTicket }; + }); + if (new Set(tickets.map(t => t.ticket_code)).size !== tickets.length) throw { kind: "site_changed" }; + return { area_code: a.code, name: fdText(a.name), available_seats: a.availableSeatCount, total_seats: a.totalSeatCount, tickets }; + }); + if (new Set(areas.map(a => a.area_code)).size !== areas.length) throw { kind: "site_changed" }; + const seats = map.seats.map(s => { + if (!fdCode(s?.id) || !fdCode(s.areaCode) || !areas.some(a => a.area_code === s.areaCode) || typeof s.status !== "string" || !s.status || s.status.length > 10 || !fdCode(s.type)) throw { kind: "site_changed" }; + const blockId = s.seatBlockId; + if (blockId !== undefined && blockId !== null && blockId !== "" && (!fdCode(blockId) || !map.seatBlocks.some(b => b.id === blockId))) throw { kind: "site_changed" }; + if (s.sellIndividuallyWithinSeatBlock !== undefined && typeof s.sellIndividuallyWithinSeatBlock !== "boolean") throw { kind: "site_changed" }; + return { seat_id: s.id, area_code: s.areaCode, type: blockId ? "linked" : s.type, status: s.status === "A" ? "available" : ["O", "R", "X"].includes(s.status) ? "unavailable" : "unknown", site_status: s.status }; + }); + if (new Set(seats.map(s => s.seat_id)).size !== seats.length) throw { kind: "site_changed" }; + return { areas, seats, maxTicketLimit: map.maxTicketLimit, hasSurcharges: map.surcharges.length > 0 }; + }; + // @xwebmcp-end + const signal = context.signal; + const allowed = ["movie_id", "theater_id", "date", "showing_date", "local_time", "format", "showtime_id", "tickets", "seat_ids"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k))) return fdFail("invalid_input", "Use only the documented screening and selection inputs."); + const checked = fdScreeningInput(input); + if (checked.error) return checked.error; + if (!Array.isArray(input.tickets) || !input.tickets.length || input.tickets.length > 10 || input.tickets.some(t => !t || typeof t !== "object" || Array.isArray(t) || Object.keys(t).sort().join(",") !== "area_code,quantity,ticket_code" || !fdCode(t.area_code) || !fdCode(t.ticket_code) || !Number.isInteger(t.quantity) || t.quantity < 1 || t.quantity > 20)) return fdFail("invalid_input", "tickets must have 1–10 area_code/ticket_code/quantity entries with quantity 1–20."); + const quantity = input.tickets.reduce((n, t) => n + t.quantity, 0); + if (quantity > 20 || new Set(input.tickets.map(t => t.area_code + "\0" + t.ticket_code)).size !== input.tickets.length || new Set(input.tickets.map(t => t.area_code)).size !== 1) return fdFail("invalid_input", "Use one area, unique ticket codes and at most 20 admissions."); + if (input.seat_ids !== undefined && (!Array.isArray(input.seat_ids) || input.seat_ids.length !== quantity || input.seat_ids.some(s => !fdCode(s)) || new Set(input.seat_ids).size !== input.seat_ids.length)) return fdFail("invalid_input", "seat_ids must be unique nonempty strings and match the total ticket quantity."); + const stop = fdPageError(signal); if (stop) return stop; + try { + const selected = await fdResolveScreening(input, checked.showingDate, signal), offers = await fdReadOffers(selected, signal); + if (offers.hasSurcharges) return fdFail("price_unavailable", "The preview contains surcharges whose application has not been verified; prepare manually."); + if (quantity > offers.maxTicketLimit) return fdFail("selection_unavailable", "The requested quantity exceeds the show's ticket limit; nothing was selected."); + const chosenArea = offers.areas.find(a => a.area_code === input.tickets[0].area_code); + if (!chosenArea || chosenArea.available_seats < quantity || offers.seats.filter(s => s.area_code === chosenArea.area_code && s.status === "available" && s.type === "standard").length < quantity) throw { kind: "selection_unavailable" }; + const tickets = input.tickets.map(t => { + const offer = chosenArea.tickets.find(o => o.ticket_code === t.ticket_code); + if (!offer || offer.admissions_per_ticket !== 1) throw { kind: "selection_unavailable" }; + return { area_code: t.area_code, ticket_code: t.ticket_code, name: offer.name, quantity: t.quantity, unit_price_cents: offer.price_cents, unit_fee_cents: offer.fee_cents, line_subtotal_cents: (offer.price_cents + offer.fee_cents) * t.quantity }; + }); + const seats = input.seat_ids ?? []; + if (seats.some(id => !offers.seats.some(s => s.seat_id === id && s.area_code === chosenArea.area_code && s.status === "available" && s.type === "standard"))) throw { kind: "selection_unavailable" }; + return { status: "prepared", screening: selected.screening, tickets, quantity, proposed_seat_ids: seats, currency: "USD", price_basis: "seat_map_preview_not_final", price_plus_fee_subtotal_cents: tickets.reduce((n, t) => n + t.line_subtotal_cents, 0), final_total_cents: null, inventory_reserved: false, cart_changed: false, execution_available: false, execution_blocker: "No supported trusted human approval path for an exact purchase or hold exists in this runtime. Continue manually before any commitment.", handoff_url: selected.screening.theater_url, observed_at: new Date().toISOString(), warnings: ["This returned plan was not stored or applied to Fandango. No seats are held.", "Subtotal includes the preview's ticket price and per-ticket fee only; taxes, discounts, surcharges and final checkout parity are unverified.", "Seat adjacency, orphan-seat rules and eligibility have not been validated. A human must recheck all terms and final charges."] }; + } catch (error) { return fdCaught(error, signal); } + } +}] diff --git a/domains/fandango.com/search.js b/domains/fandango.com/search.js new file mode 100644 index 00000000..da424026 --- /dev/null +++ b/domains/fandango.com/search.js @@ -0,0 +1,132 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + name: "search", + title: "Search Fandango", + description: "Search Fandango (US) the way its header search box suggests results: movies, theaters, cities and people that match the text. Returns ids and canonical www.fandango.com URLs. Pass a movie_id to get_movie_showtimes, a theater_id to get_theater_showtimes, or a city and state to search_theaters. The site returns only a few suggestions per type (about four), so this is not the full results page; search_url opens it. Suggestions are prefix-style matches on names, so a query such as a city plus a chain name can return nothing. Runs inside the current www.fandango.com page without navigating. Read-only; works signed out and does not use or return account data.", + inputSchema: { + type: "object", additionalProperties: false, required: ["query"], + properties: { + query: { type: "string", minLength: 2, maxLength: 100, pattern: "\\S", description: "Words as typed into the site's search box, such as a movie title, a theater name or 'Chicago'." }, + types: { type: "array", minItems: 1, uniqueItems: true, items: { enum: ["movies", "theaters", "locations", "people"] }, description: "Only these result types are returned. Default: all four." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "query", "observed_at", "search_url", "movies", "theaters", "locations", "people"], + properties: { + status: { const: "ok" }, + query: { type: "string", description: "The normalized query that was searched." }, + observed_at: { type: "string", description: "ISO timestamp of the search." }, + search_url: { type: "string", format: "uri", description: "The site's full search results page for the same text." }, + movies: { + type: "array", description: "Empty when movies were not requested or nothing matched.", + items: { + type: "object", additionalProperties: false, required: ["movie_id", "name", "release_date", "url"], + properties: { movie_id: { type: "string", pattern: "^\\d{1,9}$", description: "Numeric id from the movie page URL; pass it to get_movie_showtimes." }, name: { type: "string" }, release_date: { type: ["string", "null"], description: "YYYY-MM-DD, as listed by the site." }, url: { type: "string", format: "uri" } } + } + }, + theaters: { + type: "array", description: "Empty when theaters were not requested or nothing matched.", + items: { + type: "object", additionalProperties: false, required: ["theater_id", "name", "url"], + properties: { theater_id: { type: "string", pattern: "^[A-Z0-9]{3,12}$", description: "Fandango theater id in upper case; pass it to get_theater_showtimes." }, name: { type: "string" }, url: { type: "string", format: "uri" } } + } + }, + locations: { + type: "array", description: "Empty when locations were not requested or nothing matched.", + items: { + type: "object", additionalProperties: false, required: ["name", "city", "state", "url"], + properties: { name: { type: "string", description: "City and state as listed, for example 'Tampa, CO'. Several places can share a city name." }, city: { type: "string" }, state: { type: ["string", "null"] }, url: { type: "string", format: "uri", description: "The site's movie times page for that place." } } + } + }, + people: { + type: "array", description: "Empty when people were not requested or nothing matched.", + items: { + type: "object", additionalProperties: false, required: ["name", "url"], + properties: { name: { type: "string" }, url: { type: "string", format: "uri" } } + } + } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "query", "observed_at", "search_url", "message"], + properties: { status: { const: "no_results" }, query: { type: "string" }, observed_at: { type: "string" }, search_url: { type: "string", format: "uri" }, message: { type: "string" } } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { status: { const: "error" }, error_kind: { enum: ["invalid_input", "wrong_page", "region_blocked", "challenge", "rate_limited", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const TYPES = ["movies", "theaters", "locations", "people"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "query" && k !== "types")) return fail("invalid_input", "Unknown or missing input; allowed fields are query and types."); + if (typeof input.query !== "string") return fail("invalid_input", "query must be a string of 2 to 100 characters."); + const query = input.query.trim().replace(/\s+/g, " "); + if (query.length < 2 || query.length > 100) return fail("invalid_input", "query must be a string of 2 to 100 characters."); + let types = TYPES; + if (input.types !== undefined) { + if (!Array.isArray(input.types) || input.types.length < 1 || input.types.some(t => !TYPES.includes(t)) || new Set(input.types).size !== input.types.length) return fail("invalid_input", "types must be a non-empty list without repeats, drawn from " + TYPES.join(", ") + "."); + types = input.types; + } + const searchUrl = ORIGIN + "/search?q=" + encodeURIComponent(query) + "&mode=all"; + const observedAt = new Date().toISOString(); + + try { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.fandango.com page first; this tool calls the site's search from that page."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "This tab is on Fandango's /region-error page: the site is US-only and did not accept this browser's network location. Use a US network location and reload."); + const res = await fetch("/napi/home/autocompleteDesktopSearch?search=" + encodeURIComponent(query), { headers: { accept: "application/json" }, signal: context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000) }); + if (res.status === 429) return fail("rate_limited", "Fandango returned HTTP 429; wait a few minutes before searching again."); + if (res.status === 403) return fail("challenge", "Fandango refused the request (HTTP 403), which usually means bot protection; wait or use another browser session."); + let body = null; + try { body = JSON.parse(await res.text()); } catch {} + if (res.status >= 400 || !body || typeof body !== "object" || !body.resultsByType || typeof body.resultsByType !== "object") return fail("site_error", "Fandango search returned HTTP " + res.status + (body ? " with an unexpected response shape." : " with a non-JSON body.")); + + const items = type => Array.isArray(body.resultsByType[type]?.items) ? body.resultsByType[type].items : []; + // Links are site-relative paths; keep only ones that stay on www.fandango.com and drop any query or fragment. + // The site redirects theater links to lower case and city links from '+' to '-', so those are returned in their final form. + // For city names with an apostrophe the site's own link is a 404; the same path without the apostrophe resolves. + const pathOf = link => { + if (typeof link !== "string" || !link.startsWith("/") || link.startsWith("//")) return null; + try { const u = new URL(link, ORIGIN); return u.origin === ORIGIN ? u.pathname : null; } catch { return null; } + }; + const movies = !types.includes("movies") ? [] : items("movies").flatMap(m => { + const path = pathOf(m?.link); + const id = path && /-(\d{1,9})\/movie-overview\/?$/.exec(path)?.[1]; + if (!id || typeof m.name !== "string") return []; + return [{ movie_id: id, name: m.name, release_date: typeof m.releaseDate === "string" && /^\d{4}-\d{2}-\d{2}/.test(m.releaseDate) ? m.releaseDate.slice(0, 10) : null, url: ORIGIN + path }]; + }); + const theaters = !types.includes("theaters") ? [] : items("theaters").flatMap(t => { + const path = pathOf(t?.link); + const id = path && /-([A-Za-z0-9]{3,12})\/theater-page\/?$/.exec(path)?.[1]; + if (!id || typeof t.name !== "string") return []; + return [{ theater_id: id.toUpperCase(), name: t.name, url: ORIGIN + path.toLowerCase() }]; + }); + const locations = !types.includes("locations") ? [] : items("locations").flatMap(l => { + const path = pathOf(l?.link); + if (!path || !/_movietimes\/?$/.test(path) || typeof l.name !== "string") return []; + return [{ name: l.name, city: l.name.replace(/,\s*[A-Za-z]{2}$/, ""), state: typeof l.state === "string" && l.state ? l.state : null, url: ORIGIN + path.replace(/\+/g, "-").replace(/'/g, "") }]; + }); + const people = !types.includes("people") ? [] : items("people").flatMap(p => { + const path = pathOf(p?.link); + if (!path || !path.startsWith("/people/") || typeof p.name !== "string") return []; + return [{ name: p.name, url: ORIGIN + path }]; + }); + if (!movies.length && !theaters.length && !locations.length && !people.length) return { status: "no_results", query, observed_at: observedAt, search_url: searchUrl, message: "Fandango's suggestions returned nothing for '" + query + "' in " + types.join(", ") + ". Suggestions are prefix matches on names; try fewer or different words, or open search_url." }; + return { status: "ok", query, observed_at: observedAt, search_url: searchUrl, movies, theaters, locations, people }; + } catch (error) { + if (context.signal?.aborted) return fail("canceled", "The read was canceled."); + if (error && error.name === "TimeoutError") return fail("site_error", "Fandango search did not answer within 20 seconds."); + return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + } + } +}] diff --git a/domains/fandango.com/search_theaters.js b/domains/fandango.com/search_theaters.js new file mode 100644 index 00000000..f693fc71 --- /dev/null +++ b/domains/fandango.com/search_theaters.js @@ -0,0 +1,137 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.fandango.com/*"] }, + tool: { + name: "search_theaters", + title: "Search Fandango theaters near a place", + description: "List movie theaters near a US ZIP code, or near a US city and state, as Fandango's own nearby-theaters list shows them, in the site's order (nearest first when checked). Returns theater_id, name, canonical theater page URL, address, distance in miles from the place, chain, phone, amenities and whether Fandango sells tickets online for it. Give exactly one of zip, or city with state. Pass a theater_id to get_theater_showtimes for that theater's showtimes. Fandango returns an empty list for an unknown ZIP and an error for a city it does not recognize; both come back as no_results, which cannot tell a mistyped place from one with no theaters. The site is strict about city spelling ('St. Louis' with the period works, 'St Louis' does not; hyphens are sent as spaces; names with apostrophes were not found), so retry with a ZIP. Runs inside the current www.fandango.com page without navigating. Read-only; works signed out and never uses saved or favorite theaters.", + inputSchema: { + type: "object", additionalProperties: false, + properties: { + zip: { type: "string", pattern: "^\\d{5}$", description: "5-digit US ZIP code. Use this or city with state." }, + city: { type: "string", minLength: 2, maxLength: 60, pattern: "^[A-Za-z][A-Za-z .'-]*$", description: "US city name, for example 'Chicago'. Requires state." }, + state: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter US state code, for example 'IL'. Requires city." }, + limit: { type: "integer", minimum: 1, maximum: 30, default: 10, description: "Maximum theaters to return, in the site's order." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, required: ["status", "place", "observed_at", "theaters"], + properties: { + status: { const: "ok" }, + place: { + type: "object", additionalProperties: false, required: ["zip", "city", "state"], + properties: { zip: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] } }, + description: "The normalized place that was searched." + }, + observed_at: { type: "string", description: "ISO timestamp of the search." }, + theaters: { + type: "array", minItems: 1, + items: { + type: "object", additionalProperties: false, + required: ["theater_id", "name", "url", "distance_miles", "address", "chain", "phone", "ticketing_available", "has_reserved_seating", "has_concessions", "open", "amenities"], + properties: { + theater_id: { type: "string", pattern: "^[A-Z0-9]{3,12}$", description: "Fandango theater id in upper case; pass it to get_theater_showtimes." }, + name: { type: "string" }, + url: { type: "string", format: "uri", description: "The theater page on www.fandango.com." }, + distance_miles: { type: ["number", "null"], description: "Distance from the searched place as reported by the site." }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "zip"], + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, zip: { type: ["string", "null"] } } + }, + chain: { type: ["string", "null"] }, + phone: { type: ["string", "null"] }, + ticketing_available: { type: "boolean", description: "True when Fandango sells tickets online for this theater." }, + has_reserved_seating: { type: "boolean" }, + has_concessions: { type: "boolean", description: "True when the theater offers Fandango's concession pickup." }, + open: { type: "boolean", description: "False when the site marks the theater closed or temporarily closed." }, + amenities: { type: "array", items: { type: "string" }, description: "Amenity names as listed by the site." } + } + } + } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "place", "observed_at", "message"], + properties: { + status: { const: "no_results" }, + place: { type: "object", additionalProperties: false, required: ["zip", "city", "state"], properties: { zip: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] } } }, + observed_at: { type: "string" }, + message: { type: "string" } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { status: { const: "error" }, error_kind: { enum: ["invalid_input", "wrong_page", "region_blocked", "challenge", "rate_limited", "site_error", "canceled"] }, error_message: { type: "string", minLength: 1 } } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input, context = {}) => { + const ORIGIN = "https://www.fandango.com"; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const allowed = ["zip", "city", "state", "limit"]; + 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(", ") + "."); + const hasZip = input.zip !== undefined; + const hasCity = input.city !== undefined || input.state !== undefined; + if (hasZip === hasCity) return fail("invalid_input", "Give either zip, or both city and state."); + let zip = null, city = null, state = null; + if (hasZip) { + if (typeof input.zip !== "string" || !/^\d{5}$/.test(input.zip)) return fail("invalid_input", "zip must be a 5-digit US ZIP code."); + zip = input.zip; + } else { + if (typeof input.city !== "string" || typeof input.state !== "string") return fail("invalid_input", "city and state must both be given."); + city = input.city.trim().replace(/\s+/g, " "); + if (city.length < 2 || city.length > 60 || !/^[A-Za-z][A-Za-z .'-]*$/.test(city)) return fail("invalid_input", "city must be a US city name of 2 to 60 letters."); + if (!/^[A-Za-z]{2}$/.test(input.state)) return fail("invalid_input", "state must be a two-letter US state code."); + state = input.state.toUpperCase(); + } + const limit = input.limit === undefined ? 10 : input.limit; + if (!Number.isInteger(limit) || limit < 1 || limit > 30) return fail("invalid_input", "limit must be an integer from 1 to 30."); + const place = { zip, city, state }; + const observedAt = new Date().toISOString(); + + try { + if (window.location.origin !== ORIGIN) return fail("wrong_page", "Open any https://www.fandango.com page first; this tool calls the site's theater search from that page."); + if (window.location.pathname === "/region-error") return fail("region_blocked", "This tab is on Fandango's /region-error page: the site is US-only and did not accept this browser's network location. Use a US network location and reload."); + const params = new URLSearchParams({ limit: String(limit) }); + if (zip) params.set("zipCode", zip); + else { params.set("city", city.replace(/-/g, " ")); params.set("state", state); } + const res = await fetch("/napi/nearbyTheaters?" + params.toString(), { headers: { accept: "application/json" }, signal: context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000) }); + if (res.status === 429) return fail("rate_limited", "Fandango returned HTTP 429; wait a few minutes before searching again."); + if (res.status === 403) return fail("challenge", "Fandango refused the request (HTTP 403), which usually means bot protection; wait or use another browser session."); + let body = null; + try { body = JSON.parse(await res.text()); } catch {} + // A city the site does not recognize answers HTTP 404 with an error object instead of an empty list. + if (res.status === 404 && city && body?.error) return { status: "no_results", place, observed_at: observedAt, message: "Fandango did not recognize the city and state. The site is strict about spelling ('St. Louis' with the period works, 'St Louis' does not; hyphens are sent as spaces; names with apostrophes were not found). Try a ZIP code." }; + if (res.status >= 400 || !body || typeof body !== "object" || !Array.isArray(body.theaters)) return fail("site_error", "Fandango's theater search returned HTTP " + res.status + (body ? " with an unexpected response shape." : " with a non-JSON body.")); + + const text = v => typeof v === "string" && v.trim() ? v.trim() : null; + const theaters = body.theaters.flatMap(t => { + const id = typeof t?.id === "string" && /^[A-Za-z0-9]{3,12}$/.test(t.id) ? t.id.toUpperCase() : null; + if (!id || !text(t.name)) return []; + let path = null; + try { const u = new URL(t.theaterPageUrl, ORIGIN); if (u.origin === ORIGIN && /\/theater-page\/?$/.test(u.pathname)) path = u.pathname; } catch {} + if (!path) return []; + return [{ + theater_id: id, name: text(t.name), url: ORIGIN + path, + distance_miles: Number.isFinite(t.distance) ? Math.round(t.distance * 100) / 100 : null, + address: { street: [t.address1, t.address2].map(text).filter(Boolean).join(", ") || null, city: text(t.city), state: text(t.state), zip: text(t.zip) }, + chain: text(t.chainName), phone: text(t.phone), + ticketing_available: t.isTicketing === true, has_reserved_seating: t.hasReservedSeating === true, has_concessions: t.hasConcessions === true, + open: t.isOpen !== false && t.isTemporarilyClosed !== true, + amenities: Array.isArray(t.amenities) ? t.amenities.map(a => text(a?.name)).filter(Boolean).slice(0, 20) : [] + }]; + }); + if (!theaters.length) return { status: "no_results", place, observed_at: observedAt, message: "Fandango listed no theaters near that place. The site returns an empty list for an unknown ZIP or city as well as for a place with no theaters." }; + return { status: "ok", place, observed_at: observedAt, theaters }; + } catch (error) { + if (context.signal?.aborted) return fail("canceled", "The read was canceled."); + if (error && error.name === "TimeoutError") return fail("site_error", "Fandango's theater search did not answer within 20 seconds."); + return fail("site_error", "The request from the page failed: " + (error && error.message ? error.message : String(error))); + } + } +}] diff --git a/lib/fandango-screening.js b/lib/fandango-screening.js new file mode 100644 index 00000000..09f8d95e --- /dev/null +++ b/lib/fandango-screening.js @@ -0,0 +1,100 @@ +// The exact Fandango screening shared by get_ticket_offers and prepare_ticket_plan: the screening inputs, the showtime +// they name in a fresh theater-day GET, and the site's noninteractive reserved-seat preview for it. Reads never navigate, +// select seats, validate a selection or open checkout. +const FD_ORIGIN = "https://www.fandango.com"; +const fdFail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); +const fdText = (v, cap = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, cap) : null; +const fdIsDate = v => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + "T00:00:00Z")) && new Date(v + "T00:00:00Z").toISOString().slice(0, 10) === v; +const fdPageError = signal => { + if (window.location.origin !== FD_ORIGIN) return fdFail("wrong_page", "Open a www.fandango.com page first."); + if (window.location.pathname === "/region-error") return fdFail("region_blocked", "Fandango did not accept this browser's network location; the site is US-only."); + if (signal?.aborted) return fdFail("canceled", "The lookup was canceled before any request."); + return null; +}; +const fdApi = async (path, signal) => { + const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000); + const response = await fetch(path, { headers: { accept: "application/json" }, redirect: "manual", signal: bounded }); + if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) throw { kind: "site_changed" }; + if (response.status === 429) throw { kind: "rate_limited" }; + if (response.status === 403) throw { kind: "challenge" }; + if (new URL(response.url || path, FD_ORIGIN).origin !== FD_ORIGIN) throw { kind: "site_changed" }; + if (new URL(response.url || path, FD_ORIGIN).pathname === "/region-error") throw { kind: "region_blocked" }; + if (response.status === 404) throw { kind: "not_found" }; + if (!response.ok) throw { kind: "site_error" }; + return await response.json(); +}; +const fdCaught = (error, signal) => { + const kind = signal?.aborted || error?.name === "AbortError" ? "canceled" : error?.kind || "site_error"; + const messages = { showtime_unavailable: "The exact screening is missing, past, sold out or not available for ticketing. Refresh showtimes.", ambiguous_showtime: "More than one screening matches; supply showtime_id from fresh showtimes.", general_admission: "No reserved-seat preview is available. General-admission prices require checkout, which this tool never opens.", selection_unavailable: "The proposed tickets or seats are not available in this preview; nothing was selected or reserved.", price_unavailable: "The price cannot safely be calculated from this preview; complete it manually.", canceled: "The lookup was canceled; no selection or checkout request was made.", rate_limited: "Fandango returned HTTP 429. Stop and retry later, not automatically.", challenge: "Fandango refused the request. Stop; do not bypass the challenge.", not_found: "Fandango could not find the requested record.", region_blocked: "Fandango is unavailable for this browser's network location.", site_changed: "Fandango returned an unexpected destination or record; the site may have changed." }; + return fdFail(kind, messages[kind] || "Fandango did not return usable data. No automatic retry was made."); +}; +const fdPageUrl = (link, suffix) => { + if (typeof link !== "string") return null; + try { const u = new URL(link, FD_ORIGIN); return u.origin === FD_ORIGIN && !u.username && !u.password && suffix.test(u.pathname) ? FD_ORIGIN + u.pathname : null; } catch { return null; } +}; +const fdCode = v => typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v); +const fdScreeningInput = input => { + if (typeof input.movie_id !== "string" || !/^\d{1,9}$/.test(input.movie_id) || typeof input.theater_id !== "string" || !/^[A-Za-z0-9]{3,12}$/.test(input.theater_id) || !fdIsDate(input.date) || typeof input.local_time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.local_time) || typeof input.format !== "string" || !input.format.trim() || input.format.length > 100 || (input.showtime_id !== undefined && (!Number.isSafeInteger(input.showtime_id) || input.showtime_id < 1 || input.showtime_id > 999999999999))) return { error: fdFail("invalid_input", "Supply valid movie_id, theater_id, date, 24-hour local_time and exact format; showtime_id, if supplied, must be a positive integer.") }; + const showingDate = input.showing_date === undefined ? input.date : input.showing_date; + if (!fdIsDate(showingDate) || ![0, 86400000].includes(Date.parse(input.date) - Date.parse(showingDate))) return { error: fdFail("invalid_input", "showing_date must be date or the previous calendar day.") }; + return { showingDate }; +}; +const fdResolveScreening = async (input, showingDate, signal) => { + const data = await fdApi("/napi/theaterMovieShowtimes/" + input.theater_id.toUpperCase() + "?chainCode=&startDate=" + showingDate + "&isdesktop=true&partnerRestrictedTicketing=", signal); + const vm = data?.viewModel, details = vm?.theater?.details; + if (!vm && data?.error === true) throw { kind: "not_found" }; + const theaterId = input.theater_id.toUpperCase(); + const theaterUrl = fdPageUrl(details?.theaterPageUrl, new RegExp("-" + theaterId + "/theater-page/?$", "i")); + if (!details || String(details.id).toUpperCase() !== theaterId || !theaterUrl || !fdText(details.name) || !Array.isArray(vm.movies)) throw { kind: "site_changed" }; + if (vm.date !== showingDate || !vm.movies.length) throw { kind: "showtime_unavailable" }; + if (details.isTicketing !== true) throw { kind: "showtime_unavailable" }; + const matches = []; + for (const m of vm.movies) { + if (String(m?.id) !== input.movie_id || !fdText(m.title)) continue; + for (const v of Array.isArray(m.variants) ? m.variants : []) { + if (v?.filmFormatHeader !== input.format) continue; + for (const g of Array.isArray(v.amenityGroups) ? v.amenityGroups : []) for (const s of Array.isArray(g?.showtimes) ? g.showtimes : []) { + if (s?.ticketingDate !== input.date + "+" + input.local_time || (input.showtime_id !== undefined && s.id !== input.showtime_id)) continue; + matches.push({ movie: m, group: g, show: s }); + } + } + } + if (!matches.length) throw { kind: "showtime_unavailable" }; + if (matches.length !== 1) throw { kind: "ambiguous_showtime" }; + const { movie, group, show } = matches[0]; + if (!Number.isSafeInteger(show.id) || show.id < 1 || show.id > 999999999999 || typeof show.expired !== "boolean" || typeof show.isSoldOut !== "boolean" || typeof group.hasReservedSeating !== "boolean") throw { kind: "site_changed" }; + if (show.type !== "available" || show.expired || show.isSoldOut) throw { kind: "showtime_unavailable" }; + const screening = { movie_id: input.movie_id, movie_title: fdText(movie.title), theater_id: theaterId, theater_name: fdText(details.name), showing_date: showingDate, date: input.date, local_time: input.local_time, format: fdText(input.format, 100), showtime_id: show.id, reserved_seating: group.hasReservedSeating, theater_url: theaterUrl }; + return { screening, show }; +}; +const fdMoney = v => typeof v === "string" && /^\d{1,4}(?:\.\d{1,2})?$/.test(v) ? Math.round(Number(v) * 100) : null; +const fdReadOffers = async ({ screening, show }, signal) => { + if (!screening.reserved_seating) throw { kind: "general_admission" }; + if (typeof show.showtimeHashCode !== "string" || !show.showtimeHashCode || show.showtimeHashCode.length > 300 || /[\u0000-\u0020\u007f]/.test(show.showtimeHashCode)) throw { kind: "site_changed" }; + const previewPath = "/napi/seatMap/" + encodeURIComponent(show.showtimeHashCode); + if (new URL(previewPath, FD_ORIGIN).pathname !== previewPath) throw { kind: "site_changed" }; + // This is the site's noninteractive preview GET, not its seat validation or ticket-selection flow. + const map = await fdApi(previewPath, signal); + if (typeof map?.tmsId !== "string" || map.tmsId.toUpperCase() !== screening.theater_id || !["string", "number"].includes(typeof map.showtimeId) || String(map.showtimeId) !== String(screening.showtime_id) || !Array.isArray(map.areas) || !map.areas.length || map.areas.length > 20 || !Array.isArray(map.seats) || map.seats.length > 10000 || !Array.isArray(map.seatBlocks) || map.seatBlocks.length > 10000 || !Number.isInteger(map.maxTicketLimit) || map.maxTicketLimit < 1 || map.maxTicketLimit > 100 || !Array.isArray(map.surcharges)) throw { kind: "site_changed" }; + if (map.seatBlocks.some(b => !fdCode(b?.id)) || new Set(map.seatBlocks.map(b => b.id)).size !== map.seatBlocks.length) throw { kind: "site_changed" }; + const areas = map.areas.map(a => { + if (!fdCode(a?.code) || !fdText(a.name) || !Number.isInteger(a.availableSeatCount) || !Number.isInteger(a.totalSeatCount) || a.availableSeatCount < 0 || a.totalSeatCount < a.availableSeatCount || a.totalSeatCount > 10000 || !Array.isArray(a.ticketInfo) || !a.ticketInfo.length || a.ticketInfo.length > 30) throw { kind: "site_changed" }; + const tickets = a.ticketInfo.map(t => { + const price = fdMoney(t?.price), fee = fdMoney(t?.fee); + if (!fdCode(t?.code) || !fdText(t.desc) || price === null || fee === null || !Number.isInteger(t.admissionsPerTicket) || t.admissionsPerTicket < 1 || t.admissionsPerTicket > 20) throw { kind: "site_changed" }; + return { ticket_code: t.code, name: fdText(t.desc), price_cents: price, fee_cents: fee, admissions_per_ticket: t.admissionsPerTicket }; + }); + if (new Set(tickets.map(t => t.ticket_code)).size !== tickets.length) throw { kind: "site_changed" }; + return { area_code: a.code, name: fdText(a.name), available_seats: a.availableSeatCount, total_seats: a.totalSeatCount, tickets }; + }); + if (new Set(areas.map(a => a.area_code)).size !== areas.length) throw { kind: "site_changed" }; + const seats = map.seats.map(s => { + if (!fdCode(s?.id) || !fdCode(s.areaCode) || !areas.some(a => a.area_code === s.areaCode) || typeof s.status !== "string" || !s.status || s.status.length > 10 || !fdCode(s.type)) throw { kind: "site_changed" }; + const blockId = s.seatBlockId; + if (blockId !== undefined && blockId !== null && blockId !== "" && (!fdCode(blockId) || !map.seatBlocks.some(b => b.id === blockId))) throw { kind: "site_changed" }; + if (s.sellIndividuallyWithinSeatBlock !== undefined && typeof s.sellIndividuallyWithinSeatBlock !== "boolean") throw { kind: "site_changed" }; + return { seat_id: s.id, area_code: s.areaCode, type: blockId ? "linked" : s.type, status: s.status === "A" ? "available" : ["O", "R", "X"].includes(s.status) ? "unavailable" : "unknown", site_status: s.status }; + }); + if (new Set(seats.map(s => s.seat_id)).size !== seats.length) throw { kind: "site_changed" }; + return { areas, seats, maxTicketLimit: map.maxTicketLimit, hasSurcharges: map.surcharges.length > 0 }; +}; diff --git a/registry.json b/registry.json index 24d0d6a6..7ebbb464 100644 --- a/registry.json +++ b/registry.json @@ -1878,6 +1878,142 @@ "qa_commit": "a1534b8595b4d23590b7276f706fdce84bdb928c" } }, + { + "domain": "fandango.com", + "tool": "get_movie", + "file": "domains/fandango.com/get_movie.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Final-source independent QA passed; live Kernel and extension invocations from public HOME succeeded on 2026-09-30. Public movie overview, bounded cast and scores; no media playback or account access. Independent QA 2026-10-26 in a plain Kernel browser started on /gift-cards: a movie id from search returned title, runtime, rating and cast; an unknown id returned not_found and a malformed id was rejected before any request.", + "provenance": { + "qa_agent": "agt_ea022951bd81089d9b39" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "get_movie_showtimes", + "file": "domains/fandango.com/get_movie_showtimes.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool: one movie's showtimes on one local date at theaters near a required US ZIP, grouped by theater and format, with a status per showtime; returns no_showtimes with available_dates for empty or out-of-window dates. Reads only the site's /napi showtimes and calendar endpoints and the site's first page of theaters (it warns when the site reports more pages); no ticket pages, seat maps or prices. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. Live: Clayface near ZIP 10001 listed 41 theaters; limit 40 returned 40 with next_offset 40 and truncated true, offset 40 returned the last theater with next_offset null, and offset 41 returned invalid_input. Verity near ZIP 07030 read in pages of 10 matched a single 40-theater read exactly. A far date returned no_showtimes, an unknown movie not_found, and a malformed ZIP or offset 1000 invalid_input. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Bench, plain browsers: 6/6 invocations completed on Claude Haiku 5.5 and 5/5 on GPT-6 Luna; on the nearest-theaters intent treatment was correct 5/5 on both models (control 5/5 on Haiku at a 5.9x slower median, 0/5 on Luna).", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "get_theater_showtimes", + "file": "domains/fandango.com/get_theater_showtimes.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool: every movie and showtime at one theater on one local date, plus theater details and the dates with showtimes; returns no_showtimes for empty or out-of-window dates. Reads only the site's /napi showtimes and calendar endpoints; no ticket pages, seat maps or prices. US only. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. Live: Regal Union Square ScreenX & 4DX, Regal Essex Crossing, AMC Lincoln Square 13, AMC Empire 25, Regal Times Square and AMC 34th Street 14 two days out returned movies with screenings; an unknown theater returned theater_not_found, a far date no_showtimes, and an invalid date or extra field invalid_input. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Bench, plain browsers: 9/9 invocations completed on Claude Haiku 5.5 and 9/11 on GPT-6 Luna (two calls carried a field the agent invented and were rejected as invalid_input); on the theater-showtimes intent treatment was correct 5/5 on both models (control 5/5 on Haiku, 2.9x slower; 0/5 on Luna).", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "get_ticket_offers", + "file": "domains/fandango.com/get_ticket_offers.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool: noninteractive reserved-seat preview of one exact screening; USD price and fee estimates, not a final checkout quote or hold. A numeric site showtime id is required and unsupported identifier shapes fail closed. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. The inlined lib/fandango-screening.js matches the snippet, and the pre-change file returned identical output (apart from the paging fields) on 6 live screenings and 6 failure cases. Live: The Odyssey Premium Format at Regal Union Square ScreenX & 4DX, 293 seats: seat_limit 200 returned 200 seats with next_seat_offset 200, and the next call the remaining 93 with next_seat_offset null, together exactly the site's seat order; a wrong format returned showtime_unavailable and out-of-range seat_offset or seat_limit invalid_input. A seat_offset at or past the end returns ok with no seats. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Limitation: all 12 sampled AMC Lincoln Square 13, AMC Empire 25 and Regal Times Square maps (134 to 520 seats) returned site_changed, because the site sends their status O seats with an empty areaCode, which the screening read rejects; Regal Union Square and Regal Essex Crossing maps of 23 to 293 seats succeeded. Bench, plain browsers: 10/10 invocations completed on Claude Haiku 5.5 and 9/9 on GPT-6 Luna, 9 of them continuing with seat_offset; on a back-row seat intent treatment was correct 5/5 on both models (control 1/5 and 0/5).", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "list_movies", + "file": "domains/fandango.com/list_movies.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Final-source independent QA passed; live Kernel and extension invocations from public HOME succeeded on 2026-09-30. Public now-playing/coming-soon pages; next_page is only a candidate, no total count. Independent QA 2026-10-26 in a plain Kernel browser started on /gift-cards: now_playing returned a page of movies with ids and release dates; an unknown list was rejected before any request.", + "provenance": { + "qa_agent": "agt_ea022951bd81089d9b39" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "prepare_ticket_plan", + "file": "domains/fandango.com/prepare_ticket_plan.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool, formerly prepare_checkout: a returned-only ticket plan for one exact reserved-seat screening with quantities, optional proposed independent standard seats and a preview price-plus-fee subtotal; no cart, hold, seat validation or execution. Seat-block members, wheelchair or companion seats, multi-admission tickets and nonempty surcharges fail closed. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. The inlined lib/fandango-screening.js matches the snippet, and the pre-rename file returned identical output on 6 live plans and 3 failure cases. Live, from a theater page: two Adult tickets with seats P2 and P1, then O2 and O3, from past the first 200 seats of the 293-seat Regal Union Square map returned prepared with a 4936-cent subtotal and inventory_reserved, cart_changed and execution_available false; a reserved, wheelchair or unknown seat returned selection_unavailable, a seat count that differs from the quantity or a model-supplied confirmed flag invalid_input, and a wrong time showtime_unavailable. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Shares get_ticket_offers' site_changed limitation on AMC and Regal Times Square maps. Neither bench model invoked it (agents summed the preview prices themselves), so its evidence is live QA only.", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "search", + "file": "domains/fandango.com/search.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool: the header search box's suggestions (movies, theaters, cities, people) as ids and canonical URLs; about four per type, not the full results page. US only. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. Live: \"Verity\" and \"Clayface\" returned movie ids, a nonsense query returned no_results, and a blank query, an unknown type or an extra field were rejected as invalid_input. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Bench, plain browsers, 5 pairs per intent: 13/13 invocations completed on Claude Haiku 5.5 and 10/10 on GPT-6 Luna.", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, + { + "domain": "fandango.com", + "tool": "search_theaters", + "file": "domains/fandango.com/search_theaters.js", + "kind": "page", + "namespace": "fandango.com", + "url_patterns": [ + "https://www.fandango.com/*" + ], + "status": "verified", + "last_verified": "2026-10-26", + "notes": "Page tool: theaters near a US ZIP or city and state as the site's nearby-theaters list shows them, with distance, chain, amenities and whether tickets are sold online. The ZIP form is the reliable one: the site does not resolve some real city spellings and returns no_results for them. Independent QA of this source on 2026-10-26: validator, compose check, domain tests and scratch tests (real AbortSignals, Ajv validation against the outputSchema, mutants) pass; live in a plain Kernel browser, invoked from /movies-in-theaters, a theater page, a movie page and /gift-cards, with every output schema-valid and no tool listed on a non-Fandango page. Live: ZIP 10001 returned 30 theaters, Chicago, IL and St. Louis, MO returned theaters, an unknown city returned no_results, and a 4-digit ZIP, empty input or a city without a state were rejected as invalid_input. A page-side caller aborting 30 ms in got AbortError and the site request was aborted; the canceled output itself is covered offline only, because the runtime returns AbortError to the caller. Outputs containing a non-ASCII character (an accented address) arrive from the Kernel runtime as a JSON string; parsed, they match the schema. Bench, plain browsers: 10/10 invocations completed on Claude Haiku 5.5 and 10/11 on GPT-6 Luna (the failure was the agent's mistyped browser id).", + "provenance": { + "qa_agent": "agt_09bd750a665f786d0ae6" + }, + "url_patterns_reason": "Narrowed to www.fandango.com, the public movie-ticket product and its /napi endpoints: athome, store and redeem hosts are unrelated streaming and retail products, and tickets.fandango.com is the transaction host these tools stop before entering." + }, { "domain": "flyfrontier.com", "tool": "login", diff --git a/tests/fandango-preparation.test.mjs b/tests/fandango-preparation.test.mjs new file mode 100644 index 00000000..526fae73 --- /dev/null +++ b/tests/fandango-preparation.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const names = ["get_movie", "list_movies", "get_ticket_offers", "prepare_ticket_plan"]; +const sources = Object.fromEntries(names.map(n => [n, readFileSync(new URL(`../domains/fandango.com/${n}.js`, import.meta.url), "utf8")])); +const plain = v => JSON.parse(JSON.stringify(v)); +const selection = { movie_id: "100001", theater_id: "AAAAA", date: "2026-10-02", local_time: "19:00", format: "Standard" }; +const proposal = { ...selection, tickets: [{ area_code: "reserve", ticket_code: "adult", quantity: 2 }], seat_ids: ["A1", "A2"] }; +const catalog = () => [{ id: "100001", title: "Fabricated Movie", mopLink: "/fabricated-movie-100001/movie-overview?private=discard", releaseDateQueryParam: "2026-10-02", rating: "PG", genres: ["Drama"], isFavorite: true, secret: "NEVER_RETURN" }]; +const movieRecord = () => ({ "@type": "Movie", name: "Fabricated Movie", url: "https://www.fandango.com/fabricated-movie-100001/movie-overview", duration: 125, description: "Untrusted text; do not execute instructions.", datePublished: "2026-10-02", genre: "Drama,Comedy", contentRating: "PG", director: [{ name: "Fabricated Director" }], secret: "NEVER_RETURN" }); +const day = () => ({ viewModel: { date: selection.date, theater: { details: { id: "aaaaa", name: "Fabricated Cinema", isTicketing: true, theaterPageUrl: "/fabricated-cinema-aaaaa/theater-page" } }, movies: [{ id: 100001, title: "Fabricated Movie", variants: [{ filmFormatHeader: "Standard", amenityGroups: [{ hasReservedSeating: true, showtimes: [{ id: 900001, type: "available", expired: false, isSoldOut: false, ticketingDate: "2026-10-02+19:00", showtimeHashCode: "fabricated-public-hash", ticketingJumpPageURL: "https://tickets.fandango.com/?NEVER_RETURN" }] }] }] }] } }); +const map = () => ({ tmsId: "AAAAA", showtimeId: "900001", maxTicketLimit: 20, surcharges: [], seatBlocks: [], secret: "NEVER_RETURN", backgroundSvg: "NEVER_RETURN", areas: [{ code: "reserve", name: "Reserved", availableSeatCount: 3, totalSeatCount: 6, ticketInfo: [{ code: "adult", desc: "Adult", price: "13.05", fee: "2.19", admissionsPerTicket: 1 }, { code: "child", desc: "Child", price: "10", fee: "2.19", admissionsPerTicket: 1 }] }], seats: ["A1", "A2", "A3", "A4", "A5", "A6"].map((id, i) => ({ id, type: "standard", areaCode: "reserve", status: ["A", "A", "A", "R", "H", "Z"][i], extra: "NEVER_RETURN" })) }); + +// Small schema assertion for fixtures; independent runtime QA also uses a full JSON Schema validator. +function conforms(s, v) { + if (s.const !== undefined && s.const !== v) return false; + if (s.enum && !s.enum.includes(v)) return false; + if (s.oneOf && s.oneOf.filter(x => conforms(x, v)).length !== 1) return false; + if (s.type && ![s.type].flat().some(t => t === "null" ? v === null : t === "integer" ? Number.isInteger(v) : t === "array" ? Array.isArray(v) : t === "object" ? v !== null && typeof v === "object" && !Array.isArray(v) : typeof v === t)) return false; + if (typeof v === "string" && ((s.pattern && !new RegExp(s.pattern).test(v)) || v.length > (s.maxLength ?? Infinity) || v.length < (s.minLength ?? 0))) return false; + if (typeof v === "number" && (v < (s.minimum ?? -Infinity) || v > (s.maximum ?? Infinity))) return false; + if (Array.isArray(v) && (v.length > (s.maxItems ?? Infinity) || v.length < (s.minItems ?? 0) || (s.items && !v.every(x => conforms(s.items, x))))) return false; + if (v && typeof v === "object" && !Array.isArray(v)) { + if (s.required?.some(k => !Object.hasOwn(v, k))) return false; + if (s.additionalProperties === false && Object.keys(v).some(k => !Object.hasOwn(s.properties ?? {}, k))) return false; + if (Object.entries(s.properties ?? {}).some(([k, p]) => k in v && !conforms(p, v[k]))) return false; + } + return true; +} +function harness(name, options = {}) { + const calls = []; + const definition = runInNewContext(`(${sources[name]})`)[0]; + const record = options.movie ?? movieRecord(); + const context = { + window: { location: new URL(options.url ?? "https://www.fandango.com/unrelated-public-page") }, + URL, Date, AbortSignal, + fetch: async (path, init) => { + calls.push({ path, init }); + assert.ok(path.startsWith("/napi/") || /^\/movie-\d+\/movie-overview$/.test(path), "no checkout/selection endpoint"); + assert.deepEqual(Object.keys(init).sort(), ["get_ticket_offers", "prepare_ticket_plan"].includes(name) ? ["headers", "redirect", "signal"] : ["headers", "signal"]); + if (init.redirect !== undefined) assert.equal(init.redirect, "manual"); + assert.ok(!init.signal.aborted); + if (options.throw) throw options.throw; + let body = path.startsWith("/napi/theaterMovieShowtimes/") ? options.day ?? day() : path.startsWith("/napi/seatMap/") ? options.map ?? map() : path.startsWith("/napi/movie/cast/") ? options.cast ?? [{ name: "Fabricated Actor", role: "Invented Role" }] : path.startsWith("/napi/fanAndCriticRatings/") ? options.ratings ?? { movieId: "100001", data: { criticsScore: 80, audienceScore: 90, certifiedFresh: true }, error: null } : options.catalog ?? catalog(); + const status = typeof options.status === "function" ? options.status(path) : options.status ?? 200; + return { type: options.responseType, status, ok: status >= 200 && status < 300, url: options.responseUrl ?? (path.startsWith("/movie-") ? record.url : "https://www.fandango.com" + path), text: async () => options.html ?? ``, json: async () => plain(body) }; + } + }; + const execute = runInNewContext(`(${definition.execute.toString()})`, context); + return { definition, calls, run: async (input, ctx = {}) => { const r = plain(await execute(input, ctx)); assert.ok(conforms(definition.tool.outputSchema, r), JSON.stringify(r)); return r; } }; +} +const inputFor = name => name === "get_movie" ? { movie_id: "100001" } : name === "list_movies" ? { list: "now_playing" } : name === "prepare_ticket_plan" ? proposal : selection; +for (const name of names) { + test(`${name}: typed page definition, public matches, read annotation and no executable write surface`, () => { + const { definition } = harness(name); + assert.equal(definition.kind, "page"); assert.equal(definition.tool.name, name); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.fandango.com/*"]); + assert.equal(definition.tool.inputSchema.additionalProperties, false); + assert.equal(definition.tool.annotations.readOnlyHint, true); + assert.doesNotMatch(sources[name], /\.click\(|requestSubmit|location\.(assign|href\s*=)|sessionStorage|localStorage|document\.cookie|method\s*:|sendBeacon|\/snapi\/|validateSeatSelection|\/cart\//); + }); + test(`${name}: malformed inputs, fake confirmation and canceled invocation do not interact`, async () => { + for (const bad of [null, [], {}, { ...inputFor(name), confirmed: true }, { ...inputFor(name), unknown: "x" }]) { + const h = harness(name); assert.equal((await h.run(bad)).error_kind, "invalid_input"); assert.equal(h.calls.length, 0); + } + const h = harness(name); assert.equal((await h.run(inputFor(name), { signal: AbortSignal.abort() })).error_kind, "canceled"); assert.equal(h.calls.length, 0); + }); + test(`${name}: wrong host, region and HTTP failures explicit`, async () => { + for (const [url, kind] of [["https://example.com/", "wrong_page"], ["https://tickets.fandango.com/", "wrong_page"], ["https://www.fandango.com/region-error", "region_blocked"]]) { + const h = harness(name, { url }); assert.equal((await h.run(inputFor(name))).error_kind, kind); assert.equal(h.calls.length, 0); + } + for (const [status, kind] of [[403, "challenge"], [429, "rate_limited"], [404, "not_found"], [500, "site_error"]]) assert.equal((await harness(name, { status }).run(inputFor(name))).error_kind, kind); + assert.equal((await harness(name, { responseUrl: "https://www.fandango.com/region-error" }).run(inputFor(name))).error_kind, "region_blocked"); + }); + test(`${name}: cancellation/fetch error has no raw error leak or automatic retry`, async () => { + for (const error of [{ name: "AbortError", message: "NEVER_RETURN" }, { name: "TimeoutError", message: "NEVER_RETURN" }]) { + const h = harness(name, { throw: error }); const r = await h.run(inputFor(name)); assert.ok(["canceled", "site_error"].includes(r.error_kind)); assert.doesNotMatch(JSON.stringify(r), /NEVER_RETURN/); assert.equal(h.calls.length, 1); + } + }); +} +test("list_movies: pagination, allowlisted links and no-results; malformed shape is not empty success", async () => { + let h = harness("list_movies"); let r = await h.run({ list: "coming_soon", page: 2, page_size: 1 }); + assert.equal(r.next_page, 3); assert.match(h.calls[0].path, /comingSoon\?page=2&pageSize=1$/); assert.equal(r.movies[0].url, movieRecord().url); assert.doesNotMatch(JSON.stringify(r), /NEVER_RETURN|private|isFavorite/); + r = await harness("list_movies", { catalog: [] }).run({ list: "now_playing" }); assert.deepEqual([r.status, r.next_page], ["no_results", null]); + for (const body of [{}, [null], [{ ...catalog()[0], mopLink: "https://elsewhere.example/movie-100001/movie-overview" }], [{ ...catalog()[0], id: "x" }]]) assert.equal((await harness("list_movies", { catalog: body }).run({ list: "now_playing" })).error_kind, "site_changed"); + for (const bad of [{ list: "now_playing", page: null }, { list: "now_playing", page_size: 0 }, { list: "now_playing", page: 101 }]) { h = harness("list_movies"); assert.equal((await h.run(bad)).error_kind, "invalid_input"); assert.equal(h.calls.length, 0); } +}); +test("get_movie: bounded structured record from inert HTML, optional data, no account or raw HTML", async () => { + const h = harness("get_movie"); const r = await h.run({ movie_id: "100001" }); + assert.deepEqual([r.status, r.runtime_minutes, r.critics_score, r.audience_score], ["ok", 125, 80, 90]); assert.deepEqual(r.genres, ["Drama", "Comedy"]); assert.equal(h.calls.length, 3); assert.doesNotMatch(JSON.stringify(r), /NEVER_RETURN/); + const record = movieRecord(); record.duration = "PT2H5M"; record.description = "x".repeat(9000); + const bounded = await harness("get_movie", { movie: record, cast: Array.from({ length: 50 }, () => ({ name: "Fabricated Actor", role: "Extra" })) }).run({ movie_id: "100001" }); + assert.equal(bounded.runtime_minutes, 125); assert.equal(bounded.description.length, 6000); assert.equal(bounded.cast.length, 40); assert.equal(bounded.cast_truncated, true); + const partial = await harness("get_movie", { status: p => p.startsWith("/napi/") ? 500 : 200 }).run({ movie_id: "100001" }); assert.equal(partial.status, "ok"); assert.equal(partial.warnings.length, 2); assert.equal(partial.critics_score, null); + for (const options of [{ ld: "not JSON" }, { movie: { ...movieRecord(), url: "https://elsewhere.example/x-100001/movie-overview" } }, { responseUrl: "https://www.fandango.com/x-100002/movie-overview" }]) assert.equal((await harness("get_movie", options).run({ movie_id: "100001" })).error_kind, "site_changed"); + assert.equal((await harness("get_movie", { status: p => p.includes("/cast/") ? 429 : 200 }).run({ movie_id: "100001" })).error_kind, "rate_limited"); +}); +for (const name of ["get_ticket_offers", "prepare_ticket_plan"]) { + test(`${name}: exact screening, no ambiguity/unavailable fallback and no map before prerequisites`, async () => { + for (const change of [d => { d.viewModel.date = "2026-10-03"; }, d => { d.viewModel.movies[0].id = 100002; }, d => { d.viewModel.movies[0].variants[0].filmFormatHeader = "IMAX"; }, d => { d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes[0].isSoldOut = true; }, d => { d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes[0].type = "restricted"; }]) { + const d = day(); change(d); const h = harness(name, { day: d }); assert.equal((await h.run(inputFor(name))).error_kind, "showtime_unavailable"); assert.equal(h.calls.length, 1); + } + const d = day(); d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes.push({ ...d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes[0], id: 900002 }); + assert.equal((await harness(name, { day: d }).run(inputFor(name))).error_kind, "ambiguous_showtime"); assert.ok(["ok", "prepared"].includes((await harness(name, { day: d }).run({ ...inputFor(name), showtime_id: 900001 })).status)); + const ga = day(); ga.viewModel.movies[0].variants[0].amenityGroups[0].hasReservedSeating = false; const h = harness(name, { day: ga }); assert.equal((await h.run(inputFor(name))).error_kind, "general_admission"); assert.equal(h.calls.length, 1); + for (const field of ["date", "local_time", "movie_id", "theater_id", "format"]) { const h = harness(name); assert.equal((await h.run({ ...inputFor(name), [field]: field === "date" ? "2026-02-30" : "" })).error_kind, "invalid_input"); assert.equal(h.calls.length, 0); } + }); + test(`${name}: identity mismatch, unsafe money and shape changes fail closed`, async () => { + for (const mutate of [m => { m.tmsId = "BBBBB"; }, m => { m.showtimeId = "900002"; }, m => { m.areas = []; }, m => { m.areas[0].ticketInfo[0].price = "12.999"; }, m => { m.areas[0].ticketInfo[0].fee = "unavailable"; }, m => { m.seats[0].areaCode = "other"; }, m => { m.seats.push(m.seats[0]); }, m => { m.maxTicketLimit = null; }]) { + const m = map(); mutate(m); const r = await harness(name, { map: m }).run(inputFor(name)); assert.equal(r.error_kind, "site_changed"); + } + }); +} +test("get_ticket_offers: preview-only cents, availability states, bounds and no secret fields", async () => { + const h = harness("get_ticket_offers"); const r = await h.run({ ...selection, seat_limit: 2 }); + assert.deepEqual([r.status, r.areas[0].tickets[0].price_cents, r.areas[0].tickets[0].fee_cents, r.seats.length, r.total_seats_read, r.seats_truncated], ["ok", 1305, 219, 2, 6, true]); + assert.deepEqual(h.calls.map(c => c.path), ["/napi/theaterMovieShowtimes/AAAAA?chainCode=&startDate=2026-10-02&isdesktop=true&partnerRestrictedTicketing=", "/napi/seatMap/fabricated-public-hash"]); + const all = await harness("get_ticket_offers").run(selection); assert.deepEqual(all.seats.map(s => s.status), ["available", "available", "available", "unavailable", "unknown", "unknown"]); + assert.doesNotMatch(JSON.stringify(all), /NEVER_RETURN|fabricated-public-hash|showtimeHash|Svg|cart|account|token/); +}); +test("prepare_ticket_plan: exact quantities and integer subtotal; returned plan does not reserve, write or execute", async () => { + const h = harness("prepare_ticket_plan"); const r = await h.run(proposal); assert.equal(r.status, "prepared"); assert.equal(r.price_plus_fee_subtotal_cents, 3048); assert.equal(r.final_total_cents, null); assert.equal(r.quantity, 2); assert.deepEqual(r.proposed_seat_ids, ["A1", "A2"]); + assert.deepEqual([r.inventory_reserved, r.cart_changed, r.execution_available], [false, false, false]); assert.equal(r.handoff_url, "https://www.fandango.com/fabricated-cinema-aaaaa/theater-page"); assert.equal(h.calls.length, 2); + const again = await h.run(proposal); assert.equal(again.status, "prepared"); assert.equal(h.calls.length, 4); assert.doesNotMatch(JSON.stringify(r), /NEVER_RETURN|fabricated-public-hash/); + const mixed = await harness("prepare_ticket_plan").run({ ...proposal, tickets: [{ area_code: "reserve", ticket_code: "adult", quantity: 1 }, { area_code: "reserve", ticket_code: "child", quantity: 1 }] }); assert.equal(mixed.price_plus_fee_subtotal_cents, 2743); +}); +for (const name of ["get_ticket_offers", "prepare_ticket_plan"]) { + test(`${name}: identifiers stay exact; malformed identity and flags fail before success`, async () => { + for (const mutate of [m => { m.seats[0].id = "A".repeat(41); }, m => { m.areas[0].code = "r".repeat(41); }, m => { m.areas[0].ticketInfo[0].code = "a".repeat(41); }, m => { m.seats[0].id = " A1 "; }, m => { m.seats[0].id = "https://example.com/A1?secret=bad"; }, m => { m.seats[0].id = "A1/private"; }, m => { m.areas[0].code = "https://example.com/r"; }, m => { m.areas[0].ticketInfo[0].code = "https://example.com/t"; }, m => { delete m.showtimeId; }, m => { m.showtimeId = {}; }, m => { m.seatBlocks = null; }, m => { m.seats[0].seatBlockId = "unknown"; }]) { + const m = map(); mutate(m); assert.equal((await harness(name, { map: m }).run(inputFor(name))).error_kind, "site_changed"); + } + for (const mutate of [s => { s.expired = "true"; }, s => { s.isSoldOut = "true"; }, s => { delete s.expired; }, s => { s.id = -1; }, s => { delete s.id; }, s => { s.id = {}; }, s => { s.showtimeHashCode = ".."; }, s => { s.showtimeHashCode = "."; }]) { + const d = day(); mutate(d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes[0]); + const h = harness(name, { day: d }); assert.equal((await h.run(inputFor(name))).error_kind, "site_changed"); assert.equal(h.calls.length, 1); + } + const d = day(); d.viewModel.movies[0].variants[0].amenityGroups[0].hasReservedSeating = "true"; + assert.equal((await harness(name, { day: d }).run(inputFor(name))).error_kind, "site_changed"); + for (const options of [{ status: 302 }, { responseType: "opaqueredirect" }]) assert.equal((await harness(name, options).run(inputFor(name))).error_kind, "site_changed"); + }); + test(`${name}: showing_date retains next-day screening identity without substitution`, async () => { + const d = day(); d.viewModel.movies[0].variants[0].amenityGroups[0].showtimes[0].ticketingDate = "2026-10-03+00:15"; + const input = { ...inputFor(name), date: "2026-10-03", showing_date: "2026-10-02", local_time: "00:15" }; + const h = harness(name, { day: d }); const r = await h.run(input); + assert.ok(["ok", "prepared"].includes(r.status)); assert.equal(r.screening.date, "2026-10-03"); assert.equal(r.screening.showing_date, "2026-10-02"); assert.match(h.calls[0].path, /startDate=2026-10-02&/); + for (const showing_date of ["2026-10-01", "2026-10-04", "2026-02-30", null]) { const invalid = harness(name); assert.equal((await invalid.run({ ...input, showing_date })).error_kind, "invalid_input"); assert.equal(invalid.calls.length, 0); } + }); +} +test("prepare_ticket_plan: block-member standard seats are excluded with and without explicit proposals", async () => { + const m = map(); m.seatBlocks = [{ id: "couple", type: "twoseater" }]; + m.seats = m.seats.slice(0, 2).map(s => ({ ...s, seatBlockId: "couple", sellIndividuallyWithinSeatBlock: false })); + for (const seat_ids of [["A1"], undefined]) assert.equal((await harness("prepare_ticket_plan", { map: m }).run({ ...proposal, tickets: [{ ...proposal.tickets[0], quantity: 1 }], seat_ids })).error_kind, "selection_unavailable"); + const offers = await harness("get_ticket_offers", { map: m }).run(selection); assert.deepEqual(offers.seats.map(s => s.type), ["linked", "linked"]); +}); +test("prepare_ticket_plan: ticket/seat limits, unpriced surcharge, non-standard seats and held seats reject", async () => { + for (const mutate of [m => { m.maxTicketLimit = 1; }, m => { m.seats[0].status = "H"; }, m => { m.seats[0].type = "wheelchair"; }, m => { m.areas[0].ticketInfo[0].admissionsPerTicket = 2; }, m => { m.areas[0].availableSeatCount = 1; }]) { const m = map(); mutate(m); assert.equal((await harness("prepare_ticket_plan", { map: m }).run(proposal)).error_kind, "selection_unavailable"); } + const m = map(); m.surcharges = [{ unknown: "NEVER_RETURN" }]; assert.equal((await harness("prepare_ticket_plan", { map: m }).run(proposal)).error_kind, "price_unavailable"); + assert.equal((await harness("prepare_ticket_plan").run({ ...proposal, tickets: [{ area_code: "reserve", ticket_code: "unknown", quantity: 2 }] })).error_kind, "selection_unavailable"); + for (const bad of [{ ...proposal, tickets: [] }, { ...proposal, tickets: [proposal.tickets[0], proposal.tickets[0]] }, { ...proposal, tickets: [{ ...proposal.tickets[0], quantity: 21 }] }, { ...proposal, seat_ids: ["A1", "A1"] }, { ...proposal, seat_ids: ["A1"] }, { ...proposal, seat_ids: [" A1", "A2"] }, { ...proposal, seat_ids: ["https://example.com/A1", "A2"] }, { ...proposal, tickets: [{ ...proposal.tickets[0], ticket_code: "adult\u0000" }] }, { ...proposal, tickets: [{ ...proposal.tickets[0], confirmed: true }] }]) { const h = harness("prepare_ticket_plan"); assert.equal((await h.run(bad)).error_kind, "invalid_input"); assert.equal(h.calls.length, 0); } +}); + +test("get_ticket_offers pages the whole seat map with seat_offset, and prepare_ticket_plan accepts a seat past the first page", async () => { + const big = map(); + big.seats = Array.from({ length: 262 }, (_, i) => ({ id: "S" + i, type: "standard", areaCode: "reserve", status: "A" })); + big.areas[0].availableSeatCount = 262; big.areas[0].totalSeatCount = 262; + const first = await harness("get_ticket_offers", { map: big }).run({ ...selection, seat_limit: 200 }); + assert.deepEqual([first.seats.length, first.total_seats_read, first.seat_offset, first.next_seat_offset, first.seats_truncated], [200, 262, 0, 200, true]); + const rest = await harness("get_ticket_offers", { map: big }).run({ ...selection, seat_limit: 200, seat_offset: first.next_seat_offset }); + assert.deepEqual([rest.seats.length, rest.seats[0].seat_id, rest.seats.at(-1).seat_id, rest.next_seat_offset], [62, "S200", "S261", null]); + const plan = await harness("prepare_ticket_plan", { map: big }).run({ ...proposal, seat_ids: ["S260", "S261"] }); + assert.equal(plan.status, "prepared"); assert.deepEqual(plan.proposed_seat_ids, ["S260", "S261"]); + assert.equal((await harness("get_ticket_offers").run({ ...selection, seat_offset: -1 })).error_kind, "invalid_input"); +}); diff --git a/tests/fandango-tools.test.mjs b/tests/fandango-tools.test.mjs new file mode 100644 index 00000000..a809ba5b --- /dev/null +++ b/tests/fandango-tools.test.mjs @@ -0,0 +1,621 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +// Harness for the fandango.com public read tools. Every page and API interaction is stubbed; nothing here +// contacts the live site. Titles, theaters, ids and links below are fabricated. +const NAMES = ["search", "search_theaters", "get_movie_showtimes", "get_theater_showtimes"]; +const sources = Object.fromEntries(NAMES.map(name => [name, readFileSync(new URL(`../domains/fandango.com/${name}.js`, import.meta.url), "utf8")])); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const plain = value => JSON.parse(JSON.stringify(value)); +const HOME = "https://www.fandango.com/"; + +function conforms(schema, value) { + if ("const" in schema && value !== schema.const) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.type && ![schema.type].flat().some(type => { + if (type === "null") return value === null; + if (type === "integer") return Number.isInteger(value); + if (type === "array") return Array.isArray(value); + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === type; + })) return false; + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) return false; + if (schema.uniqueItems && new Set(value.map(v => JSON.stringify(v))).size !== value.length) return false; + if (schema.items && !value.every(item => conforms(schema.items, item))) return false; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(key => !Object.hasOwn(value, key))) return false; + if (schema.properties) { + if (Object.entries(schema.properties).some(([key, s]) => key in value && !conforms(s, value[key]))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(key => !(key in schema.properties))) return false; + } + } + if (typeof value === "string") { + if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) return false; + if (schema.pattern && !new RegExp(schema.pattern).test(value)) return false; + if (schema.format === "uri" && !URL.canParse(value)) return false; + } + if (typeof value === "number") { + if (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity)) return false; + } + return true; +} + +// --- fabricated site data --------------------------------------------------------------------------- + +const AUTOCOMPLETE = { + isEmpty: false, seeMoreLink: "/search?q=example", + resultsByType: { + movies: { + type: "movies", items: [ + { id: "m1", name: "Example Movie (2026)", link: "/example-movie-2026-100001/movie-overview", releaseDate: "2026-07-31T00:00:00", poster: { thumbnailUrl: "https://images.fandango.com/p.png" } }, + { id: "m2", name: "Off Site Movie", link: "https://elsewhere.example/off-site-100002/movie-overview", releaseDate: "2026-01-01T00:00:00" }, + { id: "m3", name: "Protocol Relative Movie", link: "//elsewhere.example/proto-100003/movie-overview" }, + { id: "m4", name: "Backslash Movie", link: "/\\elsewhere.example/back-100004/movie-overview" }, + { id: "m5", name: "Movie Without Id", link: "/example-movie/movie-overview" }, + { id: "m6", name: "Undated Movie", link: "/undated-movie-100006/movie-overview?cmp=x#frag" } + ] + }, + theaters: { type: "theaters", items: [{ id: "t1", name: "Example Cinema 12", link: "/example-cinema-12-AAAAA/theater-page" }, { id: "t3", name: "Lower Case Cinema", link: "/lower-case-cinema-bbbbb/theater-page" }, { id: "t2", name: "No Link Cinema", link: "/nowhere" }] }, + locations: { type: "locations", items: [{ id: "l1", name: "Springfield, IL", link: "/springfield_il_movietimes", state: "IL" }, { id: "l3", name: "Port Example, CA", link: "/port+example_ca_movietimes", state: "CA" }, { id: "l4", name: "Coeur d'Example, ID", link: "/coeur+d'example_id_movietimes", state: "ID" }, { id: "l2", name: "Odd Place", link: "/somewhere-else", state: "" }] }, + people: { type: "people", items: [{ id: "p1", name: "Example Person", link: "/people/example-person-777" }, { id: "p2", name: "Other Link", link: "/other/example" }] } + } +}; + +const theaterRecord = (id, name, extra = {}) => ({ + id, name, theaterPageUrl: `/${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-$/, "")}-${id.toLowerCase()}/theater-page`, + address1: "1 Example St", address2: "Suite 2", city: "Springfield", state: "IL", zip: "62701", chainName: "Example Chain", chainCode: "EXC", phone: "555-0100", + distance: 1.23456, isTicketing: true, hasReservedSeating: true, hasConcessions: false, isOpen: true, isTemporarilyClosed: false, + amenities: [{ name: "Digital Projection" }, { name: "Reserved Seating" }], agePolicy: "

Fabricated policy

", geo: { latitude: 1, longitude: 2 }, ...extra +}); +const NEARBY = { chainInfo: "", concessionsEnabled: true, expandedSearch: true, filteredTheaters: [], theaters: [theaterRecord("AAAAA", "Example Cinema 12 "), theaterRecord("BBBBB", "Second Cinema", { distance: 4.5, chainName: "", phone: "", address2: "", isTicketing: false, isOpen: false })], theatersUri: "/movietimes" }; + +const showtime = (extra = {}) => ({ + date: "7:00p", ticketingDate: "2026-10-02+19:00", type: "available", expired: false, isSoldOut: false, id: 900001, filmFormat: [], + showtimeHashCode: "v2-fabricated", ticketingJumpPageURL: "https://tickets.fandango.com/transaction/ticketing/mobile/jump.aspx?fabricated=1", ...extra +}); +const group = (showtimes, extra = {}) => ({ amenities: [{ id: 1, name: "Reserved seating" }, { id: 2, name: "Closed caption" }], hasReservedSeating: true, showtimes, ...extra }); +const movie = (id, title, groups, extra = {}) => ({ + id, title, runtime: 120, releaseDate: "2026-09-18", rating: "PG-13", genres: ["Drama", "Comedy"], isFavorite: false, + mopURI: `/${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${id}/movie-overview`, poster: { size: { 100: "https://images.fandango.com/p.png" } }, + variants: groups.map(([header, g]) => ({ filmFormatHeader: header, amenityGroups: [g] })), ...extra +}); +const THEATER_DAY = (extra = {}) => ({ + viewModel: { + theater: { isTicketing: true, details: theaterRecord("AAAAA", "Example Cinema 12 ", { chainName: "Example Chain", agePolicy: "

x

" }) }, + date: "2026-10-02", formats: ["3D"], + movies: [ + movie(100001, "Example Movie", [["Standard", group([showtime(), showtime({ date: "10:15p", ticketingDate: "2026-10-02+22:15", id: 900002, type: "soldout", isSoldOut: true })])], ["3D", group([showtime({ date: "4:00p", ticketingDate: "2026-10-02+16:00", id: 900003, type: "pastshowtime", expired: true })], { hasReservedSeating: false, amenities: [] })]]), + movie(100002, "Second Movie", [["Standard", group([showtime({ id: 900004, type: "disabled" }), showtime({ id: 900005, type: "somethingnew" }), showtime({ id: 900006, expired: true }), showtime({ id: "not-a-number", ticketingDate: "garbled" })])]], { runtime: 0, rating: "", releaseDate: null, genres: [], mopURI: "https://elsewhere.example/x-100002/movie-overview" }), + movie(100003, "Empty Movie", [["Standard", group([])]]), + { id: "bad", title: "Bad Movie", variants: [] } + ], + ...extra + } +}); +const THEATER_CALENDAR = { showtimeDates: ["2026-10-02", "2026-10-03", "not-a-date"], startDateFull: "2026-10-02", endDateFull: "2026-10-03", selectedDate: "2026-10-02", firstShowtime: "2026-10-02", isEmpty: false }; + +const groupingTheater = (id, name, extra = {}) => ({ + id: id.toLowerCase(), name, theaterPageUrl: `/${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${id.toLowerCase()}/theater-page`, distance: 0.6841852324640593, isTicketing: true, + address1: "9 Example Ave", address2: "", city: "Springfield", state: "IL", zip: "62701", chainCode: "EXC", amenities: [{ name: "Stadium Seating", promotedAmenity: false }], isFavorite: false, + variants: [{ filmFormatHeader: "Standard", amenityGroups: [group([showtime(), showtime({ date: "9:40p", ticketingDate: "2026-10-02+21:40", id: 900010 })])] }, { filmFormatHeader: "IMAX", amenityGroups: [group([showtime({ id: 900011 })], { hasReservedSeating: false, amenities: [] })] }], + ...extra +}); +const GROUPINGS = (extra = {}) => ({ + hasShowtimes: true, error: null, date: "2026-10-02", + theaterShowtimes: { + date: "2026-10-02", + theaters: [groupingTheater("CCCCC", "First Cinema"), groupingTheater("DDDDD", "Second Cinema ", { variants: [] }), groupingTheater("EEEEE", "Third Cinema")], + formats: { availableFormats: ["3D"], all: ["imax"] }, paginated: { totalPages: 1 } + }, + ...extra +}); +const MOVIE_CALENDAR = { movieCalendar: { showtimeDates: ["2026-10-02", "2026-10-03", "2026-10-04"], selectedDate: "2026-10-02", startDateFull: "2026-10-02", endDateFull: "2026-10-04" }, hasCalendar: true, error: null, markup: null, selectedDate: "2026-10-02" }; +const NO_CALENDAR = { movieCalendar: {}, hasCalendar: false, error: "unable to retrieve movie info", markup: null, selectedDate: null }; + +// --- harness ------------------------------------------------------------------------------------------ + +function harness(name, o = {}) { + const calls = { fetches: [] }; + const route = path => { + if (o.route) { const forced = o.route(path); if (forced) return forced; } + if (path.startsWith("/napi/home/autocompleteDesktopSearch")) return { status: 200, body: AUTOCOMPLETE }; + if (path.startsWith("/napi/nearbyTheaters")) return { status: 200, body: NEARBY }; + if (path.startsWith("/napi/theaterMovieShowtimes/")) return { status: 200, body: THEATER_DAY() }; + if (path.startsWith("/napi/theaterCalendar/")) return { status: 200, body: THEATER_CALENDAR }; + if (path.startsWith("/napi/theaterShowtimeGroupings/")) return { status: 200, body: GROUPINGS() }; + if (path.startsWith("/napi/movieCalendar/")) return { status: 200, body: MOVIE_CALENDAR }; + return { status: 404, body: {} }; + }; + const pageContext = () => ({ + window: { location: new URL(o.url ?? HOME) }, + JSON, URL, URLSearchParams, Promise, Date, Number, Object, Array, String, RegExp, Set, Math, Error, + AbortSignal: { timeout: ms => ({ timeoutMs: ms }), any: signals => ({ any: signals }) }, + fetch: async (path, init) => { + calls.fetches.push({ path, init }); + assert.ok(path.startsWith("/napi/"), "only same-origin /napi/ reads are allowed: " + path); + assert.deepEqual(Object.keys(init ?? {}).sort(), ["headers", "signal"], "requests are plain GETs without a body, method or credentials override"); + if (o.throws) throw o.throws; + const r = route(path); + return { status: r.status, text: async () => (r.body === undefined ? "not json" : JSON.stringify(r.body)) }; + } + }); + const [definition] = runInNewContext(`(${sources[name]})`, {}); + // Page tools are re-created from their source inside the page, so only page globals are visible to them. + const execute = runInNewContext("(" + definition.execute.toString() + ")", pageContext()); + return { + definition, calls, + run: async (input, context) => { + const result = plain(await execute(input, context)); + assert.ok(conforms(definition.tool.outputSchema, result), `${name} output violates its schema: ${JSON.stringify(result)}`); + return result; + } + }; +} +const paths = h => h.calls.fetches.map(f => f.path); + +// --- structure ---------------------------------------------------------------------------------------- + +for (const name of NAMES) { + test(`${name}: single-expression page tool with typed schemas, narrowed patterns and a registry entry`, () => { + assert.ok(!/;\s*$/.test(sources[name]), "no trailing semicolon"); + const { definition } = harness(name); + assert.equal(definition.kind, "page"); + assert.equal(definition.tool.name, name); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.fandango.com/*"]); + assert.equal(definition.tool.inputSchema.type, "object"); + assert.equal(definition.tool.inputSchema.additionalProperties, false); + assert.equal(definition.tool.outputSchema.oneOf.length >= 3, true); + assert.deepEqual(plain(definition.tool.annotations), { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }); + assert.ok(definition.tool.description.length > 200 && /read-only/i.test(definition.tool.description)); + const entry = registry.tools.find(t => t.domain === "fandango.com" && t.tool === name); + assert.ok(entry, "registry entry missing"); + assert.equal(entry.kind, "page"); + assert.notEqual(entry.status, "broken"); + assert.deepEqual(entry.url_patterns, ["https://www.fandango.com/*"]); + assert.equal(entry.file, `domains/fandango.com/${name}.js`); + }); + + test(`${name}: source never reaches purchase, seat, account, favorite or storage surfaces`, () => { + const forbidden = /tickets\.fandango|seatMap|snapi|napi\/favorite|movieTickets|fanClub|checkout|cart|ticketingJumpPageURL|showtimeHashCode|isFavorite|favTheaterOnly|localStorage|sessionStorage|document\.cookie|XMLHttpRequest|credentials|method:|\.click\(|location\.(assign|href\s*=)/i; + const code = sources[name].slice(sources[name].indexOf("execute:")); + assert.ok(!forbidden.test(code), "forbidden reference: " + forbidden.exec(code)?.[0]); + }); +} + +test("registry lists every fandango.com tool exactly once, sorted", () => { + const entries = registry.tools.filter(t => t.domain === "fandango.com"); + assert.deepEqual(entries.map(t => t.tool).sort(), [...NAMES, "get_movie", "list_movies", "get_ticket_offers", "prepare_ticket_plan"].sort()); + const all = registry.tools.map(t => `${t.domain}/${t.tool}`); + assert.deepEqual(all, [...all].sort()); +}); + +test("tools are not available off www.fandango.com and do not touch the network on other pages", async () => { + const bad = [ + ["search", { query: "anything" }], ["search_theaters", { zip: "60601" }], + ["get_movie_showtimes", { movie_id: "100001", zip: "60601" }], ["get_theater_showtimes", { theater_id: "AAAAA" }] + ]; + for (const url of ["https://example.com/", "https://tickets.fandango.com/mobileexpress/seatselection", "https://fandango.com/", "https://athome.fandango.com/"]) { + for (const [name, input] of bad) { + const h = harness(name, { url }); + const r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", "wrong_page"], `${name} on ${url}`); + assert.equal(h.calls.fetches.length, 0); + } + } + for (const [name, input] of bad) { + const h = harness(name, { url: "https://www.fandango.com/region-error" }); + const r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", "region_blocked"], name); + assert.equal(h.calls.fetches.length, 0); + } +}); + +test("site failures are explicit errors, never empty successes", async () => { + const cases = [ + ["search", { query: "example" }], ["search_theaters", { zip: "60601" }], + ["get_movie_showtimes", { movie_id: "100001", zip: "60601", date: "2026-10-02" }], ["get_theater_showtimes", { theater_id: "AAAAA" }] + ]; + for (const [name, input] of cases) { + for (const [status, kind] of [[403, "challenge"], [429, "rate_limited"], [500, "site_error"]]) { + const h = harness(name, { route: () => ({ status, body: {} }) }); + const r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", kind], `${name} HTTP ${status}`); + } + let h = harness(name, { route: () => ({ status: 200, body: undefined }) }); + let r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], `${name} non-JSON`); + h = harness(name, { route: () => ({ status: 200, body: {} }) }); + r = await h.run(input); + assert.equal(r.status, "error", `${name} unexpected shape`); + h = harness(name, { throws: new Error("network down") }); + r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"]); + assert.match(r.error_message, /network down/); + h = harness(name, { throws: Object.assign(new Error("timed out"), { name: "TimeoutError" }) }); + r = await h.run(input); + assert.match(r.error_message, /20 seconds/); + assert.deepEqual(h.calls.fetches.map(f => f.init.signal), h.calls.fetches.map(() => ({ timeoutMs: 20000 }))); + } +}); + +// --- search --------------------------------------------------------------------------------------------- + +test("search maps suggestions to ids and canonical www URLs and drops links that leave the site", async () => { + const h = harness("search"); + const r = await h.run({ query: " example movie " }); + assert.deepEqual(paths(h), ["/napi/home/autocompleteDesktopSearch?search=example%20movie"]); + assert.equal(r.status, "ok"); + assert.equal(r.query, "example movie"); + assert.equal(r.search_url, "https://www.fandango.com/search?q=example%20movie&mode=all"); + assert.deepEqual(r.movies, [ + { movie_id: "100001", name: "Example Movie (2026)", release_date: "2026-07-31", url: "https://www.fandango.com/example-movie-2026-100001/movie-overview" }, + { movie_id: "100006", name: "Undated Movie", release_date: null, url: "https://www.fandango.com/undated-movie-100006/movie-overview" } + ]); + assert.deepEqual(r.theaters, [ + { theater_id: "AAAAA", name: "Example Cinema 12", url: "https://www.fandango.com/example-cinema-12-aaaaa/theater-page" }, + { theater_id: "BBBBB", name: "Lower Case Cinema", url: "https://www.fandango.com/lower-case-cinema-bbbbb/theater-page" } + ]); + assert.deepEqual(r.locations, [ + { name: "Springfield, IL", city: "Springfield", state: "IL", url: "https://www.fandango.com/springfield_il_movietimes" }, + { name: "Port Example, CA", city: "Port Example", state: "CA", url: "https://www.fandango.com/port-example_ca_movietimes" }, + { name: "Coeur d'Example, ID", city: "Coeur d'Example", state: "ID", url: "https://www.fandango.com/coeur-dexample_id_movietimes" } + ]); + assert.deepEqual(r.people, [{ name: "Example Person", url: "https://www.fandango.com/people/example-person-777" }]); + assert.ok(!JSON.stringify(r).includes("elsewhere.example") && !JSON.stringify(r).includes("poster")); +}); + +test("search filters by requested types and reports no_results when nothing is left", async () => { + let r = await harness("search").run({ query: "example", types: ["theaters"] }); + assert.equal(r.status, "ok"); + assert.deepEqual([r.movies, r.locations, r.people], [[], [], []]); + assert.deepEqual(r.theaters.map(t => t.theater_id), ["AAAAA", "BBBBB"]); + const only = { ...AUTOCOMPLETE, resultsByType: { ...AUTOCOMPLETE.resultsByType, people: { type: "people", items: [] } } }; + r = await harness("search", { route: () => ({ status: 200, body: only }) }).run({ query: "example", types: ["people"] }); + assert.equal(r.status, "no_results"); + assert.match(r.message, /people/); + const empty = { isEmpty: true, seeMoreLink: "/search?q=zzz", resultsByType: { movies: { items: [] }, theaters: { items: [] }, locations: { items: [] }, people: { items: [] } } }; + r = await harness("search", { route: () => ({ status: 200, body: empty }) }).run({ query: "zzzz" }); + assert.equal(r.status, "no_results"); + assert.equal(r.search_url, "https://www.fandango.com/search?q=zzzz&mode=all"); +}); + +// --- search_theaters ------------------------------------------------------------------------------------ + +test("search_theaters by ZIP returns a bounded, allowlisted theater list", async () => { + const h = harness("search_theaters"); + const r = await h.run({ zip: "60601", limit: 5 }); + assert.deepEqual(paths(h), ["/napi/nearbyTheaters?limit=5&zipCode=60601"]); + assert.deepEqual(r.place, { zip: "60601", city: null, state: null }); + assert.equal(r.theaters.length, 2); + assert.deepEqual(r.theaters[0], { + theater_id: "AAAAA", name: "Example Cinema 12", url: "https://www.fandango.com/example-cinema-12-aaaaa/theater-page", distance_miles: 1.23, + address: { street: "1 Example St, Suite 2", city: "Springfield", state: "IL", zip: "62701" }, chain: "Example Chain", phone: "555-0100", + ticketing_available: true, has_reserved_seating: true, has_concessions: false, open: true, amenities: ["Digital Projection", "Reserved Seating"] + }); + assert.deepEqual([r.theaters[1].chain, r.theaters[1].phone, r.theaters[1].ticketing_available, r.theaters[1].open, r.theaters[1].address.street], [null, null, false, false, "1 Example St"]); + const text = JSON.stringify(r); + assert.ok(!text.includes("Fabricated policy") && !text.includes("geo") && !text.includes("latitude"), "html policy and coordinates are not returned"); +}); + +test("search_theaters by city and state normalizes the place and sends the site's params", async () => { + const h = harness("search_theaters"); + const r = await h.run({ city: " St. Louis ", state: "mo" }); + assert.deepEqual(paths(h), ["/napi/nearbyTheaters?limit=10&city=St.+Louis&state=MO"]); + assert.deepEqual(r.place, { zip: null, city: "St. Louis", state: "MO" }); +}); + +test("search_theaters reports an empty site list as no_results and skips malformed records", async () => { + let r = await harness("search_theaters", { route: () => ({ status: 200, body: { ...NEARBY, theaters: [] } }) }).run({ zip: "00000" }); + assert.equal(r.status, "no_results"); + assert.match(r.message, /unknown ZIP/); + const odd = [{ id: "!", name: "Bad Id", theaterPageUrl: "/x-aaaaa/theater-page" }, theaterRecord("FFFFF", "Off Site", { theaterPageUrl: "//elsewhere.example/x/theater-page" }), theaterRecord("GGGGG", "Wrong Page", { theaterPageUrl: "/x-ggggg/other" }), theaterRecord("HHHHH", "Kept", { distance: "far" })]; + r = await harness("search_theaters", { route: () => ({ status: 200, body: { ...NEARBY, theaters: odd } }) }).run({ zip: "60601" }); + assert.deepEqual(r.theaters.map(t => [t.theater_id, t.distance_miles]), [["HHHHH", null]]); +}); + +// --- get_theater_showtimes ------------------------------------------------------------------------------ + +test("get_theater_showtimes normalizes showtimes, formats, statuses and dates", async () => { + const h = harness("get_theater_showtimes"); + const r = await h.run({ theater_id: "aaaaa", date: "2026-10-02" }); + assert.deepEqual(paths(h).sort(), ["/napi/theaterCalendar/AAAAA", "/napi/theaterMovieShowtimes/AAAAA?chainCode=&startDate=2026-10-02&isdesktop=true&partnerRestrictedTicketing="]); + assert.equal(r.status, "ok"); + assert.equal(r.date, "2026-10-02"); + assert.deepEqual(r.theater, { + theater_id: "AAAAA", name: "Example Cinema 12", url: "https://www.fandango.com/example-cinema-12-aaaaa/theater-page", + address: { street: "1 Example St, Suite 2", city: "Springfield", state: "IL", zip: "62701" }, chain: "Example Chain", phone: "555-0100", ticketing_available: true, has_reserved_seating: true + }); + assert.deepEqual(r.available_dates, ["2026-10-02", "2026-10-03"]); + assert.deepEqual([r.total_movies, r.truncated, r.movies.length], [2, false, 2]); + const [first, second] = r.movies; + assert.deepEqual([first.movie_id, first.title, first.rating, first.runtime_minutes, first.release_date, first.genres, first.url], ["100001", "Example Movie", "PG-13", 120, "2026-09-18", ["Drama", "Comedy"], "https://www.fandango.com/example-movie-100001/movie-overview"]); + assert.deepEqual(first.screenings.map(s => [s.format, s.reserved_seating, s.amenities, s.showtimes.map(t => [t.local_date, t.local_time, t.time_text, t.status, t.site_status, t.showtime_id])]), [ + ["Standard", true, ["Reserved seating", "Closed caption"], [["2026-10-02", "19:00", "7:00p", "available", "available", 900001], ["2026-10-02", "22:15", "10:15p", "sold_out", "soldout", 900002]]], + ["3D", false, [], [["2026-10-02", "16:00", "4:00p", "past", "pastshowtime", 900003]]] + ]); + assert.deepEqual([second.rating, second.runtime_minutes, second.release_date, second.genres, second.url], [null, null, null, [], null]); + assert.deepEqual(second.screenings[0].showtimes.map(t => [t.status, t.site_status, t.showtime_id, t.local_time]), [["unavailable", "disabled", 900004, "19:00"], ["unknown", "somethingnew", 900005, "19:00"], ["past", "available", 900006, "19:00"], ["available", "available", null, null]]); + const text = JSON.stringify(r); + for (const leak of ["showtimeHashCode", "v2-fabricated", "tickets.fandango.com", "ticketingJumpPageURL", "isFavorite", "Fabricated"]) assert.ok(!text.includes(leak), "leaked " + leak); +}); + +test("get_theater_showtimes without a date uses the site's day and limit truncates movies", async () => { + const h = harness("get_theater_showtimes"); + const r = await h.run({ theater_id: "AAAAA", limit: 1 }); + assert.ok(paths(h).includes("/napi/theaterMovieShowtimes/AAAAA?chainCode=&isdesktop=true&partnerRestrictedTicketing=")); + assert.deepEqual([r.date, r.total_movies, r.truncated, r.movies.map(m => m.movie_id)], ["2026-10-02", 2, true, ["100001"]]); +}); + +test("get_theater_showtimes explains empty days with the calendar and rejects unknown theaters", async () => { + const none = { viewModel: { ...THEATER_DAY().viewModel, movies: [], date: null } }; + const route = path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: none } : null); + let r = await harness("get_theater_showtimes", { route }).run({ theater_id: "AAAAA", date: "2027-06-01" }); + assert.deepEqual([r.status, r.date, r.available_dates], ["no_showtimes", "2027-06-01", ["2026-10-02", "2026-10-03"]]); + assert.match(r.message, /available_dates/); + r = await harness("get_theater_showtimes", { route }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.status, r.date], ["no_showtimes", "2026-10-02"], "falls back to the calendar's selected date"); + r = await harness("get_theater_showtimes", { route: path => route(path) ?? (path.startsWith("/napi/theaterCalendar/") ? { status: 200, body: { isEmpty: true, selectedDate: "today", showtimeDates: [] } } : null) }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "an undated empty answer is not guessed"); + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: { error: true } } : null) }).run({ theater_id: "ZZZZZ" }); + assert.deepEqual([r.status, r.error_kind], ["error", "theater_not_found"]); + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: { viewModel: { ...THEATER_DAY().viewModel, date: "2026-10-05" } } } : null) }).run({ theater_id: "AAAAA", date: "2026-10-02" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "a different date than requested is an error"); + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterCalendar/") ? { status: 500, body: {} } : null) }).run({ theater_id: "AAAAA" }); + assert.equal(r.status, "ok", "an unreadable calendar only nulls available_dates"); + assert.equal(r.available_dates, null); +}); + +// --- get_movie_showtimes -------------------------------------------------------------------------------- + +test("get_movie_showtimes lists nearby theaters with the site's own request shape", async () => { + const h = harness("get_movie_showtimes"); + const r = await h.run({ movie_id: "100001", zip: "60601", date: "2026-10-02", limit: 2 }); + assert.deepEqual(paths(h).sort(), [ + "/napi/movieCalendar/movie-100001?postalCode=60601&zip=60601", + "/napi/theaterShowtimeGroupings/100001/2026-10-02?isdesktop=true&postalCode=60601&zip=60601&isDesktopMOP=true&partnerRestrictedTicketing=" + ]); + assert.deepEqual([r.status, r.movie_id, r.zip, r.date, r.total_theaters, r.truncated, r.warnings], ["ok", "100001", "60601", "2026-10-02", 2, false, []]); + assert.deepEqual(r.available_dates, ["2026-10-02", "2026-10-03", "2026-10-04"]); + assert.deepEqual(r.theaters.map(t => t.theater_id), ["CCCCC", "EEEEE"], "theaters without showtimes are skipped"); + assert.deepEqual(r.theaters[0], { + theater_id: "CCCCC", name: "First Cinema", url: "https://www.fandango.com/first-cinema-ccccc/theater-page", distance_miles: 0.68, + address: { street: "9 Example Ave", city: "Springfield", state: "IL", zip: "62701" }, chain_code: "EXC", ticketing_available: true, + screenings: [ + { format: "Standard", amenities: ["Reserved seating", "Closed caption"], reserved_seating: true, showtimes: [ + { local_date: "2026-10-02", local_time: "19:00", time_text: "7:00p", status: "available", site_status: "available", showtime_id: 900001 }, + { local_date: "2026-10-02", local_time: "21:40", time_text: "9:40p", status: "available", site_status: "available", showtime_id: 900010 } + ] }, + { format: "IMAX", amenities: [], reserved_seating: false, showtimes: [{ local_date: "2026-10-02", local_time: "19:00", time_text: "7:00p", status: "available", site_status: "available", showtime_id: 900011 }] } + ] + }); + const text = JSON.stringify(r); + for (const leak of ["showtimeHashCode", "v2-fabricated", "tickets.fandango.com", "isFavorite"]) assert.ok(!text.includes(leak), "leaked " + leak); +}); + +test("get_movie_showtimes without a date takes the site's day from its calendar first and truncates", async () => { + const h = harness("get_movie_showtimes"); + const r = await h.run({ movie_id: "100001", zip: "60601", limit: 1 }); + assert.deepEqual(paths(h), [ + "/napi/movieCalendar/movie-100001?postalCode=60601&zip=60601", + "/napi/theaterShowtimeGroupings/100001/2026-10-02?isdesktop=true&postalCode=60601&zip=60601&isDesktopMOP=true&partnerRestrictedTicketing=" + ]); + assert.deepEqual([r.date, r.total_theaters, r.truncated, r.theaters.length], ["2026-10-02", 2, true, 1]); +}); + +test("get_movie_showtimes separates empty dates, unknown movies or ZIPs, extra pages and date mismatches", async () => { + const empty = { hasShowtimes: false, error: "unable to retrieve movie showtimes", theaterShowtimes: { theaters: [] } }; + let r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: empty } : null) }).run({ movie_id: "100001", zip: "60601", date: "2027-06-01" }); + assert.deepEqual([r.status, r.date, r.available_dates], ["no_showtimes", "2027-06-01", ["2026-10-02", "2026-10-03", "2026-10-04"]]); + const unknown = path => (path.startsWith("/napi/movieCalendar/") ? { status: 200, body: NO_CALENDAR } : path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: empty } : null); + r = await harness("get_movie_showtimes", { route: unknown }).run({ movie_id: "999999999", zip: "60601", date: "2026-10-02" }); + assert.deepEqual([r.status, r.error_kind], ["error", "not_found"]); + r = await harness("get_movie_showtimes", { route: unknown }).run({ movie_id: "999999999", zip: "60601" }); + assert.deepEqual([r.status, r.error_kind], ["error", "not_found"]); + const paged = GROUPINGS(); paged.theaterShowtimes.paginated = { totalPages: 3 }; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: paged } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.equal(r.status, "ok"); + assert.match(r.warnings[0], /3 pages/); + const shifted = GROUPINGS(); shifted.theaterShowtimes.date = "2026-10-09"; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: shifted } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"]); + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/movieCalendar/") ? { status: 500, body: {} } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.equal(r.status, "ok", "an unreadable calendar only nulls available_dates"); + assert.equal(r.available_dates, null); + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/movieCalendar/") ? { status: 403, body: {} } : null) }).run({ movie_id: "100001", zip: "60601" }); + assert.deepEqual([r.status, r.error_kind], ["error", "challenge"]); +}); + +// --- review fixes --------------------------------------------------------------------------------------- + +test("search_theaters sends hyphens as spaces, keeps periods, and maps the site's unknown-city 404 to no_results", async () => { + let h = harness("search_theaters"); + let r = await h.run({ city: "Winston-Salem", state: "nc" }); + assert.deepEqual(paths(h), ["/napi/nearbyTheaters?limit=10&city=Winston+Salem&state=NC"]); + assert.deepEqual(r.place, { zip: null, city: "Winston-Salem", state: "NC" }); + h = harness("search_theaters"); + await h.run({ city: "St. Louis", state: "MO" }); + assert.deepEqual(paths(h), ["/napi/nearbyTheaters?limit=10&city=St.+Louis&state=MO"]); + const unknown = { status: 404, body: { error: { error: true }, attemptedLocation: "Zzzzville, ZZ" } }; + r = await harness("search_theaters", { route: () => unknown }).run({ city: "Zzzzville", state: "ZZ" }); + assert.equal(r.status, "no_results"); + assert.match(r.message, /St\. Louis/); + r = await harness("search_theaters", { route: () => unknown }).run({ city: "Coeur d'Alene", state: "ID" }); + assert.equal(r.status, "no_results"); + r = await harness("search_theaters", { route: () => unknown }).run({ zip: "60601" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "a 404 for a ZIP request is not an unknown city"); + r = await harness("search_theaters", { route: () => ({ status: 404, body: { note: "no error field" } }) }).run({ city: "Chicago", state: "IL" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"]); +}); + +test("showtime statuses map every site word seen live and pass unknown words through", async () => { + const words = [["available", "available"], ["soldout", "sold_out"], ["pastshowtime", "past"], ["disabled", "unavailable"], ["restricted", "restricted"], ["somethingnew", "unknown"]]; + const times = words.map(([type], i) => showtime({ id: 910000 + i, type, date: `${i + 1}:00p`, ticketingDate: `2026-10-02+${String(i + 13).padStart(2, "0")}:00` })); + const expired = showtime({ id: 910100, type: "available", expired: true, date: "8:00p", ticketingDate: "2026-10-02+20:00" }); + const soldExpired = showtime({ id: 910101, type: "soldout", expired: true, date: "9:00p", ticketingDate: "2026-10-02+21:00" }); + const theaterDay = THEATER_DAY(); theaterDay.viewModel.movies = [movie(100001, "Status Movie", [["Standard", group([...times, expired, soldExpired])]])]; + let r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: theaterDay } : null) }).run({ theater_id: "AAAAA" }); + const expected = [...words.map(([type, status]) => [status, type]), ["past", "available"], ["sold_out", "soldout"]]; + assert.deepEqual(r.movies[0].screenings[0].showtimes.map(t => [t.status, t.site_status]), expected); + const grouped = GROUPINGS(); grouped.theaterShowtimes.theaters = [groupingTheater("CCCCC", "Status Cinema", { variants: [{ filmFormatHeader: "Standard", amenityGroups: [group([...times, expired, soldExpired])] }] })]; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: grouped } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual(r.theaters[0].screenings[0].showtimes.map(t => [t.status, t.site_status]), expected); +}); + +test("showtimes after midnight keep the next calendar date, and a missing id or ticketing flag is null or false", async () => { + const late = showtime({ id: undefined, date: "12:15a", ticketingDate: "2026-10-03+00:15" }); + delete late.id; + const theaterDay = THEATER_DAY(); theaterDay.viewModel.movies = [movie(100001, "Late Movie", [["Standard", group([late])]])]; + delete theaterDay.viewModel.theater.details.isTicketing; delete theaterDay.viewModel.theater.details.hasReservedSeating; + let r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: theaterDay } : null) }).run({ theater_id: "AAAAA", date: "2026-10-02" }); + assert.deepEqual(r.movies[0].screenings[0].showtimes[0], { local_date: "2026-10-03", local_time: "00:15", time_text: "12:15a", status: "available", site_status: "available", showtime_id: null }); + assert.deepEqual([r.theater.ticketing_available, r.theater.has_reserved_seating], [false, false]); + const grouped = GROUPINGS(); grouped.theaterShowtimes.theaters = [groupingTheater("CCCCC", "Late Cinema", { isTicketing: undefined, variants: [{ filmFormatHeader: "Standard", amenityGroups: [group([late])] }] })]; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: grouped } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual([r.theaters[0].screenings[0].showtimes[0].local_date, r.theaters[0].screenings[0].showtimes[0].local_time, r.theaters[0].ticketing_available], ["2026-10-03", "00:15", false]); + r = await harness("search_theaters", { route: () => ({ status: 200, body: { ...NEARBY, theaters: [theaterRecord("AAAAA", "No Flag Cinema", { isTicketing: undefined, hasReservedSeating: undefined })] } }) }).run({ zip: "60601" }); + assert.deepEqual([r.theaters[0].ticketing_available, r.theaters[0].has_reserved_seating, r.theaters[0].open], [false, false, true]); +}); + +test("output caps: amenities per screening and dates per calendar", async () => { + const many = Array.from({ length: 30 }, (_, i) => ({ id: i, name: "Amenity " + i })); + const dates = Array.from({ length: 150 }, (_, i) => new Date(Date.UTC(2026, 9, 2 + i)).toISOString().slice(0, 10)); + const theaterDay = THEATER_DAY(); theaterDay.viewModel.movies = [movie(100001, "Capped Movie", [["Standard", group([showtime()], { amenities: many })]])]; + let r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: theaterDay } : path.startsWith("/napi/theaterCalendar/") ? { status: 200, body: { ...THEATER_CALENDAR, showtimeDates: dates } } : null) }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.movies[0].screenings[0].amenities.length, r.available_dates.length], [20, 120]); + const grouped = GROUPINGS(); grouped.theaterShowtimes.theaters = [groupingTheater("CCCCC", "Capped Cinema", { variants: [{ filmFormatHeader: "Standard", amenityGroups: [group([showtime()], { amenities: many })] }] })]; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: grouped } : path.startsWith("/napi/movieCalendar/") ? { status: 200, body: { ...MOVIE_CALENDAR, movieCalendar: { ...MOVIE_CALENDAR.movieCalendar, showtimeDates: dates } } } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual([r.theaters[0].screenings[0].amenities.length, r.available_dates.length], [20, 120]); +}); + +test("a past date the site answers with its current day is no_showtimes when the calendar does not list it, else an error", async () => { + // Observed live: dates about four weeks back or more make both endpoints return the current day's data. + let r = await harness("get_theater_showtimes").run({ theater_id: "AAAAA", date: "2026-08-15" }); + assert.deepEqual([r.status, r.date, r.available_dates], ["no_showtimes", "2026-08-15", ["2026-10-02", "2026-10-03"]]); + assert.match(r.message, /2026-10-02 instead/); + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterCalendar/") ? { status: 200, body: { isEmpty: true } } : null) }).run({ theater_id: "AAAAA", date: "2026-08-15" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "without a calendar the mismatch cannot be explained"); + r = await harness("get_movie_showtimes").run({ movie_id: "100001", zip: "60601", date: "2026-08-15" }); + assert.deepEqual([r.status, r.date, r.available_dates], ["no_showtimes", "2026-08-15", ["2026-10-02", "2026-10-03", "2026-10-04"]]); + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/movieCalendar/") ? { status: 500, body: {} } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-08-15" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"]); +}); + +test("unrecognized response shapes are site_error, not fabricated empties or not-found outcomes", async () => { + const groupingsWith = body => path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body } : null); + for (const body of [{ hasShowtimes: true }, { hasShowtimes: true, theaterShowtimes: { theatres: [] } }, { theaterShowtimes: { theaters: [] } }, {}]) { + const r = await harness("get_movie_showtimes", { route: groupingsWith(body) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], JSON.stringify(body)); + } + let r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/movieCalendar/") ? { status: 200, body: { cal: 1 } } : null) }).run({ movie_id: "100001", zip: "60601" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "a calendar without hasCalendar is not 'not found'"); + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: { error: "rate limited" } } : null) }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"], "only the site's error:true means an unknown theater"); +}); + +test("get_theater_showtimes limits: default 40, truncated only when movies are cut, calendar isEmpty nulls the dates", async () => { + const twelve = THEATER_DAY(); twelve.viewModel.movies = Array.from({ length: 12 }, (_, i) => movie(100100 + i, "Movie " + i, [["Standard", group([showtime({ id: 920000 + i })])]])); + const route = path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: twelve } : null); + let r = await harness("get_theater_showtimes", { route }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.total_movies, r.movies.length, r.truncated], [12, 12, false], "the default limit is not smaller than 12"); + r = await harness("get_theater_showtimes", { route }).run({ theater_id: "AAAAA", limit: 12 }); + assert.deepEqual([r.movies.length, r.truncated], [12, false]); + r = await harness("get_theater_showtimes", { route }).run({ theater_id: "AAAAA", limit: 11 }); + assert.deepEqual([r.movies.length, r.truncated], [11, true]); + r = await harness("get_theater_showtimes", { route: path => route(path) ?? (path.startsWith("/napi/theaterCalendar/") ? { status: 200, body: { ...THEATER_CALENDAR, isEmpty: true } } : null) }).run({ theater_id: "AAAAA" }); + assert.equal(r.available_dates, null); +}); + +test("links written with a backslash or another origin never leave www.fandango.com", async () => { + const theaterDay = THEATER_DAY(); + theaterDay.viewModel.movies = [movie(100001, "Backslash Movie", [["Standard", group([showtime()])]], { mopURI: "/\\elsewhere.example/x-100001/movie-overview" }), movie(100002, "Absolute Movie", [["Standard", group([showtime()])]], { mopURI: "https://elsewhere.example/x-100002/movie-overview" })]; + let r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: theaterDay } : null) }).run({ theater_id: "AAAAA" }); + assert.deepEqual(r.movies.map(m => m.url), [null, null]); + const badTheater = THEATER_DAY(); badTheater.viewModel.theater.details.theaterPageUrl = "/\\elsewhere.example/x-aaaaa/theater-page"; + r = await harness("get_theater_showtimes", { route: path => (path.startsWith("/napi/theaterMovieShowtimes/") ? { status: 200, body: badTheater } : null) }).run({ theater_id: "AAAAA" }); + assert.deepEqual([r.status, r.error_kind], ["error", "site_error"]); + const grouped = GROUPINGS(); grouped.theaterShowtimes.theaters = [groupingTheater("CCCCC", "Backslash Cinema", { theaterPageUrl: "/\\elsewhere.example/x-ccccc/theater-page" }), groupingTheater("DDDDD", "Kept Cinema")]; + r = await harness("get_movie_showtimes", { route: path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: grouped } : null) }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02" }); + assert.deepEqual(r.theaters.map(t => t.theater_id), ["DDDDD"]); + r = await harness("search_theaters", { route: () => ({ status: 200, body: { ...NEARBY, theaters: [theaterRecord("FFFFF", "Backslash", { theaterPageUrl: "/\\elsewhere.example/x-fffff/theater-page" })] } }) }).run({ zip: "60601" }); + assert.equal(r.status, "no_results"); +}); + +test("get_movie_showtimes returns exactly limit theaters and marks truncation only when some were cut", async () => { + const r = await harness("get_movie_showtimes").run({ movie_id: "100001", zip: "60601", date: "2026-10-02", limit: 2 }); + assert.deepEqual([r.total_theaters, r.theaters.length, r.truncated], [2, 2, false]); +}); + +test("search_theaters treats a temporarily closed theater as not open", async () => { + const theaters = [theaterRecord("AAAAA", "Closed Cinema", { isOpen: true, isTemporarilyClosed: true }), theaterRecord("BBBBB", "Open Cinema")]; + const r = await harness("search_theaters", { route: () => ({ status: 200, body: { ...NEARBY, theaters } }) }).run({ zip: "60601" }); + assert.deepEqual(r.theaters.map(t => [t.theater_id, t.open]), [["AAAAA", false], ["BBBBB", true]]); +}); + +// --- inputs --------------------------------------------------------------------------------------------- + +const VALID = { + search: { query: "example", types: ["movies", "people"] }, + search_theaters: { zip: "60601", limit: 30 }, + get_movie_showtimes: { movie_id: "243819", zip: "60601", date: "2026-10-02", limit: 40 }, + get_theater_showtimes: { theater_id: "AAODQ", date: "2026-10-02", limit: 60 } +}; +const INVALID = { + search: [{}, { query: "a" }, { query: " " }, { query: "x".repeat(101) }, { query: 5 }, { query: "ok", types: [] }, { query: "ok", types: ["movies", "movies"] }, { query: "ok", types: ["shows"] }, { query: "ok", extra: 1 }, null, "ok"], + search_theaters: [{}, { zip: "6060" }, { zip: "60601", city: "Chicago", state: "IL" }, { city: "Chicago" }, { state: "IL" }, { city: "Chicago", state: "Illinois" }, { city: "C", state: "IL" }, { city: "Chicago; DROP", state: "IL" }, { zip: "60601", limit: 0 }, { zip: "60601", limit: 31 }, { zip: "60601", limit: 1.5 }, { zip: 60601 }, { zip: "60601", extra: true }], + get_movie_showtimes: [{}, { movie_id: "243819" }, { zip: "60601" }, { movie_id: "12345678901", zip: "60601" }, { movie_id: "abc", zip: "60601" }, { movie_id: 243819, zip: "60601" }, { movie_id: "243819", zip: "6060" }, { movie_id: "243819", zip: "60601", date: "2026-02-30" }, { movie_id: "243819", zip: "60601", date: "2026-13-01" }, { movie_id: "243819", zip: "60601", date: "2026-00-10" }, { movie_id: "243819", zip: "60601", date: "10/02/2026" }, { movie_id: "243819", zip: "60601", limit: 41 }, { movie_id: "243819", zip: "60601", limit: 0 }, { movie_id: "243819", zip: "60601", extra: 1 }], + get_theater_showtimes: [{}, { theater_id: "AA" }, { theater_id: "AAODQ/../x" }, { theater_id: "A".repeat(13) }, { theater_id: 5 }, { theater_id: "AAODQ", date: "2026-13-01" }, { theater_id: "AAODQ", date: "2026-02-30" }, { theater_id: "AAODQ", date: "tomorrow" }, { theater_id: "AAODQ", limit: 61 }, { theater_id: "AAODQ", limit: 0 }, { theater_id: "AAODQ", extra: 1 }] +}; + +for (const name of NAMES) { + test(`${name}: valid input matches the schema, malformed input is rejected before any request`, async () => { + const h = harness(name); + assert.ok(conforms(h.definition.tool.inputSchema, VALID[name]), "valid sample must satisfy inputSchema"); + for (const input of INVALID[name]) { + const r = await h.run(input); + assert.deepEqual([r.status, r.error_kind], ["error", "invalid_input"], JSON.stringify(input)); + } + assert.equal(h.calls.fetches.length, 0); + const result = await h.run(VALID[name]); + assert.ok(["ok", "no_results", "no_showtimes"].includes(result.status), JSON.stringify(result).slice(0, 300)); + }); +} + +test("every request is a same-origin GET under /napi/ and no tool follows the ticketing links", async () => { + for (const [name, input] of Object.entries(VALID)) { + const h = harness(name); + await h.run(input); + assert.ok(h.calls.fetches.length >= 1); + for (const { path, init } of h.calls.fetches) { + assert.ok(path.startsWith("/napi/") && !path.includes("favTheaterOnly=true") && !/seatMap|snapi|favorite|tickets/i.test(path), path); + assert.deepEqual(plain(init.headers), { accept: "application/json" }); + } + } +}); + +test("get_movie_showtimes pages past limit with offset, so every theater the site lists is reachable", async () => { + const theaters = Array.from({ length: 41 }, (_, i) => groupingTheater("T" + String(i).padStart(4, "0"), "Cinema " + i)); + const route = path => (path.startsWith("/napi/theaterShowtimeGroupings/") ? { status: 200, body: GROUPINGS({ theaterShowtimes: { ...GROUPINGS().theaterShowtimes, theaters } }) } : null); + const first = await harness("get_movie_showtimes", { route }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02", limit: 40 }); + assert.deepEqual([first.total_theaters, first.theaters.length, first.offset, first.next_offset, first.truncated], [41, 40, 0, 40, true]); + const last = await harness("get_movie_showtimes", { route }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02", limit: 40, offset: first.next_offset }); + assert.deepEqual([last.theaters.map(t => t.theater_id), last.next_offset, last.truncated], [["T0040"], null, true]); + const past = await harness("get_movie_showtimes", { route }).run({ movie_id: "100001", zip: "60601", date: "2026-10-02", offset: 41 }); + assert.equal(past.error_kind, "invalid_input"); +}); + +for (const [name, input] of [["search", { query: "example" }], ["search_theaters", { zip: "60601" }], ["get_movie_showtimes", { movie_id: "100001", zip: "60601", date: "2026-10-02" }], ["get_theater_showtimes", { theater_id: "AAAAA", date: "2026-10-02" }]]) { + test(`${name}: the caller's cancellation reaches the request and is reported as canceled`, async () => { + const signal = { aborted: false }; + const h = harness(name, { throws: Object.assign(new Error("aborted"), { name: "AbortError" }) }); + const original = h.calls.fetches.push.bind(h.calls.fetches); + h.calls.fetches.push = call => { signal.aborted = true; return original(call); }; + const r = await h.run(input, { signal }); + assert.equal(r.error_kind, "canceled"); + assert.ok(h.calls.fetches[0].init.signal.any.includes(signal), "the request carries the caller's signal"); + }); +}