Watching 20 Continente staples daily — on the Starter pack
This prices a fixed basket of 20 Portuguese staples once a day with a single /catalog call in its preselected form, saves a snapshot, and prints what moved since the last run. The watchlist itself comes from a plain shopping list, resolved once with /products. Each daily run costs €0.96 and works on the Starter pack, which the full catalog does not.
Run this yourself
$ PEPESTO_API_KEY=your_key node continente-staples-price-watch.jsFull script: continente-staples-price-watch.js. You'll need an API key to run it — get one here.
Getting started
export PEPESTO_API_KEY=your_key_here
node continente-staples-price-watch.jsBuilding the watchlist
The preselected form of /catalog wants product page URLs, and nobody knows Continente's by heart. So the first run starts from the list as you would write it on paper — leite meio-gordo 1L, ovos médios, manteiga com sal… — and hands it to /products, which matches each line to real Continente products. Every match carries its page URL as product_id. The best match per line goes into continente-watchlist.json next to the script, and that file is the watchlist from then on.
async function buildWatchlist() {
console.log(`Matching ${SHOPPING_LIST.length} items at Continente...`);
const res = await fetch(`${BASE_URL}/products`, {
method: 'POST',
headers,
body: JSON.stringify({
supermarket_domain: DOMAIN,
manual_shopping_list: SHOPPING_LIST.join(', '),
item_names_locale: 'pt-PT',
}),
});
if (!res.ok) throw new Error(`/products failed: ${res.status}`);
const data = await res.json();
const watchlist = [];
for (const item of data.items ?? []) {
const best = item.products?.[0]?.product;
if (!best) {
console.log(` no match for "${item.item_name}", leaving it out`);
continue;
}
watchlist.push({ item: item.item_name, url: best.product_id });
}
writeFileSync(WATCHLIST_FILE, JSON.stringify(watchlist, null, 2));
console.log(`Watchlist of ${watchlist.length} products saved to ${path.basename(WATCHLIST_FILE)}.\n`);
return watchlist;
}Treat that file as a first draft, not a verdict. In the sample run /products did well on the produce and the dry goods, and picked things worth a second look elsewhere: "leite meio-gordo" came back as a skimmed milk, "atum em água" as tuna in oil, and "café moído" as a decaf instant cappuccino. Open the file, swap the URL for the product you actually buy, and the script never asks again. That one-time edit is cheaper than any amount of matching logic.
The preselected call
/catalog has three forms and they differ only in the request body. Send just the domain and you get every product Pepesto indexes for the chain, thousands of them, for €9.60. Add promo_only and you get the ones on offer for €3.20. Add product_urls, a list of up to 50 product page URLs, and you get exactly those products for €0.96. The first two need the Growth plan. The preselected form is available on Starter.
For a watchlist that is the right shape anyway. The rest of the range is noise, and paying for it daily would cost more than the basket.
async function fetchWatchlist(watchlist) {
console.log(`Pricing ${watchlist.length} Continente staples...`);
const res = await fetch(`${BASE_URL}/catalog`, {
method: 'POST',
headers,
body: JSON.stringify({ supermarket_domain: DOMAIN, product_urls: watchlist.map(w => w.url) }),
});
if (!res.ok) throw new Error(`/catalog failed: ${res.status}`);
const data = await res.json();
return data.parsed_products ?? {};
}The reply is the same parsed_products map every /catalog form returns, keyed by product URL, so code written against the full catalog works unchanged. Each product carries the Portuguese name, the price in cents, the unit price as a display string, the pack size, and promo with a promo_percentage when Continente publishes one. A URL Continente no longer lists is simply absent from the reply rather than returned with an error, so the script keeps the watchlist order and reports the gaps at the end instead of letting the basket total quietly shrink.
The daily report
Every run writes continente-staples-YYYY-MM-DD.json next to the script and reads the newest earlier one as its baseline. Run it from cron and the price history builds itself, with no database and nothing to configure. The report prints today's price for each item, flags what is on offer, totals the basket, and shows each item that moved since the previous snapshot.
function printReport(rows, previous) {
const byUrl = new Map((previous?.rows ?? []).map(r => [r.url, r]));
console.log(`\n=== Continente staples basket${previous ? ` — changes since ${previous.date}` : ''} ===\n`);
let total = 0;
let promos = 0;
const missing = [];
for (const row of rows) {
if (!row.listed) {
missing.push(row);
continue;
}
total += row.price;
if (row.promo) promos += 1;
const before = byUrl.get(row.url);
let movement = '';
if (before?.listed && before.price !== row.price) {
const delta = row.price - before.price;
movement = ` (${delta > 0 ? '+' : '-'}${formatPrice(Math.abs(delta))} since ${previous.date})`;
}
const promo = row.promo ? (row.promoPercentage ? ` — ${row.promoPercentage}% off` : ' — on offer') : '';
console.log(`${formatPrice(row.price).padStart(7)} ${row.name}${promo}${movement}`);
if (row.perUnit) console.log(` ${row.perUnit}`);
}
console.log(`\nBasket total: ${formatPrice(total)} for ${rows.length - missing.length} items, ${promos} on offer.`);
if (previous) {
const beforeTotal = previous.rows.filter(r => r.listed).reduce((sum, r) => sum + r.price, 0);
const delta = total - beforeTotal;
console.log(`Since ${previous.date}: ${delta === 0 ? 'no change' : `${delta > 0 ? '+' : '-'}${formatPrice(Math.abs(delta))}`}.`);
}
if (missing.length > 0) {
console.log(`\n${missing.length} watched product(s) are no longer listed:`);
missing.forEach(row => console.log(` ${row.item}: ${row.url}`));
}
}What the data showed
On the first run the 20 items came to €24.68, with seven on offer, and every offer carried a percentage: 35% off the Gallo olive oil, 25% off the Pitéu tuna, 22% off the coffee, 21% off the Danone yoghurt, then 12%, 7% and 6% on the milk, butter and chickpeas. Own-label Continente dominates the basket, and the produce is cheap: bananas at €0.24 for 600g and a bag of red potatoes at €0.39.
Two things to know before trusting the numbers. First, price_per_meausure_unit is a display string in Continente's own format, and a few came back with a space where the decimal point should be — 1 00€/lt, 1 77€/kg. Print it, do not parse it. The basket total works from price, which is always an integer in cents. Second, /products is not deterministic: run the matching twice and a handful of lines land on a neighbouring product — the rice moved from a Saludães pack to Continente's own, the coffee from an instant decaf to L'Or capsules. That is exactly why the watchlist is resolved once and saved. After that the URLs are fixed and every day's price is for the same product.
The result
A cron line runs the script every morning. Each run costs €0.96, plus nothing for the watchlist after the first day, so a month of daily checks costs less than three full catalog pulls — and it runs on Starter credits with no monthly commitment. The snapshot files are plain JSON, so a spreadsheet or a small chart can read the history directly.
What else you could do?
Keep one shopping list and build a watchlist per chain, then compare the same basket across chains. Alert only when the basket moves by more than a threshold, or when a specific item goes on offer. Push the snapshot into a Google Sheet instead of a file and share the running index. Keep it to 50 URLs per call and split larger watchlists across calls.