Technical
How to scrape competitor prices without breaking every week
Scraping competitor prices reliably means finding the right product pages, reading schema.org Product structured data where a site publishes it, falling back to a per-domain extraction recipe where it does not, handling prices rendered by JavaScript, normalising currency and locale, validating every value before storage, scheduling politely, and keeping price history append-only.
Scraping a competitor price once is a twenty-minute job. Scraping four thousand of them daily for two years, and knowing each morning which ones are wrong, is the actual problem. The method that survives is boring: prefer the structured data a site already publishes about its own products, fall back to a versioned per-domain recipe, validate every number before it reaches the database, schedule at a rate nobody notices, and monitor for silence as carefully as for errors.
How to scrape competitor prices: the pipeline in order
A price collection system has seven stages, and each one has a distinct failure mode. Building them as separate stages matters, because when something goes wrong you need to know whether you fetched the wrong page, parsed the right page badly, or stored a number that was never plausible.
- 1Discovery. Decide which URLs on a competitor domain are the product pages you care about.
- 2Fetch. Retrieve the page politely, with the right headers and a retry policy that backs off.
- 3Extract. Read a price out of the response, structured data first, per-domain recipe second.
- 4Normalise. Turn a display string into a currency-tagged decimal with a known tax and shipping context.
- 5Validate. Decide whether the number is plausible before it is allowed anywhere near your reporting.
- 6Store. Append an immutable observation, never overwrite a previous one.
- 7Monitor. Watch coverage, freshness and shape, because the expensive failures are silent.
Stage one: find the product pages, do not crawl the site
The wrong instinct is to crawl a competitor domain looking for products. It is slow, it generates enormous request volume, it picks up thousands of pages you will never use, and it is the single most likely thing to get you noticed for the wrong reason.
The right instinct is to arrive at product URLs by the shortest available route, in this order of preference.
- A product sitemap. Most platforms publish one at /sitemap.xml or a sitemap index linking to product children. It is authoritative, meant to be read by machines, and usually carries a lastmod date you can use to skip untouched pages.
- A shopping feed. If the competitor advertises through a shopping channel, their catalogue is already structured somewhere public. The cheapest and most stable source there is.
- Category pagination. Walk the category you compete in, not the whole store. One request per results page, with a hard page cap so a pagination bug cannot loop forever.
- On-site search by identifier. Searching a competitor site for a GTIN or manufacturer part number often lands directly on the page you want.
Whatever route you use, resolve to a canonical URL and store the product identity separately from the URL. URLs change constantly during replatforms and SEO work. If your record of a competitor product is a URL, every URL change looks like a discontinued product. If your record is a GTIN plus a domain, a moved URL is a rediscovery problem rather than a data loss.
Stage two and three: read schema.org Product data before you write a selector
A large share of ecommerce sites publish structured data about their own products, because it is what generates rich results in search. It is usually JSON-LD in a script tag, occasionally microdata attributes in the markup. When it is present it is the best extraction target available: it is a documented schema, it is machine-readable by design, it is maintained by the site because their own search visibility depends on it, and it survives visual redesigns that would destroy a CSS selector.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Northline 20V Cordless Drill Kit, 4.0Ah",
"sku": "NL-DRL-2040",
"gtin13": "0715432198765",
"mpn": "NL2040DK",
"brand": { "@type": "Brand", "name": "Northline" },
"offers": {
"@type": "Offer",
"url": "https://www.voltbay.example/p/nl-drl-2040",
"price": "179.00",
"priceCurrency": "USD",
"priceValidUntil": "2026-08-31",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": { "@type": "Organization", "name": "Voltbay" }
}
}
</script>That single block gives you price, currency, availability, condition, seller and three identifiers you can match on. Read it first, always. But read it sceptically, because there are four well-known ways it lies.
- Stale price. The JSON-LD comes from a template that caches separately from the price component. The page shows $159.00 and the structured data still says $179.00. Cross-check against the visible price and quarantine when they disagree by more than a rounding cent.
- List price instead of sale price. Some templates emit the regular price in offers.price and render the promotional price only in the DOM. If the page carries a sale badge and your structured price equals MSRP, treat it as suspect.
- AggregateOffer instead of Offer. A marketplace-style page emits lowPrice and highPrice across sellers rather than one price. The lowest offer from any seller is a different metric than the retailer's own price, and mixing them produces an index that drifts without explanation.
- Multiple Product nodes. Variant-heavy pages emit an array, or a hasVariant list. Taking the first node is how a 2.0Ah kit price ends up recorded against a 4.0Ah SKU.
Our own extraction reads schema.org Product data first wherever a site publishes it, and treats it as a candidate rather than as truth. The cross-checks above are why a structured-data read still passes through the same validation gate as a selector-based read.
Falling back to a per-domain extraction recipe
Where structured data is absent or untrustworthy, you need selectors. The mistake here is writing one generic price finder for the whole internet. A regular expression for a currency symbol followed by digits will happily return the price of a cross-sell accessory, a finance instalment, a shipping threshold, a bundle add-on, a strikethrough list price, or the RRP of the wrong variant.
Write a recipe per domain instead, and anchor it on structure rather than styling.
- Anchor inside the buy-box container, not on the page as a whole, so an accessory carousel can never be the match.
- Prefer attributes that exist for machine reasons over classes that exist for visual reasons: itemprop="price", data-price, data-product-id, meta tags. Class names change with every redesign; data attributes survive because code reads them.
- Capture the strikethrough price as a separate field rather than discarding it. Knowing that $179.00 is down from $219.00 is worth more than knowing $179.00, and it is your best signal that a promotion is live.
- Record the selector version alongside every observation. When a value looks wrong six weeks later, the first question is which recipe produced it.
- Fail loudly on multiple matches. If a selector returns three prices, that is not a tie to break, it is a broken recipe.
Prices rendered by JavaScript, and how a scraper breaks on them
Here is the mechanical version of the most common breakage, because knowing the shape of it makes it recognisable in five minutes rather than five days.
A retailer moves pricing into a client-side component so they can personalise it, run promotions without a full deploy, or reflect regional inventory. The HTML that arrives at your fetcher now contains an empty container. The price is fetched afterwards by an XHR call to an internal endpoint and injected into the DOM by the browser. Your selector still matches the container. The container is empty. Your extractor returns null, or worse, it returns the placeholder text and your parser reads it as zero.
Note which failure that is. It is not an HTTP error. The fetch returned 200, the page was the right page, the selector matched. Every naive health check passes. This is precisely why error rate is a poor monitor and null rate is a good one.
There are three responses, in ascending order of cost.
| Approach | What it reads | Typical breakage | Relative cost per page | Best used for |
|---|---|---|---|---|
| schema.org JSON-LD | A documented Product and Offer node in the served HTML | Stale or list-price values; AggregateOffer ambiguity; variant arrays | Lowest | The default first attempt on every domain |
| Microdata or RDFa attributes | itemprop markup embedded in the rendered elements | Partial implementations that omit currency or availability | Lowest | Older platforms that never migrated to JSON-LD |
| Per-domain CSS or XPath recipe | Specific containers in the served HTML | Any redesign; class-name churn; variant switching | Low | Sites with no usable structured data |
| The site's own internal JSON endpoint | The XHR response the page itself calls for pricing | Endpoint versioning; added request signing; payload reshaping | Low, once found | JS-rendered pricing where the call is public and unauthenticated |
| Headless browser render | The DOM after scripts have executed | Timing and wait conditions; memory; silent partial renders | Highest, by an order of magnitude | The last resort, on the minority of domains that need it |
Cost here is compute plus maintenance attention, not licence fees. A headless render is not just slower; it introduces timing bugs that produce plausible wrong numbers rather than clean failures.
If you do use a headless browser, never wait on a fixed timeout. Wait on a condition: the price node exists and its text matches a currency pattern. A fixed two-second wait works on your laptop and fails at 3am under load, and the failure is a null price rather than a crash, which means you will find out about it in a report.
Data quality — source health
Coverage
98.8%
of tracked URLs
Checks today
1,106
Auto-healed (7d)
7
▲ no data gaps
Human queue
2
awaiting review
| Source | Method | Coverage | Last full pass | Status |
|---|---|---|---|---|
| Voltbay | Structured data | 100% | 38 min ago | Healthy |
| Harborline | Domain recipe | 98.4% | 1 h 12 m ago | Self-healed Recipe regenerated 3 h ago after a layout change |
| PrimeDeck | Marketplace API | 99.6% | 22 min ago | Healthy |
| Casa & Kin | Domain recipe | 96.1% | 2 h 04 m ago | Degraded 12 URLs returning 404 — queued for re-discovery |
| Belmont Direct | Structured data | 100% | 51 min ago | Healthy |
Incident log
- Today 04:12HarborlinePrice selector returned null on 214 URLs.New extraction recipe generated and validated — live 04:19
- Today 01:50Casa & Kin12 product URLs returned 404.Re-discovery queued via GTIN lookup
- Yesterday 19:31PrimeDeckRate limit reached.Backed off and completed the cycle 22 min late
Normalising currency, locale and what the shopper actually pays
A price string is not a number. Turning one into the other is where a surprising share of bad data is created, because the conversions look trivial and are not.
- Decimal and thousands separators invert by locale. 1.299,00 and 1,299.00 are the same amount, and a naive parser turns one into 1.299. Parse using the storefront's locale, not your server's.
- The currency symbol is not the currency. A dollar sign can be USD, CAD, AUD or several others. Take priceCurrency from structured data where present, otherwise lock it as a per-domain constant rather than re-detecting per page.
- Tax inclusion differs by market. US storefronts typically show a pre-tax price and add sales tax at checkout. Many European storefronts show a VAT-inclusive price. Comparing the two produces a gap that is entirely an artefact.
- Shipping changes the real price. A $179.00 item with $12.00 shipping is not competing with a $185.00 item that ships free. Record the shipping threshold, and decide once whether your index compares item prices or delivered prices.
- Promotions hide in the cart. Coupon codes, automatic cart discounts and multi-buy offers mean the advertised price is not always the paid price. Record the promotional wording alongside the number.
- Pack size is the quiet killer. A four-pack at $47.96 against a single unit at $12.99 is a unit-of-measure error, not a price advantage. Normalise to a per-unit price when pack quantity is known, and refuse to compare when it is not.
Validate before you store, not after
The most valuable component in a price scraping pipeline is the gate between extraction and storage. Without it, a bad number does not stay a bad number; it becomes a data point in a chart, a trigger for a repricing rule, and a decision someone makes on a Tuesday. Once it is stored, it is expensive to unwind.
validate(observation, history, catalog, page):
# 1. shape
if observation.price is null: reject("no price extracted")
if observation.price <= 0: reject("non-positive price")
if observation.currency not in EXPECTED[observation.domain]:
reject("unexpected currency for this storefront")
# 2. plausibility against this product's own past
last = history.last_accepted(observation.product_id, observation.domain)
if last exists:
move = abs(observation.price - last.price) / last.price
if move > 0.40:
quarantine("moved 40%+ since last read; needs a confirming fetch")
# 3. plausibility against the catalogue
if observation.price < 0.25 * catalog.msrp: quarantine("implausibly low vs MSRP")
if observation.price > 3.00 * catalog.msrp: quarantine("implausibly high vs MSRP")
# 4. the list-price-instead-of-sale-price trap
if page.has_sale_badge and observation.price == catalog.msrp:
quarantine("sale badge present but list price captured")
# 5. batch-level shape, checked per domain per run
if batch.distinct_prices == 1 and batch.size > 20:
fail_batch("every product on this domain returned the same value")
if batch.null_rate > 0.20:
fail_batch("null rate above threshold; treat as an extraction outage")
accept(observation)Three things there are worth stating explicitly. Quarantine is not rejection: the value is held for a confirming fetch, because genuine 40 percent moves do happen on clearance. Batch-level checks catch what per-row checks cannot, since the signature of a broken recipe is uniformity rather than an outlier. And a failed batch must not overwrite yesterday's good data, which is the argument for the storage model below.
Schedule politely, and store history append-only
Politeness is not only an ethical position, it is an uptime strategy. A collector a site never notices does not get blocked, and never needs the maintenance burden that evasion brings. Cap concurrency per domain rather than globally. Spread a daily run across hours rather than firing it at midnight. Honour Retry-After. Back off exponentially on 429 and 5xx, because a retry storm is how a mild rate limit becomes a permanent block.
Choose the refresh interval from category behaviour, not from ambition. Any price move shorter than your interval is invisible to you, and your average detection delay is roughly half the interval. Slow-moving durables are fine on a daily read. Categories where marketplace repricers run all day need several checks a day to mean anything.
On storage, there is one rule: never update a price row in place. Append an observation. Each observation carries the product identity, the domain, the URL as read, the price, the currency, the stock state, the promotion wording, the extraction method and recipe version, and the timestamp of collection rather than the timestamp of processing.
Append-only storage buys you four things that overwriting destroys forever: you can chart a real history rather than a current state; you can tell a price change apart from an extraction change; you can recompute an index after fixing a recipe, because the raw observations are still there; and a bad run degrades coverage instead of corrupting the record. That last property is what makes it safe for a validation gate to fail a whole batch.
Cadence 200 Bookshelf Speakers (pair) — Sand — 60-day price history
Detecting breakage, because the expensive failures are silent
A scraper that throws errors is a scraper you will fix. A scraper that returns confident, wrong, unchanging numbers is the one that costs money. Monitor for the second kind.
| Signal | Most likely cause | First thing to check |
|---|---|---|
| Null rate on one domain jumps while HTTP status stays 200 | Price moved into a JS-rendered component, or the container was renamed | Fetch one page raw and search the response body for the price string |
| Every product on a domain returns an identical price | The selector is matching a shared element such as a shipping threshold or a banner | The recipe's anchor container, and whether it is inside the buy box |
| Prices frozen at the same value for an unusually long run | A cache layer, or a recipe reading a static list price rather than the live price | Whether the strikethrough field is also frozen, which distinguishes the two |
| Coverage falls quietly on one domain over several days | Product URLs moved during a replatform and are now 404 or redirecting | Whether identity is stored by URL rather than by identifier |
| Prices all shift by a consistent factor around the same date | Currency or tax-inclusion assumption changed on the storefront | priceCurrency in the structured data, and the storefront country setting |
| Violation or undercut counts drop to zero | Collection stopped. An empty result set looks exactly like a clean week | Last successful read time per source, before celebrating |
Every row here is a silent failure. None of them raises an exception, and none of them shows up in an error-rate dashboard.
The real cost is maintenance, not the first fetch
This is the part that vendor pages and weekend prototypes both skip. Writing the first extractor for a competitor domain takes an afternoon. Keeping forty of them correct is a permanent job, and the work does not arrive on a schedule you control.
Every source is independently redesigning, replatforming, adding bot defences, restructuring URLs, introducing regional pricing and running promotions your parser has never seen. None will tell you. The signal is usually a number that is quietly wrong, which means the cost is not the repair, it is the interval between the break and the discovery.
- Budget engineering time per source per month, not a one-off build cost. A source is a subscription, not a purchase.
- Version every extraction recipe and record which version produced every observation, so a repair can be applied retrospectively.
- Keep a raw response sample for recently changed sources, so a diff answers the question of what moved.
- Automate regeneration where you can. When a layout changes, rediscovering the price node is a task a system can do repeatedly; ours regenerates the extraction recipe automatically and re-discovers moved products by identifier rather than by URL.
- Measure coverage and freshness as first-class metrics next to the price data itself, and put them in front of whoever consumes the numbers.
There is an honest build-versus-buy answer in that list. If you monitor a handful of stable sources and have engineering capacity, build it. If you monitor dozens across marketplaces and storefronts, collection was never the hard part; maintenance, matching and validation are, and they never finish. The trade-off is in how to choose price monitoring software, and the managed version is price scraping.
Skip the maintenance treadmill
Structured data read first, per-domain recipes as fallback, plausibility validation on every price, append-only history, and self-healing extraction when a source changes shape. 14-day free trial, no credit card.
Frequently asked questions
What is the most reliable way to extract a competitor price?
Read the schema.org Product structured data the site publishes about itself, usually as JSON-LD in a script tag. It gives price, currency, availability and identifiers in a documented schema, and it survives visual redesigns that break CSS selectors. Cross-check it against the visible price, because some templates emit a stale or list price while the DOM shows the sale price.
How do you scrape a price that is rendered by JavaScript?
First check whether the page calls an internal JSON endpoint for pricing that you can read directly, which is far cheaper than rendering. If not, use a headless browser but wait on a condition rather than a fixed timeout: the price node exists and its text matches a currency pattern. Fixed waits pass locally and produce null prices under load.
How often should competitor prices be scraped?
Match the interval to how fast the category moves. Any price change shorter than the interval is invisible, and average detection delay is about half the interval. Daily suits slow-moving durables. Categories with automated marketplace repricing need several checks a day. Faster collection has a real cost in request volume, so raise it deliberately rather than by default.
Why does a price scraper return the same price for every product?
The selector is almost certainly matching a shared page element rather than something inside the buy box, such as a free-shipping threshold, a promotional banner or a finance instalment figure. It is a batch-level failure, so per-row validation will not catch it. Check that a run against one domain returns more than one distinct value before storing anything.
Should scraped prices overwrite the previous value?
No. Append every observation with its own collection timestamp, extraction method and recipe version. Append-only storage lets you chart real history, distinguish a price change from an extraction change, recompute after fixing a recipe, and fail a bad batch without corrupting good data. Overwriting destroys all four, permanently and silently.
Is it legal to scrape competitor prices?
A price is a fact, and facts are not protected by copyright, which makes price data comparatively favourable ground. The live questions are about method rather than data: stay logged out, do not circumvent access controls, rate limit, and store numbers rather than descriptions or images. See our guide on whether web scraping is legal, which is educational and not legal advice.
How much maintenance does a price scraper need?
More than the initial build, indefinitely. Every source independently redesigns, replatforms, moves URLs and adds bot defences without telling you, and the usual symptom is a number that is quietly wrong rather than an error. Budget recurring engineering time per source per month, version every extraction recipe, and monitor coverage and freshness as first-class metrics.
Keep reading
- Is web scraping legalThe access, contract, copyright and data-protection questions this guide deliberately sets aside.
- Price scrapingThe managed version of this pipeline, including self-healing extraction when a source changes.
- Data qualityCoverage, freshness and source-health monitoring, so silent breakage is caught before a report is wrong.
- How to choose price monitoring softwareThe build-versus-buy questions, and what to test during an evaluation.
- API and webhooksPulling matched, validated observations into your own systems instead of parsing pages yourself.
- Competitor price monitoringWhat collected prices become once matching, validation and reporting sit on top of them.
Start monitoring in the next ten minutes
Connect your store, match your catalogue and get your first competitor comparison in the same session.
14 days, no credit card, cancel in one click.