Malaysia Grocery Prices & Promo Tracker
Pricing
from $1.00 / 1,000 product price rows
Malaysia Grocery Prices & Promo Tracker
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.
Pricing
from $1.00 / 1,000 product price rows
Rating
0.0
(0)
Developer
subimpact
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
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 ownsitemap-products.xml(only product URLs; no search, sort or?page=URLs, whichrobots.txtdisallows). 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.jsoncatalogue (250 products per page, stops at the first empty page) and emits one row per variant with SKU, seller entity, product type andcompare_at_pricepromo 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 fromstorage.data.gov.mydirectly. 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 bypackCountunitPriceRm= 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:
{"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.
[{"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.
{"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: 100costs 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
itemKeyconvention 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
pricePerPackItemRmandunitPriceRm, 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, becauserobots.txtdisallows 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 assepingganandgelas kecilare 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_pricestrictly above the price on Jaya Grocer. PriceCatcher has no promo flag, soisPromois 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.jsoncatalogue. 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.