I once spent an afternoon writing selectors to pull a price off a product page. Wrote the CSS path, handled the currency symbol, dealt with the sale-price-versus-list-price mess, tested it across a dozen listings. Then I opened view-source, scrolled a bit, and found the same price sitting in a <script type="application/ld+json"> block, typed, labeled, and currency-tagged. The retailer had published it there on purpose. I'd done a morning's work to reconstruct data that was already handed to me clean.

That block wasn't there for scrapers. It was there for Google. And that's exactly why it's the best starting point you have.

Retailers publish machine-readable product data for search engines

A huge share of e-commerce pages carry a structured description of the product embedded right in the HTML, separate from the visual markup. It exists so that Google, Bing, Pinterest, and every other crawler can show rich results: the price, the star rating, the in-stock badge you see under a search listing. Retailers are strongly incentivized to keep it accurate and current, because getting it wrong means losing the rich result or getting penalized for mismatched data.

There are three common shapes. JSON-LD is the one you want most, a self-contained JSON object following the schema.org vocabulary. OpenGraph tags are <meta> elements in the head, coarser but nearly universal, great for name, image, and sometimes price. Microdata is the old inline style, attributes like itemprop sprinkled through the visible HTML. All three describe the same underlying thing. JSON-LD just does it in one clean block instead of scattered across the DOM.

Reading a JSON-LD Product block

The pattern is boring in the best way. Find the script tags, parse each as JSON, and look for the object whose @type is Product.

import * as cheerio from "cheerio";

function extractProduct(html) {
  const $ = cheerio.load(html);
  const blocks = $('script[type="application/ld+json"]')
    .map((_, el) => $(el).contents().text())
    .get();

  for (const raw of blocks) {
    let data;
    try {
      data = JSON.parse(raw);
    } catch {
      continue; // malformed block, skip it
    }

    // JSON-LD is often an array or wrapped in @graph
    const nodes = Array.isArray(data) ? data : data["@graph"] || [data];
    const product = nodes.find((n) => n && n["@type"] === "Product");
    if (!product) continue;

    const offer = [].concat(product.offers || [])[0] || {};
    return {
      name: product.name,
      sku: product.sku,
      image: product.image,
      price: offer.price,
      currency: offer.priceCurrency,
      availability: offer.availability, // e.g. schema.org/InStock
    };
  }
  return null;
}

Enter fullscreen mode Exit fullscreen mode

No currency-symbol parsing. No sale-versus-list guesswork. The availability field is a plain URL you can map to a boolean. You get name, sku, image, price, and stock state from one parse, and it survives visual redesigns because the retailer maintains it independently of the layout their designers keep changing.

Beyond being easier, it's more polite. You're reading a static block that's already in the HTML the retailer chose to serve, not hammering internal endpoints or driving a headless browser through a render loop. Less load on them, less cost for you, more stability for both.

Rank your sources by reliability

Once you internalize that pages carry data at several levels of quality, extraction becomes a preference order rather than a single brittle path. Conceptually, structured data sits above hand-written selectors. A JSON-LD Product block is typed and maintained for Google; a CSS selector against rendered markup is your guess about someone else's HTML, and it breaks the day their build tool renames a class.

So the order I reason in: prefer the typed, purpose-published structured block first. Fall back to OpenGraph and microdata for the fields it covers. Reach for hand selectors last, for the fields nothing else exposes. The higher up the stack you can stay, the longer your scraper lives between repairs.

The catch: structured data lies sometimes

It's cleaner, not infallible. Structured blocks can be stale, cached from a build that ran before the last price change. They can be partial, a Product with a name and image but no offers. They can be templated wrong, every variant reporting the parent's price. Some sites inject the JSON-LD client-side, so it isn't in the initial HTML at all. Trust it as the best signal, not the only one.

Which means you validate what you pull and fall back when it fails the check. If the JSON-LD has no price, drop to OpenGraph. If that's empty too, try the selector. Run the fill-rate and required-field checks over the final merged record so a silently-empty offers array gets caught before it ships. The structured block gives you a fast, stable default; the fallbacks and the validation are what make it trustworthy.

That layered approach, several independent ways to reach each field with a clear preference order and validation across them, is the core of how I build extraction in production. I won't get into how the strategies are weighted or combined against any specific site, because that tuning is the actual work and it shifts constantly. But the principle is public and it's the right instinct: before you write a single selector, view-source and check whether the retailer already handed you the answer in a block they maintain for Google. Half the time, they did.

I'm building Cartpie, an e-commerce product-data platform built on exactly this multi-source approach, and I publish proxyless scrapers on Apify that start from structured data first.