# FoodHero Scraper - Grocery Deals, Prices & Stores (`abotapi/foodhero-surplus-grocery-scraper`) Actor

Scrape FoodHero surplus grocery deals across Canada by area, store or offer ID. Extract products, brands, regular and discounted prices, savings, portions left, best-before dates and CO2 savings, plus store names, chains, addresses, coordinates and types.

- **URL**: https://apify.com/abotapi/foodhero-surplus-grocery-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** E-commerce, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 deal or store records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## FoodHero Scraper - Surplus Grocery Deals in Canada

Collect discounted surplus-grocery deals from **FoodHero**, the Canadian app that
helps stores sell surplus food before it goes to waste. Search by area, or paste a
store or offer id, and get one clean row per deal with the product, both prices, the
discount, how many portions are left, the best-before date, and the store behind it.

FoodHero operates in Canada. The default connection settings work on every plan,
and which deals you get back is decided by the areas you search, not by where the
connection exits.

### What you get

One row per surplus deal (`recordType: "offer"`), with:

- **Product** - name, brand, description, size, department, photo
- **Price** - regular price, deal price, discount %, currency (CAD)
- **Availability** - portions left, sold-out flag, last-call flag, best-before date (raw and ISO)
- **Impact** - CO2 saved, new-arrival flag
- **Store** - name, chain, store type, full address, city, province, postal code, coordinates, distance from your search point, and (optionally) phone, website and description

A pasted store that lists nothing right now comes back as a single store row
(`recordType: "store"`) so a target you named always returns something.

### Input

- **Mode** - *Search deals by area* or *Store or offer ids*.
- **Areas to search** (search mode) - one or more `lat,lng` pairs or Canadian place
  names (Toronto, Montreal, Vancouver). Leave empty to cover every FoodHero city in
  Canada at once.
- **Radius (km)** - keep only stores within this distance of each area.
- **Store name contains** - keep only stores whose name matches (Sobeys, IGA,
  Safeway, Foodland, Thrifty…). Applies in both modes.
- **Include sold-out deals** - also return deals with zero portions left.
- **Add store phone, website and description** - richer store contact info (one extra
  request per store, so a little slower).
- **Store or offer ids** (URL mode) - one target per entry. FoodHero has no public web
  page for a store, so paste its 24-character id: a bare id is read as a store, or
  prefix it (`store/<id>` or `offer/<id>`) to be explicit.
- **Max items / Max pages per area** - limits.
- **Resume from a previous run** - paste an interrupted run's ID (or its dataset ID)
  to carry on without returning what it already saved.
- **Incremental mode** - for scheduled runs: after the first run, return only what is
  NEW, UPDATED or REAPPEARED. Optional Emit unchanged / Emit expired toggles.
- **MCP connectors** - optionally pipe the results into apps you already use.
- **Proxy** - the default shared pool reaches FoodHero on every plan; leave it as it
  is unless you have a reason to use your own proxy.

#### Resume and recurring updates

These are two different things.

**Resume** finishes ONE interrupted collection. Paste that run's ID (or its
dataset ID) into `resumeFromRunId` and the deals it already saved are skipped.

**Incremental mode** is for running the same selection again and again - every
morning, say, to watch fresh surplus appear and sold-out deals disappear. The
actor remembers the previous run of that selection and, from the second run on,
returns only what changed. Each row then carries `changeType`, `changedFields`,
`firstSeenAt` and `lastSeenAt`. Memory is kept per selection (mode, areas,
radius, store-name filter, sold-out toggle and pasted ids); set `stateKey` to
name a campaign or to deliberately share one.

`EXPIRED` rows are only produced when a run scanned the whole selection. A run
stopped by `maxItems` or `maxPages`, a run whose area could not be fully read, a
run where a pasted id was refused, and a resumed run all skip that step and say
so in the log: a partial scan must never report a live deal as gone.

### Example output

```json
{
  "recordType": "offer",
  "productName": "Sliced Braided Brioche",
  "productBrand": "Sample Bakery",
  "price": 13.99,
  "discountPrice": 7.0,
  "discountPercent": 50,
  "currency": "CAD",
  "itemLeft": 1,
  "isSoldOut": false,
  "bestBeforeAt": "2027-01-22T15:56:05+00:00",
  "storeName": "IGA Complexe Desjardins",
  "city": "Montreal",
  "state": "QC",
  "country": "CA",
  "latitude": 45.5075,
  "longitude": -73.5654,
  "distanceKm": 0.65
}
```

### Send results into your apps (MCP connectors)

Results can optionally be piped into apps you already use, through Model Context
Protocol connectors, as a delivery step after the collection. Authorize a
connector under Apify, Settings, API and Integrations, then select it in the
`mcpConnectors` field of the input. For Notion, also set `notionParentPageUrl`
to the page under which deal pages should be created; `maxNotifyListings` caps
how many deals are written to each connector.

What arrives in the connector is a condensed, human readable summary per deal: a
title plus the key fields flattened to plain text. Nested objects collapse to
their main value and long lists are trimmed to a few entries, so a long product
description may be shortened there. The complete record always stays in the
Apify dataset, which this step never changes. Leaving `mcpConnectors` empty
skips the export entirely.

### Notes

- The actor reads live availability, so deals change through the day - run again later
  to catch fresh surplus.
- Access to the deals is handled for you behind the scenes; no account setup is needed.
  Keep your usage respectful of FoodHero's terms.
- FoodHero currently operates in Canada, so searching outside Canada returns nothing.
  Use Canadian areas - or leave the area empty to cover every FoodHero city at once.

# Actor input Schema

## `mode` (type: `string`):

Search walks the FoodHero deals feed around each area you give (or every covered Canadian city when you give none). URL mode reads only the store or offer ids you paste.

## `locations` (type: `array`):

Search mode only. One entry per area: coordinates as lat,lng (45.5019,-73.5674), or a Canadian place name (Toronto, Montreal, Vancouver) which is looked up for you and cached. Leave empty to cover every FoodHero city in Canada at once.

## `radiusKm` (type: `integer`):

Search mode only. Keep only stores within this distance of each area, measured on the store's own coordinates. Ignored in URL mode.

## `searchStoreName` (type: `string`):

Keep only stores whose name contains this text, matched without case (for example Sobeys, IGA, Safeway, Foodland, Thrifty). Applies in BOTH search and URL mode.

## `includeSoldOutOffers` (type: `boolean`):

Off by default, so only deals with portions left are returned. Turn on to also return deals that are listed but already gone (zero left). Applies in BOTH search and URL mode.

## `enrichStoreDetails` (type: `boolean`):

Off by default. Turn on to fetch each store's detail page and add its phone, website, description and opening hours to every row. This makes one extra request per store, so it is slower.

## `urls` (type: `array`):

URL mode only. One target per entry. FoodHero has no public web page for a store, so paste its 24-character id: a bare id is read as a store, or prefix it - store/<id> or offer/<id> - to be explicit. A store returns every deal it lists right now (or one store row when it lists none); an offer returns that one deal.

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

Stop after this many records (0 = no limit; the run then stops when the deals feed runs out for every area).

## `maxPages` (type: `integer`):

Optional. Deals are read 50 at a time; 0 means as deep as the feed goes for each area, bounded by Max items.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue a collection that was interrupted, without returning the records it already saved. Use this for ONE unfinished run; for recurring updates use Incremental mode below instead.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or recurring monitoring. The first run returns every matching record as NEW. Later runs normally return only NEW, UPDATED and REAPPEARED records. Turn on Emit unchanged or Emit expired only when you also want those rows returned (and billed). State is kept separately for each search and filter setup; use State key to name or deliberately share a monitoring campaign.

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

Optional. Name this monitoring campaign to keep its state stable, or deliberately share state across differently configured runs. Leave empty to let the actor derive a key automatically from the search and filter settings.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return records that have not changed since the last run, marked UNCHANGED. This returns, and bills, extra rows you already have.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return records that were present in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked search.

## `mcpConnectors` (type: `array`):

Optionally send collected deals into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize one under Apify, Settings, API & Integrations, then select it here. Notion gets a rich page-per-deal export; other connectors get a best-effort write or digest. Leave empty to skip; never changes the dataset output.

## `notionParentPageUrl` (type: `string`):

URL or id of the Notion page under which deal pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on deals written to each connector per run. Does not affect the dataset.

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

Apify Proxy settings. The default shared pool reaches FoodHero on every plan, so leave it as it is unless you have a reason to use your own proxy. The deals you get back are decided by the areas you search, not by where the connection exits.

## Actor input object example

```json
{
  "mode": "search",
  "locations": [
    "45.5019,-73.5674"
  ],
  "radiusKm": 25,
  "includeSoldOutOffers": false,
  "enrichStoreDetails": false,
  "urls": [
    "store/620ab15687bfb210e045101a"
  ],
  "maxItems": 20,
  "maxPages": 0,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "maxNotifyListings": 50,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `overview` (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 = {
    "mode": "search",
    "locations": [
        "45.5019,-73.5674"
    ],
    "radiusKm": 25,
    "includeSoldOutOffers": false,
    "enrichStoreDetails": false,
    "urls": [
        "store/620ab15687bfb210e045101a"
    ],
    "maxItems": 20,
    "maxPages": 0,
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "maxNotifyListings": 50,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/foodhero-surplus-grocery-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 = {
    "mode": "search",
    "locations": ["45.5019,-73.5674"],
    "radiusKm": 25,
    "includeSoldOutOffers": False,
    "enrichStoreDetails": False,
    "urls": ["store/620ab15687bfb210e045101a"],
    "maxItems": 20,
    "maxPages": 0,
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "maxNotifyListings": 50,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/foodhero-surplus-grocery-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 '{
  "mode": "search",
  "locations": [
    "45.5019,-73.5674"
  ],
  "radiusKm": 25,
  "includeSoldOutOffers": false,
  "enrichStoreDetails": false,
  "urls": [
    "store/620ab15687bfb210e045101a"
  ],
  "maxItems": 20,
  "maxPages": 0,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "maxNotifyListings": 50,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call abotapi/foodhero-surplus-grocery-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/foodhero-surplus-grocery-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/axIyUuuMGBTPWzBPw/builds/nX0uDAgCZxIFmT5VW/openapi.json
