Omar EldeebBuild a grocery price tracker: a fixed basket of Waitrose, Aldi or Walmart products, scraped on a schedule, with unit prices per litre and a basket index.
A grocery price tracker is a fixed basket of product pages, scraped on a schedule, with each product's price converted to a unit price (per litre or kg) and the basket's total compared with a base date as an index (base = 100). Keep the basket fixed, compare only products priced on both dates, and record when a product disappears.
This guide builds one in Python on Waitrose and Aldi UK product pages, through an Apify actor, and shows its real output. It also lists which grocers the actor can reach, where unit prices come from, and how a home-made basket relates to official food inflation figures.
From the E-commerce Product Scraper Pro supported-sites table:
| Grocer | Status | How pages are reached | Listings and search |
|---|---|---|---|
Aldi UK (aldi.co.uk) |
supported | Datacenter | yes |
Waitrose (waitrose.com) |
supported | Datacenter | yes, including its "load more" pages |
Walmart (walmart.com) |
supported | Datacenter | yes |
Target (target.com) |
supported | Datacenter (crawler user agent) | yes |
Sainsbury's (sainsburys.co.uk) |
best-effort | Datacenter | first page only |
Lidl (lidl.co.uk) |
best-effort | Datacenter | first page only |
Tesco (tesco.com) |
best-effort | Residential | best-effort |
Ocado (ocado.com) |
best-effort | Residential | first page only |
Morrisons (groceries.morrisons.com) |
best-effort | Residential (listings datacenter) | first page only |
Iceland (iceland.co.uk) |
best-effort | Browser | best-effort |
Coles (coles.com.au) |
best-effort | Residential | best-effort |
Meijer (meijer.com) |
best-effort | Datacenter | product URLs only |
Asda, Co-op, Albertsons, Costco and BJ's are listed as unsupported: they block automated visitors on every route the actor tested, so their inputs get a status row and aren't fetched. Instacart is covered in the actor's food-delivery section, through category and product URLs.
For a tracker, product URLs beat searches. A search's results change from day to day, while a fixed list of product pages gives you the same basket every time.
The script scrapes a five-item basket, appends the prices to basket_prices.csv, and prints the basket cost and an index for every date in the file:
import csv
import os
import re
from datetime import date
from apify_client import ApifyClient
BASKET = [ # url, how many you buy per basket
("https://www.waitrose.com/ecom/products/essential-british-free-range-semi-skimmed-milk-4-pints/053457-26759-26760", 1),
("https://www.waitrose.com/ecom/products/essential-british-free-range-whole-milk-2-pints/053403-26709-26710", 1),
("https://www.waitrose.com/ecom/products/duchy-organic-semi-skimmed-milk-4-pints/053924-27034-27035", 1),
("https://www.waitrose.com/ecom/products/essential-british-free-range-semi-skimmed-milk-1-pint/053447-26747-26748", 2),
("https://www.aldi.co.uk/product/cowbelle-chocolate-fudge-milk-1-litre-000000000000543237", 1),
]
TO_BASE = {"pint": 0.568261, "pints": 0.568261, "l": 1, "litre": 1, "litres": 1, "ml": 0.001,
"kg": 1, "g": 0.001} # imperial pint -> litres; grams -> kg
def pack_size(name):
"""Litres or kg from the product name ("4 Pints", "1 Litre", "500g"); None if absent."""
m = re.search(r"(\d+(?:\.\d+)?)\s*(pints?|litres?|l|ml|kg|g)\b", name, re.I)
return round(float(m.group(1)) * TO_BASE[m.group(2).lower()], 3) if m else None
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("constructive_calm/ecommerce-product-scraper-pro").call(run_input={
"detailsUrls": [{"url": u} for u, _ in BASKET],
"maxProductResults": len(BASKET),
})
today = date.today().isoformat()
new_rows = []
for p in client.dataset(run["defaultDatasetId"]).iterate_items():
size = pack_size(p["name"])
new_rows.append({"date": today, "url": p["inputUrl"], "name": p["name"], "price": p["price"],
"currency": p["currency"], "in_stock": p["inStock"], "size": size,
"unit_price": round(p["price"] / size, 2) if size and p["price"] else None})
history = "basket_prices.csv"
exists = os.path.exists(history)
with open(history, "a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(new_rows[0]))
if not exists:
w.writeheader()
w.writerows(new_rows)
# Basket cost per date, over the items priced on every date, and an index (first date = 100)
rows = list(csv.DictReader(open(history, encoding="utf-8")))
qty = dict(BASKET)
dates = sorted({r["date"] for r in rows})
common = set.intersection(*({r["url"] for r in rows if r["date"] == d and r["price"]} for d in dates))
base = None
for d in dates:
total = sum(float(r["price"]) * qty[r["url"]] for r in rows if r["date"] == d and r["url"] in common)
base = base or total
print(f"{d} basket £{total:.2f} index {100 * total / base:.1f} ({len(common)} items)")
for r in new_rows:
print(f" £{r['price']:.2f} {r['size'] or '?'} l £{r['unit_price'] or 0:.2f}/l {r['name']}")
basket_prices.csv already held the same five products from an actor run on 2026-10-17. Output on 2026-10-20 (pip install apify-client, APIFY_TOKEN set):
2026-10-17 basket £8.40 index 100.0 (5 items)
2026-10-20 basket £8.40 index 100.0 (5 items)
£0.95 1.0 l £0.95/l Chocolate Fudge Milk 1 Litre
£1.75 2.273 l £0.77/l Essential British Free Range Semi-Skimmed Milk 4 Pints
£2.65 2.273 l £1.17/l Duchy Organic Semi-Skimmed Milk 4 pints
£1.25 1.137 l £1.10/l Essential British Free Range Whole Milk 2 Pints
£0.90 0.568 l £1.58/l Essential British Free Range Semi-Skimmed Milk 1 Pint
Nothing moved in three days, which is normal for milk. The useful line is the unit price. The 1-pint bottle costs about twice as much per litre (£1.58) as the 4-pint one (£0.77). That's the comparison a per-item price hides.
This basket is five milks to keep the demo small. A real tracker uses 20 to 50 staples across categories (milk, bread, eggs, pasta, rice, fruit, vegetables, meat, tea, washing-up liquid) with quantities that match what you actually buy.
Each product row has unitPrice and unitPriceUnit fields, but they're filled only when the page publishes a unit price in a form the actor reads:
"price": 3.48, "unitPrice": 0.027, "unitPriceUnit": "fl oz" for a gallon of Great Value whole milk.Name parsing has limits. Aldi names often omit the size ("Whole Milk 3.6% Fat"), and multipacks ("4 x 500g") need their own rule. When the name has no size, add it to your basket file by hand. A wrong pack size makes a wrong unit price, so check every product once. UK milk is sold in imperial pints, 568 ml each.
monitorMode: true, a product page that now returns 404 is reported as removed in the status dataset. Replace it with the nearest equivalent and note the date.originalPrice is filled when the page shows a was-price. Decide whether your index uses the shelf price (what you'd pay) or the regular price (the underlying level), and keep to it.proxyCountry on Auto) but has no postcode input.Official figures measure a much broader basket with fixed weights. In the UK, the ONS Consumer price inflation bulletin for August 2026 (released 16 September 2026) says: "The 12-month inflation rate for food and non-alcoholic beverages was 1.3% in August 2026, unchanged from July." The headline CPI rate was 3.1%. The September 2026 bulletin is due on 21 October 2026.
Your basket won't match that number, and it shouldn't. It covers fewer products, one or two stores, and your quantities. Its value is that it's yours: it shows what your shop costs, which store is cheaper per litre or kilo, and when a price moved, days before any monthly statistic.
Christmas food shopping concentrates spending into a few weeks. Seasonal products (turkeys, puddings, party food) appear only for the season, so they can't have a long history. Add them to a separate basket as soon as they're listed and scrape it daily, so you have a few weeks of prices before the last shopping week. Keep the everyday basket running alongside it to see whether the season moves staples too. The Christmas price tracker covers gift prices, and the holiday sales calendar has the 2026–2027 dates, including Boxing Day.
Pay per event, from the actor's pricing table. Waitrose, Aldi UK, Sainsbury's, Lidl and Walmart are reached over datacenter connections, with no traffic fee:
onlyChanges: true: unchanged products are billed as "product checked" ($0.0012 FREE, $0.0002 GOLD) and aren't written, so your script must carry the last known price forward. That's 50 × $0.0012 + $0.005 = $0.065 per run on FREE when nothing changed.Tesco, Ocado and Morrisons product pages go through residential proxies, which add $0.018 (FREE) to $0.014 (GOLD) per MB for items that delivered. The actor hasn't published a measured MB-per-product figure for those three; its README says residential product pages are usually in the 0.1–0.5 MB range. Apify's free plan includes $5 of usage every month, with no credit card required. There's no free trial inside the actor.
The free e-commerce price tracker builder turns a list of product URLs into a ready-to-run monitor-mode input for the actor, with a fixed example of the output. It doesn't fetch grocer pages from your browser.
Disclosure: I built the actor and this site.
Sources: ONS, Consumer price inflation, UK: August 2026 (checked 2026-10-20), actor runs on 2026-10-17 and 2026-10-20, and the actor's README (supported-sites table and pricing).