The £20 Shopping Shock That Started Everything
Standing in Tesco with a £43 bill for what used to cost £23 pre-pandemic, I had an idea. What if I could track every major UK supermarket's prices in real-time and automatically find the cheapest option?
As a Node.js developer earning £48k but watching my grocery bill triple, I decided to fight back with code.
The Great British Price Scrape
I built a Express.js API that monitors prices across all major UK supermarkets:
// The Express server that changed my shopping game
const express = require('express');
const puppeteer = require('puppeteer');
const cron = require('node-cron');
const mongoose = require('mongoose');
const app = express();
// Price scraping service
class UKSupermarketScraper {
constructor() {
this.supermarkets = [
{ name: 'Tesco', baseUrl: 'https://www.tesco.com' },
{ name: 'ASDA', baseUrl: 'https://groceries.asda.com' },
{ name: 'Sainsburys', baseUrl: 'https://www.sainsburys.co.uk' },
{ name: 'Morrisons', baseUrl: 'https://groceries.morrisons.com' },
{ name: 'ALDI', baseUrl: 'https://www.aldi.co.uk' },
{ name: 'Lidl', baseUrl: 'https://www.lidl.co.uk' }
];
}
async scrapeAll(productList) {
const browser = await puppeteer.launch({ headless: true });
const results = [];
for (const supermarket of this.supermarkets) {
try {
const prices = await this.scrapeStore(browser, supermarket, productList);
results.push({ store: supermarket.name, prices });
} catch (error) {
console.log(`Failed to scrape ${supermarket.name}:`, error.message);
}
}
await browser.close();
return results;
}
}
The Shocking Discovery
After scraping 50,000+ product prices daily for 2 weeks, I found:
// Price analysis that blew my mind
const priceAnalysis = {
"Heinz_Baked_Beans_415g": {
"Tesco": "£1.25",
"ASDA": "£1.00",
"Sainsburys": "£1.30",
"Morrisons": "£1.10",
"ALDI": "£0.85",
"Lidl": "£0.79",
"savings_potential": "£0.46 (37%)"
},
"weekly_shopping_basket": {
"most_expensive": "Sainsburys - £89.43",
"cheapest": "ALDI - £52.17",
"potential_savings": "£37.26 per week"
}
};
Mind-blowing insight: The same weekly shop ranged from £52 to £89 depending on the store. That's £1,938 yearly difference!
Building the Express API
// RESTful API for price comparisons
app.get('/api/compare/:barcode', async (req, res) => {
try {
const { barcode } = req.params;
const product = await Product.findOne({ barcode });
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
const prices = await Price.find({
productId: product._id,
date: { $gte: new Date(Date.now() - 24*60*60*1000) }
}).populate('store');
const comparison = {
product: product.name,
prices: prices.map(p => ({
store: p.store.name,
price: p.price,
lastUpdated: p.date
})),
cheapest: prices.reduce((min, p) => p.price < min.price ? p : min),
savings: prices.reduce((max, p) => p.price > max.price ? p : max).price -
prices.reduce((min, p) => p.price < min.price ? p : min).price
};
res.json(comparison);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Price alert system
app.post('/api/alerts', async (req, res) => {
const { userId, productId, targetPrice, email } = req.body;
const alert = new PriceAlert({
userId,
productId,
targetPrice,
email,
active: true
});
await alert.save();
res.json({ message: 'Alert created successfully' });
});
The Real-Time Monitoring System
// Cron job for continuous price monitoring
cron.schedule('0 */6 * * *', async () => {
console.log('Starting price scrape...');
const scraper = new UKSupermarketScraper();
const products = await Product.find({ active: true });
for (const product of products) {
try {
const prices = await scraper.scrapeAll([product.name]);
for (const storeData of prices) {
await Price.create({
productId: product._id,
store: storeData.store,
price: storeData.prices[0]?.price,
date: new Date()
});
// Check for price alerts
await checkPriceAlerts(product._id, storeData.prices[0]?.price);
}
} catch (error) {
console.error(`Error processing ${product.name}:`, error);
}
}
});
// Price alert checker
async function checkPriceAlerts(productId, newPrice) {
const alerts = await PriceAlert.find({
productId,
active: true,
targetPrice: { $gte: newPrice }
});
for (const alert of alerts) {
await sendPriceAlert(alert.email, productId, newPrice);
alert.active = false;
await alert.save();
}
}
It Went Viral
I shared it in our local Facebook group "Stirchley Community." Within 48 hours:
- 127 neighbors signed up
- £847 collective savings in the first week
- Local news picked it up (Birmingham Mail)
- Tesco corporate reached out (more on this below...)
The Middleware Magic
// Rate limiting for scraping protection
const rateLimit = require('express-rate-limit');
const scraperLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP'
});
app.use('/api/scrape', scraperLimit);
// Authentication middleware
const authenticateUser = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Please authenticate' });
}
};
The Reality Check
Week 3: Started getting blocked by some supermarket websites. Had to implement:
// Anti-detection measures
const scrapeWithRotation = async (url) => {
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
];
const page = await browser.newPage();
await page.setUserAgent(userAgents[Math.floor(Math.random() * userAgents.length)]);
await page.setViewport({ width: 1366, height: 768 });
// Random delays to avoid detection
await page.waitForTimeout(Math.random() * 3000 + 1000);
return page.goto(url);
};
The cat-and-mouse game: Some stores started implementing rate limiting, so I had to get creative with proxy rotation and request timing.
Building the Business: "ChopperBot"
// Subscription management
app.post('/api/subscribe', authenticateUser, async (req, res) => {
const { plan } = req.body; // 'basic' or 'premium'
const subscription = new Subscription({
userId: req.user._id,
plan,
price: plan === 'premium' ? 4.99 : 0,
startDate: new Date(),
active: true
});
await subscription.save();
if (plan === 'premium') {
// Enable premium features
await User.findByIdAndUpdate(req.user._id, {
premiumFeatures: true,
maxAlerts: 50,
advancedAnalytics: true
});
}
res.json({ message: 'Subscription activated' });
});
Revenue Streams:
- Premium subscriptions: £4.99/month (450 users)
- Price alert notifications: £1.99/month (800 users)
- API access for developers: £29/month (12 users)
- Advanced analytics dashboard: £9.99/month (150 users)
Current MRR: £4,200
Note: This is still running on localhost while I work on proper hosting and legal compliance.
The Technical Stack
// Complete Express.js architecture
const dependencies = {
"express": "^4.18.2",
"mongoose": "^7.0.3",
"puppeteer": "^19.7.2",
"node-cron": "^3.0.2",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.3",
"nodemailer": "^6.9.1",
"express-rate-limit": "^6.7.0",
"helmet": "^6.0.1",
"cors": "^2.8.5"
};
// Performance optimizations
app.use(helmet()); // Security headers
app.use(express.json({ limit: '10mb' }));
app.use(cors());
// Database connection with connection pooling
mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10,
bufferMaxEntries: 0
});
The Community Impact
Real savings from Birmingham neighbors:
"Saved £23 on my weekly shop by switching from Sainsburys to ALDI" - Sarah, Moseley
"The price alerts saved me £180 on my monthly bulk buy" - Marcus, Kings Heath
"Finally beating inflation with smart shopping!" - Jennifer, Harborne
Scaling Challenges & Solutions
// Redis caching for performance
const redis = require('redis');
const client = redis.createClient();
app.get('/api/product/:id', async (req, res) => {
try {
// Check cache first
const cached = await client.get(`product:${req.params.id}`);
if (cached) {
return res.json(JSON.parse(cached));
}
// Fetch from database
const product = await Product.findById(req.params.id);
// Cache for 1 hour
await client.setEx(`product:${req.params.id}`, 3600, JSON.stringify(product));
res.json(product);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
The Open Source Core (Coming Soon)
Currently running on localhost while I clean up the code and handle legal considerations around web scraping.
# Will be available soon at:
# github.com/yourusername/uk-price-tracker
# Current local setup:
npm install express mongoose puppeteer node-cron
node server.js
# Visit localhost:3000
Legal note: Web scraping exists in a grey area. I'm working with a solicitor to ensure compliance before public release.
What's Next: Expanding Across Europe
// European supermarket support coming soon
const europeanChains = {
"Germany": ["Aldi", "Lidl", "Rewe", "Edeka"],
"France": ["Carrefour", "Leclerc", "Auchan"],
"Spain": ["Mercadona", "Carrefour", "Dia"],
"Netherlands": ["Albert Heijn", "Jumbo", "Plus"]
};
Vision: Help European families save €50M annually through smart shopping.
Looking back, this whole project started from a moment of frustration in a Tesco aisle. What began as "I wonder if I could automate price comparisons" turned into a legitimate side business helping hundreds of families save money during tough times.
The best part? It's solving a real problem that affects everyone. While I'm still working out the legal and technical challenges, seeing neighbors save £20-40 weekly makes every late night debugging session worth it.
Sometimes the most impactful projects come from the simplest ideas: "What if I could just check all the prices automatically?"
Update: Since posting, I've had 23 developers ask to contribute and several people offering to help with the legal aspects. Turns out a lot of people want to fight back against grocery inflation! 🛒
Top comments (0)