diff --git a/bench/tasks/steampowered.com/domain.json b/bench/tasks/steampowered.com/domain.json new file mode 100644 index 00000000..60f74dc3 --- /dev/null +++ b/bench/tasks/steampowered.com/domain.json @@ -0,0 +1,5 @@ +{ + "page_actions": { + "search_store": "submits a term in the store's header search box, which opens the search results page" + } +} diff --git a/domains/steampowered.com/README.md b/domains/steampowered.com/README.md new file mode 100644 index 00000000..72d55eaf --- /dev/null +++ b/domains/steampowered.com/README.md @@ -0,0 +1,7 @@ +# steampowered.com + +| tool | description | +|------|-------------| +| get_app | Reads a Steam Store app page, the open one or any app by id from any store page: purchase options with shown prices and discounts, and the review summaries. | +| get_search_results | Reads Steam Store search results, the open search page or a term's results from any store page: rows with prices, review summaries and the applied sort and filters. Reports visible pagination links or guidance for reading more results without loading them itself. | +| search_store | Types a term into the store's header search box and submits it; the tab navigates to the results. | diff --git a/domains/steampowered.com/get_app.js b/domains/steampowered.com/get_app.js new file mode 100644 index 00000000..679c81fa --- /dev/null +++ b/domains/steampowered.com/get_app.js @@ -0,0 +1,280 @@ +(() => { + const ACTIONS = { + type: "array", + items: { + type: "object", additionalProperties: false, + required: ["tool", "enabled", "registered", "changes_page", "input", "note"], + properties: { + tool: { type: "string" }, + enabled: { type: "boolean", description: "The page offers this action now." }, + registered: { type: ["boolean", "null"], description: "Whether the tool is registered on this page; null when the page's tool registry could not be read." }, + changes_page: { type: "boolean" }, + input: { type: ["object", "null"], description: "A valid input for this exact state; null when the action is not available or needs a value the page does not suggest (see note)." }, + note: { type: "string" } + } + } + }; + const REGISTERED = { type: "object", additionalProperties: false, required: ["known", "names"], properties: { known: { type: "boolean" }, names: { type: "array", items: { type: "string" } } } }; + return [{ + kind: "page", + match: { url_patterns: ["https://store.steampowered.com/*"] }, + tool: { + name: "get_app", + description: "Read a Steam Store app page: the one open in this tab (pass {}), or any app by id or store URL (pass app) from any store page without leaving it, read from that page's markup as the store serves it to this browser. Returns, as the page shows it: the app's name, release date, developers, publishers and genres; every purchase option in the Buy area (the game itself, editions and bundles) with its title, shown price, original price and discount; the store currency; and the review summaries Steam shows next to the header, such as Recent Reviews and All or English Reviews, each with its summary (\"Very Positive\"), percent positive and review count. Read-only: never clicks, scrolls, opens a bundle or changes the review language. Prices are the store's prices for this browser's region, before tax, and are not a quote for checkout. An age-gated app returns status age_check: Steam wants a birth date entered in its own form first, which this tool does not do. An app Steam shows only to signed-in accounts (adult-only content) returns sign_in_required. An unknown app id returns not_found. Without app on a page that is not an app page it returns wrong_page with the actions available there.", + inputSchema: { + type: "object", additionalProperties: false, + properties: { app: { type: "string", minLength: 1, maxLength: 300, description: "An app id such as '413150' or a store.steampowered.com/app// URL, to read that app's page from any store page without leaving it. Omit to read the app page open in this tab." } } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "message", "app", "currency", "purchase_options", "reviews", "registered_tools", "available_actions", "next_steps"], + properties: { + status: { const: "ok" }, + message: { type: "string" }, + app: { + type: "object", additionalProperties: false, + required: ["app_id", "name", "url", "release_date", "coming_soon", "developers", "publishers", "genres"], + properties: { + app_id: { type: "string", pattern: "^[0-9]+$" }, + name: { type: "string", minLength: 1, maxLength: 300 }, + url: { type: "string", description: "The store's canonical URL for this app." }, + release_date: { type: ["string", "null"], description: "As the page shows it, for example 'Feb 26, 2016' or 'Coming soon'." }, + coming_soon: { type: "boolean", description: "The page says the app is not released yet." }, + developers: { type: "array", items: { type: "string" } }, + publishers: { type: "array", items: { type: "string" } }, + genres: { type: "array", items: { type: "string" } } + } + }, + currency: { type: ["string", "null"], description: "ISO currency of the shown prices, from the page's offer data; null when the page has none." }, + purchase_options: { + type: "array", maxItems: 30, + description: "The Buy area's options in page order. Empty when the page offers none (unreleased, delisted or region-locked).", + items: { + type: "object", additionalProperties: false, + required: ["title", "kind", "package_id", "bundle_id", "price_text", "price", "original_price", "discount_percent", "button", "promotion"], + properties: { + title: { type: "string", description: "The option's heading, for example 'Buy Stardew Valley'." }, + kind: { enum: ["package", "bundle", "other"], description: "package: a single purchasable package (the game or an edition); bundle: a set of products; other: free-to-play and similar entries." }, + package_id: { type: ["string", "null"] }, + bundle_id: { type: ["string", "null"] }, + price_text: { type: ["string", "null"], description: "The price as shown, for example '$14.99' or 'Free To Play'." }, + price: { type: ["number", "null"], description: "price_text as a number in the store currency; null when it is not an amount." }, + original_price: { type: ["number", "null"], description: "The struck-through price when discounted." }, + discount_percent: { type: ["integer", "null"] }, + button: { type: ["string", "null"], description: "The option's button label, such as 'Add to Cart' or 'Bundle info'. This tool never presses it." }, + promotion: { type: ["string", "null"], description: "Sale text shown with the option, for example when the discount ends." } + } + } + }, + reviews: { + type: "array", maxItems: 4, + description: "The review summaries shown in the page header, in page order.", + items: { + type: "object", additionalProperties: false, + required: ["label", "summary", "percent_positive", "review_count", "description"], + properties: { + label: { type: "string", description: "For example 'Recent Reviews', 'All Reviews' or 'English Reviews'." }, + summary: { type: ["string", "null"], description: "For example 'Overwhelmingly Positive' or 'No user reviews'." }, + percent_positive: { type: ["integer", "null"] }, + review_count: { type: ["integer", "null"] }, + description: { type: ["string", "null"] } + } + } + }, + registered_tools: REGISTERED, + available_actions: ACTIONS, + next_steps: { type: "array", items: { type: "string" } } + } + }, + { + type: "object", additionalProperties: false, + required: ["status", "message", "page_type", "app_id", "registered_tools", "available_actions", "next_steps"], + properties: { + status: { enum: ["age_check", "sign_in_required", "wrong_page", "not_ready", "not_found", "invalid_input"] }, + message: { type: "string" }, + page_type: { enum: ["app", "age_check", "search", "home", "other"] }, + app_id: { type: ["string", "null"] }, + registered_tools: REGISTERED, + available_actions: ACTIONS, + next_steps: { type: "array", items: { type: "string" } } + } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input = {}) => { + // @xwebmcp-inline lib/steam-store.js + // Steam Store page model shared by the steampowered.com tools: which store page this is, the shown prices and + // review summaries, the header search box, and the actions the caller can take next. Reading never clicks anything. + const STEAM_ORIGIN = "https://store.steampowered.com"; + const STEAM_TOOLS = ["get_app", "get_search_results", "search_store"]; + const steamText = (node, max = 300) => { + const value = (node?.textContent ?? "").replace(/\s+/g, " ").trim(); + return value ? value.slice(0, max) : null; + }; + // A shown price without the "Your Price:" label Steam puts inside discounted bundle prices. + const steamPriceText = node => { + const value = node ? [...node.childNodes].filter(n => !(n.nodeType === 1 && n.matches(".your_price_label"))).map(n => n.textContent).join(" ").replace(/\s+/g, " ").trim() : ""; + return value || null; + }; + const steamVisible = node => !!node && (typeof node.checkVisibility === "function" ? node.checkVisibility() : true); + // "$1,299.99", "14,99€", "¥ 1,480", "R$ 32,99" -> a number; "Free To Play" and other words -> null. + const steamAmount = value => { + const digits = typeof value === "string" ? value.replace(/[^\d.,]/g, "") : ""; + if (!/\d/.test(digits)) return null; + const decimal = /[.,](\d{2})$/.exec(digits); + const whole = (decimal ? digits.slice(0, -3) : digits).replace(/[.,]/g, ""); + const amount = Number(whole + (decimal ? "." + decimal[1] : "")); + return Number.isFinite(amount) ? amount : null; + }; + const steamInteger = value => { + const digits = typeof value === "string" ? value.replace(/[^\d]/g, "") : ""; + return digits ? Number(digits) : null; + }; + // Which store page a URL (or this tab's location) is. + const steamPage = (at = location) => { + if (at.origin !== STEAM_ORIGIN) return { type: "other", app_id: null }; + const app = /^\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (app) return { type: "app", app_id: app[1] }; + const gate = /^\/agecheck\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (gate) return { type: "age_check", app_id: gate[1] }; + if (/^\/search\/?$/.test(at.pathname)) return { type: "search", app_id: null }; + return { type: at.pathname === "/" ? "home" : "other", app_id: null }; + }; + // Loads another store page's markup from this one, with this tab's session, as opening it would: the store renders + // app pages and the first search results on the server. Nothing in it runs, so visibility comes from the markup. + // status is 0 when the request itself failed. + const steamFetchPage = async path => { + let res; + try { res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" } }); } catch { return { status: 0, url: new URL(path, STEAM_ORIGIN), doc: null }; } + const url = new URL(res.url || path, STEAM_ORIGIN); + const doc = res.ok ? new DOMParser().parseFromString(await res.text(), "text/html") : null; + return { status: res.status, url, doc }; + }; + const steamShownInMarkup = node => { + for (let e = node; e; e = e.parentElement) if (e.hasAttribute("hidden") || /display:\s*none/i.test(e.getAttribute("style") || "")) return false; + return true; + }; + // The header's "Search the store" box, present on every store page. + const steamSearchBox = () => { + const form = [...document.querySelectorAll("form")].find(f => /\/search\/?$/.test(f.getAttribute("action") || "") && f.querySelector('input[name="term"]') && steamVisible(f.querySelector('input[name="term"]')) && f.id !== "advsearchform"); + return form ? { form, input: form.querySelector('input[name="term"]'), button: form.querySelector('button[type="submit"], input[type="submit"]') } : null; + }; + // Tool names registered on this page; known is false when the page's tool registry cannot be read. + const steamRegistered = async self => { + try { + const registry = document.modelContext || navigator.modelContext; + if (!registry?.getTools) return { known: false, names: [] }; + const names = [...new Set((await registry.getTools()).map(t => String(t?.name ?? t).split(".").pop()))].filter(n => STEAM_TOOLS.includes(n)).sort(); + return names.includes(self) ? { known: true, names } : { known: false, names: [] }; + } catch { return { known: false, names: [] }; } + }; + // The actions the caller can take on this page, with an input valid for each tool's schema right now. + const steamActions = (page, registered, example_term) => { + const add = (tool, enabled, changes_page, input, note) => ({ tool, enabled, registered: registered.known ? registered.names.includes(tool) : null, changes_page, input: enabled ? input : null, note }); + const box = steamSearchBox(); + const store = location.origin === STEAM_ORIGIN; + return [ + add("get_app", store, false, page.type === "app" ? {} : null, page.type === "app" ? "Read this app's prices and review summaries." : page.type === "age_check" ? "Steam asks for a birth date first; enter it in the page's own form and press View Page, then call get_app." : "Pass app, an app id or store URL (for example a result's id from get_search_results), to read that app's page without leaving this one."), + add("get_search_results", store, false, page.type === "search" ? {} : example_term ? { term: example_term } : null, page.type === "search" ? "Read the results this search page shows, or pass term to read another search's results." : "Pass term to read the store's search results for it without leaving this page."), + add("search_store", !!box, true, example_term ? { term: example_term } : null, box ? "Type a term into the header's Search the store box and submit it; the tab navigates to the results page." + (example_term ? "" : " Needs the term to search for.") : "This page has no store search box.") + ]; + }; + // One line per action the caller can take next, other than calling the same tool again. + const steamNextSteps = (actions, self) => actions.filter(a => a.enabled && a.registered !== false && a.tool !== self).map(a => a.tool + (a.input ? " " + JSON.stringify(a.input) : "") + ": " + a.note); + // @xwebmcp-end + const page = steamPage(); + const registered = await steamRegistered("get_app"); + const actions = steamActions(page, registered, steamText(document.querySelector(page.type === "age_check" ? "title" : "#appHubAppName"))); + const notOk = (status, message, appId = page.app_id) => ({ status, message, page_type: page.type, app_id: appId, registered_tools: registered, available_actions: actions, next_steps: steamNextSteps(actions, "get_app") }); + if (input === null || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "app")) return notOk("invalid_input", "The only input is app, an app id or store URL; pass {} to read the app page open in this tab."); + let wanted = null; + if (input.app !== undefined) { + const given = typeof input.app === "string" ? input.app.trim() : ""; + let url = null; + try { url = /^\d+$/.test(given) ? null : new URL(/^store\.steampowered\.com\//i.test(given) ? "https://" + given : given, STEAM_ORIGIN); } catch { url = null; } + wanted = /^\d{1,10}$/.test(given) ? given : url && /^https?:$/.test(url.protocol) && url.hostname === "store.steampowered.com" ? (/^\/app\/(\d{1,10})(?:\/|$)/.exec(url.pathname) || [])[1] ?? null : null; + if (!wanted) return notOk("invalid_input", "app must be a Steam app id such as '413150' or a store.steampowered.com/app// URL.", null); + } + + // The open app page is read as shown; another app is read from its page's markup without leaving this one. + let doc = document, visible = steamVisible, appId = page.app_id, source = null; + if (wanted && wanted !== page.app_id) { + if (location.origin !== STEAM_ORIGIN) return notOk("wrong_page", "Open any store.steampowered.com page first; get_app reads the app's page from there.", wanted); + const fetched = await steamFetchPage("/app/" + wanted + "/"); + if (fetched.status === 0) return notOk("not_ready", "The request for app " + wanted + "'s page failed; check the connection and call get_app again.", wanted); + if (fetched.status === 429) return notOk("not_ready", "Steam answered 429 Too Many Requests for app " + wanted + "; wait before calling again.", wanted); + if (!fetched.doc && ![404, 410].includes(fetched.status)) return notOk("not_ready", "Steam did not return app " + wanted + "'s page (HTTP " + fetched.status + "); try again later.", wanted); + const landed = steamPage(fetched.url); + // Adult-only apps send signed-out visitors to the sign-in page, which returns to the app's age check. + const signIn = landed.type === "other" && /^\/login\/?$/.test(fetched.url.pathname) ? new URL(fetched.url.searchParams.get("redir") || "/", STEAM_ORIGIN) : null; + if (fetched.doc && signIn?.origin === STEAM_ORIGIN && /^\/(?:agecheck\/)?app\/(\d+)(?:\/|$)/.exec(signIn.pathname)?.[1] === wanted) return notOk("sign_in_required", "Steam shows app " + wanted + " only to signed-in accounts and sent this request to its sign-in page. This tool does not sign in; once the tab is signed in to an account allowed to see it, call get_app again.", wanted); + if (fetched.doc && landed.type === "age_check") return notOk("age_check", "Steam asks for a birth date before showing app " + wanted + ". Open store.steampowered.com/app/" + wanted + "/, enter the date in the page's own form and press View Page, then call get_app with {} there.", wanted); + if (!fetched.doc || landed.type !== "app" || landed.app_id !== wanted) return notOk("not_found", "Steam has no store page for app " + wanted + " (it answered HTTP " + fetched.status + " at " + fetched.url.pathname + ").", wanted); + doc = fetched.doc; visible = steamShownInMarkup; appId = wanted; source = fetched.url.href; + } else { + if (page.type === "age_check") return notOk("age_check", "Steam shows an age check for app " + page.app_id + " instead of its store page. Enter a birth date in the page's own form and press View Page, then call get_app again."); + if (page.type !== "app") return notOk("wrong_page", "This is not a Steam Store app page; pass app (an id or store URL) to read one from here."); + } + const name = steamText(doc.querySelector("#appHubAppName")); + if (!name) return source ? notOk("not_found", "The page for app " + appId + " has no app name; Steam may have changed it.", appId) : notOk("not_ready", "The app page has not rendered its name yet; wait for the page to load and call get_app again."); + + const details = doc.querySelector("#genresAndManufacturer"); + const rowLinks = label => [...(details?.querySelectorAll(".dev_row") ?? [])].filter(row => new RegExp("^\\s*" + label, "i").test(row.textContent || "")).flatMap(row => [...row.querySelectorAll("a")].map(a => steamText(a))).filter(Boolean); + const genres = [...(details?.querySelectorAll('a[href*="/genre/"]') ?? [])].map(a => steamText(a)).filter(Boolean); + const release = steamText(doc.querySelector(".release_date .date")); + const canonical = doc.querySelector('link[rel="canonical"]')?.getAttribute("href") || doc.querySelector('meta[property="og:url"]')?.getAttribute("content"); + + const options = [...doc.querySelectorAll("#game_area_purchase .game_area_purchase_game")].filter(visible).slice(0, 30).map(block => { + const heading = block.querySelector("h1, h2"); + const title = heading ? [...heading.childNodes].filter(n => !(n.nodeType === 1 && n.matches(".bundle_label"))).map(n => n.textContent).join(" ").replace(/\s+/g, " ").trim() : null; + const bundle = block.querySelector('input[name="bundleid"]')?.getAttribute("value") || null; + const pkg = block.querySelector('input[name="subid"]')?.getAttribute("value") || null; + const discounted = block.querySelector(".discount_final_price"); + const priceText = steamPriceText(discounted || block.querySelector(".game_purchase_price")); + const pct = steamText(block.querySelector(".discount_pct")); + return { + title: title || "", + kind: bundle ? "bundle" : pkg ? "package" : "other", + package_id: pkg, bundle_id: bundle, + price_text: priceText, price: steamAmount(priceText), + original_price: discounted ? steamAmount(steamText(block.querySelector(".discount_original_price"))) : null, + discount_percent: pct ? steamInteger(pct) : null, + button: steamText(block.querySelector(".btn_addtocart a, .btn_addtocart")), + promotion: steamText(block.querySelector(".game_purchase_discount_countdown, .game_purchase_discount_quantity")) + }; + }).filter(option => option.title); + + const reviews = [...doc.querySelectorAll("#userReviews .user_reviews_summary_row")].filter(visible).slice(0, 4).map(row => { + const description = steamText(row.querySelector(".responsive_reviewdesc"))?.replace(/^-\s*/, "") || row.getAttribute("data-tooltip-html") || null; + const percent = /(\d{1,3})%/.exec(description || ""); + return { + label: (steamText(row.querySelector(".subtitle")) || "").replace(/:$/, ""), + summary: steamText(row.querySelector(".game_review_summary")) || steamText(row.querySelector(".summary")), + percent_positive: percent ? Number(percent[1]) : null, + review_count: steamInteger(steamText(row.querySelector(".responsive_hidden")) || "") ?? steamInteger(/of the ([\d,.]+) user reviews/.exec(description || "")?.[1] || ""), + description + }; + }).filter(review => review.label); + + const app = { + app_id: appId, name, + url: canonical && canonical.startsWith(STEAM_ORIGIN + "/app/") ? canonical : STEAM_ORIGIN + "/app/" + appId + "/", + release_date: release, + coming_soon: !!doc.querySelector(".game_area_comingsoon") || /coming soon|to be announced/i.test(release || ""), + developers: rowLinks("developer"), publishers: rowLinks("publisher"), genres + }; + return { + status: "ok", + message: (source ? "Read from " + source + " without leaving this page. " : "") + name + ": " + (options.length ? options.length + " purchase option(s)" : "no purchase options shown") + "; " + (reviews.length ? reviews.map(r => r.label + " " + (r.summary || "none")).join(", ") : "no review summary shown") + ".", + app, currency: doc.querySelector('meta[itemprop="priceCurrency"]')?.getAttribute("content") || null, + purchase_options: options, reviews, + registered_tools: registered, available_actions: actions, next_steps: steamNextSteps(actions, "get_app") + }; + } + }]; +})() diff --git a/domains/steampowered.com/get_search_results.js b/domains/steampowered.com/get_search_results.js new file mode 100644 index 00000000..7325b419 --- /dev/null +++ b/domains/steampowered.com/get_search_results.js @@ -0,0 +1,266 @@ +(() => { + const ACTIONS = { + type: "array", + items: { + type: "object", additionalProperties: false, + required: ["tool", "enabled", "registered", "changes_page", "input", "note"], + properties: { + tool: { type: "string" }, + enabled: { type: "boolean", description: "The page offers this action now." }, + registered: { type: ["boolean", "null"], description: "Whether the tool is registered on this page; null when the page's tool registry could not be read." }, + changes_page: { type: "boolean" }, + input: { type: ["object", "null"], description: "A valid input for this exact state; null when the action is not available or needs a value the page does not suggest (see note)." }, + note: { type: "string" } + } + } + }; + const REGISTERED = { type: "object", additionalProperties: false, required: ["known", "names"], properties: { known: { type: "boolean" }, names: { type: "array", items: { type: "string" } } } }; + return [{ + kind: "page", + match: { url_patterns: ["https://store.steampowered.com/*"] }, + tool: { + name: "get_search_results", + description: "Read Steam Store search results: the search page open in this tab (pass {}), or the results for a term (pass term) from any store page without leaving it, read from the search page's markup as the store serves it to this browser with its default sort and filters. Returns, as the page shows it: the search term, the sort and filters Steam has applied, how many results it reports, and the rendered rows in page order, each with its type (app, package or bundle), id, store url, title, release date, platforms, review summary (\"Very Positive\", percent positive and review count, from the row's review icon) and shown price with any discount. Read-only: never clicks, scrolls to load more rows, changes the sort or filters, or follows a page link; offset and limit only bound this response (next_offset continues it). When more results follow the rendered range, more_results says so and gives a visible next-page link on the open page when available. Fetched pages and infinite-scroll pages have no reliable next-page url; next_steps explains how to continue. For a signed-in account, Steam hides owned, ignored and wishlisted rows by the account's preferences only on the open search page, so the rows read for a term can include ones that page would hide; the message says so. Each row's price and review summary are the ones the search shows for that product (prices for this browser's region, before tax). A row's price is not always the base game's: when an edition is on sale the search can show that edition's lower price, so call get_app with the row's id when you need the base game's own price, the editions or the recent-reviews summary. Without term on a page that is not a search page it returns wrong_page with the actions available there.", + inputSchema: { + type: "object", additionalProperties: false, + properties: { + term: { type: "string", minLength: 1, maxLength: 200, description: "Read the store's search results for this term from any store page, without leaving it. Omit to read the search page open in this tab." }, + offset: { type: "integer", minimum: 0, default: 0, description: "First rendered row to return (0-based); from next_offset." }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 25, description: "At most this many rows in this response." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "message", "search", "rendered_rows", "offset", "next_offset", "more_results", "results", "registered_tools", "available_actions", "next_steps"], + properties: { + status: { enum: ["ok", "no_results"] }, + message: { type: "string" }, + search: { + type: "object", additionalProperties: false, required: ["term", "sort", "active_filters", "total_results"], + properties: { + term: { type: ["string", "null"] }, + sort: { type: ["string", "null"], description: "The sort menu's label, for example 'Relevance' or 'Lowest Price'." }, + active_filters: { type: "array", items: { type: "string" }, description: "Labels of the filters checked in the page's sidebar, for example 'English'." }, + total_results: { type: ["integer", "null"], description: "The total Steam reports for this search; null when the page does not say." } + } + }, + rendered_rows: { type: "integer", minimum: 0, description: "Rows the page has rendered now." }, + offset: { type: "integer", minimum: 0 }, + next_offset: { type: ["integer", "null"], description: "Call again with this offset for more of the rendered rows; null when this response reaches the last rendered row." }, + more_results: { + type: "object", additionalProperties: false, required: ["available", "next_page_url"], + properties: { + available: { type: "boolean", description: "Steam has results after the currently rendered range." }, + next_page_url: { type: ["string", "null"], description: "A visible next-page link on the open page when more results remain. Null for fetched pages and infinite scrolling; see next_steps." } + } + }, + results: { + type: "array", + items: { + type: "object", additionalProperties: false, + required: ["position", "type", "id", "url", "title", "release_date", "platforms", "review", "price"], + properties: { + position: { type: "integer", minimum: 1, description: "1-based position among the rendered rows." }, + type: { enum: ["app", "package", "bundle"] }, + id: { type: "string", pattern: "^[0-9]+$" }, + url: { type: "string", description: "The row's store url without tracking parameters." }, + title: { type: "string", minLength: 1 }, + release_date: { type: ["string", "null"] }, + platforms: { type: "array", items: { type: "string" }, description: "Platform icons on the row, for example win, mac, linux." }, + review: { + type: ["object", "null"], additionalProperties: false, required: ["summary", "percent_positive", "review_count"], + properties: { summary: { type: "string" }, percent_positive: { type: ["integer", "null"] }, review_count: { type: ["integer", "null"] } } + }, + price: { + type: "object", additionalProperties: false, required: ["text", "amount", "original_amount", "discount_percent"], + properties: { + text: { type: ["string", "null"], description: "As shown, for example '$14.99' or 'Free'; null when the row shows no price." }, + amount: { type: ["number", "null"] }, + original_amount: { type: ["number", "null"] }, + discount_percent: { type: ["integer", "null"] } + } + } + } + } + }, + registered_tools: REGISTERED, + available_actions: ACTIONS, + next_steps: { type: "array", items: { type: "string" } } + } + }, + { + type: "object", additionalProperties: false, + required: ["status", "message", "page_type", "registered_tools", "available_actions", "next_steps"], + properties: { + status: { enum: ["wrong_page", "not_ready", "invalid_input"] }, + message: { type: "string" }, + page_type: { enum: ["app", "age_check", "search", "home", "other"] }, + registered_tools: REGISTERED, + available_actions: ACTIONS, + next_steps: { type: "array", items: { type: "string" } } + } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input) => { + // @xwebmcp-inline lib/steam-store.js + // Steam Store page model shared by the steampowered.com tools: which store page this is, the shown prices and + // review summaries, the header search box, and the actions the caller can take next. Reading never clicks anything. + const STEAM_ORIGIN = "https://store.steampowered.com"; + const STEAM_TOOLS = ["get_app", "get_search_results", "search_store"]; + const steamText = (node, max = 300) => { + const value = (node?.textContent ?? "").replace(/\s+/g, " ").trim(); + return value ? value.slice(0, max) : null; + }; + // A shown price without the "Your Price:" label Steam puts inside discounted bundle prices. + const steamPriceText = node => { + const value = node ? [...node.childNodes].filter(n => !(n.nodeType === 1 && n.matches(".your_price_label"))).map(n => n.textContent).join(" ").replace(/\s+/g, " ").trim() : ""; + return value || null; + }; + const steamVisible = node => !!node && (typeof node.checkVisibility === "function" ? node.checkVisibility() : true); + // "$1,299.99", "14,99€", "¥ 1,480", "R$ 32,99" -> a number; "Free To Play" and other words -> null. + const steamAmount = value => { + const digits = typeof value === "string" ? value.replace(/[^\d.,]/g, "") : ""; + if (!/\d/.test(digits)) return null; + const decimal = /[.,](\d{2})$/.exec(digits); + const whole = (decimal ? digits.slice(0, -3) : digits).replace(/[.,]/g, ""); + const amount = Number(whole + (decimal ? "." + decimal[1] : "")); + return Number.isFinite(amount) ? amount : null; + }; + const steamInteger = value => { + const digits = typeof value === "string" ? value.replace(/[^\d]/g, "") : ""; + return digits ? Number(digits) : null; + }; + // Which store page a URL (or this tab's location) is. + const steamPage = (at = location) => { + if (at.origin !== STEAM_ORIGIN) return { type: "other", app_id: null }; + const app = /^\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (app) return { type: "app", app_id: app[1] }; + const gate = /^\/agecheck\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (gate) return { type: "age_check", app_id: gate[1] }; + if (/^\/search\/?$/.test(at.pathname)) return { type: "search", app_id: null }; + return { type: at.pathname === "/" ? "home" : "other", app_id: null }; + }; + // Loads another store page's markup from this one, with this tab's session, as opening it would: the store renders + // app pages and the first search results on the server. Nothing in it runs, so visibility comes from the markup. + // status is 0 when the request itself failed. + const steamFetchPage = async path => { + let res; + try { res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" } }); } catch { return { status: 0, url: new URL(path, STEAM_ORIGIN), doc: null }; } + const url = new URL(res.url || path, STEAM_ORIGIN); + const doc = res.ok ? new DOMParser().parseFromString(await res.text(), "text/html") : null; + return { status: res.status, url, doc }; + }; + const steamShownInMarkup = node => { + for (let e = node; e; e = e.parentElement) if (e.hasAttribute("hidden") || /display:\s*none/i.test(e.getAttribute("style") || "")) return false; + return true; + }; + // The header's "Search the store" box, present on every store page. + const steamSearchBox = () => { + const form = [...document.querySelectorAll("form")].find(f => /\/search\/?$/.test(f.getAttribute("action") || "") && f.querySelector('input[name="term"]') && steamVisible(f.querySelector('input[name="term"]')) && f.id !== "advsearchform"); + return form ? { form, input: form.querySelector('input[name="term"]'), button: form.querySelector('button[type="submit"], input[type="submit"]') } : null; + }; + // Tool names registered on this page; known is false when the page's tool registry cannot be read. + const steamRegistered = async self => { + try { + const registry = document.modelContext || navigator.modelContext; + if (!registry?.getTools) return { known: false, names: [] }; + const names = [...new Set((await registry.getTools()).map(t => String(t?.name ?? t).split(".").pop()))].filter(n => STEAM_TOOLS.includes(n)).sort(); + return names.includes(self) ? { known: true, names } : { known: false, names: [] }; + } catch { return { known: false, names: [] }; } + }; + // The actions the caller can take on this page, with an input valid for each tool's schema right now. + const steamActions = (page, registered, example_term) => { + const add = (tool, enabled, changes_page, input, note) => ({ tool, enabled, registered: registered.known ? registered.names.includes(tool) : null, changes_page, input: enabled ? input : null, note }); + const box = steamSearchBox(); + const store = location.origin === STEAM_ORIGIN; + return [ + add("get_app", store, false, page.type === "app" ? {} : null, page.type === "app" ? "Read this app's prices and review summaries." : page.type === "age_check" ? "Steam asks for a birth date first; enter it in the page's own form and press View Page, then call get_app." : "Pass app, an app id or store URL (for example a result's id from get_search_results), to read that app's page without leaving this one."), + add("get_search_results", store, false, page.type === "search" ? {} : example_term ? { term: example_term } : null, page.type === "search" ? "Read the results this search page shows, or pass term to read another search's results." : "Pass term to read the store's search results for it without leaving this page."), + add("search_store", !!box, true, example_term ? { term: example_term } : null, box ? "Type a term into the header's Search the store box and submit it; the tab navigates to the results page." + (example_term ? "" : " Needs the term to search for.") : "This page has no store search box.") + ]; + }; + // One line per action the caller can take next, other than calling the same tool again. + const steamNextSteps = (actions, self) => actions.filter(a => a.enabled && a.registered !== false && a.tool !== self).map(a => a.tool + (a.input ? " " + JSON.stringify(a.input) : "") + ": " + a.note); + // @xwebmcp-end + const page = steamPage(); + const registered = await steamRegistered("get_search_results"); + const shownTerm = page.type === "search" ? (document.querySelector("#term")?.value ?? new URLSearchParams(location.search).get("term")) || null : null; + const actions = steamActions(page, registered, shownTerm); + const notOk = (status, message) => ({ status, message, page_type: page.type, registered_tools: registered, available_actions: actions, next_steps: steamNextSteps(actions, "get_search_results") }); + const offset = input?.offset ?? 0, limit = input?.limit ?? 25; + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100 || Object.keys(input ?? {}).some(k => !["term", "offset", "limit"].includes(k))) return notOk("invalid_input", "term must be text, offset an integer of at least 0 and limit an integer from 1 to 100."); + if (input?.term !== undefined && (typeof input.term !== "string" || !input.term.trim() || input.term.length > 200)) return notOk("invalid_input", "term must be 1 to 200 characters of text."); + + // A term is searched from the search page's markup, as opening store.steampowered.com/search/?term=... would show it. + let doc = document, visible = steamVisible, term = shownTerm, source = null; + if (input?.term !== undefined) { + if (location.origin !== STEAM_ORIGIN) return notOk("wrong_page", "Open any store.steampowered.com page first; get_search_results reads the search page from there."); + const fetched = await steamFetchPage("/search/?term=" + encodeURIComponent(input.term.trim())); + if (fetched.status === 0) return notOk("not_ready", "The request for the search page failed; check the connection and call get_search_results again."); + if (fetched.status === 429) return notOk("not_ready", "Steam answered 429 Too Many Requests; wait before searching again."); + if (!fetched.doc || steamPage(fetched.url).type !== "search") return notOk("not_ready", "Steam did not return its search page (HTTP " + fetched.status + "); try again."); + doc = fetched.doc; visible = steamShownInMarkup; term = input.term.trim(); source = fetched.url.href; + } else if (page.type !== "search") return notOk("wrong_page", "This is not the Steam Store search results page; pass term to read a search from here."); + const container = doc.querySelector("#search_resultsRows"); + if ((!source && steamVisible(document.querySelector("#search_results_loading"))) || (!container && !doc.querySelector("#search_result_container"))) return notOk("not_ready", "Steam is still loading search results; call get_search_results again once they show."); + + const rows = [...(container?.querySelectorAll("a.search_result_row") ?? [])].filter(visible); + const read = (row, index) => { + const bundle = row.getAttribute("data-ds-bundleid"), pkg = row.getAttribute("data-ds-packageid"), app = row.getAttribute("data-ds-appid"); + const [type, id] = bundle ? ["bundle", bundle] : pkg ? ["package", pkg] : ["app", (app || "").split(",")[0]]; + const tip = row.querySelector(".search_review_summary")?.getAttribute("data-tooltip-html") || ""; + const [summary, detail = ""] = tip.split(//i); + const percent = /(\d{1,3})%/.exec(detail); + const final = steamPriceText(row.querySelector(".discount_final_price")); + const pct = steamText(row.querySelector(".discount_pct")); + let url = row.getAttribute("href") || ""; + try { const u = new URL(url, STEAM_ORIGIN); u.search = ""; url = u.href; } catch { url = ""; } + return { + position: index + 1, type, id, url, + title: steamText(row.querySelector(".title")) || "", + release_date: steamText(row.querySelector(".search_released")), + platforms: [...row.querySelectorAll(".search_platforms .platform_img")].map(p => p.className.replace(/\bplatform_img\b/, "").trim()).filter(Boolean), + review: summary.trim() ? { summary: summary.trim(), percent_positive: percent ? Number(percent[1]) : null, review_count: steamInteger(/of the ([\d,.]+) user review/.exec(detail)?.[1] || "") } : null, + price: { text: final, amount: steamAmount(final), original_amount: pct ? steamAmount(steamText(row.querySelector(".discount_original_price"))) : null, discount_percent: pct ? steamInteger(pct) : null } + }; + }; + const all = rows.map(read).filter(r => /^[0-9]+$/.test(r.id) && r.title && r.url); + const pagination = steamText(doc.querySelector(".search_pagination_left")) || ""; + const countText = steamText(doc.querySelector(".search_results_count")) || pagination; + const total = steamInteger(/of\s+([\d,.]+)\s*$/.exec(countText)?.[1] || /^([\d,.]+)\s+results?/.exec(countText)?.[1] || ""); + const range = /showing\s+([\d,.]+)\s*-\s*([\d,.]+)\s+of/i.exec(pagination); + // Infinite scrolling can append rows without updating the original pagination range. + const renderedEnd = range ? Math.max(steamInteger(range[2]), steamInteger(range[1]) - 1 + all.length) : all.length; + const nextLink = [...doc.querySelectorAll(".search_pagination_right a.pagebtn")].find(a => steamText(a) === ">"); + // Steam's hidden pagination can overlap the rows loaded by infinite scrolling. + const nextUrl = !source && steamVisible(nextLink) ? new URL(nextLink.getAttribute("href"), STEAM_ORIGIN).href : null; + const shown = all.slice(offset, offset + limit); + const checked = [...new Set([...doc.querySelectorAll(".tab_filter_control_row.checked, .tab_filter_control_include.checked")].map(f => steamText(f.querySelector(".tab_filter_control_label")) || steamText(f)).filter(Boolean))]; + const search = { term, sort: steamText(doc.querySelector("#sort_by_trigger")), active_filters: checked, total_results: total }; + const hasMore = total !== null ? total > renderedEnd : !!nextLink; + const more = { available: hasMore, next_page_url: hasMore ? nextUrl : null }; + const nextOffset = offset + limit < all.length ? offset + limit : null; + // Steam's hide owned/ignored/wishlist preferences run in the live search page's script, so fetched markup keeps those rows. + const unhidden = !!source && /"logged_in"\s*:\s*true/.test(document.querySelector("#application_config")?.getAttribute("data-userinfo") || ""); + const nextSteps = steamNextSteps(actions, "get_search_results"); + if (hasMore && !nextUrl) nextSteps.push(source + ? "Open " + source + ", list the tools again, then call get_search_results with {} (omit term) to read the live page and its continuation controls." + (unhidden ? " The open page also applies this account's hide preferences." : "") + : "After reading the remaining rendered rows, scroll down to load more results, then call get_search_results with " + JSON.stringify({ offset: all.length }) + " (omit term) to read the appended rows."); + else if (unhidden) nextSteps.push("To see the rows with this account's hide preferences applied, open " + source + ", list the tools again, then call get_search_results with {} (omit term)."); + return { + status: all.length ? "ok" : "no_results", + message: all.length + ? (source ? "Read from " + source + " without leaving this page. " : "") + "Steam shows " + all.length + " row(s) for " + JSON.stringify(term) + (total !== null ? " of " + total + " result(s)" : "") + ", sorted by " + (search.sort || "the page's default") + "; returned " + shown.length + " from offset " + offset + (nextOffset !== null ? " (next_offset " + nextOffset + ")" : "") + "." + (unhidden ? " This account is signed in, and Steam applies its hide owned, ignored and wishlisted preferences only on the open search page, so these rows can include ones that page hides." : "") + : "Steam shows no results for " + JSON.stringify(term) + (checked.length ? " with filters " + checked.join(", ") : "") + ".", + search, rendered_rows: all.length, offset, next_offset: nextOffset, more_results: more, results: shown, + registered_tools: registered, available_actions: actions, next_steps: nextSteps + }; + } + }]; +})() diff --git a/domains/steampowered.com/search_store.js b/domains/steampowered.com/search_store.js new file mode 100644 index 00000000..dcbf2c84 --- /dev/null +++ b/domains/steampowered.com/search_store.js @@ -0,0 +1,128 @@ +[{ + kind: "page", + match: { url_patterns: ["https://store.steampowered.com/*"] }, + tool: { + name: "search_store", + description: "Search the Steam Store for a term with the header's own \"Search the store\" box, as a person would. To read results or an app without leaving this page, call get_search_results with term or get_app with app instead; this tool is for showing the search in the tab. It types the term into the box and submits it once. The tab then navigates to the store's search results page, so this returns only navigation_scheduled; it cannot confirm the results. After the page loads, list the tools again and call get_search_results to read them; each row shows a price and review summary. Uses Steam's default sort and filters; change those on the results page itself. Fails with no_search_box on a page without the header (such as an age check), without typing anything.", + inputSchema: { + type: "object", additionalProperties: false, required: ["term"], + properties: { term: { type: "string", minLength: 1, maxLength: 200, description: "What to search for, for example a game's name." } } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, required: ["status", "term", "from_url", "next_step"], + properties: { + status: { const: "navigation_scheduled", description: "The search was submitted after this result; whether the results page loaded is not confirmed." }, + term: { type: "string" }, + from_url: { type: "string" }, + next_step: { type: "string" } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "message"], + properties: { status: { enum: ["invalid_input", "wrong_page", "no_search_box"] }, message: { type: "string" } } + } + ] + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } + }, + execute: async (input) => { + // @xwebmcp-inline lib/steam-store.js + // Steam Store page model shared by the steampowered.com tools: which store page this is, the shown prices and + // review summaries, the header search box, and the actions the caller can take next. Reading never clicks anything. + const STEAM_ORIGIN = "https://store.steampowered.com"; + const STEAM_TOOLS = ["get_app", "get_search_results", "search_store"]; + const steamText = (node, max = 300) => { + const value = (node?.textContent ?? "").replace(/\s+/g, " ").trim(); + return value ? value.slice(0, max) : null; + }; + // A shown price without the "Your Price:" label Steam puts inside discounted bundle prices. + const steamPriceText = node => { + const value = node ? [...node.childNodes].filter(n => !(n.nodeType === 1 && n.matches(".your_price_label"))).map(n => n.textContent).join(" ").replace(/\s+/g, " ").trim() : ""; + return value || null; + }; + const steamVisible = node => !!node && (typeof node.checkVisibility === "function" ? node.checkVisibility() : true); + // "$1,299.99", "14,99€", "¥ 1,480", "R$ 32,99" -> a number; "Free To Play" and other words -> null. + const steamAmount = value => { + const digits = typeof value === "string" ? value.replace(/[^\d.,]/g, "") : ""; + if (!/\d/.test(digits)) return null; + const decimal = /[.,](\d{2})$/.exec(digits); + const whole = (decimal ? digits.slice(0, -3) : digits).replace(/[.,]/g, ""); + const amount = Number(whole + (decimal ? "." + decimal[1] : "")); + return Number.isFinite(amount) ? amount : null; + }; + const steamInteger = value => { + const digits = typeof value === "string" ? value.replace(/[^\d]/g, "") : ""; + return digits ? Number(digits) : null; + }; + // Which store page a URL (or this tab's location) is. + const steamPage = (at = location) => { + if (at.origin !== STEAM_ORIGIN) return { type: "other", app_id: null }; + const app = /^\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (app) return { type: "app", app_id: app[1] }; + const gate = /^\/agecheck\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (gate) return { type: "age_check", app_id: gate[1] }; + if (/^\/search\/?$/.test(at.pathname)) return { type: "search", app_id: null }; + return { type: at.pathname === "/" ? "home" : "other", app_id: null }; + }; + // Loads another store page's markup from this one, with this tab's session, as opening it would: the store renders + // app pages and the first search results on the server. Nothing in it runs, so visibility comes from the markup. + // status is 0 when the request itself failed. + const steamFetchPage = async path => { + let res; + try { res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" } }); } catch { return { status: 0, url: new URL(path, STEAM_ORIGIN), doc: null }; } + const url = new URL(res.url || path, STEAM_ORIGIN); + const doc = res.ok ? new DOMParser().parseFromString(await res.text(), "text/html") : null; + return { status: res.status, url, doc }; + }; + const steamShownInMarkup = node => { + for (let e = node; e; e = e.parentElement) if (e.hasAttribute("hidden") || /display:\s*none/i.test(e.getAttribute("style") || "")) return false; + return true; + }; + // The header's "Search the store" box, present on every store page. + const steamSearchBox = () => { + const form = [...document.querySelectorAll("form")].find(f => /\/search\/?$/.test(f.getAttribute("action") || "") && f.querySelector('input[name="term"]') && steamVisible(f.querySelector('input[name="term"]')) && f.id !== "advsearchform"); + return form ? { form, input: form.querySelector('input[name="term"]'), button: form.querySelector('button[type="submit"], input[type="submit"]') } : null; + }; + // Tool names registered on this page; known is false when the page's tool registry cannot be read. + const steamRegistered = async self => { + try { + const registry = document.modelContext || navigator.modelContext; + if (!registry?.getTools) return { known: false, names: [] }; + const names = [...new Set((await registry.getTools()).map(t => String(t?.name ?? t).split(".").pop()))].filter(n => STEAM_TOOLS.includes(n)).sort(); + return names.includes(self) ? { known: true, names } : { known: false, names: [] }; + } catch { return { known: false, names: [] }; } + }; + // The actions the caller can take on this page, with an input valid for each tool's schema right now. + const steamActions = (page, registered, example_term) => { + const add = (tool, enabled, changes_page, input, note) => ({ tool, enabled, registered: registered.known ? registered.names.includes(tool) : null, changes_page, input: enabled ? input : null, note }); + const box = steamSearchBox(); + const store = location.origin === STEAM_ORIGIN; + return [ + add("get_app", store, false, page.type === "app" ? {} : null, page.type === "app" ? "Read this app's prices and review summaries." : page.type === "age_check" ? "Steam asks for a birth date first; enter it in the page's own form and press View Page, then call get_app." : "Pass app, an app id or store URL (for example a result's id from get_search_results), to read that app's page without leaving this one."), + add("get_search_results", store, false, page.type === "search" ? {} : example_term ? { term: example_term } : null, page.type === "search" ? "Read the results this search page shows, or pass term to read another search's results." : "Pass term to read the store's search results for it without leaving this page."), + add("search_store", !!box, true, example_term ? { term: example_term } : null, box ? "Type a term into the header's Search the store box and submit it; the tab navigates to the results page." + (example_term ? "" : " Needs the term to search for.") : "This page has no store search box.") + ]; + }; + // One line per action the caller can take next, other than calling the same tool again. + const steamNextSteps = (actions, self) => actions.filter(a => a.enabled && a.registered !== false && a.tool !== self).map(a => a.tool + (a.input ? " " + JSON.stringify(a.input) : "") + ": " + a.note); + // @xwebmcp-end + const term = typeof input?.term === "string" ? input.term.replace(/\s+/g, " ").trim() : ""; + if (!term || term.length > 200 || Object.keys(input).some(k => k !== "term")) return { status: "invalid_input", message: "Provide term: 1 to 200 characters of search text." }; + if (location.origin !== STEAM_ORIGIN) return { status: "wrong_page", message: "Open a store.steampowered.com page first." }; + const box = steamSearchBox(); + if (!box || !box.button) return { status: "no_search_box", message: "This page shows no Search the store box; open https://store.steampowered.com/ and call search_store again." }; + // The box is a React input: set its value through the native setter so the site's own state sees the typing. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(box.input, term); + box.input.dispatchEvent(new Event("input", { bubbles: true })); + box.input.dispatchEvent(new Event("change", { bubbles: true })); + // Settle before submitting: the submission replaces this document. + setTimeout(() => box.button.click(), 100); + return { + status: "navigation_scheduled", term, from_url: location.href, + next_step: "Wait for the search results page to load, list the tools again, then call get_search_results." + }; + } +}] diff --git a/lib/steam-store.js b/lib/steam-store.js new file mode 100644 index 00000000..9714f8eb --- /dev/null +++ b/lib/steam-store.js @@ -0,0 +1,78 @@ +// Steam Store page model shared by the steampowered.com tools: which store page this is, the shown prices and +// review summaries, the header search box, and the actions the caller can take next. Reading never clicks anything. +const STEAM_ORIGIN = "https://store.steampowered.com"; +const STEAM_TOOLS = ["get_app", "get_search_results", "search_store"]; +const steamText = (node, max = 300) => { + const value = (node?.textContent ?? "").replace(/\s+/g, " ").trim(); + return value ? value.slice(0, max) : null; +}; +// A shown price without the "Your Price:" label Steam puts inside discounted bundle prices. +const steamPriceText = node => { + const value = node ? [...node.childNodes].filter(n => !(n.nodeType === 1 && n.matches(".your_price_label"))).map(n => n.textContent).join(" ").replace(/\s+/g, " ").trim() : ""; + return value || null; +}; +const steamVisible = node => !!node && (typeof node.checkVisibility === "function" ? node.checkVisibility() : true); +// "$1,299.99", "14,99€", "¥ 1,480", "R$ 32,99" -> a number; "Free To Play" and other words -> null. +const steamAmount = value => { + const digits = typeof value === "string" ? value.replace(/[^\d.,]/g, "") : ""; + if (!/\d/.test(digits)) return null; + const decimal = /[.,](\d{2})$/.exec(digits); + const whole = (decimal ? digits.slice(0, -3) : digits).replace(/[.,]/g, ""); + const amount = Number(whole + (decimal ? "." + decimal[1] : "")); + return Number.isFinite(amount) ? amount : null; +}; +const steamInteger = value => { + const digits = typeof value === "string" ? value.replace(/[^\d]/g, "") : ""; + return digits ? Number(digits) : null; +}; +// Which store page a URL (or this tab's location) is. +const steamPage = (at = location) => { + if (at.origin !== STEAM_ORIGIN) return { type: "other", app_id: null }; + const app = /^\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (app) return { type: "app", app_id: app[1] }; + const gate = /^\/agecheck\/app\/(\d+)(?:\/|$)/.exec(at.pathname); + if (gate) return { type: "age_check", app_id: gate[1] }; + if (/^\/search\/?$/.test(at.pathname)) return { type: "search", app_id: null }; + return { type: at.pathname === "/" ? "home" : "other", app_id: null }; +}; +// Loads another store page's markup from this one, with this tab's session, as opening it would: the store renders +// app pages and the first search results on the server. Nothing in it runs, so visibility comes from the markup. +// status is 0 when the request itself failed. +const steamFetchPage = async path => { + let res; + try { res = await fetch(path, { credentials: "same-origin", headers: { accept: "text/html" } }); } catch { return { status: 0, url: new URL(path, STEAM_ORIGIN), doc: null }; } + const url = new URL(res.url || path, STEAM_ORIGIN); + const doc = res.ok ? new DOMParser().parseFromString(await res.text(), "text/html") : null; + return { status: res.status, url, doc }; +}; +const steamShownInMarkup = node => { + for (let e = node; e; e = e.parentElement) if (e.hasAttribute("hidden") || /display:\s*none/i.test(e.getAttribute("style") || "")) return false; + return true; +}; +// The header's "Search the store" box, present on every store page. +const steamSearchBox = () => { + const form = [...document.querySelectorAll("form")].find(f => /\/search\/?$/.test(f.getAttribute("action") || "") && f.querySelector('input[name="term"]') && steamVisible(f.querySelector('input[name="term"]')) && f.id !== "advsearchform"); + return form ? { form, input: form.querySelector('input[name="term"]'), button: form.querySelector('button[type="submit"], input[type="submit"]') } : null; +}; +// Tool names registered on this page; known is false when the page's tool registry cannot be read. +const steamRegistered = async self => { + try { + const registry = document.modelContext || navigator.modelContext; + if (!registry?.getTools) return { known: false, names: [] }; + const names = [...new Set((await registry.getTools()).map(t => String(t?.name ?? t).split(".").pop()))].filter(n => STEAM_TOOLS.includes(n)).sort(); + return names.includes(self) ? { known: true, names } : { known: false, names: [] }; + } catch { return { known: false, names: [] }; } +}; +// The actions the caller can take on this page, with an input valid for each tool's schema right now. +const steamActions = (page, registered, example_term) => { + const add = (tool, enabled, changes_page, input, note) => ({ tool, enabled, registered: registered.known ? registered.names.includes(tool) : null, changes_page, input: enabled ? input : null, note }); + const box = steamSearchBox(); + const store = location.origin === STEAM_ORIGIN; + return [ + add("get_app", store, false, page.type === "app" ? {} : null, page.type === "app" ? "Read this app's prices and review summaries." : page.type === "age_check" ? "Steam asks for a birth date first; enter it in the page's own form and press View Page, then call get_app." : "Pass app, an app id or store URL (for example a result's id from get_search_results), to read that app's page without leaving this one."), + add("get_search_results", store, false, page.type === "search" ? {} : example_term ? { term: example_term } : null, page.type === "search" ? "Read the results this search page shows, or pass term to read another search's results." : "Pass term to read the store's search results for it without leaving this page."), + add("search_store", !!box, true, example_term ? { term: example_term } : null, box ? "Type a term into the header's Search the store box and submit it; the tab navigates to the results page." + (example_term ? "" : " Needs the term to search for.") : "This page has no store search box.") + ]; +}; +// One line per action the caller can take next, other than calling the same tool again. +const steamNextSteps = (actions, self) => actions.filter(a => a.enabled && a.registered !== false && a.tool !== self).map(a => a.tool + (a.input ? " " + JSON.stringify(a.input) : "") + ": " + a.note); diff --git a/registry.json b/registry.json index 299af8a7..c3533ab6 100644 --- a/registry.json +++ b/registry.json @@ -3624,6 +3624,48 @@ "qa_agent": "herdr:southwest-qa:01a10c2e-4e9f-7357-9f67-b683b994a2bc" } }, + { + "domain": "steampowered.com", + "tool": "get_app", + "file": "domains/steampowered.com/get_app.js", + "kind": "page", + "namespace": "steampowered.com", + "url_patterns": [ + "https://store.steampowered.com/*" + ], + "url_patterns_reason": "Narrowed to store.steampowered.com, the Steam Store whose app, search and header markup the tools read; other steampowered.com hosts are separate products (help., partner., checkout., login.) with different pages.", + "status": "draft", + "last_verified": null, + "notes": "Reads a store app page's Buy-area options (packages, bundles, free-to-play) and the header's review summaries: the open app page as shown, or, given app (an id or store URL), that app's server-rendered markup fetched from any store page without navigating. Never clicks. Author's live checks (not independent QA): on 2026-10-20 the open pages of Stardew Valley, Palworld (discount and three bundles), Hollow Knight (bundle price behind 'Your Price:'), Dota 2 (free to play), ELDEN RING (age_check) and the home page (wrong_page); on 2026-10-21 by id from the home page, 413150 (ok, under half a second), 292030 (age_check) and 1 (not_found); on 2026-10-22 by id, 1126320, an adult-only app that Steam sends signed-out visitors to its sign-in page for (sign_in_required). Unverified: non-USD stores, delisted and pre-purchase pages, and fetched markup that Steam hides with CSS classes rather than hidden or inline display: none." + }, + { + "domain": "steampowered.com", + "tool": "get_search_results", + "file": "domains/steampowered.com/get_search_results.js", + "kind": "page", + "namespace": "steampowered.com", + "url_patterns": [ + "https://store.steampowered.com/*" + ], + "url_patterns_reason": "Narrowed to store.steampowered.com, the Steam Store whose app, search and header markup the tools read; other steampowered.com hosts are separate products (help., partner., checkout., login.) with different pages.", + "status": "draft", + "last_verified": null, + "notes": "Reads a store search page's rows in page order with the applied sort, checked filters and Steam's result total: the open search page's rendered rows, or, given term, the search page's server-rendered rows fetched from any store page without navigating. offset/limit only bound the response and never load more rows. Only visible pagination links from the open page are exposed; fetched results direct the caller to open the search, and infinite-scroll results give an offset for reading newly loaded rows. Author's live checks (not independent QA): on 2026-10-20 the open 'stardew valley' search, 50 of 83 rows with the page's sort and English filter; on 2026-10-21 the same term from the home page, 50 of 83 rows in under half a second. Pagination checks on 2026-10-21 covered the final page of the same search (76-83 of 83) and scrolling from 50 to 83 rows; both reported no further results. Follow-up checks on 2026-10-21 confirmed that fetched results omit unverified pagination links, hidden links stay out on infinite-scroll pages, and both scrolling and visible pagination continued without duplicate result ids. For a signed-in account, Steam hides owned, ignored and wishlisted rows by the account's preferences only in the live search page's script, so a term's fetched rows keep them; the response says so when the page reports a signed-in account. Unverified: bundle and package rows on the live site, and any signed-in account." + }, + { + "domain": "steampowered.com", + "tool": "search_store", + "file": "domains/steampowered.com/search_store.js", + "kind": "page", + "namespace": "steampowered.com", + "url_patterns": [ + "https://store.steampowered.com/*" + ], + "url_patterns_reason": "Narrowed to store.steampowered.com, the Steam Store whose app, search and header markup the tools read; other steampowered.com hosts are separate products (help., partner., checkout., login.) with different pages.", + "status": "draft", + "last_verified": null, + "notes": "Types into the header's Search the store box (a React input, set through the native value setter) and clicks its submit button 100 ms after returning navigation_scheduled. readOnlyHint is false because it navigates the tab, as google.com's search_flights does; the bench gives it to every steampowered.com intent as a page action. Author's live check on 2026-10-20 (not independent QA): from the home page it reached /search/?term=stardew+valley, read by get_search_results." + }, { "domain": "stubhub.com", "tool": "get_ticket_offers", diff --git a/tests/steampowered-store.test.mjs b/tests/steampowered-store.test.mjs new file mode 100644 index 00000000..e3aafdec --- /dev/null +++ b/tests/steampowered-store.test.mjs @@ -0,0 +1,385 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import { Document } from "./etsy-dom.mjs"; + +// Harness for the steampowered.com tools. Each page tool is re-created from execute.toString(), as the runtime does +// inside the page, and run against fabricated Steam Store markup; nothing here contacts the live site. +const NAMES = ["get_app", "get_search_results", "search_store"]; +const sources = Object.fromEntries(NAMES.map(name => [name, readFileSync(new URL(`../domains/steampowered.com/${name}.js`, import.meta.url), "utf8")])); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const ORIGIN = "https://store.steampowered.com"; + +// Validate the JSON Schema keywords these tools use without adding a dependency. +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.maxItems !== undefined && value.length > schema.maxItems) 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 (typeof value === "number" && (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity))) return false; + return true; +} + +const header = `
`; +const page = (body, head = "") => `Fabricated${head}${header}${body}`; + +// Runs one tool on a fabricated page at url. tools lists the names the page's tool registry reports (null: unreadable). +// pages maps a fetched path to { html, url (where it landed), status }; fetched records each request. +async function run(name, input, { url, html, tools = NAMES, timers = [], pages = {}, fetched = [] }) { + const [definition] = runInNewContext(sources[name]); + const document = new Document(html); + const element = Object.getPrototypeOf(document.body); + element.checkVisibility = function () { return !this.hidden(); }; + const target = new URL(url); + const context = { + location: { origin: target.origin, pathname: target.pathname, search: target.search, href: target.href }, + document, + navigator: tools === null ? {} : { modelContext: { getTools: async () => tools.map(n => ({ name: "steampowered.com." + n })) } }, + URL, URLSearchParams, JSON, + HTMLInputElement: { prototype: element }, + Event: class { constructor(type, init = {}) { this.type = type; this.bubbles = !!init.bubbles; } }, + setTimeout: (fn, ms) => timers.push({ fn, ms }), + DOMParser: class { parseFromString(markup) { return new Document(markup); } }, + fetch: async (path, init) => { + fetched.push({ path, init }); + const reply = pages[path] ?? { status: 404, html: "" }; + if (reply.error) throw new TypeError(reply.error); + const status = reply.status ?? 200; + return { status, ok: status >= 200 && status < 300, url: new URL(reply.url ?? path, ORIGIN).href, text: async () => reply.html }; + } + }; + const execute = runInNewContext(`(${definition.execute.toString()})`, context); + const output = JSON.parse(JSON.stringify(await execute(input))); + assert.ok(conforms(definition.tool.outputSchema, output), "output violates outputSchema: " + JSON.stringify(output).slice(0, 400)); + return { output, document }; +} + +const reviewRow = (label, summary, count, desc, hidden = false) => ``; +const APP = page(` +
Fabricated Farm
+
+ ${reviewRow("Recent Reviews", "Very Positive", "1,204", "91% of the 1,204 user reviews in the last 30 days are positive.")} + ${reviewRow("All Reviews", "Overwhelmingly Positive", "52,310", "96% of the 52,310 user reviews for this game are positive.")} + ${reviewRow("Hidden Reviews", "Mixed", "3", "50% of the 3 user reviews are positive.", true)} +
+
Mar 3, 2021
+
Genre: Indie, RPG + +
+
+
+
+

Buy Fabricated Farm

+

SPECIAL PROMOTION! Offer ends in 10:00:00

+
-30%
$14.99
$10.49
+
+
+
+

Buy Fabricated Farm Deluxe Edition

+
$29.99
+
+
+
+

Buy Fabricated Farm + Soundtrack BUNDLE (?)

+
+
-15%
$19.98
Your Price:
$16.98
+ +
Fabricated DLC $2.99
+
`, ``); + +test("registry entries match the tool files", () => { + const entries = registry.tools.filter(t => t.domain === "steampowered.com"); + assert.deepEqual(entries.map(t => t.tool), NAMES); + for (const entry of entries) { + const [definition] = runInNewContext(sources[entry.tool]); + assert.equal(entry.kind, "page"); + assert.equal(definition.kind, "page"); + assert.deepEqual([...definition.match.url_patterns], entry.url_patterns); + assert.equal(definition.tool.annotations.readOnlyHint, entry.tool !== "search_store", entry.tool); + } +}); + +test("get_app reads the purchase options and the visible review summaries", async () => { + const { output } = await run("get_app", {}, { url: ORIGIN + "/app/4242/Fabricated_Farm/", html: APP }); + assert.equal(output.status, "ok"); + assert.deepEqual(output.app, { + app_id: "4242", name: "Fabricated Farm", url: "https://store.steampowered.com/app/4242/Fabricated_Farm/", release_date: "Mar 3, 2021", + coming_soon: false, developers: ["Studio One", "Studio Two"], publishers: ["Example Publishing"], genres: ["Indie", "RPG"] + }); + assert.equal(output.currency, "USD"); + assert.deepEqual(output.purchase_options, [ + { title: "Buy Fabricated Farm", kind: "package", package_id: "111", bundle_id: null, price_text: "$10.49", price: 10.49, original_price: 14.99, discount_percent: 30, button: "Add to Cart", promotion: "SPECIAL PROMOTION! Offer ends in 10:00:00" }, + { title: "Buy Fabricated Farm Deluxe Edition", kind: "package", package_id: "112", bundle_id: null, price_text: "$29.99", price: 29.99, original_price: null, discount_percent: null, button: "Add to Cart", promotion: null }, + { title: "Buy Fabricated Farm + Soundtrack", kind: "bundle", package_id: null, bundle_id: "77", price_text: "$16.98", price: 16.98, original_price: 19.98, discount_percent: 15, button: "Bundle info", promotion: null } + ]); + assert.deepEqual(output.reviews, [ + { label: "Recent Reviews", summary: "Very Positive", percent_positive: 91, review_count: 1204, description: "91% of the 1,204 user reviews in the last 30 days are positive." }, + { label: "All Reviews", summary: "Overwhelmingly Positive", percent_positive: 96, review_count: 52310, description: "96% of the 52,310 user reviews for this game are positive." } + ]); + assert.deepEqual(output.registered_tools, { known: true, names: NAMES }); + assert.deepEqual(output.available_actions.map(a => [a.tool, a.enabled, a.input]), [["get_app", true, {}], ["get_search_results", true, { term: "Fabricated Farm" }], ["search_store", true, { term: "Fabricated Farm" }]]); + assert.ok(output.next_steps.every(step => !step.startsWith("get_app"))); + // Steam can list a discounted edition first, so the message names no option's price. + const editionFirst = APP.replace("Buy Fabricated Farm", "Buy Fabricated Farm Ultimate Edition"); + const edition = await run("get_app", {}, { url: ORIGIN + "/app/4242/Fabricated_Farm/", html: editionFirst }); + assert.equal(edition.output.message, "Fabricated Farm: 3 purchase option(s); Recent Reviews Very Positive, All Reviews Overwhelmingly Positive."); +}); + +test("get_app reads a free-to-play option and a page without reviews or prices", async () => { + const free = page(`
Free Arena

Play Free Arena

Free To Play
`); + let { output } = await run("get_app", {}, { url: ORIGIN + "/app/1/", html: free }); + assert.deepEqual(output.purchase_options, [{ title: "Play Free Arena", kind: "other", package_id: null, bundle_id: null, price_text: "Free To Play", price: null, original_price: null, discount_percent: null, button: "Play Game", promotion: null }]); + assert.equal(output.currency, null); + assert.equal(output.app.url, "https://store.steampowered.com/app/1/"); + const soon = page(`
Future Game
Coming soon
Planned release date: Coming soon
`); + ({ output } = await run("get_app", {}, { url: ORIGIN + "/app/2/Future_Game/", html: soon })); + assert.equal(output.app.coming_soon, true); + assert.deepEqual([output.purchase_options, output.reviews], [[], []]); + assert.match(output.message, /no purchase options shown; no review summary shown/); +}); + +test("get_app reports the age check, other pages and an unrendered app page without reading them", async () => { + let { output } = await run("get_app", {}, { url: ORIGIN + "/agecheck/app/77/", html: `Mature Game${header}
` }); + assert.equal(output.status, "age_check"); + assert.equal(output.app_id, "77"); + assert.deepEqual(output.available_actions.find(a => a.tool === "search_store").input, { term: "Mature Game" }); + ({ output } = await run("get_app", {}, { url: ORIGIN + "/", html: page("
Welcome
") })); + assert.equal(output.status, "wrong_page"); + assert.equal(output.page_type, "home"); + assert.deepEqual(output.available_actions.find(a => a.tool === "search_store"), { tool: "search_store", enabled: true, registered: true, changes_page: true, input: null, note: "Type a term into the header's Search the store box and submit it; the tab navigates to the results page. Needs the term to search for." }); + ({ output } = await run("get_app", {}, { url: ORIGIN + "/bundle/77/", html: page("
Bundle
") })); + assert.deepEqual([output.status, output.page_type], ["wrong_page", "other"]); + assert.deepEqual(output.available_actions.map(a => [a.tool, a.enabled, a.input]), [["get_app", true, null], ["get_search_results", true, null], ["search_store", true, null]]); + assert.deepEqual(output.next_steps.map(step => step.split(":")[0]), ["get_search_results", "search_store"]); + ({ output } = await run("get_app", {}, { url: ORIGIN + "/app/5/", html: page("
Loading
"), tools: null })); + assert.equal(output.status, "not_ready"); + assert.deepEqual(output.registered_tools, { known: false, names: [] }); + assert.ok(output.available_actions.every(a => a.registered === null)); + ({ output } = await run("get_app", {}, { url: "https://help.steampowered.com/app/5/", html: page("") })); + assert.equal(output.status, "wrong_page"); + assert.equal(output.page_type, "other"); +}); + +const searchRow = ({ id, kind = "appid", title, released, review, final, original, pct, platforms = ["win"] }) => ` +
${title}
+
${platforms.map(p => ``).join("")}
+
${released}
+
${review ? `` : ""}
+
${pct ? `
${pct}
` : ""}
${original ? `
${original}
` : ""}${final ? `
${final}
` : ""}
`; +const SEARCH = page(`
+
Lowest Price
+
English
+
English
+
+ ${searchRow({ id: "4242", title: "Fabricated Farm", released: "Mar 3, 2021", review: "Overwhelmingly Positive<br>96% of the 52,310 user reviews for this game are positive.", final: "$10.49", original: "$14.99", pct: "-30%", platforms: ["win", "mac"] })} + ${searchRow({ id: "77", kind: "bundleid", title: "Fabricated Farm + Soundtrack", released: "", final: "$16.98" })} + ${searchRow({ id: "9", title: "Free Farm", released: "Coming soon", review: "Mixed<br>0% of the 2 user reviews for this game are positive.", final: "Free" })} +
+
showing 1 - 3 of 120
`); + +test("get_search_results reads the rendered rows, the applied sort and filters, and how to continue", async () => { + const { output } = await run("get_search_results", { limit: 2 }, { url: ORIGIN + "/search/?term=farm&sort_by=Price_ASC", html: SEARCH }); + assert.equal(output.status, "ok"); + assert.deepEqual(output.search, { term: "farm", sort: "Lowest Price", active_filters: ["English"], total_results: 120 }); + assert.deepEqual([output.rendered_rows, output.offset, output.next_offset], [3, 0, 2]); + assert.deepEqual(output.more_results, { available: true, next_page_url: "https://store.steampowered.com/search/?term=farm&page=2" }); + assert.deepEqual(output.results, [ + { position: 1, type: "app", id: "4242", url: "https://store.steampowered.com/app/4242/X/", title: "Fabricated Farm", release_date: "Mar 3, 2021", platforms: ["win", "mac"], review: { summary: "Overwhelmingly Positive", percent_positive: 96, review_count: 52310 }, price: { text: "$10.49", amount: 10.49, original_amount: 14.99, discount_percent: 30 } }, + { position: 2, type: "bundle", id: "77", url: "https://store.steampowered.com/bundle/77/X/", title: "Fabricated Farm + Soundtrack", release_date: null, platforms: ["win"], review: null, price: { text: "$16.98", amount: 16.98, original_amount: null, discount_percent: null } } + ]); + const rest = await run("get_search_results", { offset: 2 }, { url: ORIGIN + "/search/?term=farm", html: SEARCH }); + assert.deepEqual(rest.output.results.map(r => [r.position, r.review, r.price.amount]), [[3, { summary: "Mixed", percent_positive: 0, review_count: 2 }, null]]); + assert.equal(rest.output.next_offset, null); +}); + +test("get_search_results stops pagination at the last page even when the response is limited", async () => { + const last = SEARCH.replace("showing 1 - 3 of 120", "showing 118 - 120 of 120") + .replace(/]*>.*?<\/a>/, "") + .replace('
', '
120 results match your search.
'); + const { output } = await run("get_search_results", { limit: 1 }, { url: ORIGIN + "/search/?term=farm&page=40", html: last }); + assert.deepEqual(output.more_results, { available: false, next_page_url: null }); + assert.deepEqual([output.rendered_rows, output.results.length, output.next_offset], [3, 1, 1]); +}); + +test("get_search_results accounts for appended rows and falls back to the next-page link without a total", async () => { + for (const [pagination, keepLink, more] of [ + ["showing 26 - 28 of 120", true, true], + ["showing 1 - 2 of 3", true, false], + ["showing 26 - 27 of 28", true, false], + ["", true, true], + ["", false, false], + ]) { + let html = SEARCH.replace("showing 1 - 3 of 120", pagination); + if (!keepLink) html = html.replace(/]*>.*?<\/a>/, ""); + const { output } = await run("get_search_results", {}, { url: ORIGIN + "/search/?term=farm", html }); + assert.deepEqual(output.more_results, { available: more, next_page_url: more ? ORIGIN + "/search/?term=farm&page=2" : null }, pagination); + } +}); + +test("get_search_results does not offer hidden pagination links on infinite-scroll pages", async () => { + const html = SEARCH.replace('
', '