# Pick n Pay Scraper: South Africa Grocery Prices & Promotions (`hexagone-data/picknpay-scraper`) Actor

Scrape pnp.co.za categories: price in ZAR, crossed-out price, price per kg, every promotion (SAVE, Smart Shopper), stock, rating; optional product pages for brand, barcode, ingredients, allergens. Pay per result.

- **URL**: https://apify.com/hexagone-data/picknpay-scraper.md
- **Developed by:** [Hexagone Data](https://apify.com/hexagone-data) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$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.

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

## Pick n Pay Scraper — South African Grocery Prices & Promotions

Collect product information from **pnp.co.za** (Pick n Pay, one of South Africa's biggest supermarket chains). Paste category URLs and get every product of the category, paginated to the end of the list, with prices in South African rand, promotions, ratings, stock and the price context the site applied. Enable **Open each product page for brand, barcode and ingredients** to also get the consumer brand, barcode, description, ingredients and allergens of every delivered product.

Use dated exports to track an assortment or monitor promotions over time.

### Try it with 20 products

1. Paste a category URL into **Start URLs**.
2. Set **Maximum number of products** to `20`.
3. Start the Actor and open its dataset.
4. Read **Run summary** before exporting JSON, CSV or Excel: a successful process can still contain incomplete URLs or check warnings.

For the Apify API, a valid input for the steps above is:

```json
{
  "startUrls": [{"url": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840"}],
  "maxItems": 20
}
```

The form's product-count prefill is 20, while the schema default is 500. Set `maxItems` explicitly in API calls; `0` removes the product-count limit. Categories hold hundreds to thousands of products (Milk, Dairy & Eggs: ~980 at the time of writing): start small.

### What to paste

| Input | Data | Notes |
|---|---|---|
| Category `https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840` | Every product of the category, paginated to the end of the list (72 products per page) | The category code is the last part of the URL. Sub-categories have their own `/c/…` page and can be pasted too. |

You can paste several categories; they are scraped one after the other and deduplicated within the run. Anything else — the home page, search pages, product pages, another domain, URLs with parameters — is rejected with a reason and never blocks the other URLs.

### Supplier is not the brand — and how to get the brand

The category listings carry `supplier`: the **supplier/distributor registered by Pick n Pay** (`brandSellerId`), which is regularly **not** the consumer brand shown on the page ("LACTALIS SA" is the supplier of Parmalat products, "WINDMEUL EIERBOERE P/L" of Humpty Dumpty). Treat `supplier` as provenance data, never as the brand.

The **consumer brand** lives on each product's page, which the Actor can open for you: tick **Open each product page for brand, barcode and ingredients** (`includeDetails`). For every delivered product (within your `maxItems`), one extra request fills `brand`, `barcode` (usually EAN-13), `description`, `ingredients`, `allergens`, `features`, `certifications` and `categoryPath`. Without the option those fields are `null` — they do not exist in the category listings.

#### Complete promotions need the details option too

**Without the details option, the promotion fields can be incomplete.** The category listing caps its promotions at one per product and sometimes omits one entirely: on 25 October 2026, 12 of 150 measured products had at least one more promotion on their product page (including a public "2 For R65.00" SAVE special that the listing did not show, and one promotion created the day before). Prices are not affected — they matched on all 150 products.

With the option enabled, the promotion fields (`promotions`, `promotion`, `promoPrice`, `promotionType`, `promotionRequiresLoyaltyCard`, `promotionEndDate`) and `inStock` come from the product's own page: promotions are complete there, and the stock is what the page displays. The `detailsSource` field says which it is: `listing` (potentially incomplete promotions, listing stock) or `productPage` (complete promotions, page stock). If you analyze promotions or stock, enable the option and filter on `detailsSource: "productPage"`. `promotionsSource` is an alias of `detailsSource` with the same value, kept so existing exports are not broken.

Without the option, **stock comes from the listing too and can disagree with the product page**: on 25 October 2026, 1 of 19 weighed items was shown in stock by the listing while its page said "Currently out of stock" (and 12 of 150 products had at least one promotion missing from the listing).

#### Allergens are a list

`allergens` is a **list of texts**, exactly as the product page shows them (for example `["Milk", "Soya"]` for Rama 37% Fat Spread, measured 25 October 2026). It is `null` when the page shows none. `features` (marketing highlights) and `certifications` (e.g. Kosher, Halaal) follow the same shape, and `allergenWarnings` carries the page's separate "Allergen Warnings" section the same way — never merged into `allergens`.

The option costs **about 2 extra seconds per delivered product** (one request per product, 2 seconds apart) and changes nothing to the pricing: you are still charged per delivered product, one dataset event each. A product page that fails (unknown code, refusal, unreadable response) never blocks anything: the product is delivered without details, and the run summary counts `detailPages` and `detailErrors`; if every product page fails, a check warning says so.

### Prices are the site's default-store prices

The Actor browses like a signed-out visitor, and the site then applies its **default store** (`WC21`) to every price. The store applied is written in each record's `priceContext`. These are **not** the prices of a store you chose, and they are not a national average: compare exports only with that context in mind. Pick n Pay's API refuses store codes it does not know, so no store can be selected.

### Promotions and Smart Shopper prices

`price` is the price the site displays to a visitor **without a loyalty card** — when the site marks the price as a promotion, `oldPrice` is the crossed-out price. **A promotion can exist without changing `price`**: on 25 October 2026, 2 of 991 real products carried a public SAVE offer phrased "1 For R39.99" while their `price` stayed at 47.99 (`promoPrice` stays null because the offer's text is not a plain "R…" price — see `promotions` for the offer itself). **`price` is per kg when `priceUnit` is `"kg"`** (loose, weighed items).

A product can carry one or several promotions. `promotions` lists every promotion the record's source knows (each with `text`, `type`, `requiresLoyaltyCard`, `startDate`, `endDate`); the flat fields `promotion`, `promotionType`, `promotionRequiresLoyaltyCard` and `promotionEndDate` describe the **first** one, and `promotions` is an empty list when there is none — see the complete-promotions section above for what each source covers.

**The same offer can appear twice in `promotions`: once as a public offer and once as a loyalty-card offer.** Pick n Pay's API sometimes files one offer both as `SAVE` (public, `requiresLoyaltyCard: false`) and as `SMART_SHOPPER` (card, `requiresLoyaltyCard: true`), with the same text — e.g. Stork "2 For R55.00". This is not rare: 2 of 22 products with several offers on 28 October 2026, 4 of 15 on 25 October 2026. The Actor delivers both entries as the API publishes them, so if you filter "card-only offers", check whether the same text also exists as a public offer. The flat fields (`promotion`, `promoPrice`, `promotionType`, `promotionRequiresLoyaltyCard`, `promotionEndDate`) describe the **first** offer delivered, in the API's order (in the cases measured, the public `SAVE` entry came first); they say nothing about the second entry.

**`promoPrice` can be a loyalty-card price — always check `promotionRequiresLoyaltyCard` before using it.** When a promotion text is a plain price ("R89.99"), `promoPrice` holds that number, but most such promotions on pnp.co.za are `SMART_SHOPPER`: that price is reserved to holders of the Smart Shopper card, and `promotionRequiresLoyaltyCard` is `true`. It can even sit **above** the public promotion price (Humpty Dumpty Large Eggs 30, seen 25 October 2026: public promotion price R69.99 while the Smart Shopper price was R74.99). A `SAVE` promotion gives `promotionRequiresLoyaltyCard: false`; `WEB_DEAL` and unknown types give `null` because they do not establish whether a loyalty card is required. Multi-buy and combo texts ("2 For R34.00", "Combo For R49.99") give `promoPrice: null`: their unit price is never calculated.

In short: to price a product for the general public, use `price`; `promoPrice` is only the right number when `promotionRequiresLoyaltyCard` is `false` (or when your use case is explicitly the Smart Shopper price).

### Output

| Field | Meaning |
|---|---|
| `productCode` | Pick n Pay product code, e.g. `000000000000349246_CS`. |
| `url`, `image` | Public product page and main product image. |
| `name` | Product name as shown by the site (consecutive spaces in the site's internal label are collapsed to one, nothing else changes). |
| `supplier` | Supplier/distributor registered by Pick n Pay (`brandSellerId`) — not always the consumer brand; see the supplier section. |
| `promotion`, `promotionType`, `promotionRequiresLoyaltyCard`, `promotionEndDate`, `promoPrice`, `promotions` | The product's promotions, described by `detailsSource` (alias `promotionsSource`): from the listing (capped at one, can omit some) or from the product page (complete, details option). See the promotion section. |
| `detailsSource` | `listing` or `productPage`: where the promotions **and the stock** of this record come from. |
| `brand`, `barcode`, `description`, `ingredients`, `allergens`, `allergenWarnings`, `features`, `certifications`, `categoryPath` | Details from each product page: only filled when the details option is enabled, else null. `allergens`, `allergenWarnings`, `features` and `certifications` are lists; `categoryPath` is the product page's category path (the always-filled `category` is the listing page's). |
| `price`, `priceUnit`, `currency`, `priceDisplay` | Price in rand, always `ZAR`, and the price exactly as the site formats it (`R97.99`). **`price` is per kilogram when `priceUnit` is `"kg"`** (loose, weighed items — the site shows e.g. "R149.99/kg"); `priceUnit` is null for a per-item price and applies to `oldPrice` too. |
| `oldPrice` | Crossed-out price on promoted products, else null. |
| `inStock` | Whether the site shows the product in stock for the default store. |
| `rating`, `reviewCount` | Average rating out of 5 and review count; most products carry none (`rating: null`, `reviewCount: 0`). |
| `quantityType` | `NORMAL` or `RESTRICTED_ALLOWED_QUANTITY` (purchase limit the site applies). |
| `category` | Category path of the listing page, e.g. `All Products > Milk, Dairy & Eggs`. |
| `priceContext` | Store the site applied to a signed-out visitor (see above). |
| `position`, `page`, `sourceUrl`, `scrapedAt` | Collection provenance. `scrapedAt` is when the category listing was read: the time of `price`, `oldPrice`, `priceDisplay` and the other listing fields. |
| `detailsScrapedAt` | When this product's page was read (details option): the time of `promotions`, `inStock` and the page details, which can be read up to tens of minutes after the listing on large categories. `null` when the record carries no product-page data (`detailsSource: "listing"`). |

A field the site does not publish is `null`, never an empty string.

`priceContext` (an object) and `promotions` (a list of objects) aside, records are flat. Account for both when transforming JSON or choosing spreadsheet columns. Retain `scrapedAt` with your exports: this Actor returns observations, not a maintained history of price changes.

#### Real output example

The following record is a **real record delivered by build 0.1.8** (run `yeRafCkwWJP0Im2c0`, collected on **25 October 2026**, details option enabled; taken as-is from the run's dataset, except `detailsScrapedAt`, a field added in build 0.1.15 whose value here is illustrative). It demonstrates the format, not today's price or stock.

```json
{
  "productCode": "000000000000251804_EA",
  "url": "https://www.pnp.co.za/All-Products/Milk%2C-Dairy-%26-Eggs/Butter-%26-Margarine/Butter-%26-Butter-Spreads/springbok-unsalted-butter-500g/p/000000000000251804_EA",
  "name": "Springbok Unsalted Butter 500g",
  "supplier": "CLOVER SA LTD",
  "brand": "SPRINGBOK",
  "barcode": "6001299810213",
  "description": "Enjoy the delicious taste of Clover Springbok Unsalted Butter in your baking, cooking, or spread on bread.",
  "ingredients": "Cream (cow’s milk)",
  "allergens": [
    "Milk"
  ],
  "allergenWarnings": null,
  "features": [
    "Creamy texture",
    "Ideal for precise baking measurements",
    "Enhances the flavour and texture of baked goods",
    "Versatile for various recipes",
    "Consistent quality and taste"
  ],
  "certifications": [
    "Kosher",
    "Halaal"
  ],
  "categoryPath": "All Products > Milk, Dairy & Eggs > Butter & Margarine > Butter & Butter Spreads",
  "price": 99.99,
  "priceUnit": null,
  "currency": "ZAR",
  "priceDisplay": "R99.99",
  "oldPrice": null,
  "promotionsSource": "productPage",
  "detailsSource": "productPage",
  "inStock": true,
  "rating": null,
  "reviewCount": 0,
  "image": "https://cdn-prd-02.pnp.co.za/sys-master/images/haa/hca/11602819645470/silo-product-image-v2-11Mar2025-180120-6001299810213-Straight_on-296887-216_400Wx400H",
  "quantityType": "NORMAL",
  "category": "All Products > Milk, Dairy & Eggs",
  "priceContext": {
    "store": "WC21",
    "note": "default store for anonymous visitors"
  },
  "position": 36,
  "page": 2,
  "sourceUrl": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840",
  "scrapedAt": "2026-10-25T03:59:23+00:00",
  "detailsScrapedAt": "2026-10-25T04:02:11+00:00",
  "promotion": "Collectaboks: Buy 2 For R165.00",
  "promoPrice": null,
  "promotionType": "SMART_SHOPPER",
  "promotionRequiresLoyaltyCard": true,
  "promotionEndDate": "2026-11-08T21:59:59+0000",
  "promotions": [
    {
      "text": "Collectaboks: Buy 2 For R165.00",
      "type": "SMART_SHOPPER",
      "requiresLoyaltyCard": true,
      "startDate": "2026-10-22T22:00:00+0000",
      "endDate": "2026-11-08T21:59:59+0000"
    }
  ]
}
```

Here `price` 99.99 is the price every visitor pays, while the promotion is a Smart Shopper offer: `promotionRequiresLoyaltyCard` is **deduced from the promotion's type as Pick n Pay's API files it** (`SMART_SHOPPER` means the loyalty card is required) — the site's page can display the same offer differently, without the card mention — this happens with offers the API publishes twice, public and card (see the promotion section: 2 of 22 multi-offer products on 28 October 2026, 4 of 15 on 25 October 2026) — so treat the flag as the API's classification rather than a screenshot of the page. Without the details option this record would carry `detailsSource: "listing"` with no brand, barcode, description, ingredients, allergens, features or certifications, and its single listing promotion.

### Run summary

The **OUTPUT** record (and its readable **SUMMARY** page) tells, for every pasted URL, how many products were delivered and why a URL delivered nothing (`no_products`, `not_found`, `blocked_by_site`, …), plus the `anomalies` list — empty when the automatic checks found nothing unusual. A listing that stops before the total the site announces is delivered but reported as `partial_collection`, with an anomaly saying how many announced products are missing. Each URL's product count is the number of records actually written to the dataset: when your maximum charge per run (or a partial acceptance by the platform) stops the delivery, the URL is `stopped_at_limit` with "N of M products delivered", never `done`. A first page that answers but is not a product listing (an HTML page, an error message) is reported as `unrecognized_response` ("the site may have blocked the request"), not as an empty category. A product with unexpected data in the site's listing is skipped (not delivered, not charged) and counted in an `ANO_PRODUIT_ECARTE` anomaly; the rest of the category is delivered and the URL is `partial_collection`. An invalid URL never blocks the others; a URL that really fails is reported, and the run fails only if nothing at all could be collected.

### Pricing

**$1.50 per 1,000 delivered products**, or **$0.03 for 20 products** and **$1.50 for 1,000**. You are charged per product delivered to the dataset (the `apify-default-dataset-item` platform event); there is no separate Actor start fee, and standard Apify platform usage is included in this price. A run delivering no products incurs no product-result charge. Set a maximum product count (`maxItems`) or a maximum charge for your run — both stop the collection and the charges.

### Known limits

- **Default store only.** No store can be selected; see the price-context section above.
- **The site's search engine caps results at 10 000.** A listing that announces exactly 10 000 products may hold more than the engine reports: coverage above 10 000 products is impossible through a single query, and the run summary warns about it (`ANO_PLAFOND_MOTEUR`) instead of failing.
- **Categories only.** Product pages, search and the home page are rejected as start URLs: records come from the category listings, and the listing carries neither the brand, nor the barcode, nor complete promotions, nor the page's stock — enable the details option for those (see the supplier and promotion sections).
- Records are deduplicated by `productCode` within a run — a protection should a listing ever repeat a product across pages (no such repeat was measured: 0 on the captured pages and on 991 real products).

### Proxy

Not needed by default. Only enable the proxy configuration if your runs get blocked; the Actor then retries through it, and as a last resort through Apify datacenter then South-Africa residential proxies.

# Actor input Schema

## `startUrls` (type: `array`):

pnp.co.za category pages to scrape, e.g. https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840 (the category code is the last part of the URL). Other pages (home, search, product pages) are rejected with a reason.

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

Stops after this many unique products across all start URLs. 0 = no limit. A category holds hundreds to thousands of products: start small.

## `includeDetails` (type: `boolean`):

For every delivered product, the Actor opens its product page once (about 2 extra seconds per product) and fills brand, barcode, description, ingredients, allergens, allergenWarnings, features, certifications and categoryPath; promotions and stock then come from that page too (complete promotions, page stock — see detailsSource). Without this option those fields stay null and the category listing is the source: it carries neither the brand nor the barcode, caps promotions at one and can omit some, and its stock can differ from the page. Products are charged the same either way.

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

Not needed by default. Only enable it if your runs get blocked.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840"
    }
  ],
  "maxItems": 20,
  "includeDetails": false,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `records` (type: `string`):

All product records, one per unique product: records are deduplicated by productCode across all start URLs, so a product listed in several pasted categories is delivered once.

## `prices` (type: `string`):

Price view: unique products with current price, crossed-out price, promotion (with loyalty-card flag) and stock.

## `summary` (type: `string`):

Readable per-URL summary: status, products delivered, reason for any URL that returned nothing.

## `summaryJson` (type: `string`):

The same summary as JSON (OUTPUT record), for programmatic use.

# 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 = {
    "startUrls": [
        {
            "url": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840"
        }
    ],
    "maxItems": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("hexagone-data/picknpay-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 = {
    "startUrls": [{ "url": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840" }],
    "maxItems": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("hexagone-data/picknpay-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 '{
  "startUrls": [
    {
      "url": "https://www.pnp.co.za/c/milk-dairy-and-eggs-423144840"
    }
  ],
  "maxItems": 20
}' |
apify call hexagone-data/picknpay-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hexagone-data/picknpay-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/vWvzovLHOXLe3pRXP/builds/0mE78XdcsqWg247Ut/openapi.json
