# dm.de - Prices, Offers, Unit Price & Reviews (`abotapi/dm-de-scraper`) Actor

Scrape dm-drogerie markt (dm.de) products: current + strike-through Ausverkauf price with discount, per-unit pricing (per 100ml/kg/piece), brand incl. dm own-brands, EAN, ingredients, pack-size variants, and full reviews with rating breakdown. Search or paste links.

- **URL**: https://apify.com/abotapi/dm-de-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** E-commerce, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 product 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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## dm.de Product Scraper

Pull rich product data from dm-drogerie markt (dm.de), Germany's drugstore and personal-care retailer. Search by keyword, a real dm.de category, or the site's own currently-reduced Ausverkauf items, or paste product and listing links directly. Every record includes the current price, the strike-through original price with computed discount when a product is marked down, per-unit pricing (e.g. price per 100 ml or per kg) where the site shows it, and a complete customer review history with a full rating breakdown.

### Why This Scraper?

- **Was-price and discount, structured, not scraped from a badge.** When a product carries a reduced original price, both prices are captured plus the computed discount amount and percentage.
- **Real per-unit pricing.** Bodycare, food, and household products return their price per 100 ml / 100 g / kg / piece separately from the pack price, exactly as the site itself breaks it down.
- **Ausverkauf, first class.** A `specialsOnly` toggle narrows any search or category listing to dm's own live clearance/sellout feed -- the site's real, queryable discount signal, not a guessed category.
- **Full review history.** Overall rating, review count, a full 1 to 5 star rating breakdown, and every individual review with author, date, rating, title, and body text.
- **Rich detail on demand.** Full breadcrumb category path, flattened product information (description, ingredients/Inhaltsstoffe, usage notes -- varies per category, never hardcoded), pack-size variants with their own price and EAN, brand image, and online/store availability.
- **Brand coverage.** Every brand dm carries, including its own private-label lines (dmBio, Balea, alverde NATURKOSMETIK, and more) alongside national brands.
- **Two ways in.** Keyword or category search with real site filters (brand, rating, price range, Ausverkauf, sort), or paste any product/listing link and continue pagination automatically.
- **Resume & recurring updates.** Turn on Incremental mode to get only NEW, UPDATED, and REAPPEARED products on a scheduled re-run of the same search -- see "Resume & recurring updates" below.
- **Optional export to your apps.** Send results into Notion, Linear, Airtable, or any Apify MCP connector alongside the dataset.

### Data You Get

| Field | Example value |
|---|---|
| productId / sku | `1234567` |
| ean | `4058172548821` |
| name | `Sample Bodylotion Aloe Vera` |
| brand | `Sample Brand` |
| category / categoryPath | `Bodylotion & Hautcreme`, `["Pflege & Parfum", "Körperpflege", "Bodylotion & Hautcreme"]` |
| url | `https://www.dm.de/p/d/1234567/sample-bodylotion-aloe-vera` |
| price / currency | `3.45`, `EUR` |
| originalPrice / discountAmount / discountPercent | `4.95`, `1.50`, `30.3` |
| isOnSpecial / promoLabel | `true`, `Ausverkauf` |
| unitPrice / unitPriceBasis / packSize | `0.86`, `100 ml`, `400 ml` |
| rating / reviewCount | `4.6`, `128` |
| onlineAvailable / onlineAvailabilityStatus / storeAvailabilityStatus | `true`, `Lieferbar`, `Im Markt vorrätig` |
| image / images | `["https://www.dm.de/sample/image.png"]` |
| description / ingredients | full text fields |
| productDetails | `{"Produktmerkmale": "...", "Verwendungshinweise": "..."}` (flattened per category) |
| variants | `[{"productId": "1234568", "ean": "4058172548838", "label": "600 ml", "isSelected": false}]` |
| isPharmacy | `false` |
| reviews.averageRating / reviews.totalReviewCount | `4.6`, `128` |
| reviews.ratingDistribution | `[{"rating": 5, "count": 90}, {"rating": 4, "count": 24}, ...]` |
| reviews.items\[] | `[{"rating": 5, "title": "Sample title", "body": "Sample review text.", "author": "Sample User", "date": "2026-07-12T07:31:15.000Z", "isRecommended": true, "verifiedPurchaser": true}]` |

> Sample shape: values above are illustrative placeholders, not from a live product.

**Incremental mode only.** When `incrementalMode` is on, every returned record also carries:

| Field | Description |
|---|---|
| `changeType` | `NEW` | `UPDATED` | `UNCHANGED` | `REAPPEARED` | `EXPIRED` |
| `changedFields` | Top-level fields that changed since last seen; non-empty only for `UPDATED` |
| `firstSeenAt` | When this product was first observed by this monitoring campaign |
| `lastSeenAt` | When this product was last observed |

Change detection is based on price, discount/Ausverkauf status, unit price, and online/store availability -- the fields that actually define whether a listing "changed" for monitoring purposes. Two things are deliberately **excluded** from change detection, and will never by themselves cause a product to come back as `UPDATED`: the `reviews` array (a new review anywhere does not mean the product changed), and `rating` / `reviewCount` (these tick on nearly every run for a high-traffic catalogue like this one -- excluded so a quiet monitoring run stays quiet). If you need to track rating drift, compare it yourself across runs; it is still returned on every record, it just does not drive `changeType`.

### How to Use

**1. Keyword search, first page, with reviews (default):**

```json
{
  "mode": "search",
  "searchTerm": "bodylotion",
  "maxItems": 20
}
```

**2. Category browse, filtered to a brand and only currently-reduced Ausverkauf items:**

```json
{
  "mode": "search",
  "searchTerm": "pflege-und-parfum/koerperpflege/bodylotion-und-hautcreme",
  "brands": ["Balea"],
  "specialsOnly": true,
  "sortBy": "PRICE_ASC",
  "maxItems": 50,
  "maxPages": 5
}
```

**3. Full product detail + reviews for pasted product links:**

```json
{
  "mode": "url",
  "urls": ["https://www.dm.de/p/d/1234567/sample-bodylotion-aloe-vera"],
  "fetchDetails": true,
  "fetchReviews": true,
  "maxReviewsPerProduct": 0
}
```

**4. Paste a listing link and keep paginating forward:**

```json
{
  "mode": "url",
  "urls": ["https://www.dm.de/search?query=bodylotion"],
  "maxItems": 100,
  "maxPages": 10
}
```

### Input Parameters

| Parameter | Type | Description |
|---|---|---|
| `mode` | string | `search` or `url`. |
| `searchTerm` | string | Keyword or a real dm.de category path (search mode only). |
| `specialsOnly` | boolean | Keep only products currently in dm's Ausverkauf/clearance feed (search mode only). |
| `brands` | array | Exact brand names to keep (search mode only). |
| `minRating` | integer | 1-5, minimum rating to keep (search mode only). |
| `minPrice` / `maxPrice` | number | EUR price band to keep (search mode only). |
| `sortBy` | string | `RELEVANCE`, `PRICE_ASC`, `PRICE_DESC`, `NEWEST`, or `RATING` (search mode only). |
| `urls` | array | Product or listing URLs to scrape (url mode only). |
| `fetchDetails` | boolean | Fetch full product detail (breadcrumb, description, ingredients, pack-size variants, brand image, availability). Pasted product URLs always fetch detail, and are charged for it, regardless of this toggle. |
| `fetchReviews` | boolean | Fetch full review history. Shares one enrichment charge with `fetchDetails`, applied at most once per product. |
| `maxReviewsPerProduct` | integer | Cap on reviews per product; `0` = all. |
| `maxPages` | integer | Cap on result pages per search/category/URL entry; `0` (default) = unlimited -- the run stops at Max products total instead. |
| `maxItems` | integer | Cap on total products returned; `0` = unlimited. |
| `resumeFromRunId` | string | Continue one specific previous run/dataset: products already collected there are skipped, so this run only returns new products. For recurring daily monitoring of the same search, use Incremental mode instead -- see "Resume & recurring updates" below. |
| `incrementalMode` | boolean | Daily/recurring monitoring of this same search. First run returns everything as `NEW`; later runs return only `NEW`/`UPDATED`/`REAPPEARED` by default. See "Resume & recurring updates" below. |
| `stateKey` | string | Incremental mode only. Name this monitoring campaign, or deliberately share state across differently-configured runs. Leave empty to derive one automatically. |
| `emitUnchanged` | boolean | Incremental mode only. Also return products unchanged since the last run, marked `UNCHANGED`. Adds and bills extra rows you already have. |
| `emitExpired` | boolean | Incremental mode only. Also return products from a previous run no longer found, marked `EXPIRED`, once a run has fully scanned the search (not capped, not a resume). Adds and bills extra synthetic rows. |
| `proxy` | object | Apify proxy configuration. Works on every plan by default. |
| `mcpConnectors` | array | Optional MCP connectors to export results into (Notion, Linear, Airtable, Apify). |
| `notionParentPageUrl` | string | Notion connector only: page under which item pages are created. |
| `maxNotifyListings` | integer | Cap on items exported to each connector per run. Does not affect the dataset. |

### Output Example

```json
{
  "productId": "1234567",
  "sku": "1234567",
  "ean": "4058172548821",
  "name": "Sample Bodylotion Aloe Vera",
  "brand": "Sample Brand",
  "category": "Bodylotion & Hautcreme",
  "url": "https://www.dm.de/p/d/1234567/sample-bodylotion-aloe-vera",
  "price": 3.45,
  "currency": "EUR",
  "originalPrice": 4.95,
  "discountAmount": 1.5,
  "discountPercent": 30.3,
  "isOnSpecial": true,
  "promoLabel": "Ausverkauf",
  "unitPrice": 0.86,
  "unitPriceBasis": "100 ml",
  "packSize": "400 ml",
  "rating": 4.6,
  "reviewCount": 128,
  "onlineAvailable": true,
  "image": "https://www.dm.de/sample/image.png",
  "reviews": {
    "averageRating": 4.6,
    "totalReviewCount": 128,
    "ratingDistribution": [{ "rating": 5, "count": 90 }, { "rating": 4, "count": 24 }],
    "items": [
      {
        "rating": 5,
        "title": "Sample title",
        "body": "Sample review text.",
        "author": "Sample User",
        "date": "2026-07-12T07:31:15.000Z",
        "isRecommended": true,
        "verifiedPurchaser": true
      }
    ]
  }
}
```

> Sample shape: values above are illustrative placeholders, not from a live product.

### Resume & recurring updates

There are two different things here -- pick the one that matches what you're doing:

| Need | Use |
| --- | --- |
| A crawl stopped and should continue | `resumeFromRunId` / automatic checkpoint recovery |
| Run the same search every day and receive only changes | `incrementalMode` |
| Keep separate daily campaigns for similar searches | distinct `stateKey` values |
| Run a normal full snapshot | leave both off |

**Resume** (`resumeFromRunId`) continues one specific interrupted or previous large crawl: paste a run ID or dataset ID and this run skips products already collected there, returning only the remaining new products. An automatic same-run checkpoint also protects against platform migrations/Resurrects without any input needed.

**Incremental mode** (`incrementalMode`) is for a schedule (for example, daily): the actor remembers the previous run of the *same* search by itself, so you never paste a run ID. The first run returns everything as `NEW`. Later runs return only `NEW`, `UPDATED`, and `REAPPEARED` products by default -- duplicates and unchanged products are suppressed (and not charged). Turn on `emitUnchanged` or `emitExpired` only when you also want those rows returned (and billed for). State is isolated per search/category/URL and Fetch full product detail/Fetch reviews setup automatically; set `stateKey` to name or deliberately share a monitoring campaign.

Scheduled-run example -- same search, run daily:

Day 1 (first run ever for this search):

```json
{ "mode": "search", "searchTerm": "shampoo", "incrementalMode": true }
```

→ every product comes back with `"changeType": "NEW"`.

Day 2 (the schedule fires again, identical input):

```json
{ "mode": "search", "searchTerm": "shampoo", "incrementalMode": true }
```

→ products whose price/discount/Ausverkauf status/unit price/availability changed come back as `"changeType": "UPDATED"` with `changedFields` listing what changed, brand-new products come back as `"changeType": "NEW"`, products that vanished and came back come back as `"changeType": "REAPPEARED"` -- and products that are still there, unchanged, are **not** returned at all (suppressed, not charged) unless `emitUnchanged` is on.

### Send results into your apps (MCP connectors)

Optionally pipe results into the apps you already use through Model Context Protocol (MCP) connectors. Authorize a connector once under Apify, Settings, Integrations, then select it in the `mcpConnectors` field. Each connector receives a condensed, human-readable summary per product (name plus key fields), while the complete record always stays in the Apify dataset. For Notion, set `notionParentPageUrl` to the page the item pages should be created under, and use `maxNotifyListings` to cap how many items are exported per connector per run. Supported connectors: Notion, Linear, Airtable, and Apify. Leave the field empty to skip; it never changes the dataset output.

### A note on plans

The default connection works on every Apify plan, including the free tier. A residential proxy connection is optional and only worth turning on for very large or sustained runs.

# Actor input Schema

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

'search' finds products by keyword, by a real dm.de category (e.g. 'haare' or 'pflege-und-parfum/koerperpflege/bodylotion-und-hautcreme'), and/or the site's own currently-reduced Ausverkauf items, with real site filters. 'url' scrapes any product or category/listing page URL you paste, walking pagination forward automatically.

## `searchTerm` (type: `string`):

Free-text keyword (e.g. 'shampoo') or a real dm.de category path exactly as it appears in the site's own navigation or URLs (e.g. 'haare' or 'pflege-und-parfum/koerperpflege/bodylotion-und-hautcreme'). Leave empty and enable Ausverkauf only below to browse the whole clearance feed.

## `specialsOnly` (type: `boolean`):

dm.de's real, queryable promo taxonomy is its 'Ausverkauf' (clearance/sellout) feed -- verified live. There is no separate queryable 'Angebote'/'Aktion' product feed: the site's 'Aktionen' nav entry is an editorial content hub with no product API behind it, so it is intentionally not exposed here. Turning this on narrows to Ausverkauf items (optionally combined with the keyword/category above), each carrying the site's own strike-through original price and discount.

## `brands` (type: `array`):

Only return products from these brands, e.g. 'Balea', 'dmBio', 'alverde NATURKOSMETIK', 'Nivea'. Match the exact brand name as shown on the site (includes dm's own private-label brands). Leave empty for all brands.

## `minRating` (type: `integer`):

Only return products rated at or above this many stars, 1-5.

## `minPrice` (type: `number`):

Only return products priced at or above this amount, in euros.

## `maxPrice` (type: `number`):

Only return products priced at or below this amount, in euros.

## `sortBy` (type: `string`):

dm.de's own sort order, verified live to reorder the full server-side result set (so it stays correct across pagination, not just within one fetched page).

## `urls` (type: `array`):

Paste one or more dm.de product pages (.../p/d/<article number>/<slug>), or category/search listing pages (e.g. .../haare or .../search?query=shampoo). Pagination continues forward automatically from any ?currentPage= already in the URL. Filter fields above are ignored in this mode.

## `fetchDetails` (type: `boolean`):

A pasted product link always fetches full detail regardless of this toggle, and is charged the enrichment surcharge either way — it has no listing card to fall back on, so without the detail fetch the record would be almost empty. Everywhere else, this toggle adds the full breadcrumb category path, category codes, description, ingredients (Inhaltsstoffe), all other product characteristics (flattened, varies per category), pack-size variants with their own price/EAN, brand image, and online/store availability from the product detail API. Current price, was-price/discount, unit price and rating are already included without this toggle. This toggle and 'Fetch reviews' share one enrichment surcharge, charged at most once per product.

## `fetchReviews` (type: `boolean`):

Fetch each product's full review history: overall rating, review count, a full 1-5 rating breakdown, and per-review author/date/rating/title/body. Products with no reviews simply return an empty list. This toggle and 'Fetch full product detail' share one enrichment surcharge, charged at most once per product no matter how many reviews are fetched.

## `maxReviewsPerProduct` (type: `integer`):

Cap on reviews fetched per product when 'Fetch reviews' is on. 0 = all available reviews.

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

Stop after this many result pages per search/category/URL entry. 0 (default) = unlimited -- the run stops at Max products total instead.

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

Hard cap on total products returned across every search/category/URL entry. 0 = unlimited (still bounded by Max pages per search).

## `resumeFromRunId` (type: `string`):

Paste a previous run id (or its dataset id) to continue that run: products already collected there are skipped, so this run only returns new products (a delta). Leave empty for a normal run. For recurring daily/weekly monitoring of the SAME search instead, use Incremental changes below.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or recurring monitoring. The first run returns all matching products as NEW. Later runs return only NEW, UPDATED and REAPPEARED products by default. Turn on "Emit unchanged products" or "Emit expired products" only when you also want those products returned (and billed). State is kept separately per search/category/URL and per Fetch full product detail/Fetch reviews setup; use State key to name or deliberately share a monitoring campaign. To continue one specific interrupted run instead, use Resume from a previous run above.

## `stateKey` (type: `string`):

Optional. Name this monitoring campaign to keep its state stable, or to deliberately share state across differently-configured runs. Leave empty to let the actor derive a key automatically from the search/category/URL and Fetch full product detail/Fetch reviews settings -- different searches then never mix state with each other.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return products that have not changed since the last run, marked UNCHANGED. This returns -- and bills -- extra rows you already have, so leave it off unless you specifically want the full snapshot every run.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return products that were present in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked search/URL set -- not when Max products/Max pages capped it, or when Resume from a previous run was used. This returns -- and bills -- extra synthetic rows, so leave it off unless you need expiry tracking.

## `proxy` (type: `object`):

The default connection works on every Apify plan, including the free tier. A residential connection is optional and only needed for very large or sustained runs.

## `mcpConnectors` (type: `array`):

Optionally send the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize a connector once under Apify -> Settings -> Integrations, then select it here. The connector receives a condensed, human-readable summary per item (title + key fields), not the full JSON; the complete record stays in the dataset. Leave empty to skip. Supported: Notion (https://mcp.notion.com/mcp), Linear (https://mcp.linear.app/sse), Airtable (https://mcp.airtable.com/mcp), Apify (https://mcp.apify.com).

## `notionParentPageUrl` (type: `string`):

URL (or id) of the Notion page under which item pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on items written to each connector per run. Does not affect the dataset.

## Actor input object example

```json
{
  "mode": "search",
  "searchTerm": "shampoo",
  "specialsOnly": false,
  "brands": [],
  "sortBy": "RELEVANCE",
  "urls": [
    "https://www.dm.de/search?query=shampoo"
  ],
  "fetchDetails": false,
  "fetchReviews": true,
  "maxReviewsPerProduct": 20,
  "maxPages": 0,
  "maxItems": 20,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxy": {
    "useApifyProxy": true
  },
  "maxNotifyListings": 50
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

# 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 = {
    "mode": "search",
    "searchTerm": "shampoo",
    "brands": [],
    "urls": [
        "https://www.dm.de/search?query=shampoo"
    ],
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "proxy": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/dm-de-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 = {
    "mode": "search",
    "searchTerm": "shampoo",
    "brands": [],
    "urls": ["https://www.dm.de/search?query=shampoo"],
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "proxy": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/dm-de-scraper").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "mode": "search",
  "searchTerm": "shampoo",
  "brands": [],
  "urls": [
    "https://www.dm.de/search?query=shampoo"
  ],
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxy": {
    "useApifyProxy": true
  }
}' |
apify call abotapi/dm-de-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=abotapi/dm-de-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/tdCzjv35ZOMyD28fc/builds/PGhxMjAy4IYY3zcsK/openapi.json
