Many product pages carry a machine-readable copy of their own data: a <script type="application/ld+json"> block with a schema.org Product, written for search engines. It holds the name, the price or price range, the currency and the stock status as JSON. That makes it a steadier thing to read than CSS classes, which change whenever the theme changes.
This post builds a small standard-library Python script that:
- finds the JSON-LD blocks in a page,
- picks out every
Product, whatever shape the markup takes, - turns them into flat rows with exact decimal prices, one per
sku(or one per page when there is nosku), - saves a snapshot of several pages, politely,
- compares two snapshots and reports what was added, removed or changed, without calling a failed download a "removal".
At the end there is a real run from 27 September 2026 (UTC) and the complete script.
Before you point this at a real store
Read the site's terms and its robots.txt first. The script checks robots.txt and honours Crawl-delay, but it cannot read terms for you, and many shops offer an official feed or API that is a better source than their pages.
The examples use two public sources:
- web-scraping.dev, a sandbox shop built for scraping practice. Its home page calls itself "Safe, legal, and designed for learning". Its
robots.txtblocks only/robots-disallowedfor all agents and asks forCrawl-delay: 2. - The Internet Archive's Wayback Machine, for older copies of the same pages, so there is a real "before" to compare with.
What the markup looks like
This is the offers part of the JSON-LD on https://web-scraping.dev/product/1, fetched on 27 September 2026 at 12:54 UTC. The page has one JSON-LD block; the full block also carries a description, an image, a brand, a rating and five reviews.
Excerpt of product-1-jsonld.txt, lines 5-17:
{ "@type": "Product", "name": "Box of Chocolate Candy", "offers": { "@type": "AggregateOffer", "url": "https://web-scraping.dev/product/1", "priceCurrency": "USD", "lowPrice": "9.99", "highPrice": "19.99", "offerCount": "6", "availability": "https://schema.org/InStock" } } That is one shape. Real pages vary, and a parser has to cope with all of these:
-
offerscan be a singleOfferwithprice, anAggregateOfferwithlowPriceandhighPrice, or a list ofOfferobjects. - The price can sit in
offers.priceSpecification.price. Google's product snippet documentation says that when bothoffers.priceandoffers.priceSpecificationare present, it usesoffers.price. The script does the same. -
@typecan be a string, a list such as["Product", "Thing"], or a compact form such asschema:Product. -
availabilityis normally a full URL such ashttps://schema.org/InStock; the code also accepts thehttp://form and a bareInStock. - The data can be wrapped in a top-level array or an
@graphlist, and a page can hold several blocks, one of them broken. -
pricecan be a JSON number or a string. schema.org asks for a.decimal point and no currency symbols or thousands separators, but not every page follows that.
Step 1: collect the JSON-LD blocks
Python's html.parser treats the inside of a <script> element as raw text, so the JSON arrives in handle_data untouched. The type attribute is compared without any parameters, so a value such as application/ld+json; charset=utf-8 still matches.
Excerpt of catalog_snapshot.py, lines 24-43:
class JsonLdScripts(HTMLParser): """Collect the raw text of every <script type="application/ld+json"> element.""" def __init__(self): super().__init__() self.blocks, self._parts = [], None def handle_starttag(self, tag, attrs): kind = (dict(attrs).get("type") or "").split(";")[0].strip().lower() if tag == "script" and kind == "application/ld+json": self._parts = [] def handle_data(self, data): if self._parts is not None: self._parts.append(data) def handle_endtag(self, tag): if tag == "script" and self._parts is not None: self.blocks.append("".join(self._parts)) self._parts = None Step 2: walk the graph and find products
nodes() yields every JSON object in a block, at any depth. That covers @graph, top-level arrays, and products nested inside other objects. short() reduces a schema.org term to its last part, so the type and availability checks don't care about https://, http:// or schema:.
Excerpt of catalog_snapshot.py, lines 46-64:
def nodes(value): """Yield every JSON object in a JSON-LD value: top-level arrays, @graph, nesting.""" if isinstance(value, list): for item in value: yield from nodes(item) elif isinstance(value, dict): yield value for item in value.values(): yield from nodes(item) def short(term): """'https://schema.org/InStock' -> 'InStock', 'schema:Product' -> 'Product'.""" return re.split(r"[/:#]", term)[-1] if isinstance(term, str) else None def is_type(node, wanted): types = node.get("@type") return wanted in [short(t) for t in (types if isinstance(types, list) else [types])] Step 3: one flat row per product, with exact prices
Prices stay as text produced from Decimal, never float, so 19.99 stays 19.99 and 20.00 and 20 compare equal. A value that isn't a clean number, such as 1,299.00, becomes None rather than a wrong number.
Excerpt of catalog_snapshot.py, lines 67-104:
def clean_price(value): """'19.990' -> '19.99', 20 -> '20', '' or '1,299.00' -> None. Text, not float.""" if value is None or isinstance(value, bool): return None try: number = Decimal(str(value).strip()) except InvalidOperation: return None if not number.is_finite(): return None text = format(number, "f") return text.rstrip("0").rstrip(".") if "." in text else text def to_row(product, page_url): offers = product.get("offers") or [] offers = [o for o in (offers if isinstance(offers, list) else [offers]) if isinstance(o, dict)] lows, highs = [], [] for offer in offers: spec = offer.get("priceSpecification") price = offer.get("price", spec.get("price") if isinstance(spec, dict) else None) low, high = clean_price(offer.get("lowPrice", price)), clean_price(offer.get("highPrice", price)) lows += [Decimal(low)] if low else [] highs += [Decimal(high)] if high else [] stock = sorted({short(o["availability"]) for o in offers if o.get("availability")}) currency = sorted({str(o["priceCurrency"]) for o in offers if o.get("priceCurrency")}) count = offers[0].get("offerCount") if len(offers) == 1 else None sku = product.get("sku") return { "key": str(sku) if sku not in (None, "") else page_url, "page": page_url, "name": str(product.get("name") or "").strip() or None, "currency": "/".join(currency) or None, "low_price": str(min(lows)) if lows else None, "high_price": str(max(highs)) if highs else None, "availability": stock[0] if len(stock) == 1 else ("mixed" if stock else None), "offer_count": int(count) if str(count or "").isdigit() else len(offers), } A few decisions in to_row() matter later:
- The key is the
skuwhen the page has one, otherwise the URL you asked for. Not the product name and notoffers.url; the real run below shows why. - Several offers are reduced to the lowest and highest price.
availabilitybecomesmixedwhen the offers disagree. - Several currencies are joined (
EUR/USD). The script still takes a min and max, which means nothing across currencies, so treat a joined currency as a warning.
Step 4: extract, and report problems instead of hiding them
A broken block is reported and skipped; the other blocks on the page are still read. A page with no JSON-LD at all is reported too, because "no data" and "no products" are different situations.
Excerpt of catalog_snapshot.py, lines 107-127:
def extract(html, page_url): """Return (rows, problems) for one HTML page.""" parser = JsonLdScripts() parser.feed(html) parser.close() rows, problems = {}, [] for number, block in enumerate(parser.blocks, 1): try: data = json.loads(block) except json.JSONDecodeError as err: problems.append(f"JSON-LD block {number} is not valid JSON ({err.msg})") continue for node in nodes(data): if is_type(node, "Product"): row = to_row(node, page_url) rows.setdefault(row["key"], row) if not parser.blocks: problems.append("no JSON-LD on the page") elif not rows and not problems: problems.append("JSON-LD present but no Product") return list(rows.values()), problems Step 5: fetch politely
robots.txt is fetched once per host, with the same User-Agent as the pages. A 404 for robots.txt means nothing is disallowed. The function returns the host's Crawl-delay so the caller can wait at least that long. robots_rules() drops blank lines before parsing: urllib.robotparser ends a group at a blank line, while RFC 9309 does not, so without this step any rules after a blank line inside the User-agent: * group would be ignored.
Excerpt of catalog_snapshot.py, lines 130-158:
def get(url): request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(request, timeout=30) as response: charset = response.headers.get_content_charset() or "utf-8" return response.read().decode(charset, "replace"), response.geturl() def robots_rules(text): """Parse robots.txt. urllib.robotparser ends a group at a blank line; RFC 9309 does not.""" rules = urllib.robotparser.RobotFileParser() rules.parse([line for line in text.splitlines() if line.strip()]) return rules def allowed(url, cache): """Check robots.txt (fetched once per host, with our User-Agent). Returns the crawl delay.""" parts = urllib.parse.urlsplit(url) base = f"{parts.scheme}://{parts.netloc}" if base not in cache: try: text = get(base + "/robots.txt")[0] except urllib.error.HTTPError as err: if err.code not in (404, 410): raise text = "" # no robots.txt: nothing is disallowed cache[base] = robots_rules(text) if not cache[base].can_fetch(USER_AGENT, url): raise PermissionError(f"robots.txt disallows {url}") return cache[base].crawl_delay(USER_AGENT) or 0 The snapshot loop fetches each URL once, waits at least two seconds before every request, and records errors per page instead of stopping. With --wayback, it asks the Wayback Machine for the capture of each URL nearest to the given date. The id_ after the timestamp returned the page as it was captured, without the archive's banner, and its JSON-LD parsed exactly like the live page.
Excerpt of catalog_snapshot.py, lines 161-180:
def snapshot(url_file, out_file, wayback=None): with open(url_file, encoding="utf-8") as handle: urls = [line.strip() for line in handle if line.strip() and not line.startswith("#")] robots, rows, pages = {}, [], [] for url in urls: target = f"https://web.archive.org/web/{wayback}id_/{url}" if wayback else url page = {"url": url} try: time.sleep(max(2, allowed(target, robots))) html, page["fetched"] = get(target) found, page["problems"] = extract(html, url) rows += found except OSError as err: # HTTP errors, timeouts, robots refusals (PermissionError) page["error"] = str(err) pages.append(page) print(f"{url}: {page.get('error') or f'{len(found)} product(s)'} {page.get('problems') or ''}") captured = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") with open(out_file, "w", encoding="utf-8") as handle: json.dump({"captured_at": captured, "pages": pages, "rows": rows}, handle, indent=2) print(f"{len(rows)} product rows from {len(pages)} pages -> {out_file} ({captured})") Step 6: compare two snapshots
The diff is set arithmetic on the keys, plus a field-by-field comparison for keys present in both. One rule prevents the classic false alarm: if a page failed to load in the new snapshot, its products are reported as unknown, not removed. A timeout is not a discontinued product.
Excerpt of catalog_snapshot.py, lines 183-196:
def diff(before, after): """List (change, key, field, old, new). A page that failed to load is 'unknown', not 'removed'.""" old = {row["key"]: row for row in before["rows"]} new = {row["key...