# Pick n Pay Scraper - South African Grocery Prices & Promotions (`superslowsloth/picknpay-products`) Actor

Pick n Pay scraper: pnp.co.za product search by term or category to one row per product with price in ZAR, was-price and savings, Smart Shopper promotions, badges, stock, rating, unit price, category path and image. For South African grocery price and promotion watching.

- **URL**: https://apify.com/superslowsloth/picknpay-products.md
- **Developed by:** [Superslow Sloth](https://apify.com/superslowsloth) (community)
- **Categories:** E-commerce, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.05 / 1,000 product scrapeds

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 and promotions

This Pick n Pay scraper reads the product search of [pnp.co.za](https://www.pnp.co.za)
and returns one flat row per product: the **shelf price in rand (ZAR)**, the
was-price and saving when the price is marked down, the Smart Shopper offers
attached to the product (`Combo For R49.99`, `3 For R65.00`) with their end date,
the tile badges, whether it is in stock, rating and review count, the price per
kg for products sold by weight, the category path, the supplier, the product link
and the photo. Search by **term**, by **category**, or both, for any Pick n Pay
store. Run it on a schedule and diff the dataset to get a **South African grocery
price and promotion watch**: which milk, bread or braai-pack went on promotion,
what it cost in the Eastern Cape against the Western Cape, what dropped.

No account, no API key, no login, no browser. One request returns up to 100
products.

### Input

| Field | Notes |
|---|---|
| `searchTerms` | What to search for, one per line (`milk`, `coca cola`, `basmati rice`). Each term is a search of its own, and its rows carry it in `search_term`. Leave empty to read whole categories. |
| `categories` | Restrict the search to categories, or list whole categories when there are no search terms. One per line: a top-level name (`bakery`, `milk-dairy-and-eggs`, `liquor-store`), a category code from a pnp.co.za address (`milk-dairy-and-eggs-423144840`, or a sub-category such as `cheese44805192`), or the address itself (`https://www.pnp.co.za/c/bakery-423144840`). With search terms, every term is searched inside every category. |
| `onPromotionOnly` | Keep only products Pick n Pay lists under its **On Promotion** filter. Default off. |
| `sort` | `relevance` (best match, the default), `price-asc` or `price-desc`. It is also the order rows are written in. |
| `storeCode` | The Pick n Pay store whose prices are read, `WC44` by default. Prices differ by store (see below). A code Pick n Pay does not know fails the run with an error. |
| `maxItems` | Products written in total, across all searches (default 100, up to 10,000). |
| `maxItemsPerSearch` | Optional limit for each search term or category, so one broad term cannot use the whole `maxItems` allowance. |
| `proxyConfiguration` | Apify proxy with automatic datacenter exits by default. |

Example input:

```json
{
  "searchTerms": ["milk", "coca cola"],
  "categories": ["milk-dairy-and-eggs"],
  "onPromotionOnly": true,
  "sort": "price-asc",
  "storeCode": "WC44",
  "maxItems": 100,
  "maxItemsPerSearch": 50
}
```

The top-level categories on 2026-11-05: `food-cupboard`, `personal-care-and-hygiene`,
`liquor-store`, `chocolates-chips-and-snacks`, `outdoor-and-diy`, `beverages`,
`household-and-cleaning`, `home-and-appliances`, `health-and-wellness`, `stationery`,
`pet-care`, `milk-dairy-and-eggs`, `frozen-food`, `baby`, `coffee-tea-and-hot-drinks`,
`electronics-and-office`, `toys-and-games`, `fresh-fruit-and-vegetables`, `bakery`,
`deli-and-party`, `fresh-meat-poultry-and-seafood`, `ready-meals-and-desserts`,
`flowers-and-plants`, `office-equipment`.

### Output

Prices are numbers in South African rand; `currency` is `ZAR`. **One row is one
selling unit**: a six-pack case of milk and a single bottle of the same milk are
two products with two `code`s. The same `code` is unique within one search.

| Field | Notes |
|---|---|
| `search_term`, `category`, `sort`, `position`, `store_code` | What the row was found by: the term (null for a category listing), the category code the search was restricted to (null for none), the sort order, the place in that listing counting from 1, and the store whose prices these are. Relevance order drifts a little between requests. |
| `code`, `base_code`, `unit_code` | Pick n Pay's id of the selling unit (`000000000000214534_EA`), the part before the underscore (the same for every selling unit of one product), and the part after it: seen as `EA` (a single), `CS` (a case), `CK` (a pack) and `KGM` (sold by the kilogram). |
| `name`, `url`, `image_url` | The name as Pick n Pay prints it, the product page, and the 400 x 400 photo (null when there is none). |
| `supplier` | The supplier Pick n Pay lists the product under (`TIGER BRANDS`, `PICK N PAY` for its own range). The seller of record, not always the brand on the label. |
| `category_path` | The category segments of the product address, top level first (`["Bakery", "Cakes & Treats", "Cakes"]`). |
| `currency` | `ZAR`. |
| `price` | The shelf price for the selling unit, already net of a promotion applied to the price itself. For a product sold by weight (`price_is_average` is true) it is Pick n Pay's estimate for one pack. |
| `was_price`, `savings` | The price before the markdown and the rand saving, when Pick n Pay prints them. **Null when it does not**, never 0. |
| `on_promotion` | True when Pick n Pay marks the shelf price as a promotion price. It is what Pick n Pay says, not a comparison against price history. Many Smart Shopper offers leave the shelf price alone and show only in `promotions`. |
| `price_is_average` | True for products sold by weight, where `price` is an estimate for one pack. |
| `unit_price`, `unit_price_per` | The price per unit of measure when Pick n Pay prints one (79.99 per `1kg`). Null for both when it prints none. |
| `promotions`, `promotion_ends` | The promotion messages attached to the product as Pick n Pay words them (`Combo For R70.00`, `R17.49`, `Collectaboks: Buy 2 For R90.00`), and the earliest end date among them (`YYYY-MM-DD`, UTC). Mostly Smart Shopper (loyalty) offers. Empty list and null when there are none. |
| `badges` | The text badges on the product tile (`SAVE`, `SMART SHOPPER`, `APP DEAL`, `Online Only`). Empty when none. |
| `in_stock`, `stock_status` | Whether Pick n Pay reports the product in stock in this store, and its status word (`inStock`, `outOfStock`). Out-of-stock products are still listed, with their price. |
| `rating`, `review_count` | The average star rating (1 to 5) and the number of reviews. **Rating is null for a product with no reviews** (about 99% of rows: most products have none); `review_count` is then 0. |
| `sponsored` | True when the listing is a paid placement. |
| `scraped_at` | When the row was read, UTC. |

Example row (real values, 2026-11-05, the `bakery` category on promotion, store `WC44`):

```json
{
  "search_term": null,
  "category": "bakery-423144840",
  "sort": "relevance",
  "position": 2,
  "store_code": "WC44",
  "code": "000000000000214534_EA",
  "base_code": "000000000000214534",
  "unit_code": "EA",
  "name": "PnP Dessert Topped Buns 6 Pack",
  "url": "https://www.pnp.co.za/All-Products/Bakery/Cakes-%26-Treats/Cakes/pnp-dessert-topped-buns-6-pack/p/000000000000214534_EA",
  "image_url": "https://cdn-prd-02.pnp.co.za/sys-master/images/h04/hba/11470151876638/silo-product-image-v2-21Aug2024-180128-2005523000000-Straight_on-242177-1260_400Wx400H",
  "supplier": "PICK N PAY",
  "category_path": ["Bakery", "Cakes & Treats", "Cakes"],
  "currency": "ZAR",
  "price": 22.99,
  "was_price": 25.99,
  "savings": 3.0,
  "on_promotion": true,
  "price_is_average": false,
  "unit_price": null,
  "unit_price_per": null,
  "promotions": [],
  "promotion_ends": null,
  "badges": ["SAVE"],
  "in_stock": true,
  "stock_status": "inStock",
  "rating": null,
  "review_count": 0,
  "sponsored": false,
  "scraped_at": "2026-11-04T20:11:43Z"
}
```

#### Honest nulls, and what the search does not carry

A field the source did not give is `null`, never `0` or `""`. `promotions`,
`badges` and `category_path` are empty lists when there are none.

- **Prices are per store.** The same product costs different amounts in different
  stores: 6 x 1L UHT milk was R97.99 in `WC44` and R99.99 in `EC01` on 2026-11-05.
  Many store codes do not exist (`GP01`, `KZ01` and `FS01` were refused); the run
  fails with an error naming the code rather than returning another store's prices.
- **The search is fuzzy.** A term Pick n Pay has no product for still returns loosely
  related products (`zzqqxx` returned 16, cucumber, tomatoes and bananas first). A term inside a
  category that has none of it returns nothing (`laptop` in `bakery`), is reported in
  the log and costs nothing. Check `search_term` and `name` when a term is unusual.
- **A term does not contain `:`.** It separates the filters in Pick n Pay's query, so
  inside a term it is read as a space.
- **Promotions are what the search shows.** `promotions` and `promotion_ends` come from
  the offers attached to the product in the search result; they do not say whether a
  Smart Shopper card is needed. `on_promotion` follows the shelf price. The
  `onPromotionOnly` filter is Pick n Pay's own **On Promotion** filter, which also lists
  products whose only promotion is a Smart Shopper offer.
- **No brand, ingredients or nutrition.** The search result carries the supplier and
  the product tile, not the product page's description, brand, ingredients or
  nutrition table. This actor does not read the product pages.
- **At most 10,000 products per search.** Pick n Pay's search ends at 10,000 results
  (100 pages of 100); the log names a search that has more and says to narrow it by
  category or by the promotion filter.
- **A product is written once per search.** A repeat the live order shows on a later
  page is not written or charged again. The same product found by two different search
  terms is written once for each, with its own `search_term`.

### Pricing

Pay per event:

- **$0.002** per run (`actor-start`), charged only after your input is
  validated, so a run that fails on bad input costs nothing.
- **$0.00105** per product written (`product-scraped`)

A row that is not written is not charged: a repeat of a product the same search
already returned, and a search that fails, are free.

### How it fetches

pnp.co.za is a storefront on SAP Commerce (Hybris) whose pages call its own API.
This actor calls the same one: `POST https://www.pnp.co.za/pnphybris/v2/pnp-spa/products/search`
with the search in the query string (`query=milk:relevance`, `pageSize=100`,
`currentPage=0`, `storeCode=WC44`) and an `Accept: application/json` header. It asks for
`fields=products(FULL),pagination(DEFAULT)`, which is the products and the pager only:
the default reply adds every filter facet of the search and is 298 KB for two
products. A browser's own `Accept` header is answered with XML, so the header is sent
explicitly. The same address fetched with GET is refused (HTTP 400: Pick n Pay reads
`search` as a product code).

Things worth knowing, all measured on 2026-11-05:

- **Reachable from Apify.** The POST was answered with JSON 10 of 10 times through
  Apify's datacenter proxy, 10 of 10 from the platform's own address and 10 of 10
  through residential (a throwaway probe run on the Apify platform, a browser TLS
  fingerprint, the same request this actor makes). A refused request is retried from a
  fresh address.
- **Paging.** 100 products per page at most (a larger `pageSize` is answered with 100)
  and the reply says how many results and pages there are. A request for a small
  `maxItems` asks for a small page. A page past the end is HTTP 200 with no products.
- **Categories.** A term inside a category is `milk:relevance:category:<code>`; a whole
  category with no term is `:relevance:allCategories:<code>`. The two forms are not
  interchangeable on Pick n Pay's side, and this actor uses the right one for each. An
  unknown category code fails the run.
- **Sort and promotion filter.** `price-asc` and `price-desc` reorder the whole result;
  the On Promotion filter narrowed `milk` from 1,249 to 304 products.
- **Weight.** About 1.9 KB per product, so a 30-product run is one request and a
  second or two.

If Pick n Pay changes the API or its reply, the run fails with a message instead of
returning empty rows.

# Actor input Schema

## `searchTerms` (type: `array`):

What to search pnp.co.za for, one per line (milk, coca cola, basmati rice). Each term is a search of its own and its rows carry it in search_term. Pick n Pay's search is fuzzy: a term it has no product for still returns loosely related products. Leave empty to read whole categories.

## `categories` (type: `array`):

Restrict the search to categories, one per line, or list a whole category when there are no search terms. A top-level name (bakery, milk-dairy-and-eggs, liquor-store), a category code from the pnp.co.za address (milk-dairy-and-eggs-423144840, or a sub-category such as cheese44805192), or the address itself (https://www.pnp.co.za/c/bakery-423144840). With search terms, every term is searched inside every category. Leave empty to search the whole store.

## `onPromotionOnly` (type: `boolean`):

Keep only products Pick n Pay lists under its On Promotion filter (about a quarter of a typical search). Reads the promotion filter of the website, so it also catches Smart Shopper offers.

## `sort` (type: `string`):

The order Pick n Pay lists the products in, which is also the order the rows are read and written: best match, lowest price first, or highest price first.

## `storeCode` (type: `string`):

The Pick n Pay store whose prices are read. Prices differ by store: 6 x 1L UHT milk was R97.99 in WC44 (the default) and R99.99 in EC01 on 2026-11-05. A code Pick n Pay does not know fails the run with an error that names it. The code of your own store is in the store selector of pnp.co.za.

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

Write at most this many products in total across all searches. Pick n Pay returns up to 100 per page and at most 10,000 per search, so 30 reads one small page. Searches are read in order: the first one can use the whole allowance unless you also set a limit per search.

## `maxItemsPerSearch` (type: `integer`):

Optional. Write at most this many products for each search term (or category), so one broad term cannot use the whole Max products allowance. Leave empty for no per-search limit.

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

Apify proxy (automatic datacenter exits) is the default. pnp.co.za answered the search through datacenter, residential and the platform's own address alike when this actor was built. A refused request is retried from a fresh address.

## Actor input object example

```json
{
  "searchTerms": [
    "milk"
  ],
  "onPromotionOnly": false,
  "sort": "relevance",
  "storeCode": "WC44",
  "maxItems": 30,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (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 = {
    "searchTerms": [
        "milk"
    ],
    "maxItems": 30,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("superslowsloth/picknpay-products").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 = {
    "searchTerms": ["milk"],
    "maxItems": 30,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("superslowsloth/picknpay-products").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 '{
  "searchTerms": [
    "milk"
  ],
  "maxItems": 30,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call superslowsloth/picknpay-products --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,superslowsloth/picknpay-products"
        }
    }
}
```

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/5EVn5jj9oz5zgTk2w/builds/2WxOdE3v4yp3ig80G/openapi.json
