Price data is the most time-sensitive thing a retailer collects, and the easiest to get wrong in a way nobody notices for a month. A price scraped from the wrong city is not a small error; it is a pricing decision made on a number a customer never saw. This page covers the three things that actually decide data quality: the location you ask from, the cadence you run at, and whether you are reading the price the page displays or a number left over in the markup.
Location is part of the price
Marketplaces and large retailers vary availability, delivery estimates, tax display and sometimes the price itself by location. Collecting a single national feed and calling it "the price" produces a number that matches no customer's screen. Target the cities your customers actually buy from, and keep the location in the record.
# one sticky session per city: same shopper, same cart context import requests CITIES = [("ca", "losangeles"), ("ny", "newyork"), ("tx", "houston"), ("il", "chicago")] PWD = "PASSWORD" for state, city in CITIES: user = "ACCOUNT-country-us-state-%s-city-%s-sid-%s01-stime-30" % (state, city, city[:6]) p = "http://%s:%s@GATEWAY_HOST:PORT" % (user, PWD) r = requests.get(product_url, proxies={"http": p, "https": p}, timeout=30) print(city, r.status_code, extract_price(r.text)) # helper defined below
Read the data, not the paint
Most product pages carry the price twice: once in visible markup, once in structured data or an embedded state object. The visible one is often a pre-discount list price, or a placeholder replaced by JavaScript after load. Extracting from the structured block is cheaper and more stable than rendering.
| Source | Reliability | Cost | Notes |
|---|---|---|---|
| JSON-LD offers | Highest | Cheapest | Structured by design; currency included |
Embedded state (__NEXT_DATA__, window.__STATE__) | High | Cheap | Contains variants and stock the markup omits |
| Visible markup | Medium | Cheap | May be a pre-discount or placeholder value |
| Rendered DOM | High | 10–50x bandwidth | Use only when the first three fail |
# JSON-LD first, then the embedded state object, then a fallback selector import json, re def extract_price(html): m = re.search(r'application/ld\+json">(.*?)', html, re.S) if m: d = json.loads(m.group(1)) offers = d.get("offers") or {} if isinstance(offers, list): offers = offers[0] return offers.get("price"), offers.get("priceCurrency") m = re.search(r'"price"\s*:\s*"?([0-9.]+)', html) return (m.group(1), None) if m else (None, None)
Cadence: match it to how prices move
| Catalogue slice | Frequency | Why |
|---|---|---|
| Top 500 SKUs by revenue | Hourly | Repricing happens here first and moves margin |
| Competitor overlap set | Every 4–6 hours | Enough to catch a same-day response |
| Long tail (10k+ SKUs) | Daily | Prices are stable; hourly only adds cost |
| MAP / reseller checks | Weekly | Disputes move slower than prices |
Every extra pass costs traffic whether or not the price changed. The useful trick is to diff before you re-crawl: store a hash of the product block, and only re-parse when it changes. On a catalogue with stable prices this typically cuts parsed volume by an order of magnitude even though you still fetch the page.
What a catalogue costs
Assume 100,000 SKUs, one pass per day, 400 KB per page, 95% success rate:
| Item | Value |
|---|---|
| Pages per pass | 100,000 |
| Attempts at 95% success | 105,263 |
| Traffic per pass | 42 GB |
| Cost per pass at $1/GB | $42 |
| Monthly | $1,260 |
At $1/GB that is roughly $42 a day, or $1,260 a month, for a full daily pass. Two levers move that number far more than any unit-price negotiation: read JSON instead of rendering (often halves the bytes), and crawl only the SKUs that matter at hourly frequency while the long tail runs daily.
A monitoring loop that produces decisions, not just rows
- Define the record. SKU, retailer, country, city, price, currency, promotion text, timestamp, HTTP status, extraction source. Without the source field you cannot debug a bad number a week later.
- Validate before you store. A price that moved 70% in one hour is usually a parse error or a currency switch, not a repricing. Re-fetch once with a new session and compare.
- Alert on the second confirmation. Two consecutive passes showing the same change is worth a notification; the first is worth a retry.
- Keep an evidence copy. For MAP and channel disputes, store a screenshot hash or the raw HTML fragment alongside the price — a claim needs proof attached to the moment.
- Review the target list monthly. Sites add protection; a host that ran fine on datacenter last quarter may need residential today.
Where teams get it wrong
- One national view. Treating a US-wide crawl as "the price" hides the city-level differences that actually drive conversion.
- Rendering every page. Paying 20x the bandwidth to read a number that was already in the HTML.
- Hourly everything. Most prices do not move hourly; the traffic does.
- No timestamp discipline. Two prices without timestamps cannot be compared, which makes the whole dataset unusable for a pricing decision.
Frequently asked questions
How often should I check competitor prices?
Match the cadence to how often prices actually move. Most retailers reprice once or twice a day, so hourly crawls burn traffic without new information. Track a small set of high-value SKUs hourly and the long tail daily, then raise frequency only when you see real movement in the logs.
Can I see the price a shopper in another city sees?
Yes on the residential pool: target country, state and city in the username, for example country-us-state-ca-city-losangeles. Marketplaces use location for availability, delivery estimates and sometimes price, so a single national view misses the differences that matter.
Why is the price I collect different from what customers report?
Three usual causes: the page served a personalised or A/B variant, you read a pre-discount price from the markup while the visible price is rendered by JavaScript, or you hit a cached version. Pin the session, read the structured data rather than the rendered text where possible, and record the timestamp with every price.
How do I monitor MAP violations?
Collect the reseller page, not just the brand site, from the reseller's own country. Store the price, the currency, the promotion text and a screenshot reference together — a violation claim needs the evidence attached, and prices change faster than disputes are resolved.
Do I need static IPs for price monitoring?
Only if a target requires a logged-in account tied to one address. For public price pages, rotating residential is both cheaper and safer: it spreads requests across the pool instead of concentrating them on a handful of addresses that will get flagged.
What does a 100k-SKU daily crawl cost?
At 400 KB per page and 95% success, about 42 GB a day, so roughly $42 a day at $1/GB. Halving page weight by reading JSON instead of rendering saves more than any rate negotiation.
Run your own target list first
Residential from $1/GB billed by traffic, static ISP from $2/IP per month from 15 IPs. No monthly commitment — measure your success rate before you scale.
Create account