DEV Community

Cover image for How I parse pack sizes like 6x0,5l, 25 dag and Lt 2,0X6 across 27 European countries
Emir Mrkaljević
Emir Mrkaljević

Posted on

How I parse pack sizes like 6x0,5l, 25 dag and Lt 2,0X6 across 27 European countries

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.

Unit price badge and shrinkflation warning on a demo product

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 }
Enter fullscreen mode Exit fullscreen mode

The unit price is then just price / amount:

unitPrice(2.49, parseSize('450 g'))   // { value: 5.53, per: 'kg' }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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]
};
Enter fullscreen mode Exit fullscreen mode
Kava 25 dag                  -> 250 g
Прясно мляко 1 л             -> 1000 ml
My Fresco Γάλα Πλήρες 1lt    -> 1000 ml
Jaja M 10 kom                -> 10 pcs
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. schema.org data on the page (netContent, then weight). A size written in the title wins over weight, because some shops put the shipping weight there (1.032 kg for a 1 l carton).
  2. Labelled fields in the page language: "Inhalt", "Menge", "Contenu", "Neto količina", "Nettomängd"…
  3. A short text element right under the product title, skipping nutrition tables. Otherwise "per 100 g" becomes the pack size.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (0)