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:

  1. finds the JSON-LD blocks in a page,
  2. picks out every Product, whatever shape the markup takes,
  3. turns them into flat rows with exact decimal prices, one per sku (or one per page when there is no sku),
  4. saves a snapshot of several pages, politely,
  5. 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.txt blocks only /robots-disallowed for all agents and asks for Crawl-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:

  • offers can be a single Offer with price, an AggregateOffer with lowPrice and highPrice, or a list of Offer objects.
  • The price can sit in offers.priceSpecification.price. Google's product snippet documentation says that when both offers.price and offers.priceSpecification are present, it uses offers.price. The script does the same.
  • @type can be a string, a list such as ["Product", "Thing"], or a compact form such as schema:Product.
  • availability is normally a full URL such as https://schema.org/InStock; the code also accepts the http:// form and a bare InStock.
  • The data can be wrapped in a top-level array or an @graph list, and a page can hold several blocks, one of them broken.
  • price can 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 sku when the page has one, otherwise the URL you asked for. Not the product name and not offers.url; the real run below shows why.
  • Several offers are reduced to the lowest and highest price. availability becomes mixed when 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...
Source:https://dev.to/abdulwhab95/tracking-product-price-and-stock-changes-from-json-ld-in-python-4mm6