# Peru Grocery Basket Aggregator (Metro, Wong, PlazaVea, Vivanda) (`barefoot_grade/peru-grocery-basket-aggregator`) Actor

Compare a grocery basket across Peru's four largest chains (Metro, Wong, PlazaVea, Vivanda): cheapest chain per item, basket totals per chain, and where switching stores saves money. Prices in PEN. Use specific item names with size hints (e.g. 'aceite vegetal 1L') for best results.

- **URL**: https://apify.com/barefoot\_grade/peru-grocery-basket-aggregator.md
- **Developed by:** [Philip Kirkbride](https://apify.com/barefoot_grade) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.40 / 1,000 results

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

## Peru Grocery Basket Aggregator (Metro · Wong · PlazaVea · Vivanda)

One Apify Actor that prices a whole grocery basket across Peru's four largest
chains — **Metro, Wong, PlazaVea and Vivanda** — in a single run and answers
three questions: which chain is cheapest *per item*, what a basket costs at
each chain, and where switching stores saves money. All prices in **PEN (S/)**.

Plain-HTTP fetch of each chain's public VTEX catalog (keyless JSON endpoint,
no browser, no proxies, no login). Every item triggers one first-page search
per chain (top-sale ordering, 50 results), matched across chains, and the
run emits normalized offer records plus a basket summary.

### Input

- `basket` (optional since 0.2.3): 1–50 free-text items, e.g.
  `["arroz extra 5kg", "leche evaporada chica", "huevos", "pollo entero", "aceite vegetal 1L", "pan integral"]`;
  when omitted (empty run input `{}`) the default starter basket
  `["arroz extra 5kg", "leche evaporada chica"]` is priced instead. An
  explicit empty array is still rejected (`minItems: 1`).
  Including a size/format hint (`5kg`, `1L`, `30un`) materially improves
  cross-chain matching. An item whose text names a category in a chain's own
  tree (e.g. `huevos`, `pollo entero`) is automatically scoped to it — see
  *Category auto-scoping* below.
- `categoryHint` (optional): a free-text category scoped against **each**
  chain's own category tree (e.g. `abarrotes`); chains where it doesn't
  uniquely resolve fall back to full-text search with
  `categoryResolved: false`. An explicit hint overrides the per-item
  auto-scoping for the whole basket.
- `requestDelaySecs` (default 1.0): politeness pause between basket items;
  within an item the four chains are queried concurrently but staggered —
  each chain starts 1/4 of this delay after the previous one. Regardless of
  the delay, requests to any single host are serialized (at most one in
  flight at a time) so tree/search/retry hits never stack on one VTEX host.

#### Input guidance (read before first run)

Chain full-text search **bleeds across categories**: a generic `aceite` can
surface "Trozos de Atún en Aceite" (tuna-in-oil) and `huevos` can surface
quail eggs as cheaper substitutions. Use **specific item names with size
hints** — `aceite vegetal 1L`, `huevos de gallina`, `arroz extra 5kg` — or
pass `categoryHint` (e.g. `abarrotes`, `huevos`). Substitutions still slip
through are flagged `matched: false` in the dataset so you can tell a real
low price from a lookalike product.

Exactly these inputs are implemented — `tests/test_actor_schemas.py` guards
the schema against `normalize_input()` drift.

### Output

**Dataset** — one record per (item × chain) offer (plus one `unmatched`
marker when no chain has a plausible offer). Fields: `item`, `itemIndex`,
`store`, `productId`/`productKey`, `name`, `brand`, `category`, `price`,
`listPrice`, `currency` (always `PEN`), `unitMultiplier`,
`measurementUnit`, `availableQuantity`, `grams`, `pieceCount`,
`pricePerKg`, `pricePerPiece`, `inStock`, `url`, `imageUrl`,
`categoryResolved`, `matched` / `matchBasis` (cross-chain linkage),
`isCheapest` / `compareBasis` (the cheapest decision), `collectedAt`.
`availableQuantity` is passed through but carries the VTEX platform default
(99999) for most items — treat it as "effectively unlimited", never as real
stock.

**Key-value store `OUTPUT`** — the basket summary: `items` (cheapest chain
per item with name/price/url), `totalsByChain` (total + items + coverage per
chain), `wholeBasket` (cheapest vs priciest chain and the savings on shared
items), `mixAndMatch` (cheapest-per-item total), a human `summaryLine`, and
`warnings` (any chain that failed for an item — one flaky chain never kills
a run).

#### Category auto-scoping

VTEX full-text search bleeds across categories: a generic `aceite` surfaces
tuna-in-oil and `huevos` surfaces quail eggs as cheaper substitutions. Each
chain's category tree (`/api/catalog_system/pub/category/tree/3`, fetched
once per run and cached) is therefore propagated into matching: when no
`categoryHint` is given, each basket item's own text is resolved per chain
against that chain's tree, and a **unique** name match scopes that chain's
search (`map=ft,c,c…`, `categoryResolved: true`). Items that resolve nowhere
or ambiguously (e.g. `café soluble`) keep the plain full-text search with
`categoryResolved: false`; surviving substitutions are still flagged
`matched: false`. An explicit `categoryHint` wins over auto-scoping.

#### Cross-chain matching (best-effort, documented bias)

- Metro, Wong and Vivanda are **Cencosud** storefronts sharing one
  product-ID space: two chains' offers with the same `productId` are the
  same product (`matchBasis: productId`).
- PlazaVea has a **separate id space**: it is matched against the Cencosud
  reference by normalized `grams` (or equally unknown masses), a compatible
  `pieceCount`, and ≥ 50% shared name tokens, accent/case-insensitively
  (`matchBasis: name-unit`) — see `matching.py`.
- Each chain's offer is its **own best-ranked candidate** (token overlap +
  weight consistency + price tiebreak), *not* a forced cross-chain id group —
  a chain's genuine low price (e.g. Metro's own-brand 5kg arroz at S/17.40
  vs the shared Costeño at S/20.90) must never be dropped.
- The cheapest decision compares **uniformly**: per-kg when *every* offer
  has a per-kg price, per-piece when every offer has a pack count, nominal
  otherwise. `matched: false` offers (e.g. quail eggs for a generic "huevos"
  query) still compete — the flags tell you when the winner is a
  substitution, not the same product.

### Cost & reliability

\~4 API calls per basket item (one per chain, staggered ~1/4 of
`requestDelaySecs` apart) plus one
category-tree fetch per chain per run; a 10-item basket runs in ~15–20 s.
Measured cloud-run cost: see `docs/FINDINGS.md`
(≈ USD 0.001–0.002/run on the free tier — the four VTEX endpoints are
keyless and ~100% up).

### Local development

```bash
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt jsonschema
.venv/bin/python -m unittest discover -s tests -v     # 47 tests
.venv/bin/python scripts/live_basket_run.py           # live run -> storage/live/
```

`tests/test_metro_parity.py` imports the live sibling
`metro-peru-scraper` module from the repo checkout and asserts the vendored
VTEX client (`src/peru_grocery_aggregator/vtex_client.py`) is behaviorally
identical — the build context of an Apify Actor is folder-scoped, so the
client is vendored verbatim rather than imported, and this test keeps the
two copies from drifting.

### What this Actor is not

Not a crawler (comparison product, one first page per item), no banner
scraping, no browsers, no other stores, no calls to sibling Actors via
Apify's API (the aggregator hits the VTEX endpoints directly — no stacked
per-run billing).

# Actor input Schema

## `basket` (type: `array`):

Grocery items to price, 1–50 free-text lines (e.g. 'arroz extra 5kg', 'leche evaporada chica', 'huevos', 'pollo entero'). Use SPECIFIC item names (+ size/format hints like '5kg', '1L', '30un'): chain full-text search bleeds across categories, so a generic 'aceite' can surface tuna-in-oil and 'huevos' can surface quail eggs as cheaper substitutions. 'aceite vegetal 1L', 'huevos de gallina', or the categoryHint input fix this. Optional since 0.2.3: an empty run input {} prices the two-item starter basket below (issue #203 auto-test gate).

## `categoryHint` (type: `string`):

Optional free-text category applied to every item's search (e.g. 'abarrotes'). Resolved per chain against that chain's own category tree. By default each item is already auto-scoped the same way when its own text names a category (e.g. 'huevos', 'pollo entero' — try adding such items to the basket); set this only to override that with one category for the whole basket. Chains where the hint does not uniquely resolve fall back to plain full-text search with categoryResolved=false on their records.

## `requestDelaySecs` (type: `number`):

Politeness pause between items; within an item the four chains are queried concurrently but staggered — each chain starts 1/4 of this delay after the previous one, so the four hosts are not hit at the same instant. Minimum 0.5 seconds.

## Actor input object example

```json
{
  "basket": [
    "arroz extra 5kg",
    "leche evaporada chica"
  ],
  "requestDelaySecs": 1
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `summary` (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 = {
    "basket": [
        "arroz extra 5kg",
        "leche evaporada chica"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("barefoot_grade/peru-grocery-basket-aggregator").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 = { "basket": [
        "arroz extra 5kg",
        "leche evaporada chica",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("barefoot_grade/peru-grocery-basket-aggregator").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 '{
  "basket": [
    "arroz extra 5kg",
    "leche evaporada chica"
  ]
}' |
apify call barefoot_grade/peru-grocery-basket-aggregator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,barefoot_grade/peru-grocery-basket-aggregator"
        }
    }
}
```

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/hk8Atf9cqiTfgc8E7/builds/SjkgxzHdhVNS4MmBl/openapi.json
