# Shopify Products Scraper + Price & Stock Change Monitor (`signalclear/shopify-products-scraper`) Actor

Full product catalogs from any Shopify store via its public products.json, plus a changes mode that reports new, removed, price-dropped and back-in-stock items since the last run. Blocked or non-Shopify stores are reported, never charged.

- **URL**: https://apify.com/signalclear/shopify-products-scraper.md
- **Developed by:** [signal clear](https://apify.com/signalclear) (community)
- **Categories:** E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.40 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Shopify Products Scraper + Price & Stock Change Monitor

Scrape the full product catalog of any Shopify store: titles, vendors, tags, images, prices,
compare-at (was) prices, every variant with SKU and stock status. Or switch to **changes mode**
and get only what moved since your last run: new products, removed products, price drops,
price increases, back in stock and out of stock. Built for Black Friday and Cyber Monday
price and stock monitoring.

- **Changes since the last run.** Schedule it hourly or daily and receive only the changes,
  not the whole catalog again.
- **Blocked and non-Shopify stores are reported, never charged.** Each store is collected
  completely before anything is saved. A store that fails is listed in the `FAILED_STORES`
  record with the reason, and none of its rows reach your dataset.
- **Domains, collections or single products.** Paste `example-store.com`, a
  `/collections/...` URL or a `/products/...` URL.
- **Light and fast.** It reads the catalog JSON that every Shopify storefront publishes
  (250 products per request), so it needs no browser and no residential proxy.

### How it works

Shopify storefronts publish their public catalog at `https://<store>/products.json`,
250 products per page. The Actor pages through it (or through
`/collections/<handle>/products.json`, or `/products/<handle>.json` for one product), reads
the store's currency from `/meta.json`, and normalizes every product.

Each store is fetched with bounded retries. Every attempt uses a fresh proxy session and
resumes at the page where the previous attempt stopped. A 429 rate limit is honored
(Retry-After, capped at 30 seconds) before the next attempt. If `www.` is refused before
any page loaded, the next attempt tries the bare domain (or the reverse).

| Store answers | Status | What happens |
|---|---|---|
| Catalog JSON | `ok` | Rows saved |
| Catalog JSON with zero products | `empty` | Success, zero rows |
| 403, 503, other 5xx, bot challenge, repeated 429 | `blocked` | Retried up to `maxAttempts`, then listed in `FAILED_STORES` |
| Password-protected storefront | `blocked` | Listed at once (retrying will not help) |
| 404, non-JSON page, JSON without products | `not_shopify` | Listed at once, no retries |
| DNS or connection failure | `unreachable` | Retried, then listed |

The run fails (exit code 1) only when every store failed. Otherwise it succeeds with a
summary such as `2400 products from 3 stores saved, 1 stores failed (not charged; see FAILED_STORES)`.

### Mode 1: snapshot (default)

One row per product (or per variant), the whole catalog or up to `maxProductsPerStore`.

```json
{
  "stores": ["example-store.com", "https://another-store.com/collections/sale"],
  "maxProductsPerStore": 0,
  "includeVariants": "nested",
  "onlyAvailable": true
}
```

### Mode 2: changes

```json
{
  "stores": ["example-store.com"],
  "mode": "changes",
  "maxProductsPerStore": 0,
  "snapshotStoreName": "shopify-snapshots"
}
```

1. **First run** for a store: the Actor reads the catalog, saves it as a snapshot in the
   named key-value store `shopify-snapshots` (key = the sanitized domain, for example
   `example-store-com`) and pushes **nothing**. The log says so, and nothing is charged.
2. **Every later run**: it reads the catalog again, compares it with the saved snapshot,
   pushes one row per change, then overwrites the snapshot.

Change types: `new`, `removed`, `price_drop`, `price_increase`, `back_in_stock`,
`out_of_stock`. Price and stock are compared per variant, so a size going out of stock is
reported even when other sizes are still available. A whole new or removed product gives one
row with `variantId: null`; a new or removed variant of an existing product gives a row with
its `variantId`.

Schedule the same input on the Apify scheduler and connect a webhook or integration to get
alerts only when something changed.

### Input

| Field | Default | Notes |
|---|---|---|
| `stores` | (required) | Domains, store URLs, collection URLs or product URLs |
| `mode` | `snapshot` | `snapshot` or `changes` |
| `maxProductsPerStore` | 1000 | `0` = whole catalog. Counts products that pass your filters |
| `includeVariants` | `nested` | `nested`: variants array per product. `rows`: one row per variant |
| `onlyAvailable` | false | Snapshot mode only |
| `updatedSince` | (none) | ISO date such as `2026-11-01`; uses the product's `updated_at`. Snapshot mode only |
| `tags` | (none) | Keep products with any of these tags (case-insensitive) |
| `vendor` | (none) | Keep products from these vendors (case-insensitive) |
| `includeDescription` | false | Adds `bodyHtml` |
| `snapshotStoreName` | `shopify-snapshots` | Named key-value store for changes mode |
| `includeStoreStatus` | false | Adds one `store_status` row per store |
| `maxItems` | (none) | Total row cap for the run |
| `maxConcurrency` | 4 | Stores fetched in parallel |
| `maxAttempts` | 3 | 1 to 5, each on a fresh proxy session |
| `requestTimeoutSecs` | 30 | Per request |
| `proxyConfiguration` | Apify proxy | Datacenter is enough; groups and country are configurable |

### Output

Snapshot row (`includeVariants: "nested"`, shortened):

```json
{
  "store": "example-store.com",
  "productId": 7001234567890,
  "handle": "classic-runner-navy",
  "title": "Classic Runner - Navy",
  "vendor": "Example Store",
  "productType": "Shoes",
  "tags": ["mens", "running", "sale"],
  "createdAt": "2026-03-27T14:03:50-07:00",
  "updatedAt": "2026-10-28T11:26:43-07:00",
  "publishedAt": "2026-09-25T16:58:13-07:00",
  "url": "https://example-store.com/products/classic-runner-navy",
  "imageUrl": "https://cdn.shopify.com/s/files/1/0000/0000/files/runner-navy.jpg",
  "images": ["https://cdn.shopify.com/s/files/1/0000/0000/files/runner-navy.jpg"],
  "priceMin": 79.0,
  "priceMax": 89.0,
  "compareAtPriceMin": 110.0,
  "compareAtPriceMax": 110.0,
  "currency": "USD",
  "available": true,
  "variantCount": 2,
  "variants": [
    {"id": 41000000000001, "title": "9", "sku": "CR-NAV-9", "price": 79.0, "compareAtPrice": 110.0,
     "available": true, "option1": "9", "option2": null, "option3": null, "grams": 455, "requiresShipping": true},
    {"id": 41000000000002, "title": "10", "sku": "CR-NAV-10", "price": 89.0, "compareAtPrice": 110.0,
     "available": false, "option1": "10", "option2": null, "option3": null, "grams": 470, "requiresShipping": true}
  ],
  "scrapedAt": "2026-11-27T08:00:00+00:00"
}
```

With `includeVariants: "rows"` each variant becomes its own row with the product fields
repeated, plus `variantId`, `variantTitle`, `sku`, `price`, `compareAtPrice`, `available`
(the variant's), `productAvailable`, `option1`..`option3`, `grams`, `requiresShipping`.

Change row:

```json
{
  "type": "price_drop",
  "store": "example-store.com",
  "productId": 7001234567890,
  "variantId": 41000000000001,
  "handle": "classic-runner-navy",
  "title": "Classic Runner - Navy",
  "variantTitle": "9",
  "url": "https://example-store.com/products/classic-runner-navy",
  "priceBefore": 110.0,
  "priceAfter": 79.0,
  "availableBefore": true,
  "availableAfter": true,
  "currency": "USD",
  "detectedAt": "2026-11-27T08:00:00+00:00"
}
```

Store status row (only with `includeStoreStatus: true`):

```json
{"type": "store_status", "store": "example-store.com", "input": "example-store.com", "status": "ok",
 "productCount": 1064, "pages": 5, "error": null, "scrapedAt": "2026-11-27T08:00:00+00:00"}
```

Failures are always written to the `FAILED_STORES` record of the run's default key-value
store as `{store, input, status, error, attempts}`.

The dataset has three views: Products, Changes and Store status.

### Limits

- **Stores can turn the catalog JSON off** or put it behind bot protection. Those stores are
  reported as `blocked` or `not_shopify` and not charged; there is nothing to bypass.
- **Prices are in the store's own currency** (from `/meta.json`, `null` if the store does not
  publish it). Stores with several markets show the default market's prices.
- **Changes mode needs the named key-value store** to persist between runs; use the same
  `snapshotStoreName` every time. Snapshots are kept per store (per collection or product for
  those inputs), so comparisons are always within one store.
- **Removals are only reported when both runs read the whole catalog.** With a
  `maxProductsPerStore` cap, products past the cap are not reported as removed. Use `0` for
  complete tracking.
- Changing the `tags` or `vendor` filters starts a fresh baseline for that store (no rows that
  run), because the old snapshot tracked a different set of products.
- `onlyAvailable` and `updatedSince` are ignored in changes mode: stock and dates are what it
  tracks.
- Only published products visible on the storefront are listed. Inventory quantities are not
  part of the public catalog.

### Pricing

$0.40 per 1,000 results. A result is a dataset row: a product (or variant row) in snapshot
mode, a change row in changes mode. Baseline runs push nothing and are not
charged. Failed stores push nothing and are not charged. Optional `store_status` rows are dataset rows
too, so they count as results when you turn them on.

### Legal

This Actor reads the public catalog JSON that Shopify storefronts serve by design to any
visitor. It collects product data only: no personal data, no customer data, no login. You are
responsible for using the data in line with the store's terms and the laws that apply to you.

# Actor input Schema

## `stores` (type: `array`):

One per line: a store domain (example-store.com), a store URL, a collection URL (https://example-store.com/collections/sale) or a product URL (https://example-store.com/products/blue-shirt). Collection and product inputs scrape only that collection or product.

## `mode` (type: `string`):

Snapshot pushes the catalog. Changes compares the catalog with the snapshot saved by the previous changes-mode run and pushes only change rows (new, removed, price_drop, price_increase, back_in_stock, out_of_stock), then saves the new snapshot. The first changes-mode run for a store only saves the snapshot and pushes nothing.

## `maxProductsPerStore` (type: `integer`):

Stop after this many matching products for each store. 0 = the whole catalog. In changes mode, removals are only reported when both runs read the whole catalog, so use 0 there for complete tracking.

## `includeVariants` (type: `string`):

Nested: one row per product with a variants array. Rows: one row per variant (size, color...) with the product fields repeated. Applies to snapshot mode; change rows are always per variant.

## `onlyAvailable` (type: `boolean`):

Keep only products with at least one available variant (in variant rows: only available variants). Snapshot mode only; changes mode always tracks stock.

## `updatedSince` (type: `string`):

ISO date or date-time, such as 2026-11-01. Keeps products whose updated_at is on or after it. Snapshot mode only. The store is still read page by page; this filters, it does not skip requests.

## `tags` (type: `array`):

Keep products that have at least one of these tags (case-insensitive). Leave empty for all.

## `vendor` (type: `array`):

Keep products whose vendor (brand) is one of these (case-insensitive exact match). Leave empty for all.

## `includeDescription` (type: `boolean`):

Adds the product description as bodyHtml. Off by default because it makes rows several times larger.

## `snapshotStoreName` (type: `string`):

Named key-value store that keeps one snapshot per store between runs. Use the same name on every run you want compared. Lowercase letters, digits and hyphens.

## `includeStoreStatus` (type: `boolean`):

Also push one row per store with type store_status: status ok, empty, blocked, not_shopify or unreachable, product count and pages read. These rows are dataset items like any other. Failed stores are always listed in the FAILED_STORES record regardless of this setting.

## `maxItems` (type: `integer`):

Stop the whole run after this many dataset rows. Leave empty for no limit.

## `maxConcurrency` (type: `integer`):

Stores fetched at the same time. Pages of one store are always fetched one after another.

## `maxAttempts` (type: `integer`):

Each attempt uses a fresh proxy session and resumes at the page where the last one stopped. Stores that still fail are listed in FAILED_STORES and are not charged. Non-Shopify stores fail immediately.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for a single HTTP response before the attempt counts as failed and is retried on a fresh session.

## `proxyConfiguration` (type: `object`):

Shopify's catalog JSON works from datacenter IPs, so the default Apify proxy is usually enough. You can pick proxy groups or a country here.

## Actor input object example

```json
{
  "stores": [
    "allbirds.com"
  ],
  "mode": "snapshot",
  "maxProductsPerStore": 1000,
  "includeVariants": "nested",
  "onlyAvailable": false,
  "includeDescription": false,
  "snapshotStoreName": "shopify-snapshots",
  "includeStoreStatus": false,
  "maxConcurrency": 4,
  "maxAttempts": 3,
  "requestTimeoutSecs": 30,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `products` (type: `string`):

Dataset results in the "Products (snapshot mode)" view.

## `changes` (type: `string`):

Dataset results in the "Changes (changes mode)" view.

## `storeStatus` (type: `string`):

Dataset results in the "Store status (when enabled)" view.

## `failed` (type: `string`):

JSON list of stores that failed after every retry, with the error for each. Empty when everything succeeded. Failed inputs are never charged.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "stores": [
        "allbirds.com"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("signalclear/shopify-products-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "stores": ["allbirds.com"],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("signalclear/shopify-products-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "stores": [
    "allbirds.com"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call signalclear/shopify-products-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,signalclear/shopify-products-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/L3KFn7k9POITirV3b/builds/3lksKpZJ6EAy9FhHv/openapi.json
