# Save-On-Foods, PriceSmart, Urban Fare & Quality Foods Scraper (`yugenox/save-on-foods-scraper`) Actor

Store-level grocery prices from Save-On-Foods, PriceSmart Foods, Urban Fare and Quality Foods across Western Canada. Search, aisles or whole stores, nearest stores by postal code. Sale dates, UPC, unit price, fees, stock, nutrition.

- **URL**: https://apify.com/yugenox/save-on-foods-scraper.md
- **Developed by:** [Yugenox Corp](https://apify.com/yugenox) (community)
- **Categories:** E-commerce, Automation, Integrations
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 1,000 product rows

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

## Save-On-Foods, PriceSmart, Urban Fare & Quality Foods Scraper

Get **store-level grocery prices** from all four Pattison Food Group chains in Western Canada:
**Save-On-Foods, PriceSmart Foods, Urban Fare and Quality Foods**, about 219 stores in British Columbia,
Alberta, Saskatchewan, Manitoba and Yukon.

Search by keyword, scrape whole aisles or departments, or crawl an **entire store** (15,000 to 23,000 products).
Prices come from the store nearest your postal code, or from several stores at once so you can compare them.

Every row carries the **current price and the regular price**, the **sale's start and end dates**, an
already-scheduled **next sale**, the **UPC / GTIN barcode**, the unit price normalised to $/100 g or $/100 ml,
the package size, **deposit and eco fees**, stock status, Made in Canada / Product of Canada flags, the full
category path, the image, and a product link pinned to that store. Nutrition facts and ingredients are an
optional add-on.

No account, no API key and no browser needed.

### What you can do with it

- **Track grocery prices** at your local store week after week. Schedule the Actor and diff `price` over time.
- **Compare stores and chains.** Prices differ between stores of the same chain: a 4 L jug of Dairyland 2% was
  $5.85, $6.25, $6.35 or $6.45 depending on the store.
- **Build a deals feed** from `is_on_sale`, `was_price`, `savings_percent`, `sale_ends_at`, and
  `next_sale_price` for sales that have not started yet.
- **Monitor CPG brands** by UPC across all four chains.
- **Power food apps and AI shopping agents** with real Western Canada shelf prices, nutrition and ingredients.
- **Research food inflation** by region (Vancouver vs Kelowna vs Calgary vs Winnipeg).

### How to use it

1. Pick a **chain** (Save-On-Foods by default).
2. Add **search terms** (e.g. `milk`, `chicken breast`), pick **categories** from the list, or turn on
   **Entire store**.
3. Enter your **postal code**. The nearest store is used. Set **Nearest stores** to 3, 5 or more to compare
   stores, or give exact **Store IDs**.
4. Click **Start**. Rows stream into the dataset as they arrive. Export them as JSON, CSV, Excel or through the API.

#### Input examples

One search at the store nearest a postal code:

```json
{ "banner": "saveonfoods", "search_terms": ["milk"], "postal_code": "V6M 2P8" }
```

Everything in two aisles, at the 3 nearest Calgary stores, sale items only:

```json
{
  "banner": "saveonfoods",
  "categories": ["dairy-eggs/cheese", "meat-seafood"],
  "postal_code": "T2P 1J9",
  "stores_per_banner": 3,
  "on_sale_only": true
}
```

The same basket across all four chains:

```json
{
  "banners": ["saveonfoods", "pricesmart", "urbanfare", "qualityfoods"],
  "search_terms": ["eggs", "butter", "bread"],
  "postal_code": "V6X 1A1"
}
```

A whole store, with nutrition and ingredients:

```json
{ "locationId": "2246", "all_products": true, "include_details": true }
```

#### Input fields

| Field | What it does |
| --- | --- |
| `banner` | `saveonfoods` (default), `pricesmart`, `urbanfare` or `qualityfoods` |
| `search_terms` | Keywords. Each returns up to ~280 best matches per store (the store's own search limit) |
| `categories` | Departments or aisles from the dropdown, e.g. `dairy-eggs` or `dairy-eggs/cheese`. Every product in them is scraped |
| `categoryUrls` | Any other category by numeric ID (`30910`) or by its URL on the chain's site |
| `all_products` | Every product the store carries |
| `postal_code` | Canadian postal code; picks the nearest store(s). Default `V6M 2P8` |
| `locationId` | Exact store ID(s), comma-separated; overrides the postal code |
| `stores_per_banner` | How many nearest stores to price (1-50) |
| `all_stores` | Price every store of the chain |
| `on_sale_only` | Keep only products on sale right now |
| `include_details` | Add nutrition facts, ingredients, serving size and extra images (one extra request per product) |
| `max_items_per_search` | Limit per search term per store (default 1000) |
| `max_items` | Limit for the whole run (0 = no limit) |
| `banners` | Several chains in one run (overrides `banner`) |
| `sort` | `relevance`, `price`, `price desc`, `brand`, `brand desc`; matters when a limit cuts the list |

Search terms combined with categories search **inside** those categories.

### Output

One row per product per store. A real row (Save-On-Foods Kerrisdale, Vancouver):

```json
{
  "store": "Save-On-Foods",
  "name": "Dairyland - 2% Protein Milk",
  "brand": "Dairyland",
  "product_id": "00068700104428",
  "upc": "068700104428",
  "price": 5.99,
  "was_price": 6.99,
  "is_on_sale": true,
  "savings": 1,
  "savings_percent": 14.3,
  "sale_starts_at": "2026-09-17T07:00:00Z",
  "sale_ends_at": "2026-09-24T04:59:00Z",
  "next_sale_price": null,
  "unit_price": "$0.32/100ml",
  "comparable_unit_price": 0.32,
  "comparable_unit": "100ml",
  "package_size": "1.89 l",
  "normalized_package_size": { "size": 1890, "unit": "ml" },
  "selling_type": "by_unit",
  "deposit_fee": 0.1,
  "eco_fee": 0.02,
  "stock_status": "IN_STOCK",
  "made_in_canada": true,
  "category": "dairy-eggs/milk-creams/2-milk",
  "category_path": "Dairy & Eggs > Milk & Creams > 2% Milk",
  "image_url": "https://images.cdn.saveonfoods.com/detail/00068700104428.jpg",
  "product_url": "https://www.saveonfoods.com/sm/planning/rsid/2246/product/dairyland-2-protein-milk-id-00068700104428",
  "location": "2246",
  "location_name": "Kerrisdale",
  "location_city": "Vancouver",
  "location_postal_code": "V6M 3W4",
  "location_coordinates": { "lat": 49.23271, "lng": -123.15565 },
  "distance_km": 0.8,
  "source": "search:milk"
}
```

Rows also include `description`, `gtin`, `plu` (produce/bulk codes), `price_text`, `pricing_unit_price` and
`pricing_unit` (e.g. $6.99 / lb for meat sold by the piece), `parsed_unit_price`, `parsed_package_size`,
`weight_increment` (for items sold by weight), `fees`, `promotions`, `has_loyalty_discount`, `badges`,
`notes` (e.g. "Requires preparation time of 24 hours", "Pickup only"), `categories`, `category_id`, the store
address and province, and `scraped_at`.

With **Nutrition, ingredients & extra images** on, rows add `ingredients`, `serving_size`,
`servings_per_package`, `nutrition` (`{ "Calories": { "amount": 130, "unit": "cal" }, "Sodium": { "amount": 120,
"unit": "mg", "daily_value_percent": 5 }, ... }`) and `additional_images`.

The dataset has three table views: **Products**, **Deals** (sale windows and savings) and
**Nutrition & ingredients**.

#### Field names

The core fields (`store`, `name`, `price`, `was_price`, `is_on_sale`, `unit_price`, `comparable_unit_price`,
`package_size`, `parsed_package_size`, `normalized_package_size`, `selling_type`, `multi_buy_deal`,
`image_url`, `product_url`, `product_id`, `category`, `location`, `location_name`, `location_postal_code`,
`location_coordinates`) use the same names as other Canadian grocery scrapers, so existing sheets and
pipelines work without remapping. `price` is a number.

### FAQ

**Which provinces and stores are covered?**
British Columbia, Alberta, Saskatchewan, Manitoba and Yukon: about 195 Save-On-Foods stores, 5 PriceSmart
Foods, 5 Urban Fare and 14 Quality Foods (Vancouver Island). The store list is read live on every run, so new
stores appear automatically. A postal code outside a chain's provinces is refused with the provinces it serves.

**Why do I get about 260 results for a search term when the store has more?**
Keyword search returns the ~250-280 most relevant matches per term. For every product in an aisle or
department, use **Categories** or **Entire store**; those have no such limit.

**How long does an entire store take?**
About 2-3 minutes for a 15,000-product store. Large multi-store crawls run longer; the Actor stops cleanly
before your run's timeout and keeps everything collected so far.

**Are prices really per store?**
Yes. Every row is priced at the store in `location` / `location_name`, and `product_url` opens the product at
that store. Sale prices and stock can differ between stores of the same chain.

**What does `next_sale_price` mean?**
Some sales are scheduled in advance. When a product has an upcoming sale that has not started yet, its price
and dates are in the `next_sale_*` fields, so you can see next week's deals early.

**How do I find a store ID?**
Usually the postal code is enough. Every row reports its `location` (store ID); you can also read it from the
store's URL on the chain's site (`/rsid/2246/`).

**Can I schedule it?**
Yes. Save your input as a task and add a schedule (daily or weekly) in Apify Console, then connect the
dataset to Google Sheets, a webhook or your database.

**Can I use it from code?**
Yes, through the Apify API or the JavaScript and Python clients. Example:

```bash
curl -X POST "https://api.apify.com/v2/acts/yugenox~save-on-foods-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"banner":"saveonfoods","search_terms":["eggs","milk"],"postal_code":"V6M 2P8"}'
```

Use `run-sync-get-dataset-items` for small runs; for whole-store or multi-store runs start a run and read the
dataset when it finishes.

**Is it legal to scrape Save-On-Foods, PriceSmart Foods, Urban Fare and Quality Foods?**
This Actor only collects publicly available data: product listings, prices, sale dates, stock, nutrition facts and
store locations that anyone can see on these chains' websites without an account. Collecting publicly available
data is generally legal, but you're responsible for how you use it. You must follow privacy laws such as GDPR,
PIPEDA and CCPA, as well as each chain's terms. If you're unsure, check with a lawyer. More on this:
[Is web scraping legal?](https://blog.apify.com/is-web-scraping-legal/)

**Does it access any private data?**
No. Everything comes from pages Save-On-Foods, PriceSmart Foods, Urban Fare and Quality Foods show to any visitor
without logging in. It never uses a login, never touches private or restricted accounts, and never reaches
password-protected areas.

# Actor input Schema

## `banner` (type: `string`):

Which Pattison Food Group chain to price. Each chain prices its own stores independently. To compare several chains in one run, use "Several chains" under Advanced.

## `search_terms` (type: `array`):

Products to look up, e.g. "milk", "organic eggs", "chicken breast". Each term returns up to ~280 best matches (the store's own search limit). For every product in an aisle, use Categories instead. With Categories set, the terms search inside those categories.

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

Departments (📁) or aisles (└) to scrape completely. A department includes every aisle under it.

## `categoryUrls` (type: `array`):

Any category not in the list above: its numeric ID (e.g. "30910") or its URL from the chain's website ("...-id-30910"). Deeper aisles work too.

## `all_products` (type: `boolean`):

Scrape every product the store carries (15,000-23,000 items, a few minutes per store). Search terms and categories are optional when this is on.

## `postal_code` (type: `string`):

Canadian postal code (BC, AB, SK, MB or YT). Prices come from the nearest store. A postal code a chain does not serve is refused with the provinces it does serve.

## `locationId` (type: `string`):

Price exact stores instead of the nearest one (overrides postal code). One ID or several separated by commas, e.g. "2246" (Save-On-Foods Kerrisdale, Vancouver). Every row reports its store ID; the ID is also in the store's URL on the chain's site (/rsid/2246/).

## `stores_per_banner` (type: `integer`):

Price the N nearest stores to the postal code (1-50) and compare prices between them in one run. Prices differ by store.

## `all_stores` (type: `boolean`):

Price every store of the chosen chain(s): about 195 Save-On-Foods, 5 PriceSmart, 5 Urban Fare and 14 Quality Foods stores. Combine with a few search terms or categories.

## `on_sale_only` (type: `boolean`):

Keep only products on sale right now (with a was-price and the sale's start and end dates).

## `include_details` (type: `boolean`):

Also fetch each product's nutrition facts, ingredients, serving size and additional images. One extra request per product, so slower and billed per product as an add-on.

## `max_items_per_search` (type: `integer`):

Upper limit for each search term (or term inside a category) per store.

## `max_items` (type: `integer`):

Stop the whole run after this many products. 0 = no limit.

## `banners` (type: `array`):

Price more than one chain in one run; overrides "Grocery chain". A chain with no store in the postal code's province is skipped with a note.

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

Order results are read in. Matters only when a limit cuts the list short (e.g. price = the cheapest N matches).

## `maxConcurrency` (type: `integer`):

Requests in flight at once (1-10). The default suits almost every run.

## Actor input object example

```json
{
  "banner": "saveonfoods",
  "search_terms": [
    "milk"
  ],
  "all_products": false,
  "postal_code": "V6M 2P8",
  "stores_per_banner": 1,
  "all_stores": false,
  "on_sale_only": false,
  "include_details": false,
  "max_items_per_search": 1000,
  "max_items": 50,
  "sort": "relevance",
  "maxConcurrency": 8
}
```

# Actor output Schema

## `products` (type: `string`):

Every product row as JSON.

## `productsCsv` (type: `string`):

The same rows as CSV, for a spreadsheet or price-tracking sheet.

## `productsTable` (type: `string`):

Product, chain, store, price, was-price, sale, unit price, size, UPC and link.

# 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 = {
    "search_terms": [
        "milk"
    ],
    "postal_code": "V6M 2P8",
    "max_items": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("yugenox/save-on-foods-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 = {
    "search_terms": ["milk"],
    "postal_code": "V6M 2P8",
    "max_items": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("yugenox/save-on-foods-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 '{
  "search_terms": [
    "milk"
  ],
  "postal_code": "V6M 2P8",
  "max_items": 50
}' |
apify call yugenox/save-on-foods-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yugenox/save-on-foods-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/R7KFDSUZjxVtalQAL/builds/YIk4Fqww5RIOoC1So/openapi.json
