# Malaysia Grocery Prices & Promo Tracker (`subimpact/my-grocery-prices`) Actor

One normalised Malaysian grocery price feed from 99 Bulksales, Jaya Grocer and KPDN PriceCatcher, with pack-size parsing, RM per kg / litre / piece unit prices and run-to-run change tracking.

- **URL**: https://apify.com/subimpact/my-grocery-prices.md
- **Developed by:** [subimpact](https://apify.com/subimpact) (community)
- **Categories:** E-commerce, Business
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 product price rows

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## Malaysia Grocery Prices & Promo Tracker

One normalised grocery price feed from three Malaysian sources: **99 Bulksales** (the 99 Speedmart wholesale catalogue), **Jaya Grocer** (Shopify storefront) and **KPDN PriceCatcher** (open government data). Every row carries the pack size it was parsed from, the price in RM, the price per kg, per litre or per piece, promo and stock flags, and optional run-to-run change tracking.

Plain HTTP only. No browser, no login, no API key, no proxy by default.

### What it does

- **99 Bulksales** (`99bulksales`): discovers products from the site's own `sitemap-products.xml` (only product URLs; no search, sort or `?page=` URLs, which `robots.txt` disallows). Fetches server-rendered product pages at most 4 at a time, and parses price, PROMO badge, product name, the Non-Halal label, stock text and the per-customer purchase limit.
- **Jaya Grocer** (`jayagrocer`): pages through the Shopify `/products.json` catalogue (250 products per page, stops at the first empty page) and emits one row per variant with SKU, seller entity, product type and `compare_at_price` promo detection. The pre-filter is a case-insensitive all-words match on the product title, so a capped run stays cheap.
- **KPDN PriceCatcher** (`pricecatcher`): reads the monthly parquet price file plus the item and premise lookups from `storage.data.gov.my` directly. Returns the latest observation per outlet and item inside your lookback window, for the grocery chains you choose. Food courts, restaurants and cooked dishes are excluded by default.

Pack sizes are parsed from the listing name (99 Bulksales, for example `12*500G`, `4*6*2*225G`, `4*24S`, `8*1.6L`, `7*1.25KG`) or from the product title (Jaya Grocer, for example `14s`, `500g`, `1.5L`, `2 x 500g`, `6x330ml`). PriceCatcher catalogue units (`1kg`, `250 g`, `10 kg`, `1 biji`, `1 ekor`) are parsed the same way. When a name has no recognisable pack size, the row is still delivered with `packParseStatus: "unparsed"` and null pack fields.

Unit prices use one rule everywhere:

- `pricePerPackItemRm` = price divided by `packCount`
- `unitPriceRm` = price per kg for gram-based packs, per litre for millilitre-based packs, per piece for piece counts
- all money is rounded to 2 decimals

### Input example

The prefill is a small three-source run. It works exactly as written:

```json
{
  "sources": ["99bulksales", "jayagrocer", "pricecatcher"],
  "searchTerms": ["maggi"],
  "maxItemsPerSource": 3
}
```

An empty input (`{}`) runs all three sources with the defaults: up to 15 rows per source, all three sources, 7-day PriceCatcher lookback, change tracking off.

#### Input fields

| Field | Type | Default | Meaning |
|---|---|---|---|
| `sources` | array (multi-select) | all three | Which sources to run. |
| `searchTerms` | string list | none (prefill `["maggi"]`) | A row matches when at least one term matches and every word of that term appears in the product name, slug or PriceCatcher item name. Empty means no filter. |
| `maxItemsPerSource` | integer, 1 to 20000 | 15 | Cap on delivered rows for each source. |
| `jayaProductTypes` | string list | none | Keep only these Jaya Grocer `product_type` values (case-insensitive). |
| `pcRetailers` | multi-select | `lotuss`, `aeon`, `mydin`, `econsave`, `99speedmart` (also `giant`, `tfvalue`, `nsk`, `jayagrocer`, `villagegrocer`) | PriceCatcher chain-name patterns. Food courts and restaurants are always excluded. |
| `pcStates` | string list | none | Keep only PriceCatcher outlets in these states (case-insensitive). |
| `pcItemCategories` | string list | none | Keep only these PriceCatcher item categories (case-insensitive). |
| `pcLookbackDays` | integer, 1 to 31 | 7 | Window ending at the newest date in the PriceCatcher data. |
| `pcIncludeCookedFood` | boolean | false | Include PriceCatcher cooked dishes (`MAKANAN SIAP MASAK`). |
| `trackChanges` | boolean | false | Add `changeType`, `previousPriceRm` and `previousIsPromo` by comparing with the last run under `stateKey`. |
| `stateKey` | string | `default` | Separate change-tracking history, sanitised to `A-Z a-z 0-9 _ -`, max 50 characters. |
| `onlyChanges` | boolean | false | With `trackChanges` on, deliver and charge only rows whose `changeType` is not `unchanged`. |

### Output example

Real values from the bundled fixtures. Every field is always present, and is `null` when the source does not publish it. `scrapedAt` is trimmed here for readability; it is always a full ISO 8601 timestamp.

```json
[
  {
    "source": "99bulksales",
    "retailer": "99 Speedmart (99 Bulksales)",
    "itemKey": "99bulksales:maggi-sos-cili-12500g",
    "productName": "MAGGI SOS CILI 12*500G",
    "productUrl": "https://www.99bulksales.my/shop/product/maggi-sos-cili-12500g",
    "productCategory": null,
    "productGroup": null,
    "seller": null,
    "sku": null,
    "outletName": null,
    "outletAddress": null,
    "outletType": null,
    "outletState": null,
    "outletDistrict": null,
    "packSpec": "12*500G",
    "packCount": 12,
    "unitSizeValue": 500,
    "unitSizeUnit": "g",
    "totalQuantityValue": 6000,
    "totalQuantityUnit": "g",
    "packParseStatus": "parsed",
    "priceRm": 54.9,
    "compareAtPriceRm": null,
    "isPromo": true,
    "pricePerPackItemRm": 4.58,
    "unitPriceRm": 9.15,
    "unitPriceBasis": "per kg",
    "inStock": true,
    "stockNote": "Limit 10 quantity per customer",
    "isNonHalal": false,
    "priceDate": null,
    "changeType": null,
    "previousPriceRm": null,
    "previousIsPromo": null,
    "attribution": null,
    "scrapedAt": "2026-10-30T01:00:00.000Z",
    "error": null
  },
  {
    "source": "jayagrocer",
    "retailer": "Jaya Grocer",
    "itemKey": "jayagrocer:43094422323262",
    "productName": "Jasmine Lasella Gold Praboiled Basmathi Rice 2kg",
    "productUrl": "https://www.jayagrocer.com/products/jasmine-lasella-gold-praboiled-basmathi-rice-2kg",
    "productCategory": "Food Essentials",
    "productGroup": null,
    "seller": "TRENDCELL SDN BHD | Online Store KL",
    "sku": "194014-U",
    "outletName": null,
    "outletAddress": null,
    "outletType": null,
    "outletState": null,
    "outletDistrict": null,
    "packSpec": "2kg",
    "packCount": 1,
    "unitSizeValue": 2000,
    "unitSizeUnit": "g",
    "totalQuantityValue": 2000,
    "totalQuantityUnit": "g",
    "packParseStatus": "parsed",
    "priceRm": 20.99,
    "compareAtPriceRm": 25.9,
    "isPromo": true,
    "pricePerPackItemRm": 20.99,
    "unitPriceRm": 10.5,
    "unitPriceBasis": "per kg",
    "inStock": true,
    "stockNote": null,
    "isNonHalal": null,
    "priceDate": null,
    "changeType": null,
    "previousPriceRm": null,
    "previousIsPromo": null,
    "attribution": null,
    "scrapedAt": "2026-10-30T01:00:00.000Z",
    "error": null
  },
  {
    "source": "pricecatcher",
    "retailer": "AEON",
    "itemKey": "pricecatcher:3130:1",
    "productName": "AYAM BERSIH - STANDARD",
    "productUrl": null,
    "productCategory": "AYAM",
    "productGroup": "BARANGAN SEGAR",
    "seller": null,
    "sku": null,
    "outletName": "AEON ( METRO PRIMA )",
    "outletAddress": "NO.1, JALAN METRO PRIMA,KEPONG,",
    "outletType": "Pasar Raya / Supermarket",
    "outletState": "W.P. Kuala Lumpur",
    "outletDistrict": "Kepong",
    "packSpec": "1kg",
    "packCount": 1,
    "unitSizeValue": 1000,
    "unitSizeUnit": "g",
    "totalQuantityValue": 1000,
    "totalQuantityUnit": "g",
    "packParseStatus": "parsed",
    "priceRm": 8.8,
    "compareAtPriceRm": null,
    "isPromo": null,
    "pricePerPackItemRm": 8.8,
    "unitPriceRm": 8.8,
    "unitPriceBasis": "per kg",
    "inStock": null,
    "stockNote": null,
    "isNonHalal": null,
    "priceDate": "2026-10-29",
    "changeType": null,
    "previousPriceRm": null,
    "previousIsPromo": null,
    "attribution": "Source: KPDN PriceCatcher via data.gov.my (open data)",
    "scrapedAt": "2026-10-30T01:00:00.000Z",
    "error": null
  }
]
```

`itemKey` is the stable identity of a row: `99bulksales:<slug>`, `jayagrocer:<variantId>` or `pricecatcher:<premise_code>:<item_code>`. `error` is set only on an error row, which is pushed when a source returns an unrecognised response, and is never charged.

#### Run-to-run change tracking

With `trackChanges` on, the actor keeps `{ [itemKey]: { priceRm, isPromo } }` in the named key-value store `my-grocery-prices-state`, record `state-<stateKey>`, and adds:

| `changeType` | Meaning |
|---|---|
| `new` | The item was not in the previous run. |
| `price_down` | Price is lower than in the previous run. |
| `price_up` | Price is higher than in the previous run. |
| `promo_start` | Same price, promo flag changed from false to true. |
| `promo_end` | Same price, promo flag changed from true to false. |
| `unchanged` | Same price and same promo flag. |

The first tracked run marks every row `new`. State is saved every 500 rows and again at the end of the run, so a timed-out run keeps its progress. `onlyChanges: true` pushes and charges only rows that are not `unchanged`.

```json
{
  "sources": ["pricecatcher"],
  "pcRetailers": ["lotuss", "aeon", "mydin", "econsave", "99speedmart"],
  "maxItemsPerSource": 200,
  "trackChanges": true,
  "stateKey": "weekly-kl",
  "onlyChanges": true
}
```

### Pricing

Pay per event: **$0.001 per product price row** delivered (`product-row`). There is no start fee and no monthly fee.

- The default run (three sources, 15 rows each) delivers at most 45 rows, so at most **$0.045**.
- A one-source run with `maxItemsPerSource: 100` costs at most **$0.10**.
- Error rows and empty rows are never charged.
- A row is charged only after it has been pushed to the dataset, so a failed push is never billed.

### How this compares

- **Retailer apps and storefronts** show one retailer at a time, in the retailer's own app. This actor puts 99 Bulksales, Jaya Grocer and the KPDN PriceCatcher survey into one schema, one `itemKey` convention and one unit-price convention, so the rows can be joined or charted together.
- **Official PriceCatcher downloads** are the right source for government-published prices, but they are monthly parquet files with numeric item and premise codes. This actor joins the lookups for you, filters the chains you care about, and returns the latest observation per outlet and item in RM per kg, litre or piece.
- **Single-retailer scrapers** usually stop at "name, price, URL". This actor also parses the pack spec, computes `pricePerPackItemRm` and `unitPriceRm`, flags promos and stock, and optionally tells you what changed since the previous run.
- **Browser-based price scrapers** need a headless browser and often a proxy pool. This actor is plain HTTP with a 4-requests-per-host cap, a 30 second timeout and 3 retries with backoff, and no proxy by default.

### Limits

- 99 Bulksales discovery is sitemap-only by design: no search pages, no sort parameters, no `?page=` URLs, because `robots.txt` disallows them. The bundled fixture sitemap lists 1,015 product URLs; the cap limits how many product pages are fetched.
- Jaya Grocer serves 250 products per page. The measured catalogue is roughly 10,000 to 15,000 products (page 40 full, page 60 empty), so a search that matches late in the catalogue takes more page requests. A capped run stops paging as soon as the cap is reached.
- PriceCatcher is a price survey, not a live checkout. Rows carry `priceDate`, the observation date. Outlet addresses, types and districts come from the government lookup files.
- PriceCatcher's monthly file was about 1.28 million rows and 1.6 MB (BROTLI) when measured in October 2026. It is parsed in 100,000-row chunks; runs default to 1,024 MB of memory because decoding the PriceCatcher file needs about 550-850 MB.
- Pack parsing is a heuristic. Names without a recognisable trailing pack size are delivered with `packParseStatus: "unparsed"` and null pack fields rather than a guessed number. PriceCatcher units such as `sepinggan` and `gelas kecil` are unparsed.
- One Jaya Grocer product can produce several rows, one per variant, each with its own `itemKey`.
- Promotions are what the source publishes: the PROMO badge on 99 Bulksales, a `compare_at_price` strictly above the price on Jaya Grocer. PriceCatcher has no promo flag, so `isPromo` is null there.
- Sources run in sequence. A source that returns an unrecognised shape produces one error row for that source and the other sources continue.

### Responsible use

- 99 Bulksales is read through its own sitemap with a polite rate (at most 4 concurrent requests, 30 second timeout). No search or sort endpoints are touched.
- The Jaya Grocer storefront is read through its public `/products.json` catalogue. You are responsible for confirming that your use complies with the storefront's terms and with any agreement you have with the retailer.
- Use this actor for monitoring, price research and analytics. It is not for republishing retailer catalogues in bulk or as a replacement for the retailer's own current offer.
- PriceCatcher data is open government data published by KPDN through data.gov.my. Every PriceCatcher row carries the attribution `Source: KPDN PriceCatcher via data.gov.my (open data)`; keep that field when you redistribute the data.
- Prices, stock and promotions change without notice. The actor reports what the source published at `scrapedAt`; it does not guarantee availability or price at checkout.

# Actor input Schema

## `sources` (type: `array`):

Retailer and open-data sources to collect. 99 Bulksales is a wholesale catalogue (sitemap discovery, product pages), Jaya Grocer is a Shopify storefront (/products.json), PriceCatcher is the KPDN open dataset on data.gov.my.

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

Optional. Every word of a term must appear in the product name or slug (case-insensitive). Leave empty to collect the catalogue in source order. The prefill \["maggi"] is a small example run.

## `maxItemsPerSource` (type: `integer`):

Cap on delivered rows for each source. 99 Bulksales and PriceCatcher stop fetching once the cap is reached; Jaya Grocer stops paging. Default 15 per source = up to 45 rows.

## `jayaProductTypes` (type: `array`):

Optional. Keep only Jaya Grocer products whose product_type equals one of these values (case-insensitive), e.g. Beverages, Frozen.

## `pcRetailers` (type: `array`):

Premise-name patterns used to keep PriceCatcher outlets. Food courts and restaurants are always excluded.

## `pcStates` (type: `array`):

Optional. Keep only outlets in these states, matched case-insensitively (e.g. Selangor, Johor, W.P. Kuala Lumpur).

## `pcItemCategories` (type: `array`):

Optional. Keep only these PriceCatcher item categories, matched case-insensitively (e.g. AYAM, BERAS, MINYAK DAN LEMAK).

## `pcLookbackDays` (type: `integer`):

Window ending at the newest date in the data. The latest observation per outlet and item inside the window is returned.

## `pcIncludeCookedFood` (type: `boolean`):

PriceCatcher also prices cooked dishes (item group MAKANAN SIAP MASAK). Off by default so the feed stays comparable grocery items.

## `trackChanges` (type: `boolean`):

Compare each row with the last run stored under stateKey and add changeType (new, price_down, price_up, promo_start, promo_end, unchanged), previousPriceRm and previousIsPromo.

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

Separate change-tracking history per key, e.g. one per product list or region. Sanitised to A-Z, a-z, 0-9, \_ and - (max 50 characters).

## `onlyChanges` (type: `boolean`):

When trackChanges is on, deliver and charge only rows whose changeType is not unchanged. Requires trackChanges.

## Actor input object example

```json
{
  "sources": [
    "99bulksales",
    "jayagrocer",
    "pricecatcher"
  ],
  "searchTerms": [
    "maggi"
  ],
  "maxItemsPerSource": 15,
  "pcRetailers": [
    "lotuss",
    "aeon",
    "mydin",
    "econsave",
    "99speedmart"
  ],
  "pcLookbackDays": 7,
  "pcIncludeCookedFood": false,
  "trackChanges": false,
  "stateKey": "default",
  "onlyChanges": false
}
```

# Actor output Schema

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

One dataset item per product price: source, retailer, stable itemKey, pack size, price in RM, unit price per kg / litre / piece, promo and stock flags, and optional change tracking fields.

# 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": [
        "maggi"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("subimpact/my-grocery-prices").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": ["maggi"] }

# Run the Actor and wait for it to finish
run = client.actor("subimpact/my-grocery-prices").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": [
    "maggi"
  ]
}' |
apify call subimpact/my-grocery-prices --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,subimpact/my-grocery-prices"
        }
    }
}
```

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/lU6ttrmYilAZ5WMkg/builds/2R5zX0tPI1l89jBvg/openapi.json
