# UK Supermarket Price Scraper: Tesco, Sainsbury's, Asda, Aldi (`yappman/uk-supermarket-price-scraper`) Actor

Current shelf prices from Tesco, Aldi, Sainsbury's and Asda in one schema, with EAN barcodes, Clubcard and Nectar prices and promotions. Search, category or product URLs. Pay only per product returned.

- **URL**: https://apify.com/yappman/uk-supermarket-price-scraper.md
- **Developed by:** [Russell Yapp](https://apify.com/yappman) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 products

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

## UK Supermarket Price Scraper: Tesco, Sainsbury's, Asda and Aldi prices API

This is a UK grocery prices API returning current shelf prices from Tesco, Sainsbury's, Asda and Aldi in one schema, as structured data, searched by keyword, by retailer category, or by product page, and charged only for the products you get back. You get price, Clubcard and Nectar loyalty prices, an EAN barcode and stock per product, for price-comparison tools, competitor monitoring, inflation tracking and AI shopping agents.

### Sample output row

```json
{
  "retailer": "sainsburys",
  "retailerProductId": "3449370",
  "ean": ["5000119000201"],
  "matchKey": "sainsburys british semi skimmed milk 2.27l 4 pint 2270ml",
  "name": "Sainsbury's British Semi Skimmed Milk 2.27L (4 pint)",
  "brand": "Sainsbury's",
  "packSize": "2.27L",
  "price": 1.65,
  "wasPrice": null,
  "unitPrice": { "value": 0.73, "measure": "l" },
  "loyaltyPrice": null,
  "promotionText": null,
  "promotionEndsAt": null,
  "inStock": true,
  "categoryPath": ["Dairy, eggs & chilled", "Milk & cream", "Fresh milk"],
  "url": "https://www.sainsburys.co.uk/groceries/product/sainsburys-british-semi-skimmed-milk-2-27l-4-pint",
  "imageUrl": "https://assets.sainsburys-groceries.co.uk/gol/3449370/image.jpg",
  "rating": { "value": 4.7, "count": 1543 },
  "scrapedAt": "2026-09-23T18:05:00Z"
}
```

The EAN, `matchKey` and ids in this sample are illustrative, not real.

### What you get: the UK supermarket prices dataset

| Retailer | Modes | Barcode (EAN) coverage | Loyalty / promotion fields | Stock |
| --- | --- | --- | --- | --- |
| Tesco | search, category, product | On every row we have seen — Tesco's own product API returns a GTIN for every product | `loyaltyPrice` carries single-item Clubcard prices, e.g. `£1.60 Clubcard Price`; multibuy Clubcard offers and meal deals go in `promotionText` only ( `wasPrice` is always null) | Tesco's own online-availability flag (national, not per store) |
| Sainsbury's | search, category, product | Nearly every row — EANs come from Sainsbury's own product data (codes that fail the check digit are dropped) | `loyaltyPrice` carries the Nectar price ( `price` stays the regular shelf price); `promotionText` for other offers, e.g. `2 for £3` | Sainsbury's own availability flag |
| Asda | search, category, product | About 97% of rows — recovered from Asda's product image id and validated as a GTIN; the rest have none | `wasPrice` carries Rollback was-prices; `promotionText` carries multibuys, e.g. `Any 2 for £4` | Per store, set by the `asdaStoreId` input (default `4565`); prices themselves are national |
| Aldi | search, category, product | None — Aldi publishes no barcode anywhere, so rows carry an empty `ean` and join on `matchKey` | `wasPrice` on in-store markdowns; no loyalty price | Not published — `inStock` is always `null` |

Every row also carries `matchKey`, a normalised brand + name + pack size, so you can join Aldi products to the others even without a barcode.

This Actor is not affiliated with, endorsed by, or partnered with Aldi, Sainsbury's, Asda or Tesco. Retailer names are used only to describe the public product data returned.

### Free daily dataset: UK grocery price index

A fixed basket of 212 staple products, up to three products per retailer from Tesco, Sainsbury's, Asda and Aldi for each of 20 staples (milk, bread, eggs, butter, cheddar, chicken breast, bananas, apples, potatoes, onions, tomatoes, pasta, rice, baked beans, cereal, coffee, tea bags, sugar, orange juice, olive oil). This Actor fetches the basket every morning in product mode and appends the rows to one dataset, so the same products build a price history from 24 September 2026. Rows use the same schema as the Actor's own output, including `ean`, `matchKey`, `loyaltyPrice` and `scrapedAt`; filter or group on `scrapedAt` for a given day's snapshot.

- JSON: `https://api.apify.com/v2/datasets/ynAT9NPps2EdjMOJa/items?signature=MC4xNzkxNDcwNDMyMzg2LjEwV244Nll3OGZYYThIeTN6UkhENA`
- CSV: the same URL with `&format=csv` appended

No account or token needed. Apify's dataset items API `limit`, `offset` and `format` query parameters all apply, for example `&format=csv&limit=1000`.

Free to use and cite; attribution requested as "UK Supermarket Price Scraper (Apify), yappman/uk-supermarket-price-scraper". Prices are national shelf prices as published online by each retailer, collected once a day, provided as is; this Actor is not affiliated with the retailers.

Need other products, categories or intraday prices? Run the Actor yourself with your own `queries`, `categoryUrls` or `productUrls`.

### Tesco, Sainsbury's, Asda and Aldi: what each retailer exposes

**Tesco.** There is no public Tesco API for prices, so this Tesco price scraper is the nearest thing: it reads Tesco's own product API and returns Tesco prices, the single-item Clubcard price and an EAN barcode on every row we have seen. Multibuy and meal-deal Clubcard offers appear only in `promotionText`, and `wasPrice` is always null for Tesco. Tesco prices and stock (`inStock`) are both national; there is no store selection for Tesco.

**Sainsbury's.** This Sainsbury's price scraper reads Sainsbury's API for product data and returns the regular shelf price alongside the Nectar price, with an EAN on nearly every row (codes that fail the check digit are dropped). `loyaltyPrice` carries that loyalty price while `price` stays the regular shelf price; other offers, such as `2 for £3`, go in `promotionText`. Sainsbury's own availability flag sets `inStock`.

**Asda.** This Asda price scraper is also an Asda price tracker for Rollback deals: `wasPrice` carries the previous price whenever a Rollback reduction is shown, and `promotionText` carries multibuys such as `Any 2 for £4`. An EAN barcode is recovered from Asda's product image id on about 97% of rows; the rest carry none. Stock (`inStock`) is set per store by the `asdaStoreId` input (default `4565`), though Asda's prices themselves are national.

**Aldi.** This Aldi price scraper returns Aldi's own in-store prices exactly as published; Aldi exposes no barcode anywhere in its listing API, product API or product pages, so rows carry an empty `ean` array. Instead, every row carries `matchKey`, a normalised brand, name and pack size, so Aldi products join to the other three retailers even without a barcode. Aldi does not publish stock online, so `inStock` is always `null` for Aldi rows.

### Grocery price tracking and other use cases

- Price-comparison apps and deal-tracking channels
- Competitor and retail-analytics price monitoring
- Inflation trackers and academic basket-of-goods research
- AI agents doing UK grocery price or basket lookups

### Input reference

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `retailers` | array of `tesco` / `sainsburys` / `asda` / `aldi` | `["aldi","sainsburys"]` | Which retailers to search. Each runs as its own adapter. Ignored in `category` and `product` mode, where the retailer is inferred from each URL |
| `mode` | `search` / `category` / `product` | `search` | One mode per run |
| `queries` | array of strings | `["milk"]` | Search mode; each query runs against every selected retailer |
| `categoryUrls` | array of URLs | `[]` | Category mode, up to 250 URLs per run; the retailer is inferred from each URL's hostname. Tesco: `/shop/en-GB/browse/<department>/<aisle>/...` as copied from the site, including the `/all` form (the older `/groceries/en-GB/shop/...` form works too). Aldi: `/products/.../k/<id>`. Sainsbury's: `/groceries/browse/.../c:<id>` (or the legacy `/gol-ui/groceries/...` form). Asda: `/groceries/<category>[/<department>/<aisle>/<shelf>]`, including top-level pages like `/groceries/food-cupboard` |
| `productUrls` | array of URLs | `[]` | Product mode; retailer inferred from each URL's hostname |
| `maxItems` | integer | `100` | Hard cap on rows pushed across all retailers; `0` = no cap, bounded only by mode |
| `asdaStoreId` | string | `4565` | Asda store used for stock (`inStock`). Leave blank for store `4565`, the store asda.com uses before a postcode is entered. Asda prices are national, so this does not change prices |
| `outputDataset` | string | (empty) | Advanced: also append every row to this named dataset in your account (created on first use), so repeated runs build history in one place. Rows are charged once, on the run's own dataset. Letters, digits and hyphens only |
| `datasetName` | string | (empty) | Advanced: at the end of a successful run, name the run's own dataset with this name in your account, so it never expires and is easy to find. Placeholders: `{isoWeek}` (ISO week of the run start, UTC, e.g. `2026-W40`), `{date}` (`YYYY-MM-DD`) and `{retailer}` (the retailer when exactly one ran, otherwise `multi`). Letters, digits and hyphens only after expansion, max 63 characters, starting with a letter or digit. Skipped with a warning if the name is already taken, nothing was pushed, the run stopped early (spend cap, a failed retailer) or the run is outside the platform, so a retry can take the name |

#### Example inputs

Search three retailers for two terms, capped at 300 rows:

```json
{
  "retailers": ["aldi", "sainsburys", "asda"],
  "mode": "search",
  "queries": ["milk", "cheddar"],
  "maxItems": 300
}
```

One Asda top-level category:

```json
{
  "mode": "category",
  "categoryUrls": ["https://www.asda.com/groceries/food-cupboard"],
  "maxItems": 500
}
```

Specific product pages:

```json
{
  "mode": "product",
  "productUrls": [
    "https://www.tesco.com/shop/en-GB/products/257265436",
    "https://www.sainsburys.co.uk/groceries/product/cathedral-city-mature-cheddar-cheese-350g",
    "https://www.aldi.co.uk/product/cowbelle-chocolate-milk-000000000000543221"
  ]
}
```

Weekly category snapshot of one retailer, named by ISO week:

```json
{
  "mode": "category",
  "categoryUrls": ["https://www.tesco.com/shop/en-GB/browse/fresh-food/milk-butter-and-eggs/milk/all"],
  "maxItems": 0,
  "datasetName": "snapshot-{retailer}-{isoWeek}"
}
```

A run started on 4 October 2026 would name this run's dataset `snapshot-tesco-2026-W40`.

### Output field reference

One row per product per retailer, same shape for all four.

| Field | Type | Notes |
| --- | --- | --- |
| `retailer` | string | `tesco` / `sainsburys` / `asda` / `aldi` |
| `retailerProductId` | string | The retailer's own id (Tesco `tpnc`, Sainsbury's `product_uid`, Asda item id, Aldi SKU) |
| `ean` | array of strings | Check-digit-validated GTIN-13 strings; may hold several for multipacks; empty where the retailer exposes none (Aldi, and about 3% of Asda rows) |
| `matchKey` | string | Normalised brand + name + pack size. The join key when `ean` is empty, and the recommended way to match the same product across retailers |
| `name` | string | Product name as shown on site |
| `brand` | string or null | Own-label rows carry the retailer's name |
| `packSize` | string or null | e.g. `4x415g`, `2.27L` |
| `price` | number | GBP, current shelf price |
| `wasPrice` | number or null | Previous price when a reduction is shown |
| `unitPrice` | object or null | `{ "value": 0.99, "measure": "kg" }` |
| `loyaltyPrice` | number or null | Single-item Tesco loyalty price (Clubcard) or Sainsbury's loyalty price (Nectar); Asda and Aldi leave this null |
| `promotionText` | string or null | e.g. `2 for £3`, `Any 2 for £4` |
| `promotionEndsAt` | date or null | ISO 8601 when the site exposes it |
| `inStock` | boolean or null | `null` where the retailer does not report stock online (always `null` for Aldi) |
| `categoryPath` | array of strings | e.g. `["Dairy", "Milk", "Semi-skimmed"]` |
| `url` | string | Product page |
| `imageUrl` | string or null | Largest image available |
| `rating` | object or null | `{ "value": 4.6, "count": 212 }` |
| `scrapedAt` | string | ISO 8601, UTC |

### Pricing

Pay per event; platform usage is included in that price, so this is everything you pay:

- **$0.005** once per run (start fee)
- **$2.50 per 1,000 products** returned (**$0.0025** each) on the Free plan — discounts on higher Apify plans

**Worked example.** A run searching 5 terms across all four retailers that returns 300 products costs 300 × $0.0025 + $0.005 = **$0.755**.

Cap spend with Apify's maximum-charge setting on the run — the Actor stops pushing rows once the cap is reached — and/or with the `maxItems` input.

### Limitations

- Aldi prices are the in-store reference prices Aldi publishes online; Aldi reports no stock and no barcode for any product.
- Asda stock (`inStock`) is for a single store, set by `asdaStoreId`; Asda prices themselves are national.
- Sainsbury's rows the retailer itself lists at £0.00 are skipped rather than returned with a fake price.
- Very large Asda categories are capped by Asda's own search at 1,000 products per department; the Actor splits these automatically, but extremely broad top-level categories may still not return every product.
- Public, logged-out data only: no basket, account or delivery-slot data, and no Nectar or Asda Rewards account is used.
- Prices can change during the day; treat `scrapedAt` as the price's timestamp.
- Tesco search results include marketplace-seller and F\&F clothing listings; the Actor skips both and returns only Tesco grocery products.
- Tesco prices and stock (`inStock`) are both national; there is no store selection for Tesco.

### FAQ

**Is there a Tesco API for prices?**
No. Tesco does not publish one; this Tesco price scraper is the nearest thing, reading Tesco's own product API since v0.2, in search, category and product mode. It returns an EAN on every row we have seen, and single-item loyalty pricing in `loyaltyPrice` (multibuy and meal-deal offers appear in `promotionText` instead).

**Does it return Clubcard and Nectar prices?**
Yes. `loyaltyPrice` carries the single-item Clubcard and Nectar prices for Tesco and Sainsbury's; Asda and Aldi leave the field null.

**Can I track Asda Rollback prices?**
Yes. `wasPrice` carries the was-price whenever a reduction is shown, and `promotionText` carries multibuys such as `Any 2 for £4`.

**Does it include Aldi prices?**
Yes, in search, category and product mode, exactly as Aldi publishes them online: no barcode, so join on `matchKey` instead of `ean`.

**Why do Aldi rows have no EAN?**
Aldi does not publish a barcode anywhere in its listing API, product API or product pages. Aldi rows carry an empty `ean` array and a `matchKey` (normalised brand + name + pack size) instead.

**How do I match the same product across retailers?**
Use `ean` where present (Sainsbury's on nearly every row, Asda on about 97%). Where it's missing, or to match Aldi products to the others, use `matchKey`.

**Can I build a supermarket price comparison with it?**
Yes. Match rows on `ean` (or `matchKey` for Aldi) across Tesco, Sainsbury's, Asda and Aldi to build supermarket price comparison data in one schema.

**Which Asda store's stock does this use?**
Whichever store id you set in `asdaStoreId` (default `4565`, the store asda.com uses before a postcode is entered). Asda's prices are national and don't change with the store.

**How do I limit what I spend?**
Set Apify's maximum-charge cap on the run, and/or set `maxItems` to cap the number of rows returned.

**How fresh is the data?**
Every row is scraped live during the run; `scrapedAt` records when. Prices can move during the day, so treat each row as a snapshot.

**Is there a free sample or historical data?**
Yes. See [Free daily dataset: UK grocery price index](#free-daily-dataset-uk-grocery-price-index) above: a fixed 212-product basket across all four retailers, fetched daily since 24 September 2026, no account or token needed.

**Does this use my Sainsbury's or Asda account?**
No. The Actor only reads public, logged-out product data — no login, basket or account information.

After a successful run, please leave a review on this Actor's Store page.

### Changelog

See [CHANGELOG.md](./CHANGELOG.md) for version history.

### For developers

```
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements-dev.txt
.venv/Scripts/python -m pytest
apify run
```

# Changelog

This Actor's version history is a separate document: https://apify.com/yappman/uk-supermarket-price-scraper/changelog.md

# Actor input Schema

## `retailers` (type: `array`):

Which retailers to search. Each selected retailer runs as its own adapter. Ignored in category and product mode, where the retailer is inferred from each URL.

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

One mode per run. 'search' runs each query against every selected retailer. 'category' and 'product' take retailer URLs.

## `queries` (type: `array`):

Keyword searches to run in 'search' mode. Each query runs against every selected retailer.

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

Category page URLs to crawl in 'category' mode. The retailer is inferred from each URL's hostname.

## `productUrls` (type: `array`):

Product page URLs to fetch in 'product' mode. The retailer is inferred from each URL's hostname.

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

Hard cap on rows pushed across all retailers. 0 means no cap, bounded only by mode.

## `asdaStoreId` (type: `string`):

Asda store used for stock (inStock). Leave blank for store 4565, the store asda.com uses before a postcode is entered. Asda prices are national, so this does not change prices. Digits only.

## `outputDataset` (type: `string`):

Advanced. In addition to the run's own dataset, append every row to this named dataset in your account, created on first use, so repeated runs accumulate history in one place. Rows are charged once, on the run's own dataset. Letters, digits and hyphens only.

## `datasetName` (type: `string`):

Advanced. At the end of a successful run, give the run's own dataset this name in your account, so it never expires and is easy to find. Placeholders: {isoWeek} (ISO week of the run start, UTC, e.g. 2026-W40), {date} (YYYY-MM-DD), {retailer} (the retailer when exactly one ran, otherwise multi). Letters, digits and hyphens only after expansion; skipped with a warning if the name is already taken, no rows were pushed, or the run stopped early (spend cap, a failed retailer), so a retry can take the name.

## Actor input object example

```json
{
  "retailers": [
    "aldi",
    "sainsburys"
  ],
  "mode": "search",
  "queries": [
    "milk"
  ],
  "categoryUrls": [],
  "productUrls": [],
  "maxItems": 100
}
```

# Actor output Schema

## `results` (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 = {
    "retailers": [
        "aldi",
        "sainsburys"
    ],
    "mode": "search",
    "queries": [
        "milk"
    ],
    "categoryUrls": [],
    "productUrls": [],
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("yappman/uk-supermarket-price-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 = {
    "retailers": [
        "aldi",
        "sainsburys",
    ],
    "mode": "search",
    "queries": ["milk"],
    "categoryUrls": [],
    "productUrls": [],
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("yappman/uk-supermarket-price-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 '{
  "retailers": [
    "aldi",
    "sainsburys"
  ],
  "mode": "search",
  "queries": [
    "milk"
  ],
  "categoryUrls": [],
  "productUrls": [],
  "maxItems": 100
}' |
apify call yappman/uk-supermarket-price-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yappman/uk-supermarket-price-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/dhjcbRXRxzxHJHcX4/builds/UbzygJWErdx569efh/openapi.json
