DEV Community

Greta
Greta

Posted on

Building a Price Monitoring Pipeline for Non-Amazon Retailers: The General Architecture

Building a Price Monitoring Pipeline for Non-Amazon Retailers: The General Architecture

Amazon scraping gets all the attention — the tutorials, the tools, the war stories. But most price-monitoring projects I've built have nothing to do with Amazon. They're about the long tail of retailers: electronics vendors, specialty shops, regional e-commerce sites, B2B part suppliers. And that long tail is a fundamentally different engineering problem.

On Amazon, one scraper architecture works for millions of products because the site is one site. In the general case, you're monitoring 50–500 different retailers, each with its own markup, its own anti-bot posture, its own currency, its own notion of what "in stock" means. The thesis of this post: in general price monitoring, the scraper is the easy 20% — the pipeline is the 80% that determines whether the product is useful. Getting a price is trivial. Getting a comparable, deduplicated, change-detected, alerted stream of prices across hundreds of heterogeneous stores is the actual job.

Here's the architecture that has worked for me, layer by layer.

Layer 0: The Source Registry — Your Single Source of Truth

Before writing any scraper, model the domain. Every monitored store and product goes into a registry:

from dataclasses import dataclass, field

@dataclass
class Store:
    store_id: str
    domain: str
    currency: str
    country: str                  # for geo-pinned fetching
    extractor: str                # which parser module handles it
    politeness: dict = field(default_factory=lambda: {
        "min_interval_s": 8,
        "burst": 3,
    })

@dataclass
class MonitoredProduct:
    sku: str                      # YOUR canonical product id
    canonical_name: str
    urls: dict                    # store_id -> product URL on that store

# registry entry example
REGISTRY = [
    MonitoredProduct(
        sku="LOGI-MX3-WIRELESS",
        canonical_name="Logitech MX Master 3 Wireless Mouse",
        urls={
            "store_a": "https://store-a.example.com/mx3",
            "store_b": "https://www.store-b.co.uk/logitech-mx-master-3",
        },
    ),
]
Enter fullscreen mode Exit fullscreen mode

Two things in this schema matter enormously later. First, the canonical SKU is yours, not any store's — because store product IDs change, listings get delisted, and you need a stable join key. Second, politeness is per-store. A 500-store monitor is 500 independent conversations, each with its own pacing.

Layer 1: Fetching — One Job, Many Personalities

Each store gets its fetch profile from the registry: HTTP client vs. browser, geo, session policy. A dispatcher keeps it clean:

import time, random, requests

class Fetcher:
    def __init__(self, store: Store):
        self.store = store
        self.session = requests.Session()
        self.session.proxies = {
            "http": "http://USER-cc-{}:PASS@p.thordata.com:9000".format(
                store.country),
            "https": "http://USER-cc-{}:PASS@p.thordata.com:9000".format(
                store.country),
        }
        self._last_hit = 0.0

    def get(self, url):
        # per-store politeness: the store's server sets the rhythm
        wait = self.store.politeness["min_interval_s"]
        elapsed = time.time() - self._last_hit
        if elapsed < wait:
            time.sleep(wait - elapsed + random.uniform(0, 2))
        self._last_hit = time.time()
        r = self.session.get(url, timeout=30)
        r.raise_for_status()
        return r
Enter fullscreen mode Exit fullscreen mode

Note the country-pinned proxy per store: a UK retailer should be fetched from a UK exit IP, both for correctness (geo-pricing!) and for believability. Also note what's absent: any CAPTCHA-solving. If a store serves challenges, that's a signal your pacing or fingerprint is wrong — fix the cause, don't pay to treat the symptom.

The HTTP-vs-browser decision is per-store. My rule of thumb: if the price is in the initial HTML, use requests; if it requires JavaScript, that store's extractor gets escalated to a Playwright worker. A mature pipeline is always a mix.

Layer 2: Extraction — Per-Store Selectors, Common Output Contract

Every store gets its own extractor module, but they all emit the same record:

@dataclass
class PriceObservation:
    sku: str
    store_id: str
    observed_at: str          # ISO timestamp
    price: float | None       # None = listed but no price visible
    currency: str
    in_stock: bool
    raw_text: str             # the exact scraped string, for auditing

def extract_store_a(html: str, sku: str) -> PriceObservation:
    from parsel import Selector
    sel = Selector(html)
    raw = sel.css("span.price__current::text").get()
    price = float(raw.replace("$", "").replace(",", "").strip()) if raw else None
    stock = bool(sel.css("button.add-to-cart:not([disabled])"))
    return PriceObservation(
        sku=sku, store_id="store_a",
        observed_at=_now_iso(),
        price=price, currency="USD", in_stock=stock,
        raw_text=(raw or "").strip(),
    )
Enter fullscreen mode Exit fullscreen mode

Two hard-won details here. Keep raw_text: when a selector breaks (and selectors always break — stores redesign quarterly), the raw string is what lets you diagnose whether the site changed or your extractor rotted. Make price=None explicit: "we visited the page and found no price" is different information from "we didn't visit" and from "out of stock." Conflating them corrupts your history.

Selector maintenance is the real ongoing cost of this business. Budget for it: I run a nightly job that flags any store whose extraction success rate dropped below 95%, because a silent selector break produces confidently wrong data — the worst kind.

Layer 3: Normalization — Where Comparisons Are Actually Won

Now the layer most tutorials skip: making observations comparable.

FX = {"GBP": 1.27, "EUR": 1.08, "USD": 1.0}   # refresh from an API in prod

def normalize(obs: PriceObservation, stores: dict) -> dict:
    store = stores[obs.store_id]
    usd = round(obs.price * FX[obs.currency], 2) if obs.price else None
    return {
        "sku": obs.sku,
        "store": obs.store_id,
        "price_usd": usd,
        "in_stock": obs.in_stock,
        "t": obs.observed_at,
        # flags that change the comparison semantics:
        "effective_price": usd,
        "gift_card_only": "giftcard" in obs.raw_text.lower(),
    }
Enter fullscreen mode Exit fullscreen mode

The normalization questions that actually bite: currency conversion (obvious), tax-inclusive vs. tax-exclusive pricing (EU sites include VAT, US sites don't — a 20% distortion if you miss it), per-store shipping thresholds (a "$5 higher" price with free shipping often wins), and weird payment restrictions ("gift card only" listings are not real market prices). You don't have to solve all of these on day one — but your schema needs the slots for them, or you'll be rewriting history later.

Layer 4: Change Detection — Don't Alert on Noise

Here's the mistake I made in my first price monitor: storing every observation and alerting on every delta. Prices on retail sites are noisy — A/B tests, regional experiments, temporary cart discounts, and plain flapping mean the same product can oscillate between two values all day. Alert on every change and your pipeline gets muted within a week.

The fix is to treat observations as a signal and detect sustained changes:

from statistics import median

class ChangeDetector:
    """Alerts only when the median of recent windows truly shifts."""
    def __init__(self, window=5, threshold_pct=2.0):
        self.history = {}                     # (sku, store) -> [prices]
        self.window = window
        self.threshold = threshold_pct

    def observe(self, key, price) -> dict | None:
        if price is None:
            return None
        h = self.history.setdefault(key, [])
        h.append(price)
        h[:] = h[-self.window * 2:]
        if len(h) < self.window:
            return None
        old = median(h[:-self.window]) if len(h) >= self.window * 2 else None
        new = median(h[-self.window:])
        if old and abs(new - old) / old * 100 >= self.threshold:
            return {"key": key, "old": old, "new": new}
        return None
Enter fullscreen mode Exit fullscreen mode

Median-based windows filter single-fetch flapping; the percentage threshold filters penny-changes. Tune window to your polling cadence — I poll volatile stores hourly and stable ones daily, so the window represents roughly the same wall-clock duration everywhere.

Layer 5: The Best-Price View

With clean, normalized, change-detected data, the product's payoff is one query:

def best_offer(observations, stores):
    live = [o for o in observations if o["in_stock"] and o["price_usd"]]
    return min(live, key=lambda o: o["effective_price"]) if live else None
Enter fullscreen mode Exit fullscreen mode

Which "best price" means for your use case — cheapest overall, cheapest from a reputable store, cheapest with shipping included — is a product decision, not a technical one. The architecture's job is to make any of those queries cheap once the data is disciplined.

Scheduling: Freshness Per Store, Not Global Cadence

One last piece of real engineering: don't run the monitor on one global cron. Group stores by volatility. Flash-sale electronics retailers need hourly checks; industrial B2B suppliers change prices monthly — hitting them hourly is waste (and risk). A per-store poll_class in the registry, with a scheduler that fans out accordingly, cuts your fetch volume by 60–80% in practice and keeps the data just as fresh where it matters.

Wrapping Up

The pipeline that survives is boring in the right places: a registry that owns the domain model, per-store politeness and geo, extractors with a common output contract, normalization that makes prices comparable, change detection that respects noise, and per-store freshness. The scraper is one small module in that machine.

Disclosure: I use Thordata's residential proxies for the geo-pinned fetching in this pipeline — country-level targeting per store is what keeps geo-prices accurate. Check them out at thordata.com, and use code **thor020* for 10% off.*

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to modeling the source registry is a solid foundation for managing the complexities of different retailers. I particularly appreciate how you've emphasized the importance of a canonical SKU and store-specific politeness, which is crucial for scraping reliability and ethical considerations. It might be worth exploring how a graph database could enhance your registry's flexibility, especially as the number of monitored retailers grows. If you’re looking for help refining this pipeline or implementing additional features, I’d be glad to discuss a paid collaboration. What challenges have you encountered with scaling the fetching layer as the number of stores increases?