Skip to content
ScrapeshopScrapeshop

Learn

Ecommerce Price Scraping: How to Extract Product Prices at Scale

Published 2026-09-24 · Updated 2026-09-24 · By the Scrapeshop team

Ecommerce price scraping is the automated collection of product prices, availability, and related attributes from online stores, typically on a schedule, to build a price history or compare across sellers. It is one of the most common web scraping workloads and one of the easiest to get subtly wrong: currencies get mis-parsed, “from” prices masquerade as real prices, personalised offers pollute the dataset, and a change in one retailer’s markup silently nulls a column for a month.

This guide is the technical side: what to capture, where the price actually lives in a page, how to normalise it, how to discover products and stay unblocked, and how to store and validate the result. For the business case and use cases, see price monitoring.

What data to capture

A price on its own is nearly useless. The value only becomes interpretable with the context around it. A minimal observation record should include:

  • Price — the current selling price as a decimal, never a float.
  • List price — the crossed-out, RRP, or “was” price when shown, so discounts can be computed.
  • Currency — as an ISO 4217 code (USD, EUR), not a symbol.
  • Availability — in stock, out of stock, preorder, backorder, limited.
  • Variant / SKU — the specific size, colour, or configuration the price applies to, plus any retailer identifier (SKU, ASIN, GTIN when present).
  • Seller — on marketplaces, whether the offer is first-party or a third-party seller, and which one.
  • Shipping — cost and any free-shipping threshold, since “price” often excludes it.
  • Observation metadata — timestamp, source URL, country and locale used, and whether the page was rendered.

A schema that captures this in TypeScript:

price-observation.tsts
interface PriceObservation {
  url: string;
  productId: string;          // retailer SKU / ASIN / GTIN
  variant?: string;           // "Blue / L"
  seller?: string;            // "Retailer" or third-party name
  price: string;              // decimal as string: "129.00"
  listPrice?: string;
  currency: string;           // ISO 4217
  availability: "in_stock" | "out_of_stock" | "preorder" | "backorder" | "unknown";
  shipping?: { cost: string; freeAbove?: string };
  observedAt: string;         // ISO 8601, UTC
  country: string;            // ISO 3166-1 alpha-2 of the proxy / locale
  rendered: boolean;
}

Storing price as a string or a fixed-point decimal avoids the floating-point drift that turns 19.99 into 19.989999 after a few aggregations.

Where prices live in the page

Retailers expose the same price in several places, with very different reliability. Work down this list and stop at the first source that yields a value.

1. Schema.org JSON-LD

Most stores embed a Product object with one or more Offer entries in a <script type="application/ld+json"> tag. Search engines use it for rich results, so retailers keep it accurate. It gives you price, currency, availability, and often the SKU in one machine-readable block:

parse_jsonld_offer.pypython
import json
from bs4 import BeautifulSoup

def offers_from_jsonld(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    found = []
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except json.JSONDecodeError:
            continue
        nodes = data if isinstance(data, list) else [data]
        nodes += [n for d in nodes if isinstance(d, dict) for n in d.get("@graph", [])]
        for node in nodes:
            if isinstance(node, dict) and node.get("@type") in ("Product", ["Product"]):
                offers = node.get("offers", [])
                found += offers if isinstance(offers, list) else [offers]
    return found

Watch for AggregateOffer (a lowPrice/highPrice range rather than a single price) and for sites that emit the product schema but omit the offer on variant pages.

2. Embedded state objects

Client-rendered stores serialise their initial state into the HTML: __NEXT_DATA__, window.__INITIAL_STATE__, window.__PRELOADED_STATE__, or a platform-specific blob (Shopify themes, for instance, commonly expose a product JSON with per-variant prices in minor units). These usually contain every variant’s price, which the visible page shows only one at a time.

3. Microdata and data attributes

Older schema.org markup uses itemprop="price" and itemprop="priceCurrency" attributes on HTML elements. Analytics tags also leave breadcrumbs: data-price, data-product-id, or a dataLayer push containing the ecommerce object. These are stable because marketing teams depend on them.

4. Visible HTML

The last resort: a CSS selector on the displayed price. It is the most fragile source (class names change with every redesign) and the most ambiguous (which of the three prices on screen is the real one?). Use it only as a fallback, and prefer selectors anchored on semantics — a data-testid, an ARIA label, or a text pattern near the “Add to cart” control — over generated class names.

Variants, currencies, and localisation

The value you extract is rarely the value you should store. Common traps:

  • Currency parsing. “1.299,00 €” and “$1,299.00” are the same magnitude with swapped separators. “kr 1 299” uses a space as the thousands separator. Never split on a hard-coded character; determine the locale first, or use a library such as Babel or price-parser.
  • Symbol ambiguity. “$” is used by the US, Canada, Australia, Mexico, and others. Resolve currency from JSON-LD, the page’s locale, or the storefront domain — never from the symbol alone.
  • Minor units. Embedded state often stores prices as integers in cents (12900 for 129.00). Some currencies have zero decimals (JPY) or three (KWD); divide accordingly.
  • “From” prices. Category listings and configurable products show the minimum variant price. Record it as a range or lower bound, not as the product’s price, and resolve the real value on the variant page.
  • Bundles and unit prices. Grocery and B2B sites show a pack price and a per-unit price. Capture both and the pack size; comparisons across retailers require the per-unit figure.
  • Tax display. EU consumer sites show prices including VAT; B2B and US sites often exclude tax. Record which, per site, so comparisons are like for like.

Control localisation at request time. Send an explicit Accept-Language header, use a proxy in the target country, and set any locale or currency cookie the site uses explicitly rather than letting geo-detection choose.

Dynamic and personalised pricing

Many retailers do not have a price for a product. The displayed value can depend on the visitor’s country, whether they are logged in, an A/B experiment bucket assigned via cookie, referral source, device type, or a first-visit discount. If your scraper is unaware of this, its price history will show volatility that does not exist.

Control for it by fixing every variable you can:

  1. Never scrape logged in. Member pricing is both legally riskier and not the public price.
  2. Start each session with no cookies, and either accept the cookie consent programmatically or block the consent script, consistently.
  3. Pin the proxy country and the Accept-Language header, and record both on the observation.
  4. Use a consistent, realistic user agent and viewport. Mobile and desktop can be served different offers.
  5. Sample: scrape a small control set of products two or three times from different sessions in the same run. If the same product returns different prices, you are in an experiment bucket — flag the run rather than storing the values.

Product discovery: sitemaps, categories, and pagination

Before you can scrape a price you need the product URLs. Three strategies, roughly in order of preference:

Sitemaps

Almost every retailer publishes XML sitemaps (linked from /robots.txt) listing product URLs, often with lastmod timestamps. They are the cheapest source: one request yields thousands of URLs and tells you which changed recently. Large stores split them into sitemap indexes with many child files; iterate them all.

Category crawls

When sitemaps are missing or stale, crawl category and search result pages. Parse the product links and the pagination control, and stop when a page repeats results or returns fewer items than the page size. Category pages often expose prices inline, which lets you skip detail-page requests for products whose listing price has not changed.

Internal search or category APIs

Storefronts built on Shopify, Magento, Salesforce Commerce, or a headless stack call JSON endpoints for listings. The browser’s network tab reveals them. They return structured product arrays with prices and pagination cursors — cheaper and more reliable than HTML, at the cost of being undocumented.

Deduplicate URLs before you fetch

The same product is typically reachable through several URLs (category paths, tracking parameters, variant query strings). Canonicalise by stripping tracking parameters and, where the page provides one, using the rel="canonical" URL or the product ID as the key.

Staying unblocked on major retailers

Large ecommerce sites are among the most heavily protected targets on the web. Bot management vendors sit in front of them and score every request. The techniques that keep a price scraper running are mostly about being unremarkable:

  • Rate limit per host. A few requests per second from one IP against one retailer is already conspicuous. Spread load across IPs and time, and add jitter.
  • Use residential or ISP proxies where needed. Datacenter ranges are routinely blocked outright by large retailers. Escalate to residential only for hosts that require it — it costs several times more.
  • Render only when necessary. Since prices usually exist in JSON-LD or embedded state, a plain fetch with a realistic header set is often enough and is far cheaper. Reserve headless rendering for sites that genuinely load the price client-side; see scraping JavaScript-rendered websites.
  • Match TLS and header fingerprints. Protection systems inspect the TLS handshake and header order, not just the user agent. Use an HTTP client that can impersonate a real browser’s fingerprint, or a rendering service that does.
  • Stay away from cart, checkout, and account endpoints. They are the most sensitive, the most protected, and the least defensible to scrape. Prices are on the product page; that is where you should be.
  • Handle blocks as data. A 403, a CAPTCHA page, or a suspiciously short response is a signal, not a price of zero. Detect it, back off, rotate, and never write a placeholder into the price table.

The broader toolkit — fingerprinting, proxy strategy, CAPTCHA handling — is covered in advanced web scraping techniques.

Scheduling and freshness

How often to scrape is a cost question disguised as a data question. Every product page fetch costs bandwidth, proxy credits, and detection risk; every missed change costs data fidelity. Balance the two with tiered schedules:

TierProductsCadenceRationale
HotBestsellers, competitor-matched SKUs, items with recent price changesEvery 1–6 hoursWhere repricing happens and where the business acts on the data.
WarmActive catalogue with occasional changesDailyEnough to catch promotions and stock-outs.
ColdLong tail, discontinued, stable-price categoriesWeekly, or on sitemap lastmod changeLittle value in higher frequency; keep the URL alive.

Promote and demote products between tiers automatically based on observed volatility. Use sitemap lastmod values, HTTP ETag / Last-Modified headers, or a cheap category-page check to skip detail pages that have not changed. And spread each tier’s run across the window rather than firing everything at the top of the hour.

Storing price history

Price data is time-series data with a product dimension. A straightforward relational design works well up to hundreds of millions of rows:

schema.sqlsql
CREATE TABLE product (
  id            BIGSERIAL PRIMARY KEY,
  retailer      TEXT NOT NULL,
  retailer_sku  TEXT NOT NULL,
  variant       TEXT,
  canonical_url TEXT NOT NULL,
  UNIQUE (retailer, retailer_sku, variant)
);

CREATE TABLE price_event (              -- one row per CHANGE, not per scrape
  product_id    BIGINT REFERENCES product(id),
  observed_at   TIMESTAMPTZ NOT NULL,
  price         NUMERIC(12,2) NOT NULL,
  list_price    NUMERIC(12,2),
  currency      CHAR(3) NOT NULL,
  availability  TEXT NOT NULL,
  country       CHAR(2) NOT NULL,
  PRIMARY KEY (product_id, country, observed_at)
);

CREATE TABLE last_seen (                -- proves freshness without bloat
  product_id    BIGINT REFERENCES product(id),
  country       CHAR(2) NOT NULL,
  observed_at   TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (product_id, country)
);

The key decision is deduplication: write a new price_event row only when price, list price, currency, or availability differs from the previous event for that product and country. Update last_seen on every scrape. You get a compact change log, and you can still answer “when was this price last confirmed?”. Partition the event table by month once it grows, or move it to a time-series store such as TimescaleDB or ClickHouse when analytical queries dominate.

Data quality checks

Price scrapers fail quietly. A retailer redesign rarely throws an error; it returns a page where your selector finds nothing and your pipeline stores a null, or worse, matches the wrong element and stores the shipping cost. Add automated checks that run after every batch:

  • Null rate per retailer. Alert when the share of observations missing a price rises above a baseline (for example, from 1% to 10% in one run).
  • Outlier detection. Flag any price that moved more than a threshold (say, 80% down or 300% up) from the previous event. Real promotions happen, but a batch of them across one retailer is a parsing bug.
  • Currency consistency. A product observed in USD from a US proxy should not suddenly report EUR. Mismatches indicate geo-redirects or locale drift.
  • Schema validation. Validate every observation against a schema (pydantic, zod) before it reaches storage: decimal format, ISO codes, enumerated availability.
  • Source drift. Track which extraction path succeeded (JSON-LD, embedded state, HTML). A shift from JSON-LD to HTML fallback across a retailer is an early warning that markup changed.
  • Control products. Keep a handful of manually verified products per retailer and compare each run against the known value.

A complete extraction example

The function below fetches a product page, prefers the JSON-LD offer, falls back to a CSS selector, and normalises the result to a decimal plus an ISO currency code. It is deliberately conservative: when it cannot determine the currency, it returns nothing rather than guessing.

extract_price.pypython
from decimal import Decimal, InvalidOperation
import json, re, requests
from bs4 import BeautifulSoup

CURRENCY_BY_SYMBOL = {"€": "EUR", "£": "GBP", "¥": "JPY"}  # "$" is ambiguous; resolve elsewhere

def to_decimal(text: str) -> Decimal | None:
    digits = re.sub(r"[^\d.,]", "", text)
    if "," in digits and "." in digits:
        digits = digits.replace(",", "") if digits.rfind(".") > digits.rfind(",") else digits.replace(".", "").replace(",", ".")
    elif "," in digits:
        digits = digits.replace(",", ".") if re.search(r",\d{2}$", digits) else digits.replace(",", "")
    try:
        return Decimal(digits)
    except InvalidOperation:
        return None

def extract_price(url: str, fallback_selector: str, default_currency: str | None = None):
    html = requests.get(url, headers={"Accept-Language": "en-US,en;q=0.9"}, timeout=20).text
    soup = BeautifulSoup(html, "html.parser")

    for tag in soup.find_all("script", type="application/ld+json"):          # 1. JSON-LD Offer
        try:
            data = json.loads(tag.string or "")
        except json.JSONDecodeError:
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "Product":
                offer = node.get("offers")
                offer = offer[0] if isinstance(offer, list) else offer
                if offer and offer.get("price") is not None:
                    return {"price": Decimal(str(offer["price"])), "currency": offer.get("priceCurrency"), "source": "jsonld"}

    el = soup.select_one(fallback_selector)                                    # 2. HTML fallback
    if el:
        amount = to_decimal(el.get_text())
        symbol = next((s for s in CURRENCY_BY_SYMBOL if s in el.get_text()), None)
        currency = CURRENCY_BY_SYMBOL.get(symbol) or default_currency
        if amount is not None and currency:
            return {"price": amount, "currency": currency, "source": "html"}
    return None

In production you would add the embedded-state path between the two, wrap the fetch in retries and proxy rotation, and route the result through schema validation before storage.

Prices, availability, and product specifications are facts, and facts are not protected by copyright in the US or the EU. Scraping them from publicly accessible product pages, without an account and at a respectful rate, sits on the lawful side of every major ruling to date. The boundaries that remain are contractual and behavioural: sites’ terms of service (strongest when you have clicked to accept them), continued access after an explicit revocation, and any personal data — reviewer names, seller contact details — that you may incidentally collect. Product images and descriptions are creative works; storing a copy for internal matching is different from republishing them. The full picture is in Is web scraping legal?. This article is general information, not legal advice.

Doing it with a managed API

Everything above — rendering decisions, proxy escalation, fingerprint matching, JSON-LD parsing with HTML fallback, and schema validation — is exactly the layer a managed scraping API absorbs. Scrapeshop returns Product and Offer fields (price, list price, currency, availability, SKU, seller) as validated JSON or CSV from a product URL, so your pipeline starts at the storage and quality-check stage rather than at the HTML.

Summary

  • Capture price with its context: list price, currency, variant, availability, seller, shipping, and observation metadata.
  • Prefer JSON-LD offers, then embedded state, then microdata, then visible HTML.
  • Normalise aggressively: decimals not floats, ISO currencies not symbols, explicit locale at request time.
  • Fix every variable that can personalise a price, and never scrape logged in.
  • Discover products via sitemaps first, schedule by volatility, store changes not scrapes, and validate every batch.

Frequently asked questions

Is scraping prices from ecommerce sites legal?
Prices are facts, and facts are not copyrightable in the US or EU. Scraping public product pages without logging in is generally lawful, but a site’s terms of service, rate of access, and any personal data you touch still matter. Check the target’s terms and see our legal overview before scraping at scale.
How often should I scrape prices?
Match the cadence to how fast the category moves. Consumer electronics and grocery can change several times a day; furniture or B2B catalogues may change weekly. Start conservative, measure how often prices actually change, and increase frequency only where the data shows volatility.
Where is the most reliable place to find a price on a product page?
Schema.org JSON-LD in a script tag, specifically the Product’s Offer object. It is machine-readable, includes currency and availability, and retailers keep it accurate because search engines use it. Fall back to embedded state objects, then to HTML selectors.
Do I need a headless browser to scrape prices?
Usually not. Most retailers ship the price in server-rendered HTML or JSON-LD because search engines need it. Rendering is required only when the price loads client-side after the initial response, which you can check by fetching the page with curl.
How do I handle prices that differ by region or user?
Fix every variable you can: send an explicit Accept-Language, use a proxy in the target country, clear cookies between sessions, and never log in. Record the country and currency alongside each observation so differences are explainable rather than noise.
Should I store every scrape or only price changes?
Store every observation’s timestamp but deduplicate the price rows: write a new price record only when price, currency, or availability changed. You keep a compact history and can still prove when each value was last confirmed.