In the EU, a shop that announces a price reduction must show the prior price, and Article 6a of the Price Indication Directive defines it: "the lowest price applied by the trader during a period of time not shorter than 30 days prior to the application of the price reduction." In September 2024 the EU Court of Justice added that a reduction announced as a percentage "must be determined on the basis of the lowest price applied by the trader" in those 30 days, so that traders cannot mislead consumers "by increasing the price charged before announcing a price reduction" (press release 152/24).
The rule is enforced. When consumer authorities from 23 EU countries, Iceland and Norway checked 314 online traders during Black Friday and Cyber Monday sales, in a sweep the European Commission coordinated, 30% "referenced discounts incorrectly" (Ireland's CCPC, 26 March 2026). Whether the struck price is on your own shop or a competitor's, one visit cannot tell you what the page charged in the 30 days before. Only a record kept while those days pass can, and it cannot be rebuilt afterwards.
A Python script captures each sale page once a day from the shop's country, records today's price from the page's structured data and the struck price the page shows, keeps the screenshot, and compares the struck price with the lowest price recorded in the 30 days before the reduction started. Start it a month before the sale you want to check: for Black Friday on 27 November 2026, a record started on 11 November covers 16 of those 30 days.
Check one sale page in the browser, free
- Open site-shot.com and paste the link of the sale page. Capturing is free and needs no signup.
- Leave the Country dropdown on Direct connection, our own servers without a proxy, or select United States. Those are the free choices; a view from Germany or another country on the list unlocks with any paid plan.
- Press Shot, save the image and write down the date, today's price, the struck price and any percentage the page shows.
- Repeat daily. By hand this works for a page or two; for a list of products, use the API.
Put the products in a file
You need Python 3.9 or newer, the Site-Shot SDK and an API key. Install the SDK:
pip install site-shot
For the key, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. The API quickstart gets you one. Free-plan captures come from the default location or the US only, so on the free plan leave the country column empty or put US: a capture from any other country is refused and still counts against the month, and that view needs any paid plan.
Every product costs one capture a run, and a month can hold 31 daily runs. The free plan's allowance keeps a daily record of 3 products; the $5 plan's keeps 64.
Put the sale pages in products.csv:
link,country,struck
https://shop.example/products/sweatpants,DE,Statt:
https://store.example/p/cpu,DE,.strike-price
https://brand.example/products/kettle,FR,
The country is the shop's market as a two-letter ISO code from the list of countries, such as DE or FR. The struck column tells the script where the page shows its struck price:
- leave it empty when the page's structured data carries it, as an offer price marked
StrikethroughPrice, the markup Google's merchant listings read for sale prices; - otherwise right-click the struck price on the page and choose Inspect. If it sits in an
<s>or<del>element, writesordel. If its element has a class such asstrike-price, write.strike-price. If a word comes before it, such asStatt:orWas, write the word as the page shows it, capital letters included.
Run it every day
Save this as price_record.py next to products.csv:
"""Keep a daily record of each sale page on products.csv for the EU 30-day prior-price rule.
python price_record.py (once a day)
Captures every link from its country, records today's price and the struck-through price the page
shows, and compares that struck price with the lowest price recorded in the 30 days before the
reduction started. Writes price-record/record.csv and price-record/report.html.
"""
import base64
import csv
import html
import json
import re
import time
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from site_shot import SiteShot, SiteShotError
client = SiteShot() # reads SITESHOT_API_KEY from the environment
OUT = Path("price-record")
RECORD = OUT / "record.csv"
FIELDS = ["date", "link", "country", "price", "currency", "struck", "landed", "image"]
SETTINGS = dict(
width=1280, height=1600,
no_cookie_popup=True, no_ads=True,
fresh_session=True, source_code=True,
strict_country=True,
)
# Writes the address the browser ended up on, after any redirect, into the page source.
FINAL_URL = "document.documentElement.setAttribute('data-final-url', location.href);"
WINDOW = 30 # days before the reduction: Article 6a(2) of Directive 98/6/EC
TODAY = date(*time.gmtime()[:3])
def same(a, b):
"""Whether two addresses name the same page, ignoring the case of scheme and host."""
def parts(url):
p = urlsplit(url if "//" in url else "https://" + url)
return p.scheme.lower(), p.netloc.lower(), unquote(p.path or "/"), p.query
try:
return parts(a) == parts(b)
except ValueError: # a malformed address, such as "https://[bad"
return a == b
def amount(value):
"""A price as a Decimal with two places, or None."""
text = str(value).strip()
if re.fullmatch(r"\d{1,3}(\.\d{3})*,\d{1,2}|\d+,\d{1,2}", text): # 39,99, 1,5 or 1.299,00
text = text.replace(".", "").replace(",", ".")
try:
found = Decimal(text.replace(",", ""))
return found.quantize(Decimal("0.01")) if found.is_finite() else None # not NaN
except (InvalidOperation, ValueError):
return None
def first_amount(text):
"""The first price in page text: "€ 333,00", ": 39,99€", "€ 1.299,-" or "1 299,00 €"."""
match = re.search(r"\d{1,3}(?:[ \u00a0\u202f.,]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?", text or "")
if not match:
return None
found = re.sub(r"[ \u00a0\u202f]", "", match.group(0)) # spaces that group thousands
if re.fullmatch(r"\d{1,3}(\.\d{3})+", found): # 1.299 written for 1,299
found = found.replace(".", "")
return amount(found)
def kinds(data):
kind = data.get("@type") or []
return {str(k) for k in (kind if isinstance(kind, list) else [kind])}
class Page(HTMLParser):
"""Today's price and the struck-through price a sale page shows, from its rendered HTML."""
def __init__(self, struck=""):
super().__init__(convert_charrefs=True)
self.struck = struck.strip()
self.final_url, self.offers, self.meta, self.blocks, self.text = None, [], {}, [], []
self.hidden = 0 # inside a script, style or noscript element
self.inside, self.capture, self.found = False, None, []
def wanted(self, tag, attrs):
if self.struck.startswith("."):
return self.struck[1:] in (attrs.get("class") or "").split()
return self.struck.lower() in ("s", "del", "strike") and tag == self.struck.lower()
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
key = (attrs.get("property") or "").lower()
if tag == "html":
self.final_url = attrs.get("data-final-url")
elif tag == "meta" and key in ("og:price:amount", "og:price:currency",
"product:price:amount", "product:price:currency"):
self.meta.setdefault(key, (attrs.get("content") or "").strip())
elif tag == "script" and (attrs.get("type") or "").strip().lower() == "application/ld+json":
self.inside = True
self.blocks.append("")
if tag in ("script", "style", "noscript"):
self.hidden += 1
if self.capture:
if tag == self.capture[0]:
self.capture[1] += 1
elif self.struck and self.wanted(tag, attrs):
self.capture = [tag, 1]
self.found.append("")
def handle_data(self, data):
if self.inside:
self.blocks[-1] += data
elif not self.hidden:
self.text.append(data)
if self.capture:
self.found[-1] += data
def handle_endtag(self, tag):
if tag == "script" and self.inside:
self.inside = False
try:
self.walk(json.loads(self.blocks[-1], strict=False)) # allows raw line breaks in text
except ValueError:
pass # an unreadable block declares nothing
if tag in ("script", "style", "noscript") and self.hidden:
self.hidden -= 1
if self.capture and tag == self.capture[0]:
self.capture[1] -= 1
if not self.capture[1]:
self.capture = None
def walk(self, data):
if isinstance(data, dict):
if {"Offer", "AggregateOffer"} & kinds(data):
specs = data.get("priceSpecification") or []
specs = [s for s in (specs if isinstance(specs, list) else [specs]) if isinstance(s, dict)]
struck = [amount(s.get("price")) for s in specs
if str(s.get("priceType") or "").endswith(("StrikethroughPrice", "ListPrice"))]
# An offer can carry its price only in a specification without a priceType.
plain = [s for s in specs if not s.get("priceType") and not s.get("validForMemberTier")]
source = data if "price" in data or "lowPrice" in data or not plain else plain[0]
currency = source.get("priceCurrency") or data.get("priceCurrency") or ""
self.offers.append({
"price": amount(source.get("price", source.get("lowPrice"))),
"currency": str(currency).upper() or None,
"struck": next((s for s in struck if s is not None), None),
"url": str(data.get("url") or ""),
})
data = list(data.values())
if isinstance(data, list):
for item in data:
self.walk(item)
def struck_price(self):
"""The struck-through price: the reader's selector or label, else the structured data's."""
if self.struck.startswith(".") or self.struck.lower() in ("s", "del", "strike"):
return next((a for a in map(first_amount, self.found) if a is not None), None)
if self.struck:
text = " ".join(" ".join(self.text).split())
label = r"(?<![^\W\d_])%s(?![^\W\d_])" % re.escape(self.struck) # not inside a word
for match in re.finditer(label, text):
found = first_amount(text[match.end():match.end() + 30])
if found is not None:
return found
return None
return None
def variant(url):
"""The variant id a Shopify-style product link selects, if any."""
return parse_qs(urlsplit(url).query).get("variant", [None])[0]
def capture(row, image):
"""Capture one product page, save the screenshot as OUT/image and return today's record row."""
options = dict(SETTINGS, javascript_code=FINAL_URL)
if row["country"]:
options["country"] = row["country"]
reply = client.capture_json(row["link"], **options)
(OUT / image).write_bytes(base64.b64decode(reply["image"].split(",", 1)[1]))
page = Page(row["struck"])
page.feed(reply.get("source_code") or "")
picked = [o for o in page.offers if variant(row["link"]) and variant(o["url"]) == variant(row["link"])]
offer = (picked or page.offers or [{"price": None, "currency": None, "struck": None}])[0]
meta = page.meta.get("og:price:amount") or page.meta.get("product:price:amount")
if offer["price"] is None and meta: # no price in the structured data: the price meta tag
currency = page.meta.get("og:price:currency") or page.meta.get("product:price:currency") or ""
offer = dict(offer, price=amount(meta), currency=currency.upper() or None)
struck = page.struck_price() if row["struck"] else offer["struck"]
return {
"date": TODAY, "link": row["link"], "country": row["country"],
"price": offer["price"], "currency": offer["currency"], "struck": struck,
"landed": page.final_url or row["link"], "image": image,
"status": reply.get("response", {}).get("status_code"),
"source": reply.get("source_code_status"),
}
def check(history, today):
"""What to look at in today's row, against the rows recorded before it for the same page."""
notes = [] if today["status"] == 200 else ["status %s" % today["status"]]
if today["source"] != "ok":
return notes + ["no page source (%s)" % today["source"]], {}
if not same(today["landed"], today["link"]):
notes.append("lands on %s" % today["landed"])
if today["price"] is None:
return notes + ["no price in the structured data"], {}
if today["struck"] is None:
return notes + ["no struck price"], {}
start = today["date"]
for row in reversed(history): # the reduction started on the first day of its run of struck prices
if row["price"] is None:
continue # a capture that read no price, such as a timed-out one, neither starts nor ends it
if row["struck"] is None or row["currency"] != today["currency"]:
break
start = row["date"]
window = [row for row in history if start - timedelta(days=WINDOW) <= row["date"] < start
and row["price"] is not None and row["currency"] == today["currency"]]
days = len({row["date"] for row in window})
facts = {"start": start, "days": days}
if days < WINDOW:
notes.append("the record covers %d of the %d days before %s" % (days, WINDOW, start))
if window:
low = min(window, key=lambda row: (row["price"], row["date"]))
facts.update(low=low["price"], low_date=low["date"])
if today["struck"] > low["price"]:
notes.append("struck %s, lowest recorded %s on %s" % (today["struck"], low["price"], low["date"]))
return notes, facts
def off(base, price):
"""The reduction from base to price, as a whole percentage."""
if base and base > price:
return "%d%% off" % ((base - price) * 100 / base).to_integral_value()
return "not below"
def products():
"""One dict per row of products.csv: link, country and how to find the struck price."""
with open("products.csv", newline="", encoding="utf-8-sig") as f:
first = f.readline()
f.seek(0)
reader = csv.DictReader(f, delimiter=";" if ";" in first and "," not in first else ",") # Excel
reader.fieldnames = [(name or "").strip().lower() for name in reader.fieldnames or []]
if "link" not in reader.fieldnames:
raise SystemExit("products.csv needs a link column (country and struck are optional)")
found = []
for n, row in enumerate(reader, 1):
link = (row.get("link") or "").strip()
if not link:
continue # a blank line
country = (row.get("country") or "").strip().upper()
if country and not re.fullmatch(r"[A-Z]{2}", country):
raise SystemExit("row %d: write the country as a two-letter code, such as DE" % n)
found.append({"n": n, "link": link, "country": country,
"struck": (row.get("struck") or "").strip()})
return found
def load():
"""The rows recorded on earlier days."""
if not RECORD.exists():
return []
with open(RECORD, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
for row in rows:
row["date"] = date.fromisoformat(row["date"])
row["price"], row["struck"] = amount(row["price"] or "x"), amount(row["struck"] or "x")
row["currency"] = row["currency"] or None
return rows
def main():
OUT.mkdir(exist_ok=True)
(OUT / TODAY.isoformat()).mkdir(exist_ok=True)
loaded = load()
rows = [row for row in loaded if row["date"] != TODAY] # a second run on one day replaces the first
earlier = {(row["link"], row["country"]): row for row in loaded if row["date"] == TODAY}
cells = []
for product in products():
page = (product["link"], product["country"])
history = sorted((row for row in rows if (row["link"], row["country"]) == page),
key=lambda row: row["date"])
try:
today = capture(product, "%s/%02d.png" % (TODAY.isoformat(), product["n"]))
except SiteShotError as error:
print(product["n"], "capture failed: %s" % error)
if page in earlier:
rows.append(earlier[page]) # a failed second run keeps the day's first row
continue
notes, facts = check(history, today)
rows.append({key: today[key] for key in FIELDS})
print(product["n"], "%s: %s" % (product["country"] or "default", "; ".join(notes) or "OK"))
lines = "".join("<tr><td>%s</td><td>%s</td><td>%s</td></tr>" % (
row["date"], "no price" if row["price"] is None else row["price"], row["struck"] or "")
for row in (history + [today])[-WINDOW - 1:])
said = "no price" if today["price"] is None else (
"%s %s" % (today["price"], today["currency"] or "")).strip()
said += ", struck %s" % (today["struck"] or "none")
if facts.get("low"):
said += "; %s the struck price, %s the lowest recorded" % (
off(today["struck"], today["price"]), off(facts["low"], today["price"]))
cells.append("<h2>%d. <a href='%s'>%s</a> %s</h2><p><b>%s</b><br>%s<br>landed on %s</p>"
"<p><a href='%s'><img src='%s' width='400'></a></p><table border='1' cellpadding='4'>"
"<tr><th>Date</th><th>Price</th><th>Struck</th></tr>%s</table>" % (
product["n"], html.escape(product["link"]), html.escape(product["link"]),
product["country"], html.escape("; ".join(notes) or "OK"), html.escape(said),
html.escape(today["landed"]), today["image"], today["image"], lines))
with open(RECORD, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for row in sorted(rows, key=lambda row: (row["link"], row["country"], row["date"])):
writer.writerow({key: "" if row[key] is None else row[key] for key in FIELDS})
(OUT / "report.html").write_text("<meta charset='utf-8'><p>Checked %s UTC</p>%s" % (
time.strftime("%Y-%m-%d %H:%M", time.gmtime()), "".join(cells)), encoding="utf-8")
main()
On a Linux server, keep the key in a file only you can read and let cron run the script once a day:
# /srv/price-record/key.env (chmod 600)
export SITESHOT_API_KEY=your-key-here
# crontab -e: every day at 07:17 server time
17 7 * * * cd /srv/price-record && . ./key.env && python3 price_record.py >> run.log 2>&1
Each run prints one line per product, adds today's row to price-record/record.csv, saves the screenshots in a folder for the day, such as price-record/2026-11-11/01.png, and rewrites price-record/report.html. A second run on the same UTC day replaces that day's rows and screenshots, and a product whose capture fails keeps its earlier row. Read run.log now and then: a refused key, a spent free-plan month or a lapsed plan stops the captures, and cron won't tell you. Every capture the API admits counts against the month, failed ones included.
No server? On a paid plan a schedule captures sale pages daily into your library, within the plan's weekly schedule limit, but reads no prices; the record and the comparison stay with this script.
What each setting does
countryroutes the capture through an IP address in that country and sets the browser's language, time zone and geolocation to match. An empty country column leaves it out, and the capture comes from the default location.strict_country=Truemakes a capture fail instead of falling back to a US address when the country has no capacity at that moment, so a German row never records a page rendered through the US. The script then printscapture failedwith the errorcountry_unavailableand records nothing for that product that day.javascript_codecopies the final address into the page before the HTML is read, because the API's JSON reply does not include it.source_code=Truereturns the rendered HTML in the same reply as the screenshot. The script reads today's price from the first JSON-LDOffer, or the offer for the link'svariant, and falls back to theog:priceorproduct:pricemeta tag when the structured data gives no price.width=1280, height=1600captures the top of the page at desktop size, where a sale page shows its prices.no_cookie_popup=Trueandno_ads=Trueare the two blockers behind the browser tool's No ads / No cookie box;fresh_session=Truestarts each capture without the cookies earlier captures saved, so nothing one visit unlocked carries into the next.
How the script reads a reduction
The reduction started on the first day of the latest unbroken run of record rows with a struck price; a day whose capture read no price neither starts nor ends that run. The 30 days before that day are the window, and the lowest price recorded in it, in the same currency, is what the struck price is compared with. Each run prints, per product:
- the record covers N of the 30 days before a date: the window holds fewer than 30 recorded days, so the record is too short to judge that reduction. A reduction that was already running when the record began stays here until a new one starts.
- struck X, lowest recorded Y on a date: the page shows a struck price above the lowest price the record holds for the window.
- no struck price: the page shows none today, or the
struckcolumn no longer finds it; the screenshot tells which. - lands on an address, status, no page source and no price in the structured data: the capture reached another address, the page did not answer 200 (a 504 usually means the capture ran out of time), the reply carried no HTML, or the page declares no JSON-LD
Offerprice and no price meta tag. - OK: a struck price no higher than the lowest price of a full 30-day window.
report.html adds two percentages for each product with a struck price and earlier recorded prices: today's price off the struck price, and off the lowest recorded price. The Court's percentage is the second; compare both with any badge in the screenshot.
The pattern the Court described looks like this in the record. A kettle cost 34.99 from 13 to 27 October and 39.99 from 28 October to 11 November, and on 12 November the page shows 29.99 with 39.99 struck through:
Days in record.csv |
Price | Struck price |
|---|---|---|
| 13–27 October 2026 | 34.99 EUR | none |
| 28 October–11 November 2026 | 39.99 EUR | none |
| 12 November 2026 | 29.99 EUR | 39.99 |
On 12 November the script prints struck 39.99, lowest recorded 34.99 on 2026-10-13, and the report says 25% off the struck price, 14% off the lowest recorded.
What the first run returned on 11 November 2026
Two captures from Germany, through our own key on PyPI site-shot 0.3.3. The struck column is ours, written after looking at each page:
| Row | Sale page (our struck column) |
Today's price | Struck price | Printed |
|---|---|---|---|---|
| 1 | Clothing brand's sweatpants (Statt:) |
29.99 EUR | 39.99 | lands on its address without www, with ?quantity=1; the record covers 0 of the 30 days before 2026-11-11 |
| 2 | Electronics retailer's processor (.strike-price) |
315.00 EUR | 333.00 | the record covers 0 of the 30 days before 2026-11-11 |
Row 1's page shows "-25%" next to "Statt: 39,99€". Its structured data carries 29.99 and no struck price, so only the label finds 39.99.
Row 2's struck price sits in a line-through element whose info icon calls it the best price of the last 30 days: the shop's own statement that 333,00 was its lowest price in that time. A banner on the page announces a November sale that began before our record did; for a reduction that started with it, the script will keep saying the record is too short.
If every daily capture reads a price, on 11 December the record holds 30 days, and the script can judge any reduction that starts then.
Where the honest limits are
- The record starts with the first run. Prices before it are unknown, and a reduction already running then cannot be checked.
- One capture a day. A price that held for a few hours between two runs is not recorded, so the shop's lowest price can be lower than the record's.
- Prices come from the page. Today's price is the structured data's or a price meta tag's; a page whose visible price differs from its markup records the markup. The screenshot shows what a shopper saw.
- The struck price is the first match. A product carousel above the product can hold an earlier match for a class or an element; check the first screenshots against the record.
- Member and personal prices are not seen. Each capture is a new visitor without cookies or an account.
- National rules differ at the edges. Article 6a lets each country set different rules for goods that deteriorate or expire quickly, a shorter period for products on the market for less than 30 days, and, for a reduction increased step by step, the price before its first step as the prior price. The script measures each unbroken run of struck prices from its first day, as that option does; where a country has not taken it, each deeper step is a new reduction whose prior price includes the earlier sale price, which the script does not check.
- Not every struck price is a reduction. A struck recommended retail price or a set's total value compares the price with something other than the shop's own earlier price and is judged under other rules, unless shoppers would read it as a cut in the shop's own price. The script compares whatever you point it at.
- A record, not legal advice or a sealed archive. The dates come from the machine that runs the script, and the files are yours to keep. Whether a discount breaks the rule is for a lawyer, a court or an authority to say.
- The free plan sees the default location or the US only. A capture from Germany or any other country needs any paid plan.
FAQ
What is the EU's 30-day lowest price rule for discounts? Article 6a of the Price Indication Directive, added by the Omnibus Directive for EU countries to apply from 28 May 2022, says any announcement of a price reduction must indicate the prior price: the lowest price the trader applied in a period of at least 30 days before the reduction. On 26 September 2024 the EU Court of Justice ruled that a reduction announced as a percentage must be calculated from that price.
How can I see the lowest price a shop charged in the last 30 days? Only with a record kept during those days. A Site-Shot script can capture each sale page once a day from the shop's country (a country other than the US needs any paid plan), keep the screenshot and compare the struck price with the lowest price recorded in the 30 days before the reduction started. A record started after the reduction began cannot check it.
Can I keep a 30-day price record for free? The free browser tool at site-shot.com captures a page with no signup, so a daily capture by hand works for a page or two. Through the API, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. Free-plan captures come from the default location or the US only, and a capture from Germany or another country needs any paid plan.
Does a struck price have to be the lowest price of the last 30 days? When the struck price announces a reduction of the shop's own price, the prior price must be the lowest the shop applied in at least the 30 days before, and a percentage is calculated from it. A struck recommended retail price, labelled as such, or a set's total value is a different kind of comparison, though one that shoppers would read as a cut in the shop's own price can still fall under the rule. The script compares the struck price with the prices it recorded and does not decide which rule applies.
Related reading
- How to Capture MAP Violation Evidence with Timestamped Screenshots
- How to Check a Merchant Center Price Mismatch From Each Country
- How to Schedule Automatic Website Screenshots and Keep a Dated Archive
One page? The browser tool is free, no signup. A daily record of many pages runs on the API; see pricing.