ShrinkWatch EU is a free browser extension. On grocery web shops it shows the price per kg, litre or piece, and it warns you when a pack gets smaller for the same price. It runs on about 70 shops in 27 European countries.
Reading the price is the easy part. Most of the bugs were in reading how much is in the pack. Here's what that looks like in practice.
The goal
Turn any product title or label into one number in a base unit: grams, millilitres or pieces.
parseSize('Pivo 6x0,5l')
// { amount: 3000, unit: 'ml', count: 6, each: 500 }
The unit price is then just price / amount:
unitPrice(2.49, parseSize('450 g')) // { value: 5.53, per: 'kg' }
1. Decimal commas, and the numbers that aren't sizes
Most of Europe writes 0,5 l, not 0.5 l. Titles are also full of numbers that look like sizes but aren't:
Mlijeko 2,8% m.m. 1 l -> 1 l (not 2.8)
Mleko 3,2% 1 l -> 1 l
The number pattern accepts either separator, and a size only counts when a unit follows it right away. A unit is only a unit if no other letter comes after it. That rules out "l" in "light", or "g" at the start of "gouda":
const NUM = '(\\d+(?:[.,]\\d+)?)';
const SINGLE = new RegExp('(?:^|[^\\d.,])' + NUM + '\\s*' + UNIT + '(?![a-zčćšžđа-яα-ω])', 'gi');
The negative lookahead covers Latin, Cyrillic and Greek letters, because all three show up in shop titles.
2. A unit table, not a list of regexes
Units come in many spellings. All of them map to a base unit and a factor:
const UNITS = {
g: ['g', 1], gr: ['g', 1], gramm: ['g', 1], gramos: ['g', 1],
dag: ['g', 10], // Croatia, Slovenia, Serbia, Bosnia: "25 dag" = 250 g
kg: ['g', 1000], kilogramm: ['g', 1000],
ml: ['ml', 1], cl: ['ml', 10], dl: ['ml', 100],
l: ['ml', 1000], lt: ['ml', 1000], litro: ['ml', 1000], litros: ['ml', 1000],
'г': ['g', 1], 'кг': ['g', 1000], 'мл': ['ml', 1], 'л': ['ml', 1000], // Bulgarian, Serbian
'γρ': ['g', 1], 'κιλό': ['g', 1000], 'λτ': ['ml', 1000], 'λίτρο': ['ml', 1000], // Greek
kom: ['pc', 1], stk: ['pc', 1], 'stück': ['pc', 1], pcs: ['pc', 1]
};
Kava 25 dag -> 250 g
Прясно мляко 1 л -> 1000 ml
My Fresco Γάλα Πλήρες 1lt -> 1000 ml
Jaja M 10 kom -> 10 pcs
The alternation in the regex is sorted longest first. That way kilogram wins over kg, and ml isn't read as m + l.
3. Multipacks
6x0,5l and 6 × 500 ml mean 3 litres. These are matched before single sizes:
const MULTI = new RegExp(NUM + '\\s*[x×*]\\s*' + NUM + '\\s*' + UNIT + '(?![a-zčćšžđа-яα-ω])', 'i');
Keeping count and each matters for shrinkflation. A pack going from 6 × 500 ml to 6 × 450 ml is the same "6-pack" with less in it.
4. Unit first: "Lt 2,0X6"
Some Italian shops put the unit before the number:
Sant'Anna Pet Lt 2,0X6 Naturale -> 6 × 2 l = 12 l
This is a fallback that only runs when nothing else matched. It's limited to units that can't be confused with normal words (lt, kg, gr, ml, cl):
const UNIT_FIRST = /(?:^|[^a-z])(lt|kg|gr|ml|cl)\.?\s*(\d+(?:[.,]\d+)?)\s*(?:[x×*]\s*(\d+))?(?![\d.,])/i;
A test makes sure it doesn't fire on "Raclette Grill 6 coupelles".
5. When the title has no size at all
Plenty of products are called just "Fresh milk 3%". The extension then looks in this order:
- schema.org data on the page (
netContent, thenweight). A size written in the title wins overweight, because some shops put the shipping weight there (1.032 kg for a 1 l carton). - Labelled fields in the page language: "Inhalt", "Menge", "Contenu", "Neto količina", "Nettomängd"…
- A short text element right under the product title, skipping nutrition tables. Otherwise "per 100 g" becomes the pack size.
- As a last resort, the size worked out from the shop's own price per kg. This is shown with "≈" and never triggers a shrinkflation warning.
6. Prices are messy too
"269 99" -> 269.99 (euros and cents in separate elements)
"1 59 €" -> 1.59
"32,-" -> 32
"1.234,56 €" -> 1234.56
Reading innerText instead of textContent keeps the space between the euro and cent elements. Otherwise 269 + 99 becomes 26999.
Testing against real pages
Every fix comes with a test. For each supported shop, the repo keeps a small trimmed excerpt of a real product page: the structured data, the title and the price block. It then checks that the price, currency and pack size come out right:
for (const [id, [price, currency, amount, unit]] of Object.entries(expected)) {
test(`shop fixture: ${id}`, () => {
const { document } = parseHTML(fs.readFileSync(`test/fixtures/${id}.html`, 'utf8'));
const p = extract(document, ADAPTERS.find(a => a.id === id), '');
assert.equal(p.price, price);
assert.equal(p.size.amount, amount);
});
}
The excerpts are made automatically. The script keeps removing parts of the saved page for as long as the extracted result stays the same. A 300 KB page usually ends up as 1–3 KB of HTML.
No server, no requests
Everything runs on the page you're looking at. The extension makes no network requests of its own, and the price history stays in your browser's local storage. Shops other than a small default set are optional permissions you switch on yourself.
Try it
- Website and install: https://emirmrkaljevic.github.io/shrinkwatch-eu/
- Source (MIT): https://github.com/emirmrkaljevic/shrinkwatch-eu
- Found a pack size it gets wrong? Report it with the product link. That's exactly the kind of edge case this article is about.
This article was written with AI assistance. The code, tests and examples come from the repo.

Top comments (0)