DEV Community

neuralbyte
neuralbyte

Posted on

The Hard Part of Price Scraping Is Not Scraping the Price

The first price scraper I built returned a number.

That felt like success until I looked at what the number meant.

Was it the list price or sale price? One unit or a six-pack? Before tax or after tax? Available in the requested region? Sold by the retailer or a marketplace seller?

The scraper had extracted $19.99. The monitoring system had learned almost nothing.

A price is not a string on a page. It is an observation about a specific offer at a specific time.

This is the pipeline I use to turn a retrieved page into something safe enough for alerts and analysis.

Start with the output contract

Before choosing a crawler, define the record you are willing to compare:

{
  "observed_at": "2026-09-17T09:00:00Z",
  "source_url": "https://shop.example/products/sku-42",
  "product_id": "gtin:00012345678905",
  "seller": "Example Retailer",
  "currency": "USD",
  "amount": "19.99",
  "unit_quantity": 1,
  "availability": "in_stock",
  "region": "US",
  "promotion": null,
  "evidence_ref": "artifact://obs_01J..."
}
Enter fullscreen mode Exit fullscreen mode

If the pipeline cannot produce this record with enough confidence, it should reject the observation rather than guess.

The actual pipeline

My price-monitoring flow has seven stages:

schedule
  → retrieve
  → render when necessary
  → extract
  → normalize
  → match the offer
  → validate
  → store and alert
Enter fullscreen mode Exit fullscreen mode

Each arrow has a failure state. That is the part most “scrape prices in five minutes” tutorials skip.

Stage 1: retrieve the page

Begin with the cheapest authorized retrieval method that preserves the required data.

Static HTML is enough when the price is present in the original response. A browser or managed rendering API is justified when the page creates the offer after JavaScript executes.

For recurring collection, Nstdata Crawl can return rendered and structured page artifacts, while Nstdata proxy products and Proxy Manager can support geographic consistency and routing operations. But infrastructure only solves retrieval. It does not decide whether two offers are comparable.

Stage 2: prove that the right page arrived

200 OK is not enough.

The response might contain:

  • a login wall;
  • a regional fallback;
  • an unavailable-product template;
  • a bot challenge;
  • or an HTML shell whose price appears later.

Before extraction, check stable content markers such as product identity, seller, currency, and a known page type.

def page_is_acceptable(html: str, expected_sku: str) -> bool:
    text = html.lower()
    rejected = (
        "verify you are human",
        "sign in to continue",
        "product not available in your region",
    )
    return expected_sku.lower() in text and not any(x in text for x in rejected)
Enter fullscreen mode Exit fullscreen mode

This check is intentionally domain-specific. Generic “HTML length > 1,000” tests accept surprisingly convincing error pages.

Stage 3: extract evidence, not just a value

Store the raw price text and the evidence location alongside the parsed amount.

from dataclasses import dataclass


@dataclass(frozen=True)
class ExtractedPrice:
    raw_text: str
    amount_text: str
    currency_hint: str | None
    seller_text: str | None
    availability_text: str | None
    selector: str
Enter fullscreen mode Exit fullscreen mode

When a parser changes, the evidence tells you whether the site changed, the selector broke, or the normalization rule was wrong.

Stage 4: normalize money without floats

Binary floating-point is a bad default for money.

import re
from decimal import Decimal


def parse_usd_amount(raw: str) -> Decimal:
    cleaned = re.sub(r"[^0-9.]", "", raw)
    if not cleaned or cleaned.count(".") > 1:
        raise ValueError(f"Unrecognized amount: {raw!r}")
    return Decimal(cleaned).quantize(Decimal("0.01"))


assert parse_usd_amount("$1,249.50") == Decimal("1249.50")
Enter fullscreen mode Exit fullscreen mode

Real pipelines also need locale-specific decimal separators, Unicode spaces, unit quantities, and explicit currency codes. Never convert currencies without storing the rate, source, and rate timestamp.

Stage 5: match the offer

This is usually harder than extraction.

Prefer stable identifiers such as GTIN, UPC, EAN, MPN, ASIN, or a retailer SKU when legitimately available. Then compare the dimensions that change the offer:

  • pack size;
  • variant;
  • seller;
  • fulfillment method;
  • subscription requirement;
  • membership price;
  • shipping inclusion;
  • and region.

Two pages can describe the same product and still expose non-comparable offers.

Stage 6: validate the observation

I use three classes of checks.

Structural checks

Required fields exist and have the correct types.

Semantic checks

The currency is expected, the amount is positive, the seller is known, and the observed variant matches the requested variant.

Temporal checks

The timestamp is fresh, the same observation has not already been stored, and the new amount is plausible relative to recent history.

A large price move should create a review event before it creates a repricing action.

Stage 7: store history, then alert

Do not overwrite the previous price. Append an immutable observation and derive the current state from history.

That history lets you answer:

  • Was this a one-run parser error?
  • Did the promotion last two hours or two weeks?
  • Did only one region change?
  • Did the seller change at the same time as the price?

Alerts should reference the accepted observation and its evidence artifact. “Price changed” is less useful than “validated US offer for SKU 42 changed from X to Y; seller and pack size unchanged.”

The metric that changed my design

Requests per second is an infrastructure metric. The business metric is cost per accepted observation.

cost per accepted observation
  = total collection and processing cost
    ÷ observations that pass validation
Enter fullscreen mode Exit fullscreen mode

A cheap request that produces incomplete or incomparable data is expensive. A slower rendered request that consistently produces accepted records may be cheaper overall.

Failure states should be first-class

My terminal states look like this:

accepted
rejected_wrong_product
rejected_wrong_region
rejected_missing_price
rejected_challenge
rejected_incomparable_offer
retryable_rate_limit
retryable_server_error
Enter fullscreen mode Exit fullscreen mode

This makes retries narrow and dashboards honest. “Failed” is too broad to operate.

Final takeaway

Price monitoring is a data-quality pipeline with a scraper at the front. Retrieval gets you a page. Extraction gets you a candidate. Matching and validation decide whether it becomes evidence.

If your system currently stores every parsed number, the most valuable next feature may not be a faster crawler. It may be a rejection state.

What rule would cause your current price pipeline to reject an observation?

Top comments (0)