# Complete Guide: How to Scrape bol.com Product Data Using Python

This complete guide to [bol.com](http://bol.com) Scraper shows how to run a [bol.com](http://bol.com) scraper from Python through the Apify API and get back structured, typed product records: product titles, prices in euros, reference prices, discount percentages, seller identities, delivery dates, and customer star ratings. The behaviour described below is drawn directly from empirical runs and dataset inspections.

[bol.com](http://bol.com) is a Dutch and Belgian online marketplace, so price trackers and catalog tools for the Netherlands and Belgium often need its data. A custom crawler meets two obstacles first: [bol.com](http://bol.com) answers datacenter proxy addresses with HTTP 403, and its category navigation stops at 500 pages.

In this tutorial, you will learn how to automate [bol.com](http://bol.com) data extraction using the [**bol.com**](http://bol.com) **Scraper** Apify Actor (`reventadata/bol-scraper`). Prerequisites are minimal: Python 3.11+, the official `apify-client` package, and an Apify API token. The live benchmark run supporting this guide (configured with `maxItems: 60`, `maxPages: 4`, `sort: "RATING"`, and `minRating: 4.5`) collected 60 structured product records in 40.2 seconds.

---

## TL;DR

In this tutorial, you will learn to configure and trigger the [bol.com](http://bol.com) Scraper Apify Actor from Python, inspect run execution states, extract clean dataset items, and handle volume discounts. Key steps: install `apify-client`, export your `APIFY_API_TOKEN`, define target category URLs in `startUrls`, call the Actor, and iterate over items in `run.default_dataset_id`.

Expected outcome: a JSON list of [bol.com](http://bol.com) products with price, reference price, seller, delivery promise and rating for each, ready to load into a spreadsheet or database.

The Actor is available at [apify.com/reventadata/bol-scraper](https://apify.com/reventadata/bol-scraper).

---

## Prerequisites and setup

Before running our extraction script, verify the following prerequisites:

To follow this tutorial, you will need:

1. Python 3.11+ installed locally (`apify-client` 3.x requires it).
2. The Apify API client for Python (`pip install "apify-client~=3.0"`).
3. An Apify account and API token.

An Apify account. Apify Console is the web interface where runs, datasets, and tokens are managed; your token lives on the API & Integrations page in Apify Console. The platform documentation index is at [docs.apify.com](https://docs.apify.com/).

Install the Apify client library via pip:

```bash
pip install "apify-client~=3.0"
```

Set your API token in your environment:

```bash
export APIFY_API_TOKEN="your_apify_token_here"
```

---

## Understanding [bol.com](http://bol.com) Scraper

### How it works behind the scenes

The [**bol.com**](http://bol.com) **Scraper** is architected specifically around the technical characteristics of the [bol.com](http://bol.com) e-commerce platform:

1. **URL Taxonomy & Page Routing**:
   - The Actor reads **category pages** (`/nl/nl/l/<category-name>/<category-id>/`) and **brand pages** (`/nl/nl/b/<brand-name>/<brand-id>/`).
   - The Actor normalizes start URLs to scheme, host, and path (category `/l/` or brand `/b/`), ignoring any query strings. Filters such as price ranges, minimum rating, discount status, and seller type are configured through input parameters and applied after the fetch.
   - [Bol.com](http://Bol.com)'s robots.txt disallows search query pages (`/s/`). To maintain clean compliance and sustainable throughput, the Actor operates exclusively on open category and brand endpoints.
2. **Residential Proxy Transport**:
   - Datacenter IPs and standard non-residential requests receive HTTP 403 block pages from [bol.com](http://bol.com).
   - The Actor routes traffic through Apify's residential proxy network located in the Netherlands, and retries a page on a new exit when [bol.com](http://bol.com) returns an unrecognised page.
3. **Data Extraction & Price Parsing**:
   - Instead of scraping rendered HTML elements, the Actor extracts structured JSON payloads embedded within the category pages.
   - It parses tiered quantity discounts (`bundlePrices`), manufacturer reference figures (`Adviesprijs`), the most shown non-promotion price of the last 90 days (`Meestal`), and merchant identities.

The execution pipeline flows linearly: your Python client invokes the Apify API, which spins up a dedicated container running `reventadata/bol-scraper`. The Actor queries [bol.com](http://bol.com) endpoints through residential proxies in the Netherlands, extracts embedded structured data, pushes extracted records to the Apify dataset, and returns the dataset ID to your script.

---

## Deep Dive: Understanding Reference Prices and Discounts on [bol.com](http://bol.com)

On [bol.com](http://bol.com), price presentation includes distinct baseline classifications that the Actor captures into separate fields:

1. `price`: The selling price in EUR of the best offer shown for the product.
2. `referencePrice` **and** `referencePriceLabel`:
   - `"Meestal"`: The most shown non-promotion price of the last 90 days.
   - `"Adviesprijs"`: Represents the manufacturer's recommended list price.
3. `referencePriceCrossedOut` **and** `discountPercent`:
   - When [bol.com](http://bol.com) displays the reference price crossed out above the price, the scraper sets `referencePriceCrossedOut: true` and passes through bol's stated discount percentage in `discountPercent` (null when bol states none).

---

## Handling Multi-Country Regional Variations

[Bol.com](http://Bol.com) serves the Netherlands and Belgium. The website taxonomy reflects this distinction in URL path segments:

- **Netherlands (Dutch)**: `https://www.bol.com/nl/nl/l/...`
- **Belgium (Dutch)**: `https://www.bol.com/be/nl/l/...`
- **Belgium (French)**: `https://www.bol.com/be/fr/l/...`

The scraper inspects the domain path of each start URL and preserves locale context:

- The `country` field records `"NL"` or `"BE"`.
- The `language` field records `"nl"` or `"fr"`.
- Titles and `delivery` promises follow the language of the source endpoint: Dutch listings state Dutch text (such as `morgen in huis`), while French-language Belgian endpoints (`/be/fr/`) return French titles and delivery text. Promotional `labels` can stay in Dutch (`"Budgettopper"`).

---

## Step-by-step walkthrough

### Step 1: Navigating to the Actor

Visit the [bol.com Scraper Store page](https://apify.com/reventadata/bol-scraper). The page details input schemas, pricing parameters, and author documentation. From here, you can click **Try for free** to inspect the configuration dashboard in Apify Console.

### Step 2: Defining inputs and target categories

Navigate to `bol.com` in your browser, browse to your target category, and copy the full URL. For this guide, we use top-rated dishwashers: `https://www.bol.com/nl/nl/l/vaatwassers/11067/`

We configure our input dictionary with category filters, setting a minimum customer rating of 4.5 and sorting by rating:

```python
import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with token from environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Define the extraction parameters
run_input = {
    "startUrls": [
        {"url": "https://www.bol.com/nl/nl/l/vaatwassers/11067/"}
    ],
    "maxItems": 20,
    "maxPages": 1,
    "sort": "RATING",
    "minRating": 4.5,
}
```

*(Note: While this introductory snippet specifies* `maxItems: 20` *and* `maxPages: 1` *for a quick test, our live benchmark run was configured with* `maxItems: 60`*,* `maxPages: 4`*,* `sort: "RATING"`*, and* `minRating: 4.5`*.)*

### Step 3: Executing via Apify Python Client

Using `client.actor("reventadata/bol-scraper").call(run_input=run_input)` triggers execution in an isolated cloud container. The client polls until execution finishes and returns the populated run object:

```python
# 3. Start the Actor run and wait for it to complete
print("Starting bol.com extraction...")
run = client.actor("reventadata/bol-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the resulting dataset items
dataset = client.dataset(run.default_dataset_id).list_items().items

print(f"Extraction complete! Retrieved {len(dataset)} products.\n")
```

Execution characteristics:

- It iterates across category pagination up to `maxPages` or until `maxItems` is satisfied.
- In our test run (configured with `maxItems: 60`, `maxPages: 4`, `sort: "RATING"`, and `minRating: 4.5`), the Actor completed 60 products in 40.2 seconds.

### Step 4: Iterating and processing dataset items

Once the run reaches `SUCCEEDED`, fetch the dataset items. The client provides methods to list all items or iterate over large datasets without loading everything into memory simultaneously:

```python
for item in dataset:
    title = item.get("title")
    price = item.get("price")
    seller = item.get("seller")
    rating = item.get("rating")
    review_count = item.get("reviewCount")
    delivery = item.get("delivery")

    print(f"Product: {title}")
    print(f"  Price: €{price}")
    print(f"  Seller: {seller}")
    if rating is None:
        print("  Rating: not rated yet")
    else:
        print(f"  Rating: {rating} / 5.0 ({review_count} reviews)")
    print(f"  Delivery: {delivery}")
    print("-" * 50)
```

---

## Verbatim dataset sample

Below is a complete, unedited record extracted during the benchmark run:

```json
{
    "id": "9300000184000912",
    "title": "Labirent LMV8-7PZW1 - Mini Vaatwasser - 8 Couverts - 7 Programma’s - Modern - Kleine Vrijstaande Afwasmachine - Zwart",
    "url": "https://www.bol.com/nl/nl/p/labirent-ddd08-55bm-mini-vaatwasser-8-couverts-kleine-vrijstaande-vaatwasser-zwart/9300000184000912/",
    "brand": "Labirent",
    "brandUrl": "https://www.bol.com/nl/nl/b/labirent/604508015/",
    "price": 299.95,
    "currency": "EUR",
    "referencePrice": null,
    "referencePriceLabel": null,
    "referencePriceCrossedOut": false,
    "discountPercent": null,
    "unitPrice": null,
    "unitMeasure": null,
    "bundlePrices": [],
    "selectPrice": null,
    "seller": "Labirent",
    "sellerId": "1698826",
    "soldByBol": false,
    "condition": "new",
    "delivery": "Op voorraad. Voor 17:00 uur besteld, morgen in huis",
    "deliveryDate": "2026-10-24",
    "isScarce": false,
    "isSelect": false,
    "labels": [],
    "rating": 4.9,
    "reviewCount": 39,
    "categories": [
        "Huishouden",
        "Vaatwassen",
        "Vaatwassers",
        "Vrijstaande vaatwassers"
    ],
    "packQuantity": null,
    "image": "https://media.s-bol.com/m4vZ3Ovy79RR/76DpV28/550x589.jpg",
    "isSponsored": false,
    "country": "NL",
    "language": "nl",
    "sourceUrl": "https://www.bol.com/nl/nl/l/vaatwassers/11067/",
    "page": 1,
    "scrapedAt": "2026-10-23T08:17:28.906183+00:00"
}
```

A row with a discount looks like this. It comes from a saved bol.com page (2026-10-21) and shows the reference-price fields filled in:

```json
{
    "id": "9200000090597626",
    "title": "12x Glow in the Dark kleurige 3D Vlinders met magneet en sticker, voor muur, koelkast, deuren etc.",
    "url": "https://www.bol.com/nl/nl/p/12x-glow-in-the-dark-kleurige-3d-vlinders-met-magneet-en-sticker-voor-muur-koelkast-deuren-etc/9200000090597626/",
    "brand": "Favorite Things",
    "brandUrl": "https://www.bol.com/nl/nl/b/favorite-things/15956481/",
    "price": 4.39,
    "currency": "EUR",
    "referencePrice": 7.45,
    "referencePriceLabel": "Meestal",
    "referencePriceCrossedOut": true,
    "discountPercent": 41,
    "unitPrice": null,
    "unitMeasure": null,
    "bundlePrices": [
        {"minimumQuantity": 2, "price": 4.26, "discountPercent": 3},
        {"minimumQuantity": 4, "price": 4.13, "discountPercent": 6},
        {"minimumQuantity": 6, "price": 3.95, "discountPercent": 10}
    ],
    "selectPrice": null,
    "seller": "Knaak",
    "sellerId": "1476045",
    "soldByBol": false,
    "condition": "new",
    "delivery": "Uiterlijk 28 oktober in huis",
    "deliveryDate": "2026-10-28",
    "isScarce": false,
    "isSelect": false,
    "labels": ["Budgettopper"],
    "rating": 4.2,
    "reviewCount": 30,
    "categories": ["Wonen", "Woonaccessoires", "Wanddecoratie", "Muurstickers"],
    "packQuantity": null,
    "image": "https://media.s-bol.com/nMgn4BNWK4E/550x550.jpg",
    "isSponsored": false,
    "country": "NL",
    "language": "nl",
    "sourceUrl": "https://www.bol.com/nl/nl/l/muurstickers/14158/",
    "page": 1,
    "scrapedAt": "2026-10-21T12:00:00+00:00"
}
```

---

## Detailed schema breakdown

Each dataset record provides comprehensive e-commerce attributes:

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | [bol.com](http://bol.com) product id string (16 digits). |
| `title` | string | Complete product listing title. |
| `url` | string | Canonical product URL. |
| `brand` | string / null | Extracted brand name. |
| `brandUrl` | string / null | URL to the brand's directory page on [bol.com](http://bol.com). |
| `price` | number | Selling price in EUR of the best offer shown for the product. |
| `currency` | string | Always `"EUR"`. |
| `referencePrice` | number / null | Reference price in euros shown next to the price, when there is one (see `referencePriceLabel` and `referencePriceCrossedOut`). |
| `referencePriceLabel` | string / null | Classification label (`"Meestal"` or `"Adviesprijs"`). |
| `referencePriceCrossedOut` | boolean | True when bol displays the reference price crossed out. |
| `discountPercent` | integer / null | Discount percentage against the crossed-out reference price as stated by bol; null when bol states none. |
| `bundlePrices` | array | Quantity discounts: `minimumQuantity`, `price` per item in euros, and `discountPercent`. |
| `selectPrice` | number / null | Price for bol Select members, when it differs. |
| `seller` | string | Name of the seller of the best offer; bol itself is named bol. |
| `sellerId` | string | Merchant unique ID (`"0"` indicates [bol.com](http://bol.com) direct). |
| `soldByBol` | boolean | True when bol itself sells the best offer. |
| `delivery` | string | Displayed delivery promise text. |
| `deliveryDate` | string | Latest estimated delivery date formatted as YYYY-MM-DD. |
| `rating` | number / null | Average user rating from 1.0 to 5.0. |
| `reviewCount` | integer / null | Number of customer ratings, null when unrated. |
| `categories` | array | Hierarchical category taxonomy path. |
| `scrapedAt` | string | ISO 8601 extraction timestamp. |

---

## Advanced filtering and volume pricing

1. **Volume Tier Discounts (**`bundlePrices`**)**: When a seller offers quantity prices, they land in `bundlePrices` (see the README example): each element provides `minimumQuantity`, `price` per item in euros, and `discountPercent`.
2. **Differentiating Seller Types**: Setting `seller: "BOL"` restricts output to products retailed directly by [bol.com](http://bol.com) itself (`sellerId: "0"`). Conversely, setting `seller: "PARTNER"` collects only third-party marketplace merchants.

---

## Troubleshooting

When operating the [bol.com](http://bol.com) Scraper in production, you may encounter edge cases. Here is how to diagnose and resolve them:

- **Empty dataset / Run marked FAILED:** To protect downstream data pipelines from silent failures, the Actor deliberately fails if zero rows match your parameters. If this occurs, verify that your start URL is valid and that your `minPrice` or `minRating` thresholds are not too restrictive.
- **HTTP 403 blocks on datacenter proxy configurations:** Datacenter IPs receive HTTP 403 block pages from [bol.com](http://bol.com). Always use the Actor's default Apify residential proxy configuration in the Netherlands.
- **Pagination limits at page 500:** A listing can have at most 500 pages; requesting page 501 returns an HTTP 404 response. If you are scraping large categories containing thousands of items, divide your crawl into narrower subcategories or brand pages to capture the full catalog.
- **Handling invalid URLs:** Supply category (`/l/`) or brand (`/b/`) links; query strings in start URLs are ignored by the Actor. Supplying search queries (`/s/`) or individual product detail pages (`/p/`) results in an immediate input validation error.

---

## Best practices

Follow these operational recommendations to maximize reliability and cost efficiency:

1. **Set** `maxItems` **as a budget ceiling:** Use `maxItems` to establish a strict ceiling on total products collected across all start URLs before terminating.
2. **Filter upstream to eliminate per-result fees:** Use `minRating`, `onlyDiscounted`, `seller`, and price boundaries in your input. Items dropped before dataset storage incur no per-result event fees.
3. **Select strategic sort orders:** Use `sort: "PRICE_ASC"` when monitoring price floors, or `sort: "RATING"` when isolating top customer-reviewed products.
4. **Partition large catalogs across subcategories:** To stay safely within the 500-page pagination limit on large departments, split crawls across specific subcategory or brand URLs.
5. **Handle nullable attributes defensively:** Unrated products return `null` for `rating` and `reviewCount`. Unbranded items return `null` for `brand`. Ensure ETL code handles nullable fields gracefully.

---

## Production Integration Patterns & Data Warehousing

When incorporating the [bol.com](http://bol.com) Scraper into production ETL pipelines, consider these deployment patterns:

1. **Scheduled Runs & Cron Triggers**: In Apify Console, navigate to **Schedules** to trigger recurring runs at off-peak hours (such as 03:00 CET). Daily scheduled runs enable time-series tracking of merchant pricing and delivery changes.
2. **Webhooks and Event Alerts**: In Apify Console, you can configure an Apify webhook to trigger on run completion. The webhook payload carries the run object, which includes `defaultDatasetId`. Your server or Lambda handler can then use the Apify API client to fetch the dataset items and load them into your database or data warehouse.
3. **Downstream Data Analysis**: Because dataset records export as clean JSON or CSV, you can read them with standard data science libraries (for example, reading items into a Pandas DataFrame in your own analysis script) to track price trends and brand distribution over time.

---

## Pricing model and cost transparency

For current pricing and details, see the [Store page](https://apify.com/reventadata/bol-scraper).

---

## Conclusion and resources

With the [**bol.com**](http://bol.com) **Scraper** and a few lines of Python, you get typed [bol.com](http://bol.com) records for price monitoring, catalog tracking and market research.

Try [bol.com](http://bol.com) Scraper yourself and let me know what you build!

*I build and maintain scrapers for European marketplaces under the reventadata name on the Apify Store.*

- 🚀 **Actor on Apify Store**: [bol.com Scraper](https://apify.com/reventadata/bol-scraper)
- 📚 **API Client Documentation**: [Apify Python Client Documentation](https://docs.apify.com/api/client/python/)
- 💬 **Support & Custom Inquiries**: Use the Issues tab on the Actor's Apify Store page for feature requests or category extensions.
