Since 2022 every shop in the EU that advertises a price reduction has to show the lowest price it charged in the 30 days before the reduction. That is Article 6a of the Price Indication Directive, added by the Omnibus Directive 2019/2161. The rule is the same everywhere. The way shops print it is not.
I maintain truediscount, a free, open source extension for Chrome and Firefox that puts a small badge on product pages and tells you whether a discount is real. This post is about the messiest part of that job: finding the 30-day price on a page, in whatever language and layout the shop chose.
What the label looks like across countries
A few real phrasings, collected from shop pages:
| Country | Typical label |
|---|---|
| Germany, Austria | Niedrigster Preis der letzten 30 Tage: 49,99 € |
| France | Prix le plus bas des 30 derniers jours : 49,99 € |
| Italy | Prezzo più basso degli ultimi 30 giorni: 49,99 € |
| Spain | Precio más bajo de los últimos 30 días: 49,99 € |
| Netherlands, Belgium | Laagste prijs van de afgelopen 30 dagen: € 49,99 |
| Poland | Najniższa cena z 30 dni przed obniżką: 49,99 zł |
| Czechia | Nejnižší cena za posledních 30 dní: 1 299 Kč |
| Romania | Cel mai mic preț din ultimele 30 de zile: 249,99 lei |
| Sweden | Lägsta pris senaste 30 dagarna: 499 kr |
On top of the language there are layout differences:
- Inline label. The number sits in the same short element as the words. This is the easy case.
- Footnote. The struck price carries an asterisk and the explanation lives in the page footer, far away from the number. About You does this with "Letzter niedrigster Preis: der letzten 30 Tage vor der Preisermäßigung".
- Tooltip or popover. Alternate puts the explanation in a popover attribute on an info icon next to the price.
- Nothing at all next to the struck price. The crossed out number is the shop's own earlier price and you have to trust that it is the 30-day low.
- RRP instead. The struck price is the manufacturer's recommended price ("UVP" in German, "PVPR" in Spanish, "adviesprijs" in Dutch). Comparing against RRP is legal, but it says nothing about what the shop charged last month.
Case 5 matters most for shoppers, because "was 599, now 349" reads like a 42% cut when it is really a cut against a list price nobody paid.
How the extension reads it
The extractor runs in three passes, all on the page that is already open. Nothing is crawled and nothing leaves the browser.
Pass 1: the current price. Structured data first (JSON-LD Product and Offer, including products nested inside other types, then microdata), then a per shop selector if one exists, then generic selectors.
Pass 2: the advertised old price. Per shop selectors first, then generic ones such as s, del, [style*="line-through"] and class names containing old-price or strike. A candidate only counts if it is higher than the current price and less than 20 times it, which filters out unrelated numbers.
The kind of old price is decided by its label. The newest fix (v0.9.0) came from Lidl, which renders UVP: <s>599.00</s>. The word sits in the parent element, not inside the <s>, so the extension now also looks at the short parent text:
const label = (el.parentElement?.textContent || '').length < 60 ? el.parentElement.textContent : '';
oldKind = /rrp|uvp|retail price|adviesprijs|\bmsrp\b/i.test(`${selector} ${el.textContent} ${label} ${hint}`)
? 'rrp' : 'previous';
On lidl.fr the same layout shows a bare <s>49.99 €</s> with no label, and that stays a "previous price".
Pass 3: the shop's own 30-day disclosure. Two regular expressions do most of the work (shortened here): one for "lowest" in about 15 languages and one for "30 days":
const LOW_WORD = /(lowest|niedrigste|tiefstpreis|le plus bas|più basso|más bajo|laagste|najniższa|nejnižší|cel mai mic|lägsta|laveste|alhaisin|legalacsonyabb|najniža)/iu;
const DAYS_30 = /30[\s\u00a0-]*(?:tage?n?|days?|jours|giorni|días|dagen|dagarna|dni|dní|zile|dana|nap)/giu;
When both match inside a short element, the first price in that element becomes the shop's declared 30-day low. Prices are parsed with EU formats in mind: 1.299,99 €, 1 299 Kč, 49,- and € 49,99 all come out as numbers.
The verdict
With those three numbers the badge says one of a few things:
- The old price matches the shop's 30-day low: the discount is real.
- The old price is higher than the declared 30-day low: the discount is inflated, and the badge shows the real percentage.
- The old price is an RRP: the badge says so and shows the 30-day low if the shop printed one.
- No disclosure on the page: the extension falls back to its own local price history (one price per day, kept 90 days on your device).
What is still hard
- Footnote layouts (case 2) are only understood when the footnote is close to the price. Matching asterisks across a whole page gives too many false hits.
- Many shops render prices with JavaScript after load, so the content script watches the page for changes and checks again once it settles.
- Several big shops block automated requests, so I can only build per shop rules from pages I can actually load. The supported shops page says which shops are tested against live pages and which only use generic rules.
If a shop near you prints the 30-day label in a way the extension misses, the popup has a "Report a problem" button that opens a prefilled GitHub issue with just the domain. That is the fastest way to get a new country covered before Black Friday. And if the badge gets in the way on a site you do not care about, v0.10.0 lets you hide it per domain from the popup; nothing is recorded on hidden sites.
Code and builds: github.com/emirmrkaljevic/truediscount
This article was written with AI assistance.
Top comments (0)