DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

Trust, but verify: cross-checking scraped prices against the shop's own unit price

ShrinkWatch EU is an open source browser extension that shows the price per kg, litre or piece on European supermarket web shops and warns when a pack got smaller since you last saw it. It works out the unit price itself: pack price divided by pack size. Both numbers come from messy HTML, and either can be read wrong.

Luckily, EU law (directive 98/6/EC) makes shops print their own unit price too. So the page usually holds a second opinion. This post is about how the extension finds it and uses it.

Why bother checking

A wrong pack size is not just a wrong badge. The extension stores what it saw, and a later reading that looks smaller would raise a shrinkflation warning. One bad parse today can become a false accusation next week. That is the real reason for the check.

Real examples that the cross-check caught or would have caught:

  • A German shop where a fallback joined two stray "08" cent elements into a price of 8.08 for a 6 kg multipack. The shop said "Grundpreis: 15.08 / kg".
  • A Greek shop where a pack size was misread, while the shop said "1,05 €/λίτρο".

Many ways to write the same number

Shops print their unit price in a surprising number of forms:

2,84 €/kg          (most of the EU)
1,05 €/λίτρο       (Greece)
Grundpreis: 15.08 / kg
1 kg = 344,66 Kč   (Lidl CZ)
1 l = 4 998 Ft     (Lidl HU, thousands separated by a space)
1,367 €/L          (three decimals)
Enter fullscreen mode Exit fullscreen mode

The extension handles two shapes. "Amount per unit":

const UNIT_PRICE = /(?<![\d.,])(\d+(?:[.,]\d{1,2})?)\s*(?:€|eur|kr|kč|zł|ft|lei)?\s*\/\s*(?:1\s*)?(kg|l|100\s*g|100\s*ml)/i;
Enter fullscreen mode Exit fullscreen mode

and "unit equals amount", which Lidl uses in several countries:

const UNIT_PRICE_EQ = /(?<![\d.,])(1|100)\s*(kg|g|l|ml)\s*=\s*(\d{1,3}(?:[ \u00a0\u202f]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)(?![\d])/i;
Enter fullscreen mode Exit fullscreen mode

Two details matter. The lookbehind (?<![\d.,]) makes sure the number starts the figure, so "1,367 €/L" is never read as "367 €/L". And the thousands group accepts a normal space, a no-break space and a narrow no-break space, because "4 998" can be written with any of them.

Don't read the unit price as the pack size

Lidl CZ prints pack size and unit price in one line: "580 g, 1 kg = 344,66 Kč". A size parser happily finds "1 kg" there. So before parsing the size, the "1 kg = ..." clause is removed:

const BASE_CLAUSE = /\(?\s*(?<![\d.,])(?:1|100)\s*(?:kg|g|l|ml|cl)\s*=[^)]*\)?/gi;
const size = parseSize(text.replace(BASE_CLAUSE, ' ')); // "580 g"
Enter fullscreen mode Exit fullscreen mode

The rule

Once both figures exist, the comparison is simple:

if (shop && shop.per === unitPrice.per && Math.abs(unitPrice.value / shop.value - 1) > 0.05) {
  unitPrice = { value: shop.value, per: shop.per };
  unitPriceFromShop = true;
}
Enter fullscreen mode Exit fullscreen mode

If the computed unit price is more than 5 % away from the shop's figure, the badge shows the shop's figure, and the reading is not stored in the history. So it can never trigger a shrinkflation warning later. The 5 % margin allows for rounding: one Irish shop cuts instead of rounding, and prints €1.12 where the real value is €1.125.

Sizes that were only estimated from the shop's unit price are never cross-checked against that same figure, and they never raise a warning either.

Tested without a browser

Every form above has a test built from real page markup, run with node --test:

test('Lidl CZ: "580 g, 1 kg = 344,66 Kč" in one line, priced in koruna', () => {
  const p = extract(lidlPlain('Tea concentrate', '199,90Kč', '580 g, 1 kg = 344,66 Kč'), adapterFor('www.lidl.cz'), url);
  assert.strictEqual(formatSize(p.size), '580 g');
});
Enter fullscreen mode Exit fullscreen mode

The fixes were then checked on real product pages in a normal desktop Chrome.

No requests

All of this runs on the page you are looking at. The extension makes no network requests of its own, and history stays in your browser.

This article was written with AI assistance. The code, tests and examples come from the repo.

Top comments (0)