--- name: shopify-store-scraper description: Read any Shopify storefront's public catalogue as structured records via the Apify Actor arman-bd/shopify-store-scraper. Returns titles, handles, vendors, product types, tags, price ranges, per-variant prices, compare-at prices, SKUs, stock flags, images and plain-text descriptions, either one row per product with variants nested or one flat row per variant. A detect mode instead reports, per domain, whether it is a Shopify store and whether its catalogue is readable. Use when a task needs competitor price and assortment monitoring, sourcing research, launch tracking or qualifying a prospect list. Not for stock quantities, orders, customers, non-Shopify platforms or anything behind a merchant login. --- # Shopify Store Scraper Apify Actor `arman-bd/shopify-store-scraper`. Give it storefront domains, get the catalogue: one row per product, one row per variant, or a one-row verdict per domain. It runs without credentials and reads only what the store publishes. ## When to use it - Daily competitor price monitoring, joined on SKU and diffed on `price` and `available`. - Assortment analysis across many brands, grouped by `productType` and `vendor`. - Sourcing and dropshipping research: filter thousands of catalogues by type and price band. - Launch tracking: watch `publishedAt` across a set of stores. - Qualifying a prospect list with `detectOnly` before spending on a full scrape. ## When not to use it - Stock quantities. The catalogue exposes `available` as a boolean and nothing more. No quantity, no barcode, no inventory policy. - Orders, customers, discounts, checkout or anything behind a merchant login. - Non-Shopify stores. A domain on another platform is reported as a failure, and `detectOnly` will tell you so in one cheap pass. - Unpublished or draft products, and products hidden from the storefront. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/shopify-store-scraper').call({ stores: ['allbirds.com', 'kith.com'], outputMode: 'variant', maxProductsPerStore: 250, minPrice: 0, detectOnly: false, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); if (summary.failures.length) console.warn(summary.failures); ``` 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~shopify-store-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"stores":["allbirds.com"],"outputMode":"variant","maxProductsPerStore":50}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/shopify-store-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `stores` | string[] | yes | | Bare domains, full URLs or `.myshopify.com` hosts. Everything after the host is stripped and the result is lowercased, so pasting a collection URL is harmless. Duplicates removed. | | `collectionHandles` | string[] | no | `[]` | Restrict to collections. The handle is the slug after `/collections/`; full URLs are accepted and the handle extracted. Empty reads the whole catalogue. | | `maxProductsPerStore` | integer | no | `250` | Cap on **products** read per store, not rows and not per collection. `0` means everything the store will serve. | | `outputMode` | string | no | `product` | `product` for one row per product with variants nested, `variant` for one flat row per size or colour. Different field sets, see Output. | | `includeVariants` | boolean | no | `true` | Embed the variant list in product rows. Ignored in variant mode. Off gives a much narrower dataset that still keeps `priceMin`, `priceMax`, `variantCount` and `available`. | | `minPrice` | number | no | `0` | Drop anything cheaper, in the store's own currency. No conversion is applied. `0` keeps everything. | | `detectOnly` | boolean | no | `false` | Skip the catalogue and emit one verdict row per domain. Ignores every other filter. | **`outputMode` decides your row count, and the cap does not protect you from it.** `maxProductsPerStore` counts products in both modes, so a 250-product apparel catalogue in `variant` mode is routinely three thousand rows and more. Choose `product` mode for catalogue snapshots, assortment work and readable CSV exports; choose `variant` mode when you need per-size price, SKU and stock, which is what price monitoring actually joins on. When several `collectionHandles` are given, the cap still applies per store and the collections are read in the order you listed them, so a low cap starves the later ones. ## Output Three shapes, selected by `outputMode` and `detectOnly`. **Product mode.** One record per product. | Field | Type | Notes | |---|---|---| | `store` | string | Normalised domain you passed. | | `storeName` | string \| null | Shop name from the store record. `null` when that record was unreachable. | | `productId` | number | Shopify product id. The stable diff key. | | `title` | string \| null | Display title. | | `handle` | string \| null | URL slug. | | `vendor` | string \| null | Brand. | | `productType` | string \| null | Shopify product type. | | `tags` | string[] | Always an array, even when the store publishes a comma-separated string. | | `currency` | string \| null | Store currency. `null` when the store record was unreachable. | | `url` | string | Direct product URL. | | `variantCount` | number | Number of variants, regardless of `includeVariants`. | | `priceMin` | number \| null | Cheapest variant price. `null` when no variant has a price. | | `priceMax` | number \| null | Dearest variant price. | | `available` | boolean | True when **any** variant is purchasable. | | `variants` | object[] | Only when `includeVariants` is on; the key is absent otherwise. Keys: `id`, `title`, `sku`, `price`, `compareAtPrice`, `available`, `position`, `options`, `grams`, `requiresShipping`, `taxable`, `updatedAt`. | | `images` | string[] | Every product image URL. | | `featuredImage` | string \| null | The first image. | | `bodyPlain` | string | Description as plain text, HTML stripped and entities decoded. Empty string when the store publishes none. | | `publishedAt` | string \| null | Product publish timestamp, store-local offset. | | `updatedAt` | string \| null | Last product update. | | `scrapedAt` | string | Run timestamp, ISO 8601. | **Variant mode.** One record per variant. Carries `store`, `storeName`, `productId`, `title`, `handle`, `vendor`, `productType`, `tags`, `currency` and `url` exactly as above, plus: | Field | Type | Notes | |---|---|---| | `variantId` | number | Shopify variant id. Join key for price history. | | `variantTitle` | string \| null | Variant label, `8` or `Blue / M`. | | `sku` | string \| null | Merchant SKU. `null` when unset. | | `price` | number \| null | This variant's price, as a number. | | `compareAtPrice` | number \| null | Was-price. `null` when not on sale. | | `onSale` | boolean | True when `compareAtPrice` is above `price`. | | `available` | boolean | Purchasable now. | | `options` | string[] | The variant's option values in order. | | `optionNames` | string[] | The product's option names, `["Size", "Colour"]`. Zip with `options`. | | `grams` | number \| null | Shipping weight. | | `position` | number \| null | Variant order on the product page. | | `featuredImage` | string \| null | Product's first image. There is no per-variant image. | | `publishedAt` | string \| null | Product publish timestamp. | | `updatedAt` | string \| null | Variant update time, falling back to the product's. | | `scrapedAt` | string | Run timestamp. | **Detect mode.** One record per domain: `store`, `isShopify`, `productsEndpointOpen`, `storeName`, `myshopifyDomain`, `currency`, `country`, `publishedProductsCount`, `publishedCollectionsCount`, `detail` (a short note on how the verdict was reached, or the error when the catalogue was refused) and `scrapedAt`. A real product record from a live run, variant list abridged from thirteen to one: ```json { "store": "allbirds.com", "storeName": "Allbirds", "productId": 7292464955472, "title": "Men's Cruiser - Shadow Blue (Natural White Sole)", "handle": "mens-cruiser-shadow-blue-natural-white-sole", "vendor": "Allbirds", "productType": "Shoes", "tags": ["DNAM BRANDS", "EC STOCK"], "currency": "USD", "url": "https://allbirds.com/products/mens-cruiser-shadow-blue-natural-white-sole", "variantCount": 13, "priceMin": 105, "priceMax": 105, "available": true, "variants": [ { "id": 41990816759888, "title": "8", "sku": "A12856M080", "price": 105, "compareAtPrice": null, "available": true, "position": 1, "options": ["8"], "grams": 1000, "requiresShipping": true, "taxable": true, "updatedAt": "2026-08-06T04:38:01-07:00" } ], "images": ["https://cdn.shopify.com/s/files/1/1104/4168/files/All-birds_0010.png?v=1783535519"], "featuredImage": "https://cdn.shopify.com/s/files/1/1104/4168/files/All-birds_0010.png?v=1783535519", "bodyPlain": "Inspired by a classic court style, this signature shoe delivers the right balance of style and comfort…", "publishedAt": "2026-07-08T11:32:23-07:00", "updatedAt": "2026-08-06T04:32:26-07:00", "scrapedAt": "2026-08-06T11:37:56.404Z" } ``` And a detect row: ```json { "store": "gymshark.com", "isShopify": true, "productsEndpointOpen": true, "storeName": "Gymshark US", "myshopifyDomain": "gymshark.myshopify.com", "currency": "USD", "country": "GB", "publishedProductsCount": 9301, "publishedCollectionsCount": 1723, "detail": "shop record served …", "scrapedAt": "2026-08-06T11:39:12.008Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is the only place that separates "the store refused us" from "your filters matched nothing". ```json { "storesRequested": 4, "storesFailed": 2, "failures": [ { "store": "shop.example.com", "page": 1, "error": "blocked (403) …" }, { "store": "example.com", "page": 1, "error": "endpoint disabled or not a Shopify storefront (404)" } ], "requestsMade": 8, "productsListed": 389, "recordsSaved": 10, "filters": { "collectionHandles": [], "maxProductsPerStore": 5, "minPrice": 0, "outputMode": "product", "includeVariants": true, "detectOnly": false }, "finishedAt": "2026-08-06T11:38:03.780Z" } ``` `productsListed` against `recordsSaved` is the diagnostic: listed high and saved low means `minPrice` or the cap bit, listed zero with no failure means an empty collection or a handle that does not exist. `failures[].store` is the source label, so it reads `domain/collections/handle` when collections were used, and `page` tells you whether the store failed outright or partway through. A 403 or 404 in `error` is the store refusing the catalogue, not a bug in your input. ## Behaviour to plan around - **There are no stock quantities, only `available`.** Nothing in the public catalogue carries inventory counts, and the Actor does not invent them. A product-level `available: true` means at least one variant is buyable, which is not the same as the variant you care about being in stock. - **Prices are raw numbers in the store's own currency, never converted.** `minPrice` compares those raw numbers, so a run mixing a EUR and a USD store with a price floor will not do what you meant. Split the run by currency, or filter downstream on `currency`. - **`minPrice` behaves differently per mode.** In product mode a product survives if **any** variant clears the floor, and its cheap variants are still nested in `variants`. In variant mode each variant is judged on its own, so a product can come back partially. - **An unknown collection handle is not an error.** It returns an empty page, which is logged as a warning and leaves no entry in `failures`. Check the spelling when a collection yields nothing. - **Some storefronts refuse the catalogue.** Headless front ends and edge layers can answer with 403, 404 or 429 even when the store really is Shopify. That store is recorded in `failures` and the run continues; `detectOnly` settles it in two cheap requests per domain, and `isShopify` and `productsEndpointOpen` can legitimately disagree. - **The apex and `www.` hosts can behave differently.** If one form is refused, try the other before concluding the store is not Shopify. - **`currency` and `storeName` come from the store record, not the catalogue.** If that record is unreachable the products still come back, with both fields `null`. - **Roughly 25000 products is the ceiling per store**, and the Actor stops there. Very large catalogues cannot be read in full through this route; scope with `collectionHandles` instead. - **Transient errors are retried** three times with backoff, honouring any retry hint. Bad-request, forbidden, not-found and non-JSON responses are final for that store. - **The Actor throws only when every store failed.** One refusal among several never aborts the run. ## Recipes **Qualify a prospect list.** Two requests per domain, no product rows. ```json { "stores": ["allbirds.com", "gymshark.com", "example.com"], "detectOnly": true } ``` Keep the domains where `isShopify` and `productsEndpointOpen` are both true; `publishedProductsCount` tells you how big the real scrape would be. **Daily price monitoring.** One row per variant, whole catalogue. ```json { "stores": ["allbirds.com"], "outputMode": "variant", "collectionHandles": ["mens"], "maxProductsPerStore": 0 } ``` Key on `store` plus `variantId`, diff `price`, `compareAtPrice`, `onSale` and `available` against yesterday. **Assortment snapshot across brands.** Narrow product rows, no nested variants. ```json { "stores": ["allbirds.com", "kith.com", "brooklinen.com"], "outputMode": "product", "includeVariants": false, "maxProductsPerStore": 1000 } ``` Group by `vendor` and `productType`, and use `priceMin` and `priceMax` for price banding. **Launch tracking.** Newest products, cheapest possible shape. ```json { "stores": ["allbirds.com"], "collectionHandles": ["new-arrivals"], "includeVariants": false, "maxProductsPerStore": 250 } ``` Diff `productId` against the previous run and sort what is new by `publishedAt`.