# German Supermarket Offers — Aldi, Penny, Edeka, Lidl, REWE (`nerdrx/de-supermarket-offers`) Actor

Weekly offers and prices from 6 German supermarket chains (Aldi Süd, Aldi Nord, Penny, Edeka, Lidl, REWE) in one unified schema: price, old price, discount, unit, validity dates, image. Keyword filter, per-chain limits. Pay per offer.

- **URL**: https://apify.com/nerdrx/de-supermarket-offers.md
- **Developed by:** [Nerdrx](https://apify.com/nerdrx) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 offers

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?

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

## German Supermarket Offers

Unofficial weekly-offer scraper for six German supermarket chains. **Not affiliated with, endorsed by, or officially connected to ALDI SÜD, ALDI Nord, PENNY, EDEKA, Lidl, REWE, Kaufland, or Netto Marken-Discount.** It reads each chain's own public offers page or the JSON endpoint that page itself calls — the same data anyone sees by opening the chain's "Angebote" page in a browser.

### What is German Supermarket Offers?

One Apify actor run that collects this week's advertised offers from ALDI SÜD, ALDI Nord, PENNY, EDEKA, Lidl and REWE and hands them back as a single flat dataset with one row per offer — instead of you writing and maintaining six separate scrapers against six completely different stacks (a REST API, a Next.js payload, a Magnolia REST fragment, a legacy offers service, a leaflet API, and server-rendered HTML).

### What can German Supermarket Offers do?

- Pull the current week's offers from all six chains (or any subset) in one run.
- Give every offer the same shape: price, previous price, discount percent, pack size, base price, validity window, category, image.
- Reconstruct what a chain leaves implicit — e.g. EDEKA publishes a discount but no previous price, so the previous price is computed; ALDI SÜD publishes a previous price but the discount only sometimes.
- Filter by keywords: OR-matched, case-insensitive, and **umlaut/ß-insensitive** — `kaese` matches `Käse` and vice versa, `weissbier` matches `Weißbier`.
- Target a specific store for the chains whose prices are regional (PENNY, EDEKA, ALDI SÜD).
- Keep going when one chain breaks: a failing chain is logged loudly as an error and the run continues with the rest. Individual requests retry twice with backoff and randomized realistic browser headers.
- Interleave the chains round-robin, so a run capped by `maxItems` still contains offers from every chain rather than filling up with whichever chain ran first.

### What data does it extract?

| Field | Type | Description |
|---|---|---|
| `id` | string | Stable, chain-scoped offer id, e.g. `edeka:500000377`. |
| `chain` | string | Chain slug: `aldi-sued`, `aldi-nord`, `penny`, `edeka`, `lidl`, `rewe`. |
| `chainName` | string | Human-readable chain name, e.g. `ALDI SÜD`. |
| `title` | string | Product name as advertised. |
| `brand` | string | null | Brand — only where the chain publishes it separately from the title. |
| `description` | string | null | Advertising sub-line: origin, variety, pack details. |
| `price` | number | null | Offer price in EUR. |
| `priceOld` | number | null | Reference/previous price in EUR, where published or derivable. |
| `discountPercent` | integer | null | Discount in percent, as stated by the chain or derived from `price`/`priceOld`. |
| `unit` | string | null | Pack size, e.g. `500 g`, `0,75 l`, `Stück`. |
| `pricePerUnit` | string | null | Base price exactly as printed, e.g. `1 kg = 3,18 €`. |
| `validFrom` | string | null | First day of the promotion (ISO date). |
| `validTo` | string | null | Last day of the promotion (ISO date). |
| `category` | string | null | Category the chain files the offer under. |
| `imageUrl` | string | null | Product image URL. |
| `storeId` | string | null | The store these prices belong to, for the store-specific chains. |
| `sourceUrl` | string | A page a human can open to see this offer. |
| `scrapedAt` | string | ISO-8601 timestamp this item was scraped. |

### Chains and sources

| Chain | Slug | Source type | Endpoint / page | Store id needed? | robots.txt |
|---|---|---|---|---|---|
| ALDI SÜD | `aldi-sued` | JSON API | `api.aldi-sued.de/v3/product-search` (category `Wochenangebote`) | Optional (`servicePoint`) | Allowed — `aldi-sued.de/robots.txt` only disallows `/tools` and `/*?q=` |
| ALDI Nord | `aldi-nord` | Embedded JSON in HTML | `aldi-nord.de/angebote.html` → `__NEXT_DATA__` → `apiData` → `OFFER_GET` | No (national) | Allowed — `/angebote.html` is not in the disallow list |
| PENNY | `penny` | HTML + JSON API | `penny.de/angebote` (week + categories) → `/.rest/offers/by-category/{week}/{category}?region=…`, markets from `/.rest/market` | Yes (`wwIdent`; a default market is picked otherwise) | Allowed — `penny.de/robots.txt` is `Allow: /` |
| EDEKA | `edeka` | JSON API | `edeka.de/eh/service/eh/offers?limit=…&marketId=…` | Optional (`marketId`; `1` = national list) | Allowed — `/eh/` is not disallowed |
| Lidl | `lidl` | HTML + JSON API | `lidl.de/c/online-prospekte/s10005610` → `endpoints.leaflets.schwarz/v4/flyer?flyer_identifier=…` | No (national leaflet, region 0) | Allowed — none of Lidl's disallow rules cover `/c/` or `/l/prospekte/` |
| REWE | `rewe` | Server-rendered HTML | `rewe.de/angebote/nationale-angebote/<category>/` | No (national offers only) | **Explicitly allowed** — `rewe.de/robots.txt` lists every `/angebote/nationale-angebote/…` path under `Allow:` while disallowing `/restservices/` |

#### Chains that are deliberately *not* included

| Chain | Why it was dropped |
|---|---|
| Kaufland | `kaufland.de/angebote/aktuelle-woche.html` serves a Cloudflare "Verifizierung erforderlich" bot challenge instead of the page. Getting past it would mean defeating bot detection, which this actor does not do. |
| Netto Marken-Discount | Offers are only rendered after a store is chosen in a session, the site runs Akamai Bot Manager, and `netto-online.de/robots.txt` disallows `/INTERSHOP/` (its data endpoints) except for a short whitelist that does not include offers. |

The actor's title mentions Netto and Kaufland because that is the market it addresses; the dataset does not contain them. See `NOTES.md`.

**These are the chains' own public pages and the endpoints those pages call**, not a paid data license. Endpoint shapes, selectors and category lists can change without notice — see `NOTES.md` for exactly what was live-verified and when.

### How to use

1. (Optional) Pick which `chains` to run — all six by default.
2. (Optional) Add `keywords`, e.g. `["Käse", "Kaffee", "Bio"]`. Leave empty to get every offer.
3. (Optional) Set `storeIds` for the regional chains, e.g. `{"penny": "4030085", "edeka": "10000327"}`.
4. (Optional) Adjust `maxItems` (total) and `maxPerChain`.
5. Run it. Results land in the run's dataset; the **Overview** view shows chain, product, price, discount and validity as a table.

Scheduling once a week — early on the day the new week starts (Monday for most chains) — gives you a clean weekly price history. Enable `proxyConfiguration` if you run it often.

### How much does it cost?

This actor is **pay per event**:

| Event | When it is charged | Planned price |
|---|---|---|
| `offer` | Once per offer successfully pushed to the dataset. | $0.002 |

A default run capped at `maxItems: 500` therefore costs at most **$1.00** plus Apify platform compute (the actor is HTTP-only and runs comfortably in 256–512 MB, so compute is a few cents). Nothing is charged for offers that are filtered out by your keywords, for chains that fail, or for the store/category lookup requests.

If you set a maximum charge on the run and it is reached, the actor stops pushing further offers, logs a warning, and exits cleanly — you are never charged for an item that did not make it into the dataset.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `chains` | array of enum | all six | Which chains to scrape. |
| `keywords` | array of string | `[]` | Keep only offers matching at least one keyword (umlaut-insensitive substring match on title, brand, description, category). Empty = keep everything. |
| `storeIds` | object | `{}` | Per-chain store id, e.g. `{"penny": "4030085"}`. PENNY takes a `wwIdent`, EDEKA a market id, ALDI SÜD a `servicePoint`. ALDI Nord, Lidl and REWE publish national offers and ignore it. |
| `maxItems` | integer | `500` | Maximum offers written to the dataset across all chains. |
| `maxPerChain` | integer | `200` | Maximum offers taken from each chain before keyword filtering. |
| `proxyConfiguration` | object | none | Optional proxy for all outbound requests. |

```json
{
  "chains": ["aldi-sued", "penny", "edeka", "rewe"],
  "keywords": ["Käse", "Kaffee"],
  "storeIds": { "penny": "4030085" },
  "maxItems": 300,
  "maxPerChain": 150
}
```

### Output (sample)

```json
{
  "id": "aldi-sued:000000000000230773",
  "chain": "aldi-sued",
  "chainName": "ALDI SÜD",
  "title": "Mandarinen 750 g",
  "brand": "NATUR LIEBLINGE",
  "description": null,
  "price": 1.49,
  "priceOld": 1.79,
  "discountPercent": 16,
  "unit": "0,75 kg",
  "pricePerUnit": "1,99 €/1 kg",
  "validFrom": "2026-08-31",
  "validTo": "2026-09-05",
  "category": "Frischeprodukte im Angebot",
  "imageUrl": "https://dm.emea.cms.aldi.cx/is/image/aldiprodeu/product/jpg/scaleWidth/600/706b9aa7-f1fb-484e-b14a-898a64e945f8",
  "storeId": null,
  "sourceUrl": "https://www.aldi-sued.de/produkte/wochenangebote/k/1588161426582123",
  "scrapedAt": "2026-09-02T10:00:00.000Z"
}
```

### FAQ & support

**Is this official?**
No. This is an unofficial scraper. It is not affiliated with or endorsed by any of the chains named here. Use the data for personal price comparison and research; check each chain's terms before redistributing it.

**Why aren't Kaufland and Netto in the output?**
Kaufland answers with a Cloudflare bot challenge and Netto sits behind Akamai Bot Manager with its data endpoints disallowed in `robots.txt`. Rather than work around either, they are excluded. See the table above and `NOTES.md`.

**Why do some offers have no `priceOld` or `discountPercent`?**
Because the chain does not publish one. Lidl's leaflet feed and REWE's national offer tiles state only the offer price; a "Knaller" or "Aktion" badge is not a reference price, so inventing one would be misleading. EDEKA publishes the discount without the old price, so `priceOld` there is *derived* from `price` and the stated discount — treat it as a reconstruction, not as a figure EDEKA printed.

**Are the prices what I will pay in my local store?**
Only approximately. ALDI Nord, Lidl and REWE (national) offers are country-wide. PENNY, EDEKA and ALDI SÜD prices vary by region or store — pass a `storeId` for those to get the list for a specific store. App-only prices (EDEKA App, Lidl Plus, PENNY Coupons) are not the shelf price and are not separated out in this dataset.

**How accurate are `validFrom` / `validTo`?**
ALDI Nord, EDEKA and Lidl publish machine-readable dates and those are passed through. PENNY's dates are derived from its week id plus the promotion period ("ab Montag" = Mo–Sa, "ab Donnerstag" = Do–Sa). ALDI SÜD publishes no dates on its API at all, so the current Monday–Saturday week is used. REWE's come from the "Diese Woche" tab text. `NOTES.md` lists which is which.

**REWE returned fewer categories than usual.**
REWE rate-limits by IP and can answer 403 for some categories when the run comes from a datacenter address. Each request already retries twice with backoff and browser-realistic headers; if categories are still blocked the run logs one warning naming them and returns the rest. Enabling `proxyConfiguration` with residential proxies is the reliable fix.

**Why does EDEKA return only ~20 offers?**
Because that is the whole national list. EDEKA's service reports the total result-set size in `anzahl`, and it reads 20 regardless of the `limit` you send — there is no paging to exhaust. Pass a real `storeIds.edeka` market id (from EDEKA's market search) to get that market's larger list.

**Something broke / a chain returns nothing.**
Chain sources change. Open an issue with the run id and the log line for the failing chain. Individual chain failures never abort the run — you still get the other chains' data.

# Actor input Schema

## `chains` (type: `array`):

Which supermarket chains to scrape in this run. All six working chains are selected by default.

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

Only keep offers whose title, brand, description or category contains at least one of these keywords. Matching is case-insensitive and umlaut-insensitive, so "Kase", "Käse" and "Kaese" all match each other. Leave empty to keep every offer.

## `storeIds` (type: `object`):

Optional per-chain store id, e.g. {"penny": "4030085", "edeka": "10000327"}. PENNY expects a wwIdent, EDEKA a market id from its market search, ALDI SÜD a servicePoint. ALDI Nord, Lidl and REWE publish national offers only and ignore this.

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

Maximum number of offers to output in this run, across all chains combined.

## `maxPerChain` (type: `integer`):

Maximum number of offers to take from each individual chain before the keyword filter is applied.

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

Optional proxy for all outbound requests. Recommended if you run this on a schedule, to reduce the chance of rate limiting.

## Actor input object example

```json
{
  "chains": [
    "aldi-sued",
    "aldi-nord",
    "penny",
    "edeka",
    "lidl",
    "rewe"
  ],
  "keywords": [],
  "storeIds": {},
  "maxItems": 500,
  "maxPerChain": 200,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `resultsCsv` (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 = {
    "chains": [
        "aldi-sued",
        "aldi-nord",
        "penny",
        "edeka",
        "lidl",
        "rewe"
    ],
    "keywords": [],
    "storeIds": {}
};

// Run the Actor and wait for it to finish
const run = await client.actor("nerdrx/de-supermarket-offers").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 = {
    "chains": [
        "aldi-sued",
        "aldi-nord",
        "penny",
        "edeka",
        "lidl",
        "rewe",
    ],
    "keywords": [],
    "storeIds": {},
}

# Run the Actor and wait for it to finish
run = client.actor("nerdrx/de-supermarket-offers").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 '{
  "chains": [
    "aldi-sued",
    "aldi-nord",
    "penny",
    "edeka",
    "lidl",
    "rewe"
  ],
  "keywords": [],
  "storeIds": {}
}' |
apify call nerdrx/de-supermarket-offers --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,nerdrx/de-supermarket-offers"
        }
    }
}

```

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/QC0LacmGD7ICfCAU8/builds/9sKqqdifAE4bfvy56/openapi.json
