--- name: steam-store-scraper description: Read Steam store pages as structured records via the Apify Actor arman-bd/steam-store-scraper. Returns title, type, developers, publishers, genres, categories, platforms, Metacritic score, review count, release date, DLC and achievement counts, and the live price with discount in as many countries as you ask for. Use when a task needs regional price comparison, discount-cycle monitoring through a sale, games-market research over the featured lists, or resolving game names to app IDs and metadata. Not for player counts, review text, user profiles, workshop items or Steam library data. --- # Steam Store Scraper Apify Actor `arman-bd/steam-store-scraper`. Give it app IDs, game names, or nothing at all and it takes the store's own featured lists. One dataset record per app, with a regional price array when you ask for more than one country. The Actor takes no credentials. ## When to use it - Regional price comparison: the same title in several currencies in one row. - Discount-cycle monitoring on a watchlist through a sale, tracking `discountPercent` and `priceFinal`. - Games-market research: genre, category, Metacritic and review-count distributions across the currently promoted catalogue. - Catalogue enrichment: resolve a list of game names into app IDs plus metadata. - A zero-config scheduled snapshot of specials, top sellers, new releases and upcoming titles. ## When not to use it - Concurrent player counts, review text, sentiment splits, or the positive/negative breakdown. `recommendations` is a total count and nothing more. - User profiles, libraries, inventories, achievements earned or workshop items. - Bulk catalogue walks. Full records cost one request each and are paced, so tens of thousands of apps is not a practical run. - Historical prices. Every run is a snapshot; scheduling one is how you build a series. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/steam-store-scraper').call({ appIds: ['1091500', '292030'], countryCodes: ['us', 'de', 'jp', 'br'], language: 'english', maxApps: 50, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~steam-store-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"appIds":["1091500","1086940"],"countryCodes":["us","de","jp"]}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/steam-store-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input Everything is optional. An empty input turns the featured lists on automatically and returns roughly 50 currently promoted apps. | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `appIds` | string[] | no | `[]` | Numeric app IDs, the number in a store page URL. A full store URL is accepted and reduced to the number. Non-numeric characters are stripped. | | `searchTerms` | string[] | no | `[]` | Game names, resolved through the store's own search. The single best match of type app is taken per term, one request each. Be specific: `Half-Life 2`, not `half life`. | | `countryCodes` | string[] | no | `["us"]` | ISO 3166-1 alpha-2 codes. **The first one is special**: it decides which store the full record is read from. The rest only add price entries. Empty falls back to `us`. | | `language` | string | no | `"english"` | A language name, not a locale code: `english`, `german`, `french`, `japanese`, `schinese`, `brazilian`. An unknown value silently falls back to English. | | `includeFeatured` | boolean | no | `false` | Append the currently promoted specials, top sellers, new releases and coming-soon IDs. Forced on when you give neither app IDs nor search terms. | | `maxApps` | integer | no | `50` | Cap on apps fetched. `0` means no cap. Applies to the merged, deduplicated list. | **Country order is the one input decision with real consequences.** The full record, including availability, name and description, comes from the first country only, and each extra country costs one request per 50 apps rather than one per app. Comparing eight regions across 50 apps is 58 requests, not 400. So put the region whose catalogue you trust first, and add the rest freely. The other lever is `maxApps`: full records are fetched one at a time and paced 1.5 seconds apart, so budget roughly 40 apps a minute and raise the run timeout before asking for hundreds. ## Output One record per app that resolved in the first country. | Field | Type | Notes | |---|---|---| | `appId` | number | The join key. | | `name` | string \| null | Title as the chosen store spells it. | | `type` | string \| null | `game`, `dlc`, `demo`, `music` and others. Filter on this: featured lists mix them. | | `isFree` | boolean | Free titles carry no price block. | | `country` | string | The first country code, echoed. | | `currency` | string \| null | Currency of the four price fields below. Some regions bill in USD. | | `priceInitial` | number \| null | Undiscounted price in major units, already divided down. | | `priceFinal` | number \| null | Current price. `null` for free titles, bundles with no per-app price, and titles not sold in this country. | | `discountPercent` | number \| null | `0` when there is no discount. | | `priceFormatted` | string \| null | The store's own formatted string, kept so you can check the conversion. | | `regionalPrices` | object[] | **Omitted entirely, not `null`, when only one country was requested.** One entry per country that had a price, keys `country`, `currency`, `initial`, `final`, `discountPercent`, `finalFormatted`. | | `developers` | string[] | | | `publishers` | string[] | | | `genres` | string[] | Genre labels in the chosen language. | | `categories` | string[] | Feature tags such as `Single-player` or `Steam Cloud`. Deduplicated. | | `releaseDate` | string \| null | The display string, localised, for example `Dec 9, 2020`. | | `releaseDateIso` | string \| null | `YYYY-MM-DD`. **Only filled when `language` is `english`**, because other languages return a date string the Actor will not guess at. | | `comingSoon` | boolean | Unreleased. | | `metacriticScore` | number \| null | Frequently `null`: only carried where a PC critic score exists. | | `metacriticUrl` | string \| null | | | `recommendations` | number \| null | Total review count. No positive/negative split. | | `platforms` | string[] | Only the supported ones, for example `["windows", "mac"]`. | | `requiredAge` | number | `0` when there is no age gate. | | `dlcCount` | number | Count only, no DLC list. | | `achievementsTotal` | number | `0` when the title has none. | | `supportedLanguages` | string[] | Asterisks mark full audio support and are kept. The explanatory footnote is stripped. | | `headerImage` | string \| null | | | `website` | string \| null | The publisher's own site. | | `shortDescription` | string \| null | Markup stripped. | | `storeUrl` | string | Built from `appId`, so always present. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, arrays abridged: ```json { "appId": 1091500, "name": "Cyberpunk 2077", "type": "game", "isFree": false, "country": "us", "currency": "USD", "priceInitial": 59.99, "priceFinal": 17.99, "discountPercent": 70, "priceFormatted": "$17.99", "regionalPrices": [ { "country": "us", "currency": "USD", "initial": 59.99, "final": 17.99, "discountPercent": 70, "finalFormatted": "$17.99" }, { "country": "de", "currency": "EUR", "initial": 59.99, "final": 17.99, "discountPercent": 70, "finalFormatted": "17,99€" }, { "country": "jp", "currency": "JPY", "initial": 8778, "final": 2633, "discountPercent": 70, "finalFormatted": "¥ 2,633" } ], "developers": ["CD PROJEKT RED"], "publishers": ["CD PROJEKT RED"], "genres": ["RPG"], "categories": ["Single-player", "Steam Achievements", "Full controller support"], "releaseDate": "Dec 9, 2020", "releaseDateIso": "2020-12-09", "comingSoon": false, "metacriticScore": 86, "metacriticUrl": "https://www.metacritic.com/game/pc/cyberpunk-2077", "recommendations": 877998, "platforms": ["windows", "mac"], "requiredAge": 17, "dlcCount": 3, "achievementsTotal": 57, "supportedLanguages": ["English*", "French*", "Italian*", "German*", "Japanese*"], "headerImage": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1091500/…/header.jpg", "website": "https://www.cyberpunk.net", "shortDescription": "Cyberpunk 2077 is an open-world, action-adventure RPG set in the dark future of Night City…", "storeUrl": "https://store.steampowered.com/app/1091500/", "scrapedAt": "2026-08-06T11:45:11.158Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is the only place that distinguishes "not sold here" from "the request failed". ```json { "appsRequested": 5, "appsSaved": 4, "appsUnavailable": 1, "unavailableAppIds": [999999999], "resolvedSearchTerms": [{ "appId": 1086940, "name": "Baldur's Gate 3" }], "unresolvedSearchTerms": ["zzzqqqnotathing"], "featuredIdsAdded": 0, "requestsFailed": 0, "failures": [], "filters": { "countryCodes": ["us", "de", "jp"], "language": "english", "includeFeatured": false, "maxApps": 10 }, "finishedAt": "2026-08-06T11:45:11.162Z" } ``` `appsUnavailable` means the store answered "no such app, or not sold in this country"; those IDs are listed and the run carries on. `requestsFailed` means a request never landed, with `{ source, error }` per failure. `resolvedSearchTerms` is the audit trail for name lookups: check that each term mapped to the title you meant before trusting the row. `appsRequested` is the merged, deduplicated, capped target list, so it can differ from the number of IDs and terms you sent, and `filters.maxApps` is `null` when you passed `0`. ## Behaviour to plan around - **`regionalPrices` is absent, not null, for a single-country run.** Test for the key before mapping over it, or a one-region run will throw in your code. - **Prices are already in major units.** Minor units are returned upstream for every currency, including ones with no minor unit, and the Actor divides by 100 throughout. A JPY price of 2633 is correct. `priceFormatted` is kept alongside so the conversion is checkable. - **A missing price has three different causes.** Free-to-play (`isFree: true`), a bundle with no per-app price, or the title not being sold in that country. Check `isFree` first, then whether the country appears in `regionalPrices` at all. - **`regionalPrices` can be shorter than `countryCodes`.** A title withdrawn from a region simply has no entry for it, rather than an entry full of nulls. Do not index the array by position. - **The first country decides availability, not just currency.** An app absent from that one store is dropped from the run entirely, even if it is on sale elsewhere. If your watchlist spans regions, put the widest catalogue first. - **`releaseDateIso` is `null` for every non-English run.** `releaseDate` is still there as a localised display string. Parse it yourself or run English for dates. - **Featured lists mix `type` values.** Specials and top sellers include DLC, demos and soundtracks. Filter on `type === "game"` before computing market statistics. - **The cap is applied after everything merges.** App IDs come first, then resolved search terms, then featured IDs, and only then is `maxApps` applied, so a large ID list with `includeFeatured` on can cut the featured tail off entirely. - **Search resolution takes the best single match.** One term, one app, chosen for you and recorded in `resolvedSearchTerms`. Ambiguous terms will quietly land on the wrong title. - **A refused request backs off in minutes, not seconds.** The Actor paces full records 1.5 seconds apart, and when a request is refused it waits a minute before retrying, three attempts in total. A response that arrives empty is treated the same way. Plan run timeouts with that in mind. ## Recipes **Regional price comparison for a watchlist.** ```json { "appIds": ["1091500", "1086940", "292030"], "countryCodes": ["us", "de", "jp", "br", "tr"], "language": "english" } ``` Pivot `regionalPrices` into columns keyed on `country`. Convert with your own FX rates before comparing, since `final` is in each region's own currency. **Discount-cycle monitoring during a sale.** Schedule this hourly. ```json { "appIds": ["1091500", "292030", "1174180"], "countryCodes": ["us"], "maxApps": 0 } ``` Store `appId`, `priceFinal`, `discountPercent` and `scrapedAt` per run. A change in `discountPercent` is the event; `priceInitial` moving is a price rise, which is rarer and more interesting. **Market research over the promoted catalogue.** Zero configuration. ```json {} ``` Filter to `type === "game"`, then group by `genres` and look at the `metacriticScore` and `recommendations` distributions. Expect `metacriticScore` to be `null` for a good share of rows. **Resolve names to IDs and metadata.** ```json { "searchTerms": ["Baldur's Gate 3", "Hades II", "Elden Ring"], "countryCodes": ["de"], "language": "german" } ``` Check `resolvedSearchTerms` and `unresolvedSearchTerms` in the summary before using the rows, then keep `appId` so future runs can skip the search step entirely.