Auto-planning a vegan week at Jumbo ā 5 dinners, one shopping list
This builds a vegan week meal plan by using Pepesto's /suggest endpoint filtered by the vegan tag, then prices each of 5 dinners at Jumbo.com using /products. The output is a per-recipe shopping list with estimated cost per meal.
Run this yourself
$ PEPESTO_API_KEY=your_key node jumbo-vegan-week-meal-plan.jsFull script: jumbo-vegan-week-meal-plan.js. You'll need an API key to run it ā get one here.
Getting started
The API key arrives immediately.
async function requestApiKey(email) {
console.log(`\nRequesting API key for: ${email}`);
const response = await fetch(`${BASE_URL}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`POST /api/link failed (${response.status}): ${text}`);
}
const data = await response.json();
console.log('\nā Done, data contains now your api key.');
console.log('\nOnce you have your key, run:');
console.log(' export PEPESTO_API_KEY=pep_sk_your_key_here\n');
return data;
}The first call ā finding vegan recipes with /suggest
The /suggest endpoint searches the Pepesto recipe database by query. Put the dietary requirement directly in the query string ā the API uses it to select appropriately tagged recipes from the database.
// /suggest returns 3 recipes per call, so several queries are needed to fill a
// week. The same dish can come back from two queries ā and even twice within a
// single call ā with a different kg_token each time, so duplicates have to be
// dropped by title. Each query pulls on a different style of vegan dinner.
async function suggestVeganDinners(limit = 5) {
const queries = [
'vegan dinner with beans or lentils',
'vegan pasta or noodle dinner',
'vegan curry or stir fry dinner',
];
const responses = await Promise.all(queries.map(async (query) => {
const response = await fetch(`${API_BASE}/suggest`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({ query }),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`/suggest failed: ${response.status} ${text}`);
}
return JSON.parse(text);
}));
const seen = new Set();
const recipes = [];
for (const r of responses.flatMap(d => d.recipes)) {
const key = r.title.trim().toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
recipes.push(r);
}
if (recipes.length < limit) {
console.log(`Only ${recipes.length} distinct dinners came back ā planning for those.`);
}
return recipes.slice(0, limit);
}Each recipe comes back with a full ingredient list, steps, nutrition data, and a kg_token. The token is what gets passed to /products ā it encodes the ingredient graph so the matcher can find the right Jumbo SKUs.
{
"title": "Ainsley Harriott Roasted Vegetable Couscous Bowl",
"ingredients": [
"200g couscous", "1 courgette (~250g)", "1 red pepper (~110g)",
"1 red onion (~150g)", "400g canned chickpeas", "olive oil",
"cumin", "smoked paprika", "salt", "black pepper", "lemon juice"
],
"nutrition": {
"calories": 1820,
"carbohydrates_grams": 280,
"protein_grams": 62,
"fat_grams": 38
},
"kg_token": "EjEKL0dyZWVrLXN0eWxlIGJha2VkIGZpc2ggd2l0aC..."
}Look up all five recipes at Jumbo in parallel:
async function fetchJumboProducts(kgToken) {
const response = await fetch(`${API_BASE}/products`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
recipe_kg_tokens: [kgToken],
supermarket_domain: 'jumbo.com',
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`/products failed: ${response.status} ${text}`);
}
const data = await response.json();
return data.items;
}Each ingredient line comes back with its matching Jumbo products ordered by price, so the cheapest one is the first entry:
function estimateCost(items) {
return items.reduce((total, item) => {
if (!item.products || item.products.length === 0) return total;
const prices = item.products.map(p => p.product.price.price).sort((a, b) => a - b);
return total + prices[0];
}, 0);
}Printing a day is then just a matter of walking the matched items and flagging anything the catalog could not resolve:
function printMealSummary(title, items, index) {
const costCents = estimateCost(items);
const matchedCount = items.filter(i => i.products?.length > 0).length;
console.log(`\n Day ${index}: ${title}`);
console.log(` ${'ā'.repeat(55)}`);
console.log(` Estimated cost at Jumbo: ${formatEUR(costCents)} (${matchedCount}/${items.length} items matched)\n`);
items.forEach(item => {
if (!item.products || item.products.length === 0) {
console.log(` ⢠${item.item_name.padEnd(32)} [no match found]`);
return;
}
const top = item.products[0].product;
const price = formatEUR(top.price.price);
const promo = top.price.promotion?.promo ? ' [aanbieding]' : '';
console.log(` ⢠${item.item_name.padEnd(32)} ${top.product_name} ā ${price}${promo}`);
});
}What the data showed
The Jumbo catalog matching for vegan recipes was solid. Canned goods ā AH Organic corn kernels, Akfa Tomato Puree 420g at ā¬2.39, A Trade Mark Ketjap Manis Kentel No. 1 Sweet Soy Sauce at ā¬2.99 ā all matched cleanly. Al Fez Tahini 125g at ā¬2.99 appeared in the Jumbo catalog for recipes that needed a tahini-based sauce.
Fresh produce matching was solid on staples (onions, courgette, peppers, garlic) but occasionally missed on more specific items like particular mushroom varieties. The Cooked Ham entry (Achterham Gegaard 175g at ā¬2.08) is not relevant to vegan recipes ā it came through because of an edge case in a recipe that had a ham-based sauce variation. Add a post-filter to check tags on matched products to catch these cases.
Estimated meal costs ranged from about ā¬4.50 to ā¬9.80 per recipe, depending on whether it relied on expensive fresh produce or cheap pantry staples. The weekly total for five dinners came in around ā¬32.
Next steps
The natural extension is turning the shopping list into an actual Jumbo cart. Pass the session_token from each matched product into /api/session to get a checkout link. The session response would look like:
{
"session_id": "ses_9pMzVtLkRnQw3bXj"
}Open that link and the Jumbo basket is pre-filled. Pass the session tokens from the /products response directly ā no additional state needed.
The result
A five-dinner vegan meal plan with a complete Jumbo shopping list, built in two API calls. The vegan tag filter is effective but not perfect ā occasionally a recipe includes a borderline ingredient (honey, or bread that may contain eggs). Review recipe titles before finalising.
What else you could do?
Add a "no repeats" filter: track which recipes have appeared in the last four weeks and exclude them from suggestions so the plan rotates. Add a nutritional balance check ā confirm the week has sufficient protein across all five meals using the nutrition field in the /suggest response. Compare Jumbo vs AH prices for the same week's recipes ā some weeks one supermarket is significantly cheaper depending on promotions, and switching requires only changing the supermarket_domain.