# Zara Products Scraper (`scrapyx/zara-products-scraper`) Actor

Zara catalogue products from any country store: price and currency, previous price and discount, colours, images and category path. The store that answered is verified on every run -- an unknown country code is served by Zara's Spanish store, at euro prices, without an error.

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

## Pricing

from $1.75 / 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.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Zara Products Scraper

Zara's catalogue, from any of its country stores: product name, reference,
**price in the store's own currency**, the previous price and discount where
there is one, every colourway, resolved image URLs, and the category path the
product sits under.

Built on Zara's own storefront JSON. HTTP only — no browser, no JavaScript.

### What it is for

- **Price monitoring** across markets — the same garment at US, UK, German and
  Japanese prices, each in the right currency and the right units.
- **Sale tracking** — `onlyDiscounted` returns just the reduced items, with
  what they cost before and the percentage off.
- **Assortment analysis** — which categories carry what, by section, with the
  full menu path.

### Input

| field | what it does |
| --- | --- |
| `country` | Two-letter code as in a Zara URL: `us`, `gb`, `de`, `fr`, `jp`, `kr`, `br`… |
| `language` | Language segment for that store: `en`, `de`, `ja`, `ko`… |
| `categoryIds` | Zara's internal numeric category ids. |
| `categoryUrls` | Zara category pages — the id is resolved through the store's own tree. |
| `sections` | Discovery only: narrow to `WOMAN`, `MAN`, `KIDS`, … |
| `onlyDiscounted` | Keep only products with a previous price. |
| `includeBundles` | Include "look" bundles, which are not garments. |
| `maxItems`, `maxConcurrency`, `minRequestInterval` | Limits and pacing. |

**Leave `categoryIds` and `categoryUrls` empty** and the run becomes a
discovery pass: it returns every category in the store with its id, name, full
menu path and page URL. Pick the ids you want and run it again.

### Four things about this data worth knowing before you trust a run

#### 1. An unknown country code is served by Spain, not refused

`/{cc}/{lang}/…` accepts any two-letter code. Ones Zara does not operate are
answered — HTTP 200, real products, real prices — by the **Spanish** store:

| requested | store that actually answered | currency |
| --- | --- | --- |
| `us` | us (11719) | USD |
| `de` | de (10705) | EUR |
| `jp` | jp (11723) | JPY |
| `zz`, `qq`, `aa` | **es (10701)** | **EUR** |

352 of the 374 product ids overlap the US set, so the names look right. Only
the prices are from another country. A three-or-more-letter code 404s honestly,
which is exactly what makes the two-letter case dangerous: it is the shape a
real code has.

Every run therefore begins with one request that reads back **which store
actually answered**, and stops if it is not the one you asked for.

#### 2. The price is in cents — except where it is not, and nothing says which

`"price": 3990` is **$39.90** in the US store and **¥3,990** in the Japanese
one. JPY and KRW have no minor unit, so the same integer means two things a
hundredfold apart. Measured over one category's first 40 prices:

| store | prices end in | reading |
| --- | --- | --- |
| us | `90` (39/40) | $39.90 — divide by 100 |
| gb | `99` (40/40) | £29.99 — divide by 100 |
| de | `95` (40/40) | €19.95 — divide by 100 |
| jp | 1590 … 7990 | ¥1,590 — **do not divide** |
| kr | 15900 … 59900, all `00` | ₩15,900 — **do not divide** |

And the products payload contains **no currency field at all** — searching the
whole 1.9 MB response for `currency` returns nothing. So there is no way to
read the exponent from the data and no way to audit the arithmetic afterwards.

The currency comes from the same store probe as above, the exponent from the
ISO 4217 table, and **every row keeps `priceMinorUnits`** — the untouched
upstream integer — so you can check the division yourself without re-fetching.

#### 3. The discount is not where the price is

The top-level `price` is already the reduced one. `oldPrice`,
`discountPercentage` and `discountLabel` live a level down, per colour. A
top-level read gets the right number and loses the entire fact that the item is
on sale:

```
SHORT SLEEVE CUT OUT TOP   price 23.94   oldPrice 39.90   -40%
COMBINATION KNIT TOP       price 27.54   oldPrice 45.90   -40%
```

Both levels are read, and both are in the output.

#### 4. There are two category trees and they disagree

| tree | distinct ids |
| --- | --- |
| `/{cc}/{lang}/categories?ajax=true` | 2,199 |
| `/itxrest/1/catalog/store/{id}/category` | 1,922 |
| in both | 853 |

Validating an id against either one alone rejects about half the real ones — id
`2419939` is absent from the first, present in the second, and returns 374
products. Both are loaded and merged.

An id in neither is still fetched, because the 404 is not reliable either:
`999999999` and `2419941` 404, but **`2419940` returns byte-for-byte the same
payload as `2419939`**. An id adjacent to a real one silently serves its
neighbour, and the response echoes nothing identifying what was requested. So
every summary carries `productGroupId` — two targets that alias share it, which
is the only way the collision is visible — and `categoryIdVerified` says
whether the id was in a tree at all.

### Output

Four record types in one dataset, told apart by `recordType`:

- **`PRODUCT`** — name, reference, `price` + `priceMinorUnits` + `currency`,
  `oldPrice` / `discountPercentage` / `discountLabel`, `colors[]`,
  `availableColors[]`, resolved `images[]`, `productUrl`, `categoryPath`.
- **`CATEGORY`** — a discovery row: id, name, full menu path, section,
  `isProductGrid`, `categoryUrl`.
- **`CATEGORY_SUMMARY`** — one per harvested category: `storeCountry`,
  `currency`, `currencyIsZeroDecimal`, `componentsSeen`, `productsReturned`,
  `bundlesSkipped`, `discountedRows`, `productGroupId`, `categoryIdVerified`,
  `bytesRead`.
- **`ERROR`** — one row naming what went wrong, instead of a silent empty.

### Technical notes

- **HTTP-only**, `curl_cffi` with `chrome124`. Five TLS profiles were 200 cold
  on both the storefront and the JSON; Akamai fronts the site but neither
  surface challenged from a plain IP.
- **No pagination.** A category arrives in one response — the largest measured
  was 4.5 MB for 802 products. Keep `maxConcurrency` low: the memory cost is
  per concurrent category, not per page.
- **Proxy is optional and off by default.** If you enable it, leave the pool
  unpinned: the store is chosen by the country segment in the URL, not by the
  exit IP, and every run verifies which store answered anyway.
- **robots.txt was read in full** on 2026-09-16, comments included. No AI-bot
  group, no blanket disallow. Both paths this Actor reads are allowed.

### Known limits

- **No size-level stock.** `/itxrest/*/availability` and `/itxrest/*/sizing-info`
  are disallowed by Zara's robots.txt, so availability is reported only at the
  coarse level the listing itself publishes (`in_stock` and similar). That is a
  policy limit, not a missing feature.
- **No keyword search.** Zara's search runs on a separate host that does not
  resolve publicly; only category browsing is reachable.
- Bundles ("looks") are excluded by default. They are not garments, and they
  come back both priced and price-less, so mixing them in distorts averages.

# Actor input Schema

## `country` (type: `string`):

Two-letter country code exactly as it appears in a Zara URL - us, gb, de, fr, es, it, jp, kr, br, mx, cn. Prices, currency and the assortment all differ per store. IMPORTANT: Zara does not reject an unrecognised two-letter code; it serves the SPANISH store instead, with HTTP 200 and real products at euro prices. This Actor reads back which store actually answered and stops the run if it is not the one you asked for.

## `language` (type: `string`):

Language segment for the same store, e.g. en, de, fr, ja, ko, pt. A pair Zara does not serve (for example gb/de) is refused upstream rather than guessed at.

## `categoryIds` (type: `array`):

Zara's internal numeric category ids. Leave this and Category URLs empty to run a discovery pass that lists every category in the store with its id, name and full path - then paste the ids you want back in here.

## `categoryUrls` (type: `array`):

Zara category pages, e.g. https://www.zara.com/us/en/woman-tshirts-l1362.html. The number in the URL is the SEO id, which is NOT the id the products endpoint takes - it is resolved through the store's own category tree, and a URL that does not resolve is reported instead of guessed.

## `sections` (type: `array`):

Narrow a discovery run to sections such as WOMAN, MAN, KIDS, BEAUTY, HOME. Ignored when you pass category ids or URLs.

## `onlyProductGrids` (type: `boolean`):

Zara's menu mixes real product grids with editorial pages (layout 'marketing-content-view') that may hold no products at all. On by default so a discovery run returns ids that are actually worth harvesting.

## `onlyDiscounted` (type: `boolean`):

Keep only products with a previous price. Note that Zara's top-level price field is already the reduced one - the original lives one level down, per colour, which is where this Actor reads it from.

## `includeBundles` (type: `boolean`):

Zara mixes 'Bundle' components into category responses (213 of 1,670 across four measured categories). They carry no price at all. They are excluded by default so the dataset does not fill with price-less rows that read downstream as free items.

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

Overall cap on product rows across every category, or on category rows in a discovery run. Summary rows are always emitted and do not count against it.

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

Categories fetched in parallel. Keep this low: Zara does not paginate, so one category arrives as a single response - the largest measured was 4.5 MB for 802 products - and the memory cost is per concurrent category.

## `minRequestInterval` (type: `integer`):

Politeness delay between request starts. It paces starts only and does not hold a concurrency slot, so raising it slows the run without idling workers.

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

Optional. Zara did not challenge any of five TLS profiles from a plain IP, so this is off by default. If you enable it, leave the pool unpinned: the store is chosen by the country segment in the URL, not by the exit IP, and every run verifies which store answered anyway.

## Actor input object example

```json
{
  "country": "us",
  "language": "en",
  "onlyProductGrids": true,
  "onlyDiscounted": false,
  "includeBundles": false,
  "maxItems": 500,
  "maxConcurrency": 2,
  "minRequestInterval": 0,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/zara-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 = {}

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/zara-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 '{}' |
apify call scrapyx/zara-products-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/zara-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/Hv709cgfzacLugERi/builds/RMgW6tGAciNfQXzPW/openapi.json
