Skip to main content
Paravey Products
All posts
UndercutArchitecture

Why price-monitoring tools break when a competitor redesigns (and what to do instead)

The quiet failure mode of every CSS-selector-based scraper: a competitor changes their theme, the selectors stop matching, no alert fires. Here's the architectural fix.

October 22, 20267 min readBy Jitendra Paswan

Here's the quiet failure mode of every price-monitoring tool you've ever used: a competitor redesigns their store, the scraper's CSS selectors stop matching, and nothing happens. No alert, no error, no email. Your dashboard still shows the last price it caught, and will keep showing it forever. You find out a month later when the sales team is asking why they lost a deal to a lower price you “should have known about.”

This is a direct consequence of how most of these tools work. If you've ever configured a scraper by pasting URLs and clicking “pick the price element,” this post is about why that approach has a half-life, what the alternative looks like, and how to tell which one your current tool is using.

How selector-based scrapers work

Legacy price scrapers take a competitor URL + a CSS selector per field. You visit the page, click the price element, and the tool records something like:

{
  "url": "https://competitor.com/products/widget",
  "selectors": {
    "price": ".product-info__price .money",
    "title": ".product-title h1",
    "availability": ".stock-status"
  }
}

Every scan, the tool fetches the page, runs the selectors, and saves whatever they match. If .product-info__price .money returns $39.99, that's today's price. Simple, flexible, works on any site.

The problem: selectors are DOM-structure contracts, not data contracts. The HTML they match is a presentational choice the competitor's theme developer made, and theme developers change presentational choices all the time.

Six ways a redesign breaks your scraper

1. The class name changes

.product-info__price becomes .product-pricing-block__current-price because the new theme uses BEM differently. Selector returns null.

2. The element nesting changes

Your selector is .product-info .price but the new theme wraps the price in .pdp-main__pricing, so.product-info no longer contains it. Selector returns null.

3. The price moves inside a React component that renders async

The old theme put the price in the server-rendered HTML. The new theme renders it client-side via useState + a fetch to /api/variants. Your scraper fetches the HTML and sees $— or empty. Selector returns the loading state's placeholder.

4. Multiple elements match, the first one is wrong

The new theme has a “compare at” (crossed-out) price above the current price. Your selector matches both. Depending on order, you now record the old-price-crossed-out as today's price and your “competitor dropped by 40%” alert fires on a sale that isn't.

5. The format changes

Was $39.99, now $39.99 USD or USD 39.99. Your parseFloat() returns 39 or NaN. Alerts fire on fake drops.

6. The site switches to a currency-switcher

Shopify Markets, Shopify multi-currency, or a theme that detects geo-IP and converts prices. Your scraper now reads whatever currency Cloudflare's nearest edge geolocated you to. We covered this one in detail in a previous post — Shopify will cheerfully hand you INR prices if your scanner runs from Mumbai.

The compound problem: none of these failures throw an error. Your scraper gets back “no match found,” moves on, and the dashboard keeps serving stale data. Unless the tool actively alerts on “this scraper hasn't produced a new reading in N days,” you literally cannot tell it's broken.

The alternative: Schema.org JSON-LD

Here's the thing nobody says: for every e-commerce site worth monitoring, there's already structured, semantic price data in the HTML. It's called Schema.org Product, it ships inside <script type="application/ld+json"> blocks in the <head>, and it's there because Google needs it. If a store wants its products in Google Shopping, in rich snippets, in the Google Merchant Center feed, or indexed as discoverable products at all, it has to publish this block.

Here's what it looks like on a WooCommerce store:

<script type="application/ld+json">
{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "Classic Henley - Olive",
  "image": [...],
  "offers": {
    "@type": "Offer",
    "price": "48.00",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "url": "https://store.com/product/classic-henley-olive/"
  }
}
</script>

Four reasons this is the right primitive:

It's semantic

name means product name. offers.price means price. offers.priceCurrency means currency. These keys don't depend on which theme the store is running — they're defined by Schema.org and locked by the Google Merchant feed spec.

It survives redesigns

When the store changes themes, the HTML wrapper changes. The JSON-LD block changes form (new CMS, different plugin) but keeps the same keys. In three years of running this pattern across WooCommerce, BigCommerce, Shopify-with-password-protection, and custom carts, we've seen zero redesigns break the parse.

It's what Google reads

If a competitor stops publishing Schema.org Product, they also stop appearing in Google Shopping. Nobody intentionally does that. The incentive to keep the block correct is permanent.

It handles variants and sales natively

Schema.org has Offer (one SKU/variant) and AggregateOffer (a product with price range). priceSpecification handles taxes. availability is an enumerated set, not a string you have to pattern-match. The ambiguity we listed above (crossed-out prices, loading states, currency switches) doesn't exist because the data model forbids it.

How to parse it

Fetch the HTML. Extract every <script type="application/ld+json"> block. Parse each as JSON. Find the node whose @type is Product (sometimes it's inside an @graph array). Read name, offers.price, offers.priceCurrency. That's the whole algorithm:

const res = await fetch(productUrl)
const html = await res.text()

const scripts = [...html.matchAll(
  /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
)]

for (const [, body] of scripts) {
  const data = JSON.parse(body.trim())
  const candidates = Array.isArray(data)
    ? data : (data['@graph'] ?? [data])
  for (const node of candidates) {
    if (node['@type'] !== 'Product') continue
    const offers = Array.isArray(node.offers)
      ? node.offers[0] : node.offers
    return {
      title: node.name,
      price: Number(offers.price),
      currency: offers.priceCurrency,
    }
  }
}

~20 lines. No per-site configuration. No selector-picker UI. No drift. The scanner we built for Paravey Undercut is this exact pattern, wrapped in sitemap discovery (to find the product URLs in the first place) and the usual bounded retries and timeouts.

How to tell what your current tool uses

One quick test. Open a product page you track in your current price-monitoring tool. In your browser, hit view-source: on the URL (not devtools — view-source shows what the server actually returned before any JavaScript ran). Search for application/ld+json.

If you see a Product-typed JSON-LD block with the price in it, your tool could be reading it. If your tool's configuration UI asks you to click or paste a CSS selector per field, it almost certainly isn't — it's using the DOM path you clicked, which means it's one redesign away from going silent on you.

What to actually do

If you're running a selector-based scraper and switching isn't urgent: at minimum, add a “price unchanged for N days” alert at the tool level. Pick N to be 2–3× your normal scan cadence. This won't fix the drift problem but it will tell you when a scraper has quietly died, so you can re-configure the selectors.

If you're evaluating a new tool, the question to ask is literally: “How do you extract prices? CSS selectors or Schema.org JSON-LD?” The answer tells you which failure mode you're signing up for.

If you want to try a tool that reads JSON-LD by default across Shopify, WooCommerce, BigCommerce, Squarespace, Magento, and custom carts, run the free Price Gap Snapshot — drop your store URL and three competitors and we'll scan them live, no account required. If you like what you see, Undercut starts at $99/mo with a 14-day trial, no card.