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..."
}
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
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)
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
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")
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
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
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)