# Zalando Products Scraper (`scrapyx/zalando-products-scraper`) Actor

Product listings from Zalando catalogue and category pages across 12 country storefronts: name, brand, price and was-price, discount, silhouette and images. Relevance-checked, de-duplicated by SKU.

- **URL**: https://apify.com/scrapyx/zalando-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.05 / 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

## Zalando Products Scraper

Product listings from Zalando catalogue and category pages — name, brand,
price and was-price, discount, silhouette and images — across **12 country
storefronts**.

HTTP only, no browser, no login.

### Input

```json
{
  "mode": "search",
  "market": "de",
  "keywords": ["sneaker", "laufschuhe"],
  "maxPages": 3,
  "maxItems": 200
}
```

Or point it at catalogue/category URLs:

```json
{ "mode": "urls", "market": "de",
  "listingUrls": ["https://www.zalando.de/herrenschuhe/"] }
```

### Markets

`de` `at` `ch` `uk` `fr` `it` `es` `nl` `be` `pl` `se` `dk` — each with its
own currency and language. Every one was fetched and confirmed to return
products; the market list is measured, not assumed.

This matters more than a locale toggle: **each storefront has its own
catalogue path**, and the path is not derivable from the language. `.de`,
`.at`, `.ch`, `.se`, `.dk`, `.pl` use `/katalog/`; `.fr` uses `/catalogue/`;
`.it`, `.es` use `/catalogo/`; `.nl`, `.be` use `/catalogus/`; and the **UK
uses `/catalog/`** — `/catalogue/` 404s there.

### What you get

| `recordType` | One per | Carries |
| --- | --- | --- |
| `SEARCH_SUMMARY` | keyword / URL | market, pages fetched, rows fetched vs returned, duplicates dropped, why paging stopped, and a relevance score |
| `PRODUCT` | article | SKU, URL, name, brand, price, was-price, discount %, silhouette, images — plus the raw upstream object |
| `ERROR` | failed input | why |

```jsonc
{
  "recordType": "PRODUCT",
  "sku": "NI114D0HT-A11",
  "productUrl": "https://www.zalando.de/NI114D0HT-A11.html",
  "name": "AIR FORCE 1 LE UNISEX - Trainers - white",
  "brand": "Nike Sportswear",
  "currency": "EUR", "price": 69.95, "originalPrice": 99.95,
  "isDiscounted": true, "discountPercent": 30,
  "silhouette": "SNEAKER",
  "imageUrls": ["https://img01.ztat.net/article/..."]
}
```

### Known limits — read these before you rely on the output

**Zalando has no "no results" page.** An unmatched query returns `HTTP 200`
with unrelated products. So every query's relevance is measured and reported:
`titleMatchRate` is the fraction of returned articles matching a query token
across name, brand, silhouette and model, and `lowRelevanceWarning` flags a
run below 20%. Measured: `sneaker` / `nike` / `kleider` / `laufschuhe`
**1.00**, `jacke` 0.96, `stiefel` 0.92, `hemden` 0.88, three different
nonsense strings **0.00**. Rows are never silently dropped — a low score is a
fact about your query.

The relevance stemmer is **German-aware** (`-e`, `-en`, `-er`), because with
English-only plural handling the perfectly valid query `laufschuhe` scored
0.00 and would have been wrongly flagged.

**`price` is what you pay; `originalPrice` only exists when discounted.**
Undiscounted articles carry no `promotional` block at all
(`priceDisplayMode: "BLACK_PRICE"`) and their price lives in `original`. This
actor resolves that, so `price` is always populated, and `originalPrice` is
left `null` rather than echoing `price` and inventing a markdown that does not
exist. `isDiscounted` says which case a row is.

**Prices come from a mix of cents and floats.** Upstream sends
`promotional.amount` / `original.amount` as **integer cents** (`9595`) and
`trackingCurrentAmount` as a float (`95.95`). The cents are converted
explicitly; the float is passed through beside them as a cross-check.

**Product detail pages are not supported.** Zalando listing paths end in `/`
and product pages end in `.html`; the latter are refused with an explanation.

**24 articles per page.** Page 1 is deterministic (fetched twice, identical
24/24), and page 2 overlaps page 1 by about one article, de-duplicated by SKU.

### Technical

Server-rendered. The data is neither `__NEXT_DATA__` nor JSON-LD nor an RSC
flight stream — all three were checked and are absent. It is a single JSON
object passed to `window.__hydrationDataConsume({...})` containing a
`graphqlCache` with the full article objects. Products are located
structurally (any dict with both `sku` and `name`) because that cache is keyed
by a per-deploy query hash.

No WAF: 5/5 TLS profiles returned 200 with near-identical sizes. Past the last
page Zalando answers **HTTP 400**, treated as an honest end of results.

`robots.txt` was checked at **path** level: the catalogue path with `?q=` is
ALLOWED, no `ClaudeBot` / `anthropic-ai` group, no blanket disallow.

Full recon trail is in `CRAWLING_METHOD.md`.

# Actor input Schema

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

'search' builds a catalogue URL from your keywords for the chosen market. 'urls' takes Zalando catalogue or category URLs you already have. Product detail pages (ending in .html) are refused with an explanation.

## `market` (type: `string`):

Which Zalando country storefront to use. This matters more than a locale switch: each country site has its OWN catalogue path, and the path is not derivable from the language (the UK uses /catalog/, not /catalogue/). Every market listed here was fetched and confirmed.

## `keywords` (type: `array`):

Search terms, one per row, in the market's own language. Zalando returns unrelated products rather than a 'no results' page for an unmatched term, so each keyword's relevance is measured and reported as 'titleMatchRate'.

## `listingUrls` (type: `array`):

Zalando catalogue or category URLs, e.g. https://www.zalando.de/herrenschuhe/ . Used by the 'urls' mode.

## `maxPages` (type: `integer`):

Pages to fetch per keyword or URL, via ?p=N. Each page holds 24 distinct articles. Past the last page Zalando answers HTTP 400, which is treated as an honest end of results rather than an error.

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

Optional cap on total product rows across the run, shared across every keyword. Counted after de-duplication.

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

How many requests may be in flight at once. Pages are 1.1-1.4 MB, so this trades memory for wall-clock time.

## `minRequestInterval` (type: `number`):

The honest speed control: the shortest gap between two request starts. No WAF or rate limiting was observed (5/5 TLS profiles clean); this is routine pacing.

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

Residential with no country pin is the cloud default. The market input decides the storefront, language and currency, so the proxy country does not change your data.

## Actor input object example

```json
{
  "mode": "search",
  "market": "de",
  "keywords": [
    "sneaker"
  ],
  "listingUrls": [],
  "maxPages": 3,
  "maxConcurrency": 3,
  "minRequestInterval": 0.8,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# 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/zalando-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/zalando-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/zalando-products-scraper --silent --output-dataset

```

## MCP server setup

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